Skip to content

Commit 6ff1ba6

Browse files
authored
feat: Add alert history + ack to alert editor (#2123)
## Summary This PR updates the alert editor forms with 1. A history of alert states 2. An option to Ack/Silence an alert that is firing The components and much of the new GET /alert/:id endpoint are shared with the existing alert page functionality. ### Screenshots or video <img width="799" height="838" alt="Screenshot 2026-04-15 at 10 26 23 AM" src="https://github.com/user-attachments/assets/d1cfc3da-efbf-41a3-83b8-27a2f9e3b760" /> <img width="2107" height="700" alt="Screenshot 2026-04-15 at 10 26 43 AM" src="https://github.com/user-attachments/assets/6884b876-da98-40de-98f7-1f2854def83b" /> ### How to test locally or on Vercel This must be tested locally, since alerts are not supported in the preview environment. ### References - Linear Issue: Closes HDX-3989 - Related PRs:
1 parent 7335a23 commit 6ff1ba6

File tree

13 files changed

+610
-398
lines changed

13 files changed

+610
-398
lines changed

.changeset/silly-toes-cough.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/api": patch
4+
"@hyperdx/app": patch
5+
---
6+
7+
feat: Add alert history + ack to alert editor

packages/api/src/controllers/alerts.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,20 @@ export const getAlertsEnhanced = async (teamId: ObjectId) => {
302302
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
303303
};
304304

305+
export const getAlertEnhanced = async (
306+
alertId: ObjectId | string,
307+
teamId: ObjectId,
308+
) => {
309+
return Alert.findOne({ _id: alertId, team: teamId }).populate<{
310+
savedSearch: ISavedSearch;
311+
dashboard: IDashboard;
312+
createdBy?: IUser;
313+
silenced?: IAlert['silenced'] & {
314+
by: IUser;
315+
};
316+
}>(['savedSearch', 'dashboard', 'createdBy', 'silenced.by']);
317+
};
318+
305319
export const deleteAlert = async (id: string, teamId: ObjectId) => {
306320
return Alert.deleteOne({
307321
_id: id,

packages/api/src/routers/api/__tests__/alerts.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import {
1111
randomMongoId,
1212
RAW_SQL_ALERT_TEMPLATE,
1313
} from '@/fixtures';
14-
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
14+
import Alert, {
15+
AlertSource,
16+
AlertState,
17+
AlertThresholdType,
18+
} from '@/models/alert';
19+
import AlertHistory from '@/models/alertHistory';
1520
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
1621

1722
const MOCK_TILES = [makeTile(), makeTile(), makeTile(), makeTile(), makeTile()];
@@ -728,4 +733,84 @@ describe('alerts router', () => {
728733
})
729734
.expect(400);
730735
});
736+
737+
describe('GET /alerts/:id', () => {
738+
it('returns 404 for non-existent alert', async () => {
739+
const fakeId = randomMongoId();
740+
await agent.get(`/alerts/${fakeId}`).expect(404);
741+
});
742+
743+
it('returns alert with empty history when no history exists', async () => {
744+
const dashboard = await agent
745+
.post('/dashboards')
746+
.send(MOCK_DASHBOARD)
747+
.expect(200);
748+
749+
const alert = await agent
750+
.post('/alerts')
751+
.send(
752+
makeAlertInput({
753+
dashboardId: dashboard.body.id,
754+
tileId: dashboard.body.tiles[0].id,
755+
webhookId: webhook._id.toString(),
756+
}),
757+
)
758+
.expect(200);
759+
760+
const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
761+
762+
expect(res.body.data._id).toBe(alert.body.data._id);
763+
expect(res.body.data.history).toEqual([]);
764+
expect(res.body.data.threshold).toBe(alert.body.data.threshold);
765+
expect(res.body.data.interval).toBe(alert.body.data.interval);
766+
expect(res.body.data.dashboard).toBeDefined();
767+
expect(res.body.data.tileId).toBe(dashboard.body.tiles[0].id);
768+
});
769+
770+
it('returns alert with history entries', async () => {
771+
const dashboard = await agent
772+
.post('/dashboards')
773+
.send(MOCK_DASHBOARD)
774+
.expect(200);
775+
776+
const alert = await agent
777+
.post('/alerts')
778+
.send(
779+
makeAlertInput({
780+
dashboardId: dashboard.body.id,
781+
tileId: dashboard.body.tiles[0].id,
782+
webhookId: webhook._id.toString(),
783+
}),
784+
)
785+
.expect(200);
786+
787+
const now = new Date(Date.now() - 60000);
788+
const earlier = new Date(Date.now() - 120000);
789+
790+
await AlertHistory.create({
791+
alert: alert.body.data._id,
792+
createdAt: now,
793+
state: AlertState.ALERT,
794+
counts: 5,
795+
lastValues: [{ startTime: now, count: 5 }],
796+
});
797+
798+
await AlertHistory.create({
799+
alert: alert.body.data._id,
800+
createdAt: earlier,
801+
state: AlertState.OK,
802+
counts: 0,
803+
lastValues: [{ startTime: earlier, count: 0 }],
804+
});
805+
806+
const res = await agent.get(`/alerts/${alert.body.data._id}`).expect(200);
807+
808+
expect(res.body.data._id).toBe(alert.body.data._id);
809+
expect(res.body.data.history).toHaveLength(2);
810+
expect(res.body.data.history[0].state).toBe('ALERT');
811+
expect(res.body.data.history[0].counts).toBe(5);
812+
expect(res.body.data.history[1].state).toBe('OK');
813+
expect(res.body.data.history[1].counts).toBe(0);
814+
});
815+
});
731816
});

packages/api/src/routers/api/alerts.ts

Lines changed: 106 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,90 @@
1-
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
1+
import type {
2+
AlertApiResponse,
3+
AlertsApiResponse,
4+
AlertsPageItem,
5+
} from '@hyperdx/common-utils/dist/types';
26
import express from 'express';
37
import { pick } from 'lodash';
48
import { ObjectId } from 'mongodb';
59
import { z } from 'zod';
610
import { processRequest, validateRequest } from 'zod-express-middleware';
711

8-
import { getRecentAlertHistoriesBatch } from '@/controllers/alertHistory';
12+
import {
13+
getRecentAlertHistories,
14+
getRecentAlertHistoriesBatch,
15+
} from '@/controllers/alertHistory';
916
import {
1017
createAlert,
1118
deleteAlert,
1219
getAlertById,
20+
getAlertEnhanced,
1321
getAlertsEnhanced,
1422
updateAlert,
1523
validateAlertInput,
1624
} from '@/controllers/alerts';
17-
import { sendJson } from '@/utils/serialization';
25+
import { IAlertHistory } from '@/models/alertHistory';
26+
import { PreSerialized, sendJson } from '@/utils/serialization';
1827
import { alertSchema, objectIdSchema } from '@/utils/zod';
1928

2029
const router = express.Router();
2130

31+
type EnhancedAlert = NonNullable<Awaited<ReturnType<typeof getAlertEnhanced>>>;
32+
33+
const formatAlertResponse = (
34+
alert: EnhancedAlert,
35+
history: Omit<IAlertHistory, 'alert'>[],
36+
): PreSerialized<AlertsPageItem> => {
37+
return {
38+
history,
39+
silenced: alert.silenced
40+
? {
41+
by: alert.silenced.by?.email,
42+
at: alert.silenced.at,
43+
until: alert.silenced.until,
44+
}
45+
: undefined,
46+
createdBy: alert.createdBy
47+
? pick(alert.createdBy, ['email', 'name'])
48+
: undefined,
49+
channel: pick(alert.channel, ['type']),
50+
...(alert.dashboard && {
51+
dashboardId: alert.dashboard._id,
52+
dashboard: {
53+
tiles: alert.dashboard.tiles
54+
.filter(tile => tile.id === alert.tileId)
55+
.map(tile => ({
56+
id: tile.id,
57+
config: { name: tile.config.name },
58+
})),
59+
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
60+
},
61+
}),
62+
...(alert.savedSearch && {
63+
savedSearchId: alert.savedSearch._id,
64+
savedSearch: pick(alert.savedSearch, [
65+
'_id',
66+
'createdAt',
67+
'name',
68+
'updatedAt',
69+
'tags',
70+
]),
71+
}),
72+
...pick(alert, [
73+
'_id',
74+
'interval',
75+
'scheduleOffsetMinutes',
76+
'scheduleStartAt',
77+
'threshold',
78+
'thresholdType',
79+
'state',
80+
'source',
81+
'tileId',
82+
'createdAt',
83+
'updatedAt',
84+
]),
85+
};
86+
};
87+
2288
type AlertsExpRes = express.Response<AlertsApiResponse>;
2389
router.get('/', async (req, res: AlertsExpRes, next) => {
2490
try {
@@ -39,63 +105,50 @@ router.get('/', async (req, res: AlertsExpRes, next) => {
39105

40106
const data = alerts.map(alert => {
41107
const history = historyMap.get(alert._id.toString()) ?? [];
42-
43-
return {
44-
history,
45-
silenced: alert.silenced
46-
? {
47-
by: alert.silenced.by?.email,
48-
at: alert.silenced.at,
49-
until: alert.silenced.until,
50-
}
51-
: undefined,
52-
createdBy: alert.createdBy
53-
? pick(alert.createdBy, ['email', 'name'])
54-
: undefined,
55-
channel: pick(alert.channel, ['type']),
56-
...(alert.dashboard && {
57-
dashboardId: alert.dashboard._id,
58-
dashboard: {
59-
tiles: alert.dashboard.tiles
60-
.filter(tile => tile.id === alert.tileId)
61-
.map(tile => ({
62-
id: tile.id,
63-
config: { name: tile.config.name },
64-
})),
65-
...pick(alert.dashboard, ['_id', 'updatedAt', 'name', 'tags']),
66-
},
67-
}),
68-
...(alert.savedSearch && {
69-
savedSearchId: alert.savedSearch._id,
70-
savedSearch: pick(alert.savedSearch, [
71-
'_id',
72-
'createdAt',
73-
'name',
74-
'updatedAt',
75-
'tags',
76-
]),
77-
}),
78-
...pick(alert, [
79-
'_id',
80-
'interval',
81-
'scheduleOffsetMinutes',
82-
'scheduleStartAt',
83-
'threshold',
84-
'thresholdType',
85-
'state',
86-
'source',
87-
'tileId',
88-
'createdAt',
89-
'updatedAt',
90-
]),
91-
};
108+
return formatAlertResponse(alert, history);
92109
});
110+
93111
sendJson(res, { data });
94112
} catch (e) {
95113
next(e);
96114
}
97115
});
98116

117+
type AlertExpRes = express.Response<AlertApiResponse>;
118+
router.get(
119+
'/:id',
120+
validateRequest({
121+
params: z.object({
122+
id: objectIdSchema,
123+
}),
124+
}),
125+
async (req, res: AlertExpRes, next) => {
126+
try {
127+
const teamId = req.user?.team;
128+
if (teamId == null) {
129+
return res.sendStatus(403);
130+
}
131+
132+
const alert = await getAlertEnhanced(req.params.id, teamId);
133+
if (!alert) {
134+
return res.sendStatus(404);
135+
}
136+
137+
const history = await getRecentAlertHistories({
138+
alertId: new ObjectId(alert._id),
139+
interval: alert.interval,
140+
limit: 20,
141+
});
142+
143+
const data = formatAlertResponse(alert, history);
144+
145+
sendJson(res, { data });
146+
} catch (e) {
147+
next(e);
148+
}
149+
},
150+
);
151+
99152
router.post(
100153
'/',
101154
processRequest({ body: alertSchema }),

packages/api/src/utils/serialization.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type JsonStringifiable = { toJSON(): string };
99
* toJSON(): string). This allows passing raw Mongoose data to sendJson()
1010
* while keeping type inference from the typed Express response.
1111
*/
12-
type PreSerialized<T> = T extends string
12+
export type PreSerialized<T> = T extends string
1313
? string | JsonStringifiable
1414
: T extends (infer U)[]
1515
? PreSerialized<U>[]

0 commit comments

Comments
 (0)