diff --git a/.changeset/witty-apples-speak.md b/.changeset/witty-apples-speak.md new file mode 100644 index 00000000..e5454fb4 --- /dev/null +++ b/.changeset/witty-apples-speak.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: Show created/updated metadata for saved searches and dashboards diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index 4449ad15..aa90930b 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -95,7 +95,9 @@ async function syncDashboardAlerts( export async function getDashboards(teamId: ObjectId) { const [_dashboards, alerts] = await Promise.all([ - Dashboard.find({ team: teamId }), + Dashboard.find({ team: teamId }) + .populate('createdBy', 'email name') + .populate('updatedBy', 'email name'), getTeamDashboardAlertsByDashboardAndTile(teamId), ]); @@ -117,12 +119,14 @@ export async function getDashboards(teamId: ObjectId) { export async function getDashboard(dashboardId: string, teamId: ObjectId) { const [_dashboard, alerts] = await Promise.all([ - Dashboard.findOne({ _id: dashboardId, team: teamId }), + Dashboard.findOne({ _id: dashboardId, team: teamId }) + .populate('createdBy', 'email name') + .populate('updatedBy', 'email name'), getDashboardAlertsByTile(teamId, dashboardId), ]); return { - ..._dashboard, + ..._dashboard?.toJSON(), tiles: _dashboard?.tiles.map(t => ({ ...t, config: { ...t.config, alert: alerts[t.id]?.[0] }, @@ -138,6 +142,8 @@ export async function createDashboard( const newDashboard = await new Dashboard({ ...dashboard, team: teamId, + createdBy: userId, + updatedBy: userId, }).save(); await createOrUpdateDashboardAlerts( @@ -180,6 +186,7 @@ export async function updateDashboard( { ...updates, tags: updates.tags && uniq(updates.tags), + updatedBy: userId, }, { new: true }, ); diff --git a/packages/api/src/controllers/savedSearch.ts b/packages/api/src/controllers/savedSearch.ts index 8e68beaa..203b86a5 100644 --- a/packages/api/src/controllers/savedSearch.ts +++ b/packages/api/src/controllers/savedSearch.ts @@ -1,16 +1,22 @@ -import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types'; -import { groupBy, pick } from 'lodash'; +import { + SavedSearchListApiResponse, + SavedSearchSchema, +} from '@hyperdx/common-utils/dist/types'; +import { groupBy } from 'lodash'; import { z } from 'zod'; import { deleteSavedSearchAlerts } from '@/controllers/alerts'; import Alert from '@/models/alert'; import { SavedSearch } from '@/models/savedSearch'; -import type { IUser } from '@/models/user'; type SavedSearchWithoutId = Omit, 'id'>; -export async function getSavedSearches(teamId: string) { - const savedSearches = await SavedSearch.find({ team: teamId }); +export async function getSavedSearches( + teamId: string, +): Promise { + const savedSearches = await SavedSearch.find({ team: teamId }) + .populate('createdBy', 'email name') + .populate('updatedBy', 'email name'); const alerts = await Alert.find( { team: teamId, savedSearch: { $exists: true, $ne: null } }, { __v: 0 }, @@ -27,26 +33,36 @@ export async function getSavedSearches(teamId: string) { } export function getSavedSearch(teamId: string, savedSearchId: string) { - return SavedSearch.findOne({ _id: savedSearchId, team: teamId }); + return SavedSearch.findOne({ _id: savedSearchId, team: teamId }) + .populate('createdBy', 'email name') + .populate('updatedBy', 'email name'); } export function createSavedSearch( teamId: string, savedSearch: SavedSearchWithoutId, + userId?: string, ) { - return SavedSearch.create({ ...savedSearch, team: teamId }); + return SavedSearch.create({ + ...savedSearch, + team: teamId, + createdBy: userId, + updatedBy: userId, + }); } export function updateSavedSearch( teamId: string, savedSearchId: string, savedSearch: SavedSearchWithoutId, + userId?: string, ) { return SavedSearch.findOneAndUpdate( { _id: savedSearchId, team: teamId }, { ...savedSearch, team: teamId, + updatedBy: userId, }, { new: true }, ); diff --git a/packages/api/src/models/dashboard.ts b/packages/api/src/models/dashboard.ts index 8a6cfdf8..fa24833c 100644 --- a/packages/api/src/models/dashboard.ts +++ b/packages/api/src/models/dashboard.ts @@ -7,6 +7,8 @@ import type { ObjectId } from '.'; export interface IDashboard extends z.infer { _id: ObjectId; team: ObjectId; + createdBy?: ObjectId; + updatedBy?: ObjectId; createdAt: Date; updatedAt: Date; } @@ -32,6 +34,16 @@ export default mongoose.model( savedQueryLanguage: { type: String, required: false }, savedFilterValues: { type: mongoose.Schema.Types.Array, required: false }, containers: { type: mongoose.Schema.Types.Array, required: false }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, }, { timestamps: true, diff --git a/packages/api/src/models/savedSearch.ts b/packages/api/src/models/savedSearch.ts index ac9f02ca..e2893656 100644 --- a/packages/api/src/models/savedSearch.ts +++ b/packages/api/src/models/savedSearch.ts @@ -9,6 +9,8 @@ export interface ISavedSearch _id: ObjectId; team: ObjectId; source: ObjectId; + createdBy?: ObjectId; + updatedBy?: ObjectId; createdAt: Date; updatedAt: Date; } @@ -35,6 +37,16 @@ export const SavedSearch = mongoose.model( }, tags: [String], filters: [{ type: mongoose.Schema.Types.Mixed }], + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, }, { toJSON: { virtuals: true }, diff --git a/packages/api/src/routers/api/__tests__/dashboard.test.ts b/packages/api/src/routers/api/__tests__/dashboard.test.ts index 04c20f2e..415c95aa 100644 --- a/packages/api/src/routers/api/__tests__/dashboard.test.ts +++ b/packages/api/src/routers/api/__tests__/dashboard.test.ts @@ -20,6 +20,8 @@ import { makeTile, } from '../../../fixtures'; import Alert from '../../../models/alert'; +import Dashboard from '../../../models/dashboard'; +import User from '../../../models/user'; const MOCK_DASHBOARD = { name: 'Test Dashboard', @@ -78,6 +80,45 @@ describe('dashboard router', () => { ); }); + it('sets createdBy and updatedBy on create and populates them in GET', async () => { + const created = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + + // GET all dashboards + const allDashboards = await agent.get('/dashboards').expect(200); + const dashboard = allDashboards.body.find(d => d._id === created.body.id); + expect(dashboard.createdBy).toMatchObject({ email: user.email }); + expect(dashboard.updatedBy).toMatchObject({ email: user.email }); + }); + + it('populates updatedBy with a different user after DB update', async () => { + const created = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + + // Create a second user on the same team + const secondUser = await User.create({ + email: 'second@test.com', + name: 'Second User', + team: team._id, + }); + + // Simulate a different user updating the dashboard + await Dashboard.findByIdAndUpdate(created.body.id, { + updatedBy: secondUser._id, + }); + + const allDashboards = await agent.get('/dashboards').expect(200); + const dashboard = allDashboards.body.find(d => d._id === created.body.id); + expect(dashboard.createdBy).toMatchObject({ email: user.email }); + expect(dashboard.updatedBy).toMatchObject({ + email: 'second@test.com', + }); + }); + it('can update a dashboard', async () => { const dashboard = await agent .post('/dashboards') diff --git a/packages/api/src/routers/api/__tests__/savedSearch.test.ts b/packages/api/src/routers/api/__tests__/savedSearch.test.ts index e9cc1a27..d0c2a78b 100644 --- a/packages/api/src/routers/api/__tests__/savedSearch.test.ts +++ b/packages/api/src/routers/api/__tests__/savedSearch.test.ts @@ -4,6 +4,8 @@ import { makeSavedSearchAlertInput, } from '@/fixtures'; import Alert from '@/models/alert'; +import { SavedSearch } from '@/models/savedSearch'; +import User from '@/models/user'; import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook'; const MOCK_SAVED_SEARCH = { @@ -127,6 +129,66 @@ describe('savedSearch router', () => { expect(await Alert.findById(alert.body.data._id)).toBeNull(); }); + it('sets createdBy and updatedBy on create and populates them in GET', async () => { + const created = await agent + .post('/saved-search') + .send(MOCK_SAVED_SEARCH) + .expect(200); + + // GET all saved searches + const savedSearches = await agent.get('/saved-search').expect(200); + const savedSearch = savedSearches.body.find( + s => s._id === created.body._id, + ); + expect(savedSearch.createdBy).toMatchObject({ email: user.email }); + expect(savedSearch.updatedBy).toMatchObject({ email: user.email }); + }); + + it('populates updatedBy with a different user after DB update', async () => { + const created = await agent + .post('/saved-search') + .send(MOCK_SAVED_SEARCH) + .expect(200); + + // Create a second user on the same team + const secondUser = await User.create({ + email: 'second@test.com', + name: 'Second User', + team: team._id, + }); + + // Simulate a different user updating the saved search + await SavedSearch.findByIdAndUpdate(created.body._id, { + updatedBy: secondUser._id, + }); + + const savedSearches = await agent.get('/saved-search').expect(200); + const savedSearch = savedSearches.body.find( + s => s._id === created.body._id, + ); + expect(savedSearch.createdBy).toMatchObject({ email: user.email }); + expect(savedSearch.updatedBy).toMatchObject({ + email: 'second@test.com', + }); + }); + + it('updates updatedBy when updating a saved search via API', async () => { + const created = await agent + .post('/saved-search') + .send(MOCK_SAVED_SEARCH) + .expect(200); + + await agent + .patch(`/saved-search/${created.body._id}`) + .send({ name: 'updated name' }) + .expect(200); + + // Verify updatedBy is still set in the DB + const dbRecord = await SavedSearch.findById(created.body._id); + expect(dbRecord?.updatedBy?.toString()).toBe(user._id.toString()); + expect(dbRecord?.createdBy?.toString()).toBe(user._id.toString()); + }); + it('sets createdBy on alerts created from a saved search and populates it in list', async () => { // Create a saved search const savedSearch = await agent diff --git a/packages/api/src/routers/api/savedSearch.ts b/packages/api/src/routers/api/savedSearch.ts index ec75d2af..7ad06721 100644 --- a/packages/api/src/routers/api/savedSearch.ts +++ b/packages/api/src/routers/api/savedSearch.ts @@ -1,4 +1,7 @@ -import { SavedSearchSchema } from '@hyperdx/common-utils/dist/types'; +import { + SavedSearchListApiResponse, + SavedSearchSchema, +} from '@hyperdx/common-utils/dist/types'; import express from 'express'; import _ from 'lodash'; import { z } from 'zod'; @@ -16,7 +19,9 @@ import { objectIdSchema } from '@/utils/zod'; const router = express.Router(); -router.get('/', async (req, res, next) => { +type SavedSearchListExpRes = express.Response; + +router.get('/', async (req, res: SavedSearchListExpRes, next) => { try { const { teamId } = getNonNullUserWithTeam(req); @@ -37,9 +42,13 @@ router.post( }), async (req, res, next) => { try { - const { teamId } = getNonNullUserWithTeam(req); + const { teamId, userId } = getNonNullUserWithTeam(req); - const savedSearch = await createSavedSearch(teamId.toString(), req.body); + const savedSearch = await createSavedSearch( + teamId.toString(), + req.body, + userId?.toString(), + ); return res.json(savedSearch); } catch (e) { @@ -60,7 +69,7 @@ router.patch( }), async (req, res, next) => { try { - const { teamId } = getNonNullUserWithTeam(req); + const { teamId, userId } = getNonNullUserWithTeam(req); const savedSearch = await getSavedSearch( teamId.toString(), @@ -82,6 +91,7 @@ router.patch( source: savedSearch.source.toString(), ...updates, }, + userId?.toString(), ); if (!updatedSavedSearch) { diff --git a/packages/app/eslint.config.mjs b/packages/app/eslint.config.mjs index 73977dc5..2f2bc152 100644 --- a/packages/app/eslint.config.mjs +++ b/packages/app/eslint.config.mjs @@ -91,6 +91,7 @@ export default [ 'next-env.d.ts', 'playwright-report/**', '.next/**', + '.next-e2e/**', '.storybook/**', 'node_modules/**', 'out/**', diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index a28f8a27..13a87320 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -11,7 +11,7 @@ import dynamic from 'next/dynamic'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { formatRelative } from 'date-fns'; +import { formatDistanceToNow, formatRelative } from 'date-fns'; import produce from 'immer'; import { pick } from 'lodash'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; @@ -125,6 +125,7 @@ import { } from './source'; import { parseTimeQuery, useNewTimeQuery } from './timeQuery'; import { useConfirm } from './useConfirm'; +import { FormatTime } from './useFormatTime'; import { getMetricTableName } from './utils'; import { useZIndex, ZIndexContext } from './zIndex'; @@ -1557,16 +1558,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ) : ( - - - Dashboards - - - {dashboard?.name ?? 'Untitled'} - - + + + + Dashboards + + + {dashboard?.name ?? 'Untitled'} + + + {!isLocalDashboard && dashboard && ( + + {dashboard.createdBy && ( + + Created by{' '} + {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + + )} + {dashboard.updatedAt && ( + + + {dashboard.updatedBy + ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + )} + + )} + )} - + {savedSearch && ( - - - + + + Saved Searches @@ -1589,6 +1591,33 @@ function DBSearchPage() { {savedSearch.name} + + {savedSearch.createdBy && ( + + Created by{' '} + {savedSearch.createdBy.name || savedSearch.createdBy.email}.{' '} + + )} + {savedSearch.updatedAt && ( + + + {savedSearch.updatedBy + ? ` by ${savedSearch.updatedBy.name || savedSearch.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(savedSearch.updatedAt), { addSuffix: true })}.`} + + )} + + + - - - - - - + + - { - deleteSavedSearch.mutate(savedSearch?.id ?? '', { - onSuccess: () => { - router.push('/search/list'); - }, - }); - }} - onClickSaveAsNew={() => { - setSaveSearchModalState('create'); - }} - /> + { + deleteSavedSearch.mutate(savedSearch?.id ?? '', { + onSuccess: () => { + router.push('/search/list'); + }, + }); + }} + onClickSaveAsNew={() => { + setSaveSearchModalState('create'); + }} + /> + - + )}
( + (savedSearch: SavedSearchListApiResponse) => ( ))} @@ -384,6 +386,8 @@ export default function DashboardsListPage() { Name Tags + Created By + Last Updated @@ -396,6 +400,9 @@ export default function DashboardsListPage() { href={`/dashboards/${d.id}`} tags={d.tags} onDelete={handleDelete} + createdBy={d.createdBy?.name || d.createdBy?.email} + updatedAt={d.updatedAt} + updatedBy={d.updatedBy?.name || d.updatedBy?.email} leftSection={ ))} diff --git a/packages/app/src/components/ListingCard.tsx b/packages/app/src/components/ListingCard.tsx index 598453c5..9392b5f3 100644 --- a/packages/app/src/components/ListingCard.tsx +++ b/packages/app/src/components/ListingCard.tsx @@ -1,9 +1,19 @@ import Link from 'next/link'; -import { ActionIcon, Badge, Card, Group, Menu, Text } from '@mantine/core'; +import { formatDistanceToNow } from 'date-fns'; +import { + ActionIcon, + Badge, + Card, + Group, + Menu, + Text, + Tooltip, +} from '@mantine/core'; import { IconDots, IconTrash } from '@tabler/icons-react'; import { FavoriteButton } from '@/components/FavoriteButton'; import { Favorite } from '@/favorites'; +import { FormatTime } from '@/useFormatTime'; export function ListingCard({ name, @@ -14,6 +24,8 @@ export function ListingCard({ statusIcon, resourceId, resourceType, + updatedAt, + updatedBy, }: { name: string; href: string; @@ -23,6 +35,8 @@ export function ListingCard({ statusIcon?: React.ReactNode; resourceId?: string; resourceType?: Favorite['resourceType']; + updatedAt?: string; + updatedBy?: string; }) { return ( - + + {updatedAt && ( + + + {updatedBy ? ` by ${updatedBy}` : ''} + + } + > + + Updated{' '} + {formatDistanceToNow(new Date(updatedAt), { addSuffix: true })} + + + )} + {description && ( - + {description} )} diff --git a/packages/app/src/components/ListingListRow.tsx b/packages/app/src/components/ListingListRow.tsx index 23d6f7b9..cd393897 100644 --- a/packages/app/src/components/ListingListRow.tsx +++ b/packages/app/src/components/ListingListRow.tsx @@ -1,7 +1,18 @@ import Router from 'next/router'; -import { ActionIcon, Badge, Group, Menu, Table, Text } from '@mantine/core'; +import { formatDistanceToNow } from 'date-fns'; +import { + ActionIcon, + Badge, + Group, + Menu, + Table, + Text, + Tooltip, +} from '@mantine/core'; import { IconDots, IconTrash } from '@tabler/icons-react'; +import { FormatTime } from '@/useFormatTime'; + export function ListingRow({ id, name, @@ -9,6 +20,9 @@ export function ListingRow({ tags, onDelete, leftSection, + updatedAt, + updatedBy, + createdBy, }: { id: string; name: string; @@ -16,6 +30,9 @@ export function ListingRow({ tags?: string[]; onDelete?: (id: string) => void; leftSection?: React.ReactNode; + updatedAt?: string; + updatedBy?: string; + createdBy?: string; }) { return ( + + + {createdBy ?? '-'} + + + + {updatedAt ? ( + + + {updatedBy ? ` by ${updatedBy}` : ''} + + } + > + + {formatDistanceToNow(new Date(updatedAt), { addSuffix: true })} + + + ) : ( + '-' + )} + {onDelete && ( diff --git a/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx b/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx index fbbcfede..3ddae312 100644 --- a/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx +++ b/packages/app/src/components/SavedSearches/SavedSearchesListPage.tsx @@ -157,6 +157,8 @@ export default function SavedSearchesListPage() { statusIcon={} resourceId={s.id} resourceType="savedSearch" + updatedAt={s.updatedAt} + updatedBy={s.updatedBy?.name || s.updatedBy?.email} /> ))} @@ -258,6 +260,8 @@ export default function SavedSearchesListPage() { Name Tags + Created By + Last Updated @@ -270,6 +274,9 @@ export default function SavedSearchesListPage() { href={`/search/${s.id}`} tags={s.tags} onDelete={handleDelete} + createdBy={s.createdBy?.name || s.createdBy?.email} + updatedAt={s.updatedAt} + updatedBy={s.updatedBy?.name || s.updatedBy?.email} leftSection={ } resourceId={s.id} resourceType="savedSearch" + updatedAt={s.updatedAt} + updatedBy={s.updatedBy?.name || s.updatedBy?.email} /> ))} diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index bec3e23e..296f43eb 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -37,6 +37,10 @@ export type Dashboard = { savedQueryLanguage?: SearchConditionLanguage | null; savedFilterValues?: Filter[]; containers?: DashboardContainer[]; + createdAt?: string; + updatedAt?: string; + createdBy?: { email: string; name?: string }; + updatedBy?: { email: string; name?: string }; }; const localDashboards = createEntityStore('hdx-local-dashboards'); diff --git a/packages/app/src/localStore.ts b/packages/app/src/localStore.ts index 00a27c24..5ebb00de 100644 --- a/packages/app/src/localStore.ts +++ b/packages/app/src/localStore.ts @@ -1,6 +1,9 @@ import store from 'store2'; import { hashCode } from '@hyperdx/common-utils/dist/core/utils'; -import { SavedSearch, TSource } from '@hyperdx/common-utils/dist/types'; +import { + SavedSearchListApiResponse, + TSource, +} from '@hyperdx/common-utils/dist/types'; import { HDX_LOCAL_DEFAULT_SOURCES } from './config'; import { parseJSON } from './utils'; @@ -96,6 +99,6 @@ export const localSources = createEntityStore( ); /** Saved searches store (alerts remain cloud-only; no alert fields persisted locally). */ -export const localSavedSearches = createEntityStore( +export const localSavedSearches = createEntityStore( 'hdx-local-saved-searches', ); diff --git a/packages/app/src/savedSearch.ts b/packages/app/src/savedSearch.ts index 17e1297c..4cb5bc27 100644 --- a/packages/app/src/savedSearch.ts +++ b/packages/app/src/savedSearch.ts @@ -1,4 +1,7 @@ -import { SavedSearch } from '@hyperdx/common-utils/dist/types'; +import { + SavedSearch, + SavedSearchListApiResponse, +} from '@hyperdx/common-utils/dist/types'; import { useMutation, useQuery, @@ -9,14 +12,13 @@ import { import { hdxServer } from './api'; import { IS_LOCAL_MODE } from './config'; import { localSavedSearches } from './localStore'; -import { SavedSearchWithEnhancedAlerts } from './types'; -async function fetchSavedSearches(): Promise { +async function fetchSavedSearches(): Promise { if (IS_LOCAL_MODE) { // Locally stored saved searches never have alert data (alerts are cloud-only) - return localSavedSearches.getAll() as SavedSearchWithEnhancedAlerts[]; + return localSavedSearches.getAll() as SavedSearchListApiResponse[]; } - return hdxServer('saved-search').json(); + return hdxServer('saved-search').json(); } export function useSavedSearches() { @@ -29,7 +31,7 @@ export function useSavedSearches() { export function useSavedSearch( { id }: { id: string }, options: Omit< - Partial>, + Partial>, 'select' > = {}, ) { @@ -47,9 +49,7 @@ export function useCreateSavedSearch() { return useMutation({ mutationFn: (data: Omit) => { if (IS_LOCAL_MODE) { - return Promise.resolve( - localSavedSearches.create(data) as SavedSearchWithEnhancedAlerts, - ); + return Promise.resolve(localSavedSearches.create(data)); } return hdxServer('saved-search', { method: 'POST', @@ -69,12 +69,7 @@ export function useUpdateSavedSearch() { mutationFn: (data: Partial & { id: SavedSearch['id'] }) => { if (IS_LOCAL_MODE) { const { id, ...updates } = data; - return Promise.resolve( - localSavedSearches.update( - id, - updates, - ) as SavedSearchWithEnhancedAlerts, - ); + return Promise.resolve(localSavedSearches.update(id, updates)); } return hdxServer(`saved-search/${data.id}`, { method: 'PATCH', diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 6c911538..c2ae60dc 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -1,24 +1,13 @@ -import { z } from 'zod'; import { Alert, AlertsPageItem as _AlertsPageItem, BuilderChartConfig, Filter, NumberFormat as _NumberFormat, - SavedSearchSchema, } from '@hyperdx/common-utils/dist/types'; export type NumberFormat = _NumberFormat; -type KeyValuePairs = { - 'bool.names': string[]; - 'bool.values': number[]; - 'number.names': string[]; - 'number.values': number[]; - 'string.names': string[]; - 'string.values': string[]; -}; - export type AlertsPageItem = _AlertsPageItem; export type AlertWithCreatedBy = Alert & { @@ -28,12 +17,6 @@ export type AlertWithCreatedBy = Alert & { }; }; -export type SavedSearch = z.infer; - -export type SavedSearchWithEnhancedAlerts = Omit & { - alerts?: AlertWithCreatedBy[]; -}; - export type SearchConfig = { select?: string | null; source?: string | null; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 639d8858..6151234c 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -487,6 +487,32 @@ export const SavedSearchSchema = z.object({ export type SavedSearch = z.infer; +const PopulatedUserSchema = z + .object({ email: z.string(), name: z.string().optional() }) + .optional(); + +export const SavedSearchListApiResponseSchema = SavedSearchSchema.omit({ + alerts: true, +}).extend({ + alerts: z + .array( + AlertSchema.and( + z.object({ + createdBy: PopulatedUserSchema, + }), + ), + ) + .optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + createdBy: PopulatedUserSchema, + updatedBy: PopulatedUserSchema, +}); + +export type SavedSearchListApiResponse = z.infer< + typeof SavedSearchListApiResponseSchema +>; + // -------------------------- // DASHBOARDS // --------------------------