diff --git a/.changeset/dashboard-alert-annotations.md b/.changeset/dashboard-alert-annotations.md new file mode 100644 index 0000000000..04f8478e67 --- /dev/null +++ b/.changeset/dashboard-alert-annotations.md @@ -0,0 +1,16 @@ +--- +"@hyperdx/app": minor +"@hyperdx/api": minor +--- + +feat(dashboards): overlay alert firing/recovery markers on tile charts + +Adds an optional "alert annotations" overlay to dashboard timeseries tiles. +When enabled via the dashboard menu ("Show alert annotations"), tiles that have +an alert draw a red vertical marker at the moment the alert fired and a green +marker when it recovered, so alert events can be correlated with the chart in +one view. The overlay is off by default and its state lives in the URL +(`?alertAnnotations=true`), not on the saved dashboard. Backed by a new +team-scoped `GET /api/alerts/:id/history` endpoint that returns only alert state +transitions within the requested time range, so annotations honor the +dashboard's selected window. diff --git a/packages/api/src/controllers/__tests__/alertHistory.test.ts b/packages/api/src/controllers/__tests__/alertHistory.test.ts index ab14bfaa18..1539410493 100644 --- a/packages/api/src/controllers/__tests__/alertHistory.test.ts +++ b/packages/api/src/controllers/__tests__/alertHistory.test.ts @@ -1,6 +1,7 @@ import { ObjectId } from 'mongodb'; import { + getAlertTransitionsInRange, getRecentAlertHistories, getRecentAlertHistoriesBatch, } from '@/controllers/alertHistory'; @@ -518,4 +519,208 @@ describe('alertHistory controller', () => { expect(histories![0].counts).toBe(3); }); }); + + describe('getAlertTransitionsInRange', () => { + // Minutes-ago helper so all fixtures stay recent (avoids the 30d TTL). + const now = Date.now(); + const t = (minsAgo: number) => new Date(now - minsAgo * 60_000); + + const createAlert = async () => { + const team = await Team.create({ name: 'Test Team' }); + return Alert.create({ + team: team._id, + threshold: 100, + interval: '5m', + channel: { type: null }, + }); + }; + + const createHistory = ( + alertId: any, + createdAt: Date, + state: AlertState, + counts: number, + ) => + AlertHistory.create({ + alert: alertId, + createdAt, + state, + counts, + lastValues: [{ startTime: createdAt, count: counts }], + }); + + it('returns empty array when no history exists', async () => { + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(), + interval: '5m', + startTime: t(60), + endTime: t(0), + }); + expect(transitions).toEqual([]); + }); + + it('emits a firing and a recovery for an ALERT episode', async () => { + const alert = await createAlert(); + await createHistory(alert._id, t(25), AlertState.OK, 0); + await createHistory(alert._id, t(20), AlertState.ALERT, 7); + await createHistory(alert._id, t(15), AlertState.ALERT, 8); + await createHistory(alert._id, t(10), AlertState.OK, 0); + + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime: t(28), + endTime: t(5), + }); + + expect(transitions).toHaveLength(2); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(t(20).toISOString()); + expect(transitions[1].state).toBe(AlertState.OK); + expect(transitions[1].createdAt).toBe(t(10).toISOString()); + }); + + it('detects a firing at the range edge using the preceding window', async () => { + const alert = await createAlert(); + // Window just before the range establishes the prior (non-firing) state. + await createHistory(alert._id, t(30), AlertState.OK, 0); + await createHistory(alert._id, t(25), AlertState.ALERT, 4); + await createHistory(alert._id, t(20), AlertState.ALERT, 5); + + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime: t(27), + endTime: t(5), + }); + + expect(transitions).toHaveLength(1); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(t(25).toISOString()); + }); + + it('emits a firing when history begins already in ALERT', async () => { + // A freshly-created alert that is firing from its first evaluation: there + // is no preceding non-ALERT window, but it should still be marked. + const alert = await createAlert(); + await createHistory(alert._id, t(20), AlertState.ALERT, 1); + await createHistory(alert._id, t(15), AlertState.ALERT, 1); + + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime: t(25), + endTime: t(5), + }); + + expect(transitions).toHaveLength(1); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(t(20).toISOString()); + }); + + it('pins a carry-in firing marker to the range start when already firing on entry', async () => { + // Firing before and throughout the range: a single firing marker should + // appear at the range start, not at any interior window. + const alert = await createAlert(); + await createHistory(alert._id, t(30), AlertState.ALERT, 5); + await createHistory(alert._id, t(25), AlertState.ALERT, 5); + await createHistory(alert._id, t(20), AlertState.ALERT, 5); + + const startTime = t(27); + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime, + endTime: t(5), + }); + + expect(transitions).toHaveLength(1); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(startTime.toISOString()); + }); + + it('pins a carry-in marker when no window falls inside the range', async () => { + // Alert interval wider than the dashboard window: the only evaluation is + // before startTime, so nothing lands in-range, but the alert is firing. + const alert = await createAlert(); + await createHistory(alert._id, t(30), AlertState.ALERT, 1); + + const startTime = t(20); + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '1h', // lookback reaches back past the t(30) window + startTime, + endTime: t(5), + }); + + expect(transitions).toHaveLength(1); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(startTime.toISOString()); + }); + + it('pins a carry-in firing then shows the in-range recovery', async () => { + // Fired before the range and recovered inside it: without the carry-in + // marker the recovery would appear orphaned. + const alert = await createAlert(); + await createHistory(alert._id, t(30), AlertState.ALERT, 5); // before range + await createHistory(alert._id, t(25), AlertState.ALERT, 5); // in range, firing + await createHistory(alert._id, t(20), AlertState.OK, 0); // recovery in range + + const startTime = t(27); + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime, + endTime: t(5), + }); + + expect(transitions).toHaveLength(2); + expect(transitions[0].state).toBe(AlertState.ALERT); + expect(transitions[0].createdAt).toBe(startTime.toISOString()); + expect(transitions[1].state).toBe(AlertState.OK); + expect(transitions[1].createdAt).toBe(t(20).toISOString()); + }); + + it('treats PENDING as non-firing for boundary detection', async () => { + const alert = await createAlert(); + await createHistory(alert._id, t(30), AlertState.OK, 0); + await createHistory(alert._id, t(25), AlertState.PENDING, 2); + await createHistory(alert._id, t(20), AlertState.ALERT, 6); + await createHistory(alert._id, t(15), AlertState.PENDING, 1); + + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: '5m', + startTime: t(27), + endTime: t(5), + }); + + expect(transitions.map(tr => tr.state)).toEqual([ + AlertState.ALERT, + AlertState.OK, + ]); + expect(transitions[0].createdAt).toBe(t(20).toISOString()); + expect(transitions[1].createdAt).toBe(t(15).toISOString()); + }); + + it('only considers history for the specified alert', async () => { + const alert1 = await createAlert(); + const alert2 = await createAlert(); + await createHistory(alert1._id, t(30), AlertState.OK, 0); + await createHistory(alert1._id, t(25), AlertState.ALERT, 3); + // Noise on another alert must not leak in. + await createHistory(alert2._id, t(25), AlertState.OK, 0); + await createHistory(alert2._id, t(20), AlertState.ALERT, 9); + + const transitions = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert1._id), + interval: '5m', + startTime: t(27), + endTime: t(5), + }); + + expect(transitions).toHaveLength(1); + expect(transitions[0].state).toBe(AlertState.ALERT); + }); + }); }); diff --git a/packages/api/src/controllers/alertHistory.ts b/packages/api/src/controllers/alertHistory.ts index 86f5701d5b..68caff4749 100644 --- a/packages/api/src/controllers/alertHistory.ts +++ b/packages/api/src/controllers/alertHistory.ts @@ -2,6 +2,7 @@ import PQueue from '@esm2cjs/p-queue'; import { ALERT_INTERVAL_TO_MINUTES, AlertInterval, + AlertTransition, } from '@hyperdx/common-utils/dist/types'; import { ObjectId } from 'mongodb'; @@ -129,3 +130,85 @@ export async function getRecentAlertHistoriesBatch( ), ); } + +/** + * Returns alert firing/recovery transitions (ALERT-boundary crossings) within + * [startTime, endTime] for one alert, for drawing chart annotations. One window + * before startTime is fetched to know the state on entry: if the alert is + * already firing then, a firing marker is pinned to startTime so a later + * in-range recovery isn't orphaned. PENDING/INSUFFICIENT_DATA count as + * non-firing, so only ALERT crossings are reported. + */ +export async function getAlertTransitionsInRange({ + alertId, + interval, + startTime, + endTime, +}: { + alertId: ObjectId; + interval: AlertInterval; + startTime: Date; + endTime: Date; +}): Promise { + const intervalMs = ALERT_INTERVAL_TO_MINUTES[interval] * 60 * 1000; + const lookbackStart = new Date(startTime.getTime() - intervalMs); + + // Only the per-window state is needed to detect crossings. + const windows = await AlertHistory.aggregate<{ _id: Date; states: string[] }>( + [ + { + $match: { + alert: new ObjectId(alertId), + createdAt: { $gte: lookbackStart, $lte: endTime }, + }, + }, + { $group: { _id: '$createdAt', states: { $push: '$state' } } }, + { $sort: { _id: 1 } }, + ], + ); + + const transitions: AlertTransition[] = []; + // Assume "not firing" before the earliest known window, so an alert whose + // history begins already in ALERT still yields a firing marker. + let prevIsAlert = false; + let enteredRange = false; + + // Pin a firing marker to the range start if the alert was already firing on + // entry (carried in from before startTime). + const pinCarryInIfFiring = () => { + if (prevIsAlert) { + transitions.push({ + createdAt: startTime.toISOString(), + state: AlertState.ALERT, + }); + } + }; + + for (const evalWindow of windows) { + const isAlert = + groupStateToOverallState(evalWindow.states) === AlertState.ALERT; + const inRange = evalWindow._id >= startTime; + + if (inRange && !enteredRange) { + enteredRange = true; + pinCarryInIfFiring(); + } + + if (inRange && isAlert !== prevIsAlert) { + transitions.push({ + createdAt: evalWindow._id.toISOString(), + state: isAlert ? AlertState.ALERT : AlertState.OK, + }); + } + + prevIsAlert = isAlert; + } + + // No window landed inside the range (e.g. the alert interval is wider than + // the dashboard window) but the pre-range state was firing — pin it anyway. + if (!enteredRange) { + pinCarryInIfFiring(); + } + + return transitions; +} diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts index b506f00b1e..fc6c71c65b 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -877,6 +877,108 @@ describe('alerts router', () => { }); }); + describe('GET /alerts/:id/history', () => { + const createTileAlert = async () => { + const dashboard = await agent + .post('/dashboards') + .send(MOCK_DASHBOARD) + .expect(200); + const alert = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + return alert.body.data._id as string; + }; + + it('returns firing and recovery transitions within the range', async () => { + const alertId = await createTileAlert(); + const now = Date.now(); + const at = (minsAgo: number) => new Date(now - minsAgo * 60_000); + + await AlertHistory.create({ + alert: alertId, + createdAt: at(25), + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: at(25), count: 0 }], + }); + await AlertHistory.create({ + alert: alertId, + createdAt: at(20), + state: AlertState.ALERT, + counts: 5, + lastValues: [{ startTime: at(20), count: 5 }], + }); + await AlertHistory.create({ + alert: alertId, + createdAt: at(10), + state: AlertState.OK, + counts: 0, + lastValues: [{ startTime: at(10), count: 0 }], + }); + + const res = await agent + .get(`/alerts/${alertId}/history`) + .query({ startTime: now - 30 * 60_000, endTime: now - 5 * 60_000 }) + .expect(200); + + expect(res.body.data).toHaveLength(2); + expect(res.body.data[0].state).toBe('ALERT'); + expect(res.body.data[0].createdAt).toBe(at(20).toISOString()); + expect(res.body.data[1].state).toBe('OK'); + expect(res.body.data[1].createdAt).toBe(at(10).toISOString()); + }); + + it('returns 404 for an unknown alert id', async () => { + const fakeId = randomMongoId(); + await agent + .get(`/alerts/${fakeId}/history`) + .query({ startTime: 0, endTime: Date.now() }) + .expect(404); + }); + + it("returns 404 for another team's alert", async () => { + const otherTeamAlert = await Alert.create({ + team: randomMongoId(), + threshold: 1, + interval: '5m', + channel: { type: null }, + }); + await agent + .get(`/alerts/${otherTeamAlert._id}/history`) + .query({ startTime: 0, endTime: Date.now() }) + .expect(404); + }); + + it('returns 400 when the time range is missing', async () => { + const alertId = await createTileAlert(); + await agent.get(`/alerts/${alertId}/history`).expect(400); + }); + + it('returns 400 when startTime is after endTime', async () => { + const alertId = await createTileAlert(); + const now = Date.now(); + await agent + .get(`/alerts/${alertId}/history`) + .query({ startTime: now, endTime: now - 60_000 }) + .expect(400); + }); + + it('accepts a very wide range (span is clamped, not rejected)', async () => { + const alertId = await createTileAlert(); + await agent + .get(`/alerts/${alertId}/history`) + .query({ startTime: 0, endTime: Date.now() }) + .expect(200); + }); + }); + describe('errors propagation', () => { it('returns the errors field on a single alert response', async () => { const dashboard = await agent diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 9da8c63b31..1ffe35bbb4 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -1,5 +1,6 @@ import type { AlertApiResponse, + AlertHistoryRangeApiResponse, AlertsApiResponse, AlertsPageItem, } from '@hyperdx/common-utils/dist/types'; @@ -10,6 +11,7 @@ import { z } from 'zod'; import { processRequest, validateRequest } from 'zod-express-middleware'; import { + getAlertTransitionsInRange, getRecentAlertHistories, getRecentAlertHistoriesBatch, } from '@/controllers/alertHistory'; @@ -153,6 +155,60 @@ router.get( }, ); +// Alert firing/recovery transitions within a time range, used to draw +// annotations on dashboard charts (startTime/endTime are epoch milliseconds). +// Alert history has a ~30-day TTL, so cap the queried span to bound the +// aggregation regardless of how small a startTime the caller sends. +const MAX_HISTORY_SPAN_MS = 31 * 24 * 60 * 60 * 1000; +type AlertHistoryRangeExpRes = express.Response; +router.get( + '/:id/history', + processRequest({ + params: z.object({ id: objectIdSchema }), + query: z + .object({ + startTime: z.coerce.number().int(), + endTime: z.coerce.number().int(), + }) + .refine(q => q.startTime < q.endTime, { + message: 'startTime must be less than endTime', + }), + }), + async (req, res: AlertHistoryRangeExpRes, next) => { + try { + const teamId = req.user?.team; + if (teamId == null) { + return res.sendStatus(403); + } + + // Scope to the caller's team (404 for alerts they can't see). Uses the + // populate-free lookup since we only need team ownership + interval. + const alert = await getAlertById(req.params.id, teamId); + if (!alert) { + return res.sendStatus(404); + } + + // Clamp the span so a tiny/zero startTime can't force a scan wider than + // the history retention window. + const startTime = Math.max( + req.query.startTime, + req.query.endTime - MAX_HISTORY_SPAN_MS, + ); + + const data = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: alert.interval, + startTime: new Date(startTime), + endTime: new Date(req.query.endTime), + }); + + sendJson(res, { data }); + } catch (e) { + next(e); + } + }, +); + router.post( '/', processRequest({ body: alertSchema }), diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index fee6f62e30..6190366b97 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -14,7 +14,12 @@ import { useRouter } from 'next/router'; import { formatDistanceToNow, formatRelative } from 'date-fns'; import produce from 'immer'; import { pick } from 'lodash'; -import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; +import { + parseAsArrayOf, + parseAsBoolean, + parseAsString, + useQueryState, +} from 'nuqs'; import { ErrorBoundary } from 'react-error-boundary'; import RGL, { WidthProvider } from 'react-grid-layout'; import { useForm, useWatch } from 'react-hook-form'; @@ -95,6 +100,7 @@ import { IconSearch, IconSquaresDiagonal, IconTags, + IconTimelineEvent, IconTrash, IconUpload, IconX, @@ -129,6 +135,7 @@ import { useDashboards, useDeleteDashboard, } from '@/dashboard'; +import { useAlertAnnotations } from '@/hooks/useAlertAnnotations'; import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; @@ -368,6 +375,7 @@ const Tile = forwardRef( granularity, onTimeRangeSelect, filters, + showAlertAnnotations, // Properties forwarded by grid layout className, @@ -393,6 +401,8 @@ const Tile = forwardRef( granularity: SQLInterval | undefined; onTimeRangeSelect: (start: Date, end: Date) => void; filters?: Filter[]; + // When true, draw alert firing/recovery annotations on this tile's chart. + showAlertAnnotations?: boolean; // Properties forwarded by grid layout className?: string; @@ -623,6 +633,15 @@ const Tile = forwardRef( return tooltip; }, [alert]); + // Firing/recovery markers for this tile's alert, scoped to the *visible* + // window — the fullscreen range while the fullscreen view is open, else the + // dashboard range (off unless the dashboard toggle is on). + const alertAnnotationReferenceLines = useAlertAnnotations( + alert?.id, + isFullscreen ? fullscreenDateRange : dateRange, + showAlertAnnotations, + ); + const filterWarning = useMemo(() => { const doFiltersExist = !!filters?.filter( f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), @@ -1058,6 +1077,7 @@ const Tile = forwardRef( showDisplaySwitcher={true} enabled={chartEnabled} config={effectiveQueriedConfig} + referenceLines={alertAnnotationReferenceLines} onTimeRangeSelect={ isFullscreenView ? (start, end) => setFullscreenDateRange([start, end]) @@ -1253,6 +1273,7 @@ const Tile = forwardRef( isSourceMissing, isSourceUnset, hasBeenVisible, + alertAnnotationReferenceLines, ], ); @@ -1644,6 +1665,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { 'filters', parseAsJsonEncoded(), ); + // Toggle for overlaying alert firing/recovery markers on tile charts. + // Ephemeral view state (URL param), not persisted on the dashboard. + const [showAlertAnnotations, setShowAlertAnnotations] = useQueryState( + 'alertAnnotations', + parseAsBoolean.withDefault(false), + ); // Track if we've initialized query for this dashboard const initializedDashboard = useRef(undefined); @@ -2098,6 +2125,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ...getFilterQueriesForSource(tileSourceId), ]} onTimeRangeSelect={onTimeRangeSelect} + showAlertAnnotations={showAlertAnnotations} isHighlighted={highlightedTileId === chart.id} onUpdateChart={newChart => { if (!dashboard) return; @@ -2187,6 +2215,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { where, whereLanguage, onTimeRangeSelect, + showAlertAnnotations, getFilterQueriesForSource, moveTargetContainers, handleMoveTileToGroup, @@ -2583,40 +2612,55 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - {containers.length > 0 && ( + {(hasTiles || containers.length > 0) && ( <> View - - ) : ( - - ) - } - onClick={() => setTocVisible(v => !v)} - data-testid="toggle-toc-menu-item" - > - {tocVisible - ? 'Hide table of contents' - : 'Show table of contents'} - - } - onClick={handleCollapseAll} - disabled={collapsibleContainers.length === 0} - data-testid="collapse-all-sections-menu-item" - > - Collapse all sections - - } - onClick={handleExpandAll} - disabled={collapsibleContainers.length === 0} - data-testid="expand-all-sections-menu-item" - > - Expand all sections - + {hasTiles && ( + } + onClick={() => setShowAlertAnnotations(v => !v)} + data-testid="toggle-alert-annotations-menu-item" + > + {showAlertAnnotations + ? 'Hide alert annotations' + : 'Show alert annotations'} + + )} + {containers.length > 0 && ( + <> + + ) : ( + + ) + } + onClick={() => setTocVisible(v => !v)} + data-testid="toggle-toc-menu-item" + > + {tocVisible + ? 'Hide table of contents' + : 'Show table of contents'} + + } + onClick={handleCollapseAll} + disabled={collapsibleContainers.length === 0} + data-testid="collapse-all-sections-menu-item" + > + Collapse all sections + + } + onClick={handleExpandAll} + disabled={collapsibleContainers.length === 0} + data-testid="expand-all-sections-menu-item" + > + Expand all sections + + + )} )} diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index a79c782d5b..21facd9c05 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -4,6 +4,7 @@ import ky from 'ky-universal'; import type { Alert, AlertApiResponse, + AlertHistoryRangeApiResponse, AlertsApiResponse, InstallationApiResponse, MeApiResponse, @@ -189,6 +190,34 @@ const api = { enabled: alertId != null, }); }, + getAlertHistoryQueryKey: ( + alertId: string | undefined, + startTime: number, + endTime: number, + ) => ['alertHistory', alertId, startTime, endTime] as const, + // Fetches alert firing/recovery transitions within a time range, for drawing + // annotations on dashboard charts. Bounds are quantized to the minute so a + // live/auto-refreshing dashboard doesn't produce a new query key (and refetch + // every alerted tile) on every sub-minute tick. + useAlertHistory( + alertId: string | undefined, + dateRange: [Date, Date], + { enabled = true }: { enabled?: boolean } = {}, + ) { + const BUCKET_MS = 60_000; + const startTime = + Math.floor(dateRange[0].getTime() / BUCKET_MS) * BUCKET_MS; + const endTime = Math.floor(dateRange[1].getTime() / BUCKET_MS) * BUCKET_MS; + return useQuery({ + queryKey: api.getAlertHistoryQueryKey(alertId, startTime, endTime), + queryFn: () => + hdxServer(`alerts/${alertId}/history`, { + method: 'GET', + searchParams: { startTime, endTime }, + }).json(), + enabled: enabled && alertId != null, + }); + }, useServices() { return useQuery({ queryKey: [`services`], diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx new file mode 100644 index 0000000000..90b58e2525 --- /dev/null +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -0,0 +1,59 @@ +import { ReactElement } from 'react'; + +import { + getAnnotationReferenceLines, + MAX_ANNOTATION_MARKERS, +} from '@/components/charts/chartAnnotations'; + +// ReferenceLine element props are typed as `unknown`; narrow for assertions. +const lineProps = (el: ReactElement) => + el.props as { stroke: string; x: number; label?: unknown }; + +describe('getAnnotationReferenceLines', () => { + it('returns nothing for an empty list', () => { + expect(getAnnotationReferenceLines([])).toEqual([]); + }); + + it('renders a line per annotation at the unix-second x, with color and label', () => { + const time = '2026-07-01T00:05:00.000Z'; + const [line] = getAnnotationReferenceLines([ + { time, label: 'Deploy', color: '#123456' }, + ]); + + expect(lineProps(line).x).toBe(new Date(time).getTime() / 1000); + expect(lineProps(line).stroke).toBe('#123456'); + expect(lineProps(line).label).toBeTruthy(); + }); + + it('accepts Date / epoch-ms times, defaults the color, and omits an absent label', () => { + const ms = Date.UTC(2026, 6, 1, 0, 5, 0); + const [fromMs] = getAnnotationReferenceLines([{ time: ms }]); + const [fromDate] = getAnnotationReferenceLines([{ time: new Date(ms) }]); + + expect(lineProps(fromMs).x).toBe(ms / 1000); + expect(lineProps(fromDate).x).toBe(ms / 1000); + expect(lineProps(fromMs).stroke).toBe('var(--color-border)'); + expect(lineProps(fromMs).label).toBeUndefined(); + }); + + it('caps the number of rendered markers', () => { + const many = Array.from( + { length: MAX_ANNOTATION_MARKERS + 50 }, + (_, i) => ({ time: 1_700_000_000_000 + i * 60_000 }), + ); + expect(getAnnotationReferenceLines(many)).toHaveLength( + MAX_ANNOTATION_MARKERS, + ); + }); + + it('uses a provided key and falls back to a generated one', () => { + const lines = getAnnotationReferenceLines([ + { time: '2026-07-01T00:00:00.000Z', key: 'custom' }, + { time: '2026-07-01T00:00:00.000Z' }, + ]); + + expect(lines[0].key).toBe('custom'); + expect(lines[1].key).toEqual(expect.any(String)); + expect(lines[0].key).not.toEqual(lines[1].key); + }); +}); diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx new file mode 100644 index 0000000000..62837fb612 --- /dev/null +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -0,0 +1,61 @@ +import { type ReactElement } from 'react'; +import { Label, ReferenceLine } from 'recharts'; + +/** + * A single vertical marker to overlay on a timeseries chart at a point in time. + * Source-agnostic: alerts, deployments, incidents, config changes, etc. all map + * to this shape. + */ +export type ChartAnnotation = { + /** Event time. Accepts a Date, ISO string, or epoch milliseconds. */ + time: Date | string | number; + /** Optional short label drawn at the marker. */ + label?: string; + /** Line/label color (any CSS value). Defaults to the chart border color. */ + color?: string; + /** Stable React key; defaults to the resolved timestamp + index. */ + key?: string; +}; + +// Safety valve: past this many markers the chart is unreadable anyway, and +// rendering tens of thousands of SVG nodes would freeze the tab (e.g. a +// flapping alert over a wide window). +export const MAX_ANNOTATION_MARKERS = 1000; + +/** + * Renders vertical annotation lines for a chart's `referenceLines` prop. `x` is + * in unix seconds to match the timeseries x-axis (`ts_bucket`); markers outside + * the domain are dropped by Recharts. Generic over source — feature hooks map + * their events to `ChartAnnotation[]` and call this. Capped at + * `MAX_ANNOTATION_MARKERS` to protect against pathological inputs. + */ +export function getAnnotationReferenceLines( + annotations: ChartAnnotation[], +): ReactElement[] { + return annotations.slice(0, MAX_ANNOTATION_MARKERS).map((annotation, i) => { + const seconds = new Date(annotation.time).getTime() / 1000; + const color = annotation.color ?? 'var(--color-border)'; + return ( + + ) : undefined + } + /> + ); + }); +} diff --git a/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx b/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx new file mode 100644 index 0000000000..f82113a605 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx @@ -0,0 +1,86 @@ +import { AlertState, AlertTransition } from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; + +import api from '@/api'; +import { + alertTransitionsToAnnotations, + useAlertAnnotations, +} from '@/hooks/useAlertAnnotations'; +import { getChartColorError, getChartColorSuccess } from '@/utils'; + +const makeTransition = ( + createdAt: string, + state: AlertState, +): AlertTransition => ({ createdAt, state }); + +describe('alertTransitionsToAnnotations', () => { + it('returns no annotations for an empty list', () => { + expect(alertTransitionsToAnnotations([])).toEqual([]); + }); + + it('maps a firing to a red "Alert" marker and a recovery to a green "OK" marker', () => { + const firedAt = '2026-07-01T00:05:00.000Z'; + const recoveredAt = '2026-07-01T00:20:00.000Z'; + + const annotations = alertTransitionsToAnnotations([ + makeTransition(firedAt, AlertState.ALERT), + makeTransition(recoveredAt, AlertState.OK), + ]); + + expect(annotations).toHaveLength(2); + expect(annotations[0]).toMatchObject({ + time: firedAt, + label: 'Alert', + color: getChartColorError(), + }); + expect(annotations[1]).toMatchObject({ + time: recoveredAt, + label: 'OK', + color: getChartColorSuccess(), + }); + // Distinct keys so React can reconcile the markers. + expect(annotations[0].key).not.toEqual(annotations[1].key); + }); +}); + +describe('useAlertAnnotations', () => { + const range: [Date, Date] = [ + new Date('2026-07-01T00:00:00.000Z'), + new Date('2026-07-01T01:00:00.000Z'), + ]; + + const mockHistory = (data: unknown) => + jest.spyOn(api, 'useAlertHistory').mockReturnValue({ data } as any); + + afterEach(() => jest.restoreAllMocks()); + + it('returns undefined when disabled, even if data is present', () => { + mockHistory({ + data: [{ createdAt: range[0].toISOString(), state: AlertState.ALERT }], + }); + const { result } = renderHook(() => + useAlertAnnotations('alert-1', range, false), + ); + expect(result.current).toBeUndefined(); + }); + + it('returns undefined when enabled but history is empty', () => { + mockHistory({ data: [] }); + const { result } = renderHook(() => + useAlertAnnotations('alert-1', range, true), + ); + expect(result.current).toBeUndefined(); + }); + + it('returns reference lines when transitions exist', () => { + mockHistory({ + data: [ + { createdAt: '2026-07-01T00:30:00.000Z', state: AlertState.ALERT }, + ], + }); + const { result } = renderHook(() => + useAlertAnnotations('alert-1', range, true), + ); + expect(result.current).toHaveLength(1); + }); +}); diff --git a/packages/app/src/hooks/useAlertAnnotations.tsx b/packages/app/src/hooks/useAlertAnnotations.tsx new file mode 100644 index 0000000000..b04246890e --- /dev/null +++ b/packages/app/src/hooks/useAlertAnnotations.tsx @@ -0,0 +1,53 @@ +import { type ReactElement, useMemo } from 'react'; +import { AlertState, AlertTransition } from '@hyperdx/common-utils/dist/types'; + +import api from '@/api'; +import { + ChartAnnotation, + getAnnotationReferenceLines, +} from '@/components/charts/chartAnnotations'; +import { getChartColorError, getChartColorSuccess } from '@/utils'; + +/** + * Maps alert state transitions to generic chart annotations: firing (→ ALERT) + * is a red "Alert" marker, recovery (→ OK) a green "OK" marker. Colors come + * from the theme's semantic chart palette (error / success). + */ +export function alertTransitionsToAnnotations( + transitions: AlertTransition[], +): ChartAnnotation[] { + // Resolve the two theme colors once (each reads computed styles). + const alertColor = getChartColorError(); + const okColor = getChartColorSuccess(); + return transitions.map(transition => { + const isFiring = transition.state === AlertState.ALERT; + return { + time: transition.createdAt, + label: isFiring ? 'Alert' : 'OK', + color: isFiring ? alertColor : okColor, + key: `alert-annotation-${transition.createdAt}-${transition.state}`, + }; + }); +} + +/** + * Returns alert firing/recovery annotation lines for a dashboard tile, scoped + * to the given `dateRange` (the tile's visible window). The query stays idle + * unless `enabled` is true and an `alertId` is present. + */ +export function useAlertAnnotations( + alertId: string | undefined, + dateRange: [Date, Date], + enabled: boolean = false, +): ReactElement[] | undefined { + const { data } = api.useAlertHistory(alertId, dateRange, { enabled }); + + return useMemo(() => { + if (!enabled || !data?.data.length) { + return undefined; + } + return getAnnotationReferenceLines( + alertTransitionsToAnnotations(data.data), + ); + }, [enabled, data]); +} diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 2bea4de690..a8caf2baf5 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -641,6 +641,16 @@ export const AlertHistorySchema = z.object({ export type AlertHistory = z.infer; +// A single alert state transition within a time range, used to draw +// firing/recovery annotations on dashboard charts. Only boundary crossings are +// emitted: ALERT = fired, OK = recovered. +export const AlertTransitionSchema = z.object({ + createdAt: z.string(), + state: z.nativeEnum(AlertState), +}); + +export type AlertTransition = z.infer; + // -------------------------- // FILTERS // -------------------------- @@ -2109,6 +2119,14 @@ export const AlertApiResponseSchema = z.object({ export type AlertApiResponse = z.infer; +export const AlertHistoryRangeApiResponseSchema = z.object({ + data: z.array(AlertTransitionSchema), +}); + +export type AlertHistoryRangeApiResponse = z.infer< + typeof AlertHistoryRangeApiResponseSchema +>; + // Webhooks export const WebhooksApiResponseSchema = z.object({ data: z.array(WebhookSchema),