feat: Show created/updated metadata for saved searches and dashboards (#2031)

## Summary

This PR adds createdAt/By and updatedAt/By metadata to dashboard and saved searches.

### Screenshots or video

<img width="1466" height="342" alt="Screenshot 2026-04-01 at 3 19 07 PM" src="https://github.com/user-attachments/assets/c349a3d5-f8e3-4155-9938-c8f005cdcd52" />
<img width="1216" height="433" alt="Screenshot 2026-04-01 at 3 19 57 PM" src="https://github.com/user-attachments/assets/9542a631-bdda-484c-9cef-6b780667d1dc" />
<img width="1196" height="345" alt="Screenshot 2026-04-01 at 3 19 46 PM" src="https://github.com/user-attachments/assets/c05cd0cc-2ca4-4397-8acb-e31a81b882ec" />
<img width="1409" height="433" alt="Screenshot 2026-04-01 at 3 19 38 PM" src="https://github.com/user-attachments/assets/593a96d7-86be-45b2-9f0a-b3a8f00d1353" />
<img width="1447" height="181" alt="Screenshot 2026-04-01 at 3 20 59 PM" src="https://github.com/user-attachments/assets/88742578-3dbd-4305-921f-e2ecdd11d5d4" />


### How to test locally or on Vercel

This should be tested locally. In the preview environment, these fields are not populated (since they're maintained through automatic MongoDB createdAt/updatedAt values and createdBy/updatedBy values pulled from User accounts.

### References



- Linear Issue: Closes HDX-3461
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-07 10:31:44 -04:00 committed by GitHub
parent 78a433c8ec
commit f8d2edde5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 426 additions and 101 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Show created/updated metadata for saved searches and dashboards

View file

@ -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 },
);

View file

@ -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<z.infer<typeof SavedSearchSchema>, 'id'>;
export async function getSavedSearches(teamId: string) {
const savedSearches = await SavedSearch.find({ team: teamId });
export async function getSavedSearches(
teamId: string,
): Promise<SavedSearchListApiResponse[]> {
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 },
);

View file

@ -7,6 +7,8 @@ import type { ObjectId } from '.';
export interface IDashboard extends z.infer<typeof DashboardSchema> {
_id: ObjectId;
team: ObjectId;
createdBy?: ObjectId;
updatedBy?: ObjectId;
createdAt: Date;
updatedAt: Date;
}
@ -32,6 +34,16 @@ export default mongoose.model<IDashboard>(
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,

View file

@ -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<ISavedSearch>(
},
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 },

View file

@ -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')

View file

@ -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

View file

@ -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<SavedSearchListApiResponse[]>;
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) {

View file

@ -91,6 +91,7 @@ export default [
'next-env.d.ts',
'playwright-report/**',
'.next/**',
'.next-e2e/**',
'.storybook/**',
'node_modules/**',
'out/**',

View file

@ -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 }) {
</Paper>
</>
) : (
<Breadcrumbs mb="xs" mt="xs" fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed" maw={500} truncate="end">
{dashboard?.name ?? 'Untitled'}
</Text>
</Breadcrumbs>
<Group align="flex-start" mb="xs" mt="xs" justify="space-between">
<Breadcrumbs fz="sm">
<Anchor component={Link} href="/dashboards/list" fz="sm" c="dimmed">
Dashboards
</Anchor>
<Text fz="sm" c="dimmed" maw={500} truncate="end" lh={1}>
{dashboard?.name ?? 'Untitled'}
</Text>
</Breadcrumbs>
{!isLocalDashboard && dashboard && (
<Text size="xs" c="dimmed">
{dashboard.createdBy && (
<span>
Created by{' '}
{dashboard.createdBy.name || dashboard.createdBy.email}.{' '}
</span>
)}
{dashboard.updatedAt && (
<Tooltip
label={
<>
<FormatTime value={dashboard.updatedAt} format="short" />
{dashboard.updatedBy
? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}`
: ''}
</>
}
>
<span>{`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`}</span>
</Tooltip>
)}
</Text>
)}
</Group>
)}
<Flex mt="xs" mb="md" justify="space-between" align="center">
<Flex mt="xs" mb="md" justify="space-between" align="flex-start">
<EditablePageName
key={`${dashboardHash}`}
name={dashboard?.name ?? ''}

View file

@ -12,6 +12,7 @@ import dynamic from 'next/dynamic';
import Head from 'next/head';
import Link from 'next/link';
import router from 'next/router';
import { formatDistanceToNow } from 'date-fns';
import {
parseAsBoolean,
parseAsInteger,
@ -143,6 +144,7 @@ import { LOCAL_STORE_CONNECTIONS_KEY } from './connection';
import { DBSearchPageAlertModal } from './DBSearchPageAlertModal';
import { EditablePageName } from './EditablePageName';
import { SearchConfig } from './types';
import { FormatTime } from './useFormatTime';
import searchPageStyles from '../styles/SearchPage.module.scss';
@ -1579,9 +1581,9 @@ function DBSearchPage() {
)}
<OnboardingModal />
{savedSearch && (
<Group justify="space-between" align="flex-end" mt="lg" mx="xs">
<Stack gap={0}>
<Breadcrumbs fz="sm" mb="xs">
<Stack mt="lg" mx="xs">
<Group justify="space-between">
<Breadcrumbs fz="sm">
<Anchor component={Link} href="/search/list" fz="sm" c="dimmed">
Saved Searches
</Anchor>
@ -1589,6 +1591,33 @@ function DBSearchPage() {
{savedSearch.name}
</Text>
</Breadcrumbs>
<Text size="xs" c="dimmed" lh={1}>
{savedSearch.createdBy && (
<span>
Created by{' '}
{savedSearch.createdBy.name || savedSearch.createdBy.email}.{' '}
</span>
)}
{savedSearch.updatedAt && (
<Tooltip
label={
<>
<FormatTime
value={savedSearch.updatedAt}
format="short"
/>
{savedSearch.updatedBy
? ` by ${savedSearch.updatedBy.name || savedSearch.updatedBy.email}`
: ''}
</>
}
>
<span>{`Updated ${formatDistanceToNow(new Date(savedSearch.updatedAt), { addSuffix: true })}.`}</span>
</Tooltip>
)}
</Text>
</Group>
<Group justify="space-between" align="flex-end">
<EditablePageName
key={savedSearch.id}
name={savedSearch?.name ?? 'Untitled Search'}
@ -1599,43 +1628,43 @@ function DBSearchPage() {
});
}}
/>
</Stack>
<Group gap="xs">
<FavoriteButton
resourceType="savedSearch"
resourceId={savedSearch.id}
/>
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
<Button
data-testid="tags-button"
variant="secondary"
size="xs"
style={{ flexShrink: 0 }}
<Group gap="xs">
<FavoriteButton
resourceType="savedSearch"
resourceId={savedSearch.id}
/>
<Tags
allowCreate
values={savedSearch.tags || []}
onChange={handleUpdateTags}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<Button
data-testid="tags-button"
variant="secondary"
size="xs"
style={{ flexShrink: 0 }}
>
<IconTags size={14} className="me-1" />
{savedSearch.tags?.length || 0}
</Button>
</Tags>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search/list');
},
});
}}
onClickSaveAsNew={() => {
setSaveSearchModalState('create');
}}
/>
<SearchPageActionBar
onClickDeleteSavedSearch={() => {
deleteSavedSearch.mutate(savedSearch?.id ?? '', {
onSuccess: () => {
router.push('/search/list');
},
});
}}
onClickSaveAsNew={() => {
setSaveSearchModalState('create');
}}
/>
</Group>
</Group>
</Group>
</Stack>
)}
<form
data-testid="search-form"

View file

@ -4,6 +4,10 @@ import Router, { useRouter } from 'next/router';
import cx from 'classnames';
import HyperDX from '@hyperdx/browser';
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
AlertState,
SavedSearchListApiResponse,
} from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Anchor,
@ -36,7 +40,6 @@ import InstallInstructionModal from '@/InstallInstructionsModal';
import OnboardingChecklist from '@/OnboardingChecklist';
import { useSavedSearches } from '@/savedSearch';
import { useLogomark, useWordmark } from '@/theme/ThemeProvider';
import type { SavedSearch } from '@/types';
import { UserPreferencesModal } from '@/UserPreferencesModal';
import { useUserPreferences } from '@/useUserPreferences';
import { useWindowSize } from '@/utils';
@ -204,7 +207,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) {
}, [meData]);
const renderSavedSearchLink = useCallback(
(savedSearch: SavedSearch) => (
(savedSearch: SavedSearchListApiResponse) => (
<Link
href={`/search/${savedSearch.id}`}
key={savedSearch.id}

View file

@ -234,6 +234,8 @@ export default function DashboardsListPage() {
}
resourceId={d.id}
resourceType="dashboard"
updatedAt={d.updatedAt}
updatedBy={d.updatedBy?.name || d.updatedBy?.email}
/>
))}
</SimpleGrid>
@ -384,6 +386,8 @@ export default function DashboardsListPage() {
<Table.Th w={40} />
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th>Created By</Table.Th>
<Table.Th>Last Updated</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
@ -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={
<Group gap={0} ps={4} justify="space-between" wrap="nowrap">
<FavoriteButton
@ -431,6 +438,8 @@ export default function DashboardsListPage() {
}
resourceId={d.id}
resourceType="dashboard"
updatedAt={d.updatedAt}
updatedBy={d.updatedBy?.name || d.updatedBy?.email}
/>
))}
</SimpleGrid>

View file

@ -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 (
<Card
@ -33,7 +47,7 @@ export function ListingCard({
radius="sm"
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Text
fw={500}
@ -79,8 +93,24 @@ export function ListingCard({
)}
</Group>
{updatedAt && (
<Tooltip
label={
<>
<FormatTime value={updatedAt} format="short" />
{updatedBy ? ` by ${updatedBy}` : ''}
</>
}
>
<Text size="xs" c="dimmed" mt={2}>
Updated{' '}
{formatDistanceToNow(new Date(updatedAt), { addSuffix: true })}
</Text>
</Tooltip>
)}
{description && (
<Text size="sm" c="dimmed">
<Text size="sm" c="dimmed" mt="xs">
{description}
</Text>
)}

View file

@ -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 (
<Table.Tr
@ -50,6 +67,29 @@ export function ListingRow({
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed" truncate="end">
{createdBy ?? '-'}
</Text>
</Table.Td>
<Table.Td>
{updatedAt ? (
<Tooltip
label={
<>
<FormatTime value={updatedAt} format="short" />
{updatedBy ? ` by ${updatedBy}` : ''}
</>
}
>
<Text size="xs" c="dimmed" truncate="end">
{formatDistanceToNow(new Date(updatedAt), { addSuffix: true })}
</Text>
</Tooltip>
) : (
'-'
)}
</Table.Td>
<Table.Td>
{onDelete && (
<Menu position="bottom-end" withinPortal>

View file

@ -157,6 +157,8 @@ export default function SavedSearchesListPage() {
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
resourceId={s.id}
resourceType="savedSearch"
updatedAt={s.updatedAt}
updatedBy={s.updatedBy?.name || s.updatedBy?.email}
/>
))}
</SimpleGrid>
@ -258,6 +260,8 @@ export default function SavedSearchesListPage() {
<Table.Th w={40} />
<Table.Th>Name</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th>Created By</Table.Th>
<Table.Th>Last Updated</Table.Th>
<Table.Th w={50} />
</Table.Tr>
</Table.Thead>
@ -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={
<Group gap={0} ps={4} justify="space-between" wrap="nowrap">
<FavoriteButton
@ -302,6 +309,8 @@ export default function SavedSearchesListPage() {
statusIcon={<AlertStatusIcon alerts={s.alerts} />}
resourceId={s.id}
resourceType="savedSearch"
updatedAt={s.updatedAt}
updatedBy={s.updatedBy?.name || s.updatedBy?.email}
/>
))}
</SimpleGrid>

View file

@ -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<Dashboard>('hdx-local-dashboards');

View file

@ -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<TSource>(
);
/** Saved searches store (alerts remain cloud-only; no alert fields persisted locally). */
export const localSavedSearches = createEntityStore<SavedSearch>(
export const localSavedSearches = createEntityStore<SavedSearchListApiResponse>(
'hdx-local-saved-searches',
);

View file

@ -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<SavedSearchWithEnhancedAlerts[]> {
async function fetchSavedSearches(): Promise<SavedSearchListApiResponse[]> {
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<SavedSearchWithEnhancedAlerts[]>();
return hdxServer('saved-search').json<SavedSearchListApiResponse[]>();
}
export function useSavedSearches() {
@ -29,7 +31,7 @@ export function useSavedSearches() {
export function useSavedSearch(
{ id }: { id: string },
options: Omit<
Partial<UseQueryOptions<SavedSearchWithEnhancedAlerts[], Error>>,
Partial<UseQueryOptions<SavedSearchListApiResponse[], Error>>,
'select'
> = {},
) {
@ -47,9 +49,7 @@ export function useCreateSavedSearch() {
return useMutation({
mutationFn: (data: Omit<SavedSearch, 'id'>) => {
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<SavedSearch> & { 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',

View file

@ -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<typeof SavedSearchSchema>;
export type SavedSearchWithEnhancedAlerts = Omit<SavedSearch, 'alerts'> & {
alerts?: AlertWithCreatedBy[];
};
export type SearchConfig = {
select?: string | null;
source?: string | null;

View file

@ -487,6 +487,32 @@ export const SavedSearchSchema = z.object({
export type SavedSearch = z.infer<typeof SavedSearchSchema>;
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
// --------------------------