Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions packages/api/src/controllers/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type { ObjectId } from '@/models';
import type { AlertDocument, IAlert } from '@/models/alert';
import Dashboard, { IDashboard } from '@/models/dashboard';
import { resolveAlertDisplayFields } from '@/utils/alerts';

function pickAlertsByTile(tiles: Tile[]) {
return tiles.reduce((acc, tile) => {
Expand Down Expand Up @@ -117,6 +118,23 @@ async function syncDashboardAlerts(
}
}

/**
* Attach the resolved display name/tags so the chart editor prefills real
* values for alerts written before the fields existed.
*/
function withResolvedDisplayFields(
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
alert: AlertDocument | undefined,
dashboard: Pick<IDashboard, 'name' | 'tags' | 'tiles'>,
) {
if (alert == null) {
return undefined;
}
return {
...alert.toJSON(),
...resolveAlertDisplayFields(alert, { dashboard }),
};
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
}

export async function getDashboards(teamId: ObjectId) {
const [_dashboards, alerts] = await Promise.all([
Dashboard.find({ team: teamId })
Expand All @@ -133,7 +151,10 @@ export async function getDashboards(teamId: ObjectId) {
...t,
config: {
...t.config,
alert: alerts[`${d._id.toString()}:${t.id}`]?.[0],
alert: withResolvedDisplayFields(
alerts[`${d._id.toString()}:${t.id}`]?.[0],
d,
),
},
})),
}))
Expand All @@ -158,7 +179,10 @@ export async function getDashboard(dashboardId: string, teamId: ObjectId) {
..._dashboard.toJSON(),
tiles: _dashboard.tiles.map(t => ({
...t,
config: { ...t.config, alert: alerts[t.id]?.[0] },
config: {
...t.config,
alert: withResolvedDisplayFields(alerts[t.id]?.[0], _dashboard),
},
})),
});
}
Expand Down
8 changes: 5 additions & 3 deletions packages/api/src/controllers/savedSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from 'zod';
import { deleteSavedSearchAlerts } from '@/controllers/alerts';
import Alert from '@/models/alert';
import { SavedSearch } from '@/models/savedSearch';
import { resolveAlertDisplayFields } from '@/utils/alerts';
import logger from '@/utils/logger';

type SavedSearchWithoutId = Omit<z.infer<typeof SavedSearchSchema>, 'id'>;
Expand All @@ -27,9 +28,10 @@ export async function getSavedSearches(

return savedSearches.map(savedSearch => ({
...savedSearch.toJSON(),
alerts: alertsBySavedSearchId[savedSearch._id.toString()]?.map(alert => {
return alert.toJSON();
}),
alerts: alertsBySavedSearchId[savedSearch._id.toString()]?.map(alert => ({
...alert.toJSON(),
...resolveAlertDisplayFields(alert, { savedSearch }),
})),
}));
}

Expand Down
24 changes: 24 additions & 0 deletions packages/api/src/routers/api/__tests__/dashboard.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,30 @@ describe('dashboard router', () => {
expect(storedAlert?.tags).toEqual(['ops']);
});

it('resolves displayName and tags on GET for a tile alert stored without them', async () => {
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [makeTile({ alert: mockAlert })],
tags: ['ops'],
})
.expect(200);
// Alerts written before the fields existed.
await Alert.updateMany(
{ dashboard: dashboard.body.id },
{ $set: { displayName: null, tags: null } },
);

const list = await agent.get('/dashboards').expect(200);
const fromList = list.body.find(d => d._id === dashboard.body.id);
expect(fromList.tiles[0].config.alert).toMatchObject({
displayName: 'Test Dashboard - Test Chart',
tags: ['ops'],
});
});

it('persists a tile alert displayName sent through the dashboard PATCH', async () => {
const mockAlert = makeMockAlert(webhook._id.toString());
const dashboard = await agent
Expand Down
27 changes: 27 additions & 0 deletions packages/api/src/routers/api/__tests__/savedSearch.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,4 +221,31 @@ describe('savedSearch router', () => {
expect(savedSearches.body[0].alerts[0].createdBy).toBeDefined();
expect(savedSearches.body[0].alerts[0].createdBy.email).toBe(user.email);
});

it('resolves displayName and tags on GET for an alert stored without them', async () => {
const savedSearch = await agent
.post('/saved-search')
.send({ ...MOCK_SAVED_SEARCH, tags: ['prod'] })
.expect(200);
const alert = await agent
.post('/alerts')
.send(
makeSavedSearchAlertInput({
savedSearchId: savedSearch.body._id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
// Alerts written before the fields existed.
await Alert.updateOne(
{ _id: alert.body.data._id },
{ $set: { displayName: null, tags: null } },
);

const savedSearches = await agent.get('/saved-search').expect(200);
expect(savedSearches.body[0].alerts[0]).toMatchObject({
displayName: MOCK_SAVED_SEARCH.name,
tags: ['prod'],
});
});
});
31 changes: 14 additions & 17 deletions packages/api/src/utils/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
MAX_ALERT_DISPLAY_NAME_LENGTH,
MAX_TAG_LENGTH,
MAX_TAGS,
} from '@hyperdx/common-utils/dist/types';
clampAlertDisplayName,
clampAlertTags,
formatTileAlertDisplayName,
} from '@hyperdx/common-utils/dist/alerts';
import { Types } from 'mongoose';

import type { ObjectId } from '@/models';
Expand Down Expand Up @@ -44,7 +44,6 @@ type AlertDisplayInput = {
};

const FALLBACK_DISPLAY_NAME = 'Alert';
const FALLBACK_TILE_NAME = 'Tile';

/**
* Referenced entities are read straight from Mongo and predate any of these
Expand All @@ -59,19 +58,14 @@ function normalizeName(value: unknown): string | null {
return trimmed === '' ? null : trimmed;
}

/**
* Derived tags are persisted on the alert, which validates them with
* `alertTagsSchema`, but they are copied from a dashboard/saved search whose
* own schema caps neither length nor count. Apply the alert caps here.
*/
/** Untrusted Mongo value -> tags that satisfy `alertTagsSchema`. */
function normalizeTags(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((tag): tag is string => typeof tag === 'string' && tag !== '')
.slice(0, MAX_TAGS)
.map(tag => tag.slice(0, MAX_TAG_LENGTH));
return clampAlertTags(
value.filter((tag): tag is string => typeof tag === 'string'),
);
}

/**
Expand Down Expand Up @@ -119,8 +113,10 @@ export function deriveAlertDisplayFields(
const tile = alert.tileId
? tiles.find(t => t.id === alert.tileId)
: undefined;
const tileName = normalizeName(tile?.config?.name);
displayName = `${dashboardName} - ${tileName ?? FALLBACK_TILE_NAME}`;
displayName = formatTileAlertDisplayName(
dashboardName,
normalizeName(tile?.config?.name),
);
}
tags = dashboard ? normalizeTags(dashboard.tags) : null;
break;
Expand All @@ -147,7 +143,8 @@ export function deriveAlertDisplayFields(
}

return {
displayName: displayName?.slice(0, MAX_ALERT_DISPLAY_NAME_LENGTH) ?? null,
displayName:
displayName == null ? null : clampAlertDisplayName(displayName),
tags,
};
}
Expand Down
17 changes: 7 additions & 10 deletions packages/app/src/AlertDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ import EmptyState from '@/components/EmptyState';
import { PageHeader } from '@/components/PageHeader';
import { TimePicker } from '@/components/TimePicker';
import { IS_ALERT_DETAILS_ENABLED } from '@/config';
import {
getAlertDisplayName,
getAlertSourceLabel,
getAlertSourceUrl,
} from '@/utils/alerts';
import { getAlertSourceLabel, getAlertSourceUrl } from '@/utils/alerts';

import { useBrandDisplayName } from './theme/ThemeProvider';
import api from './api';
Expand Down Expand Up @@ -135,14 +131,16 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
Alerts
</Anchor>
<Text fz="sm" c="dimmed">
{getAlertDisplayName(alert) || 'Alert'}
{alert.displayName || 'Alert'}
</Text>
</Breadcrumbs>
}
leading={
<Group gap="sm">
{alert.state != null && <AlertStateBadge state={alert.state} />}
<Text fw={500}>{getAlertDisplayName(alert)}</Text>
<Text fw={500} data-testid="alert-detail-name">
{alert.displayName}
</Text>
{alertUrl && (
/* Next to the name rather than in the actions row: it navigates
to what the alert watches, so it belongs with the identity,
Expand Down Expand Up @@ -174,7 +172,7 @@ function AlertDetailBody({ alert }: { alert: AlertsPageItem }) {
this page carries that link next to the title instead. */}
<AlertRowMenu
alert={alert}
alertName={getAlertDisplayName(alert)}
alertName={alert.displayName}
dateRange={searchedTimeRange}
onDeleted={() => router.push('/alerts')}
/>
Expand Down Expand Up @@ -255,8 +253,7 @@ export default function AlertDetailPage() {
>
<Head>
<title>
{alert ? `${getAlertDisplayName(alert)} - Alerts` : 'Alerts'} -{' '}
{brandName}
{alert ? `${alert.displayName} - Alerts` : 'Alerts'} - {brandName}
</title>
</Head>
{isLoading && (
Expand Down
15 changes: 5 additions & 10 deletions packages/app/src/AlertsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ import {
import { AlertCardList } from '@/components/alerts/AlertCardList';
import EmptyState from '@/components/EmptyState';
import { PageHeader } from '@/components/PageHeader';
import {
getAlertCreatorLabel,
getAlertDisplayName,
getAlertSourceLabel,
getAlertTags,
} from '@/utils/alerts';
import { getAlertCreatorLabel, getAlertSourceLabel } from '@/utils/alerts';

import { useBrandDisplayName } from './theme/ThemeProvider';
import api from './api';
Expand All @@ -43,7 +38,7 @@ export default function AlertsPage() {

const allTags = React.useMemo(() => {
const tags = new Set<string>();
alerts.forEach(a => getAlertTags(a).forEach(t => tags.add(t)));
alerts.forEach(a => a.tags?.forEach(t => tags.add(t)));
return Array.from(tags).sort();
}, [alerts]);

Expand All @@ -70,7 +65,7 @@ export default function AlertsPage() {
result = result.filter(a => getAlertSourceLabel(a) === sourceFilter);
}
if (tagFilter) {
result = result.filter(a => getAlertTags(a).includes(tagFilter));
result = result.filter(a => a.tags?.includes(tagFilter));
}
if (creatorFilter) {
result = result.filter(a => getAlertCreatorLabel(a) === creatorFilter);
Expand All @@ -79,14 +74,14 @@ export default function AlertsPage() {
const q = search.toLowerCase();
result = result.filter(
a =>
getAlertDisplayName(a).toLowerCase().includes(q) ||
a.displayName?.toLowerCase().includes(q) ||
// So "tile" / "saved search" narrow the list the same way the type
// filter does, without having to reach for the dropdown.
getAlertSourceLabel(a)
.toLowerCase()
.split(' ')
.some(word => word.startsWith(q)) ||
getAlertTags(a).some(t => t.toLowerCase().includes(q)),
a.tags?.some(t => t.toLowerCase().includes(q)),
);
}
return result;
Expand Down
Loading
Loading