mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
78a433c8ec
commit
f8d2edde5a
21 changed files with 426 additions and 101 deletions
6
.changeset/witty-apples-speak.md
Normal file
6
.changeset/witty-apples-speak.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Show created/updated metadata for saved searches and dashboards
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default [
|
|||
'next-env.d.ts',
|
||||
'playwright-report/**',
|
||||
'.next/**',
|
||||
'.next-e2e/**',
|
||||
'.storybook/**',
|
||||
'node_modules/**',
|
||||
'out/**',
|
||||
|
|
|
|||
|
|
@ -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 ?? ''}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// --------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue