From 34281171eaf169391bc3f0f1523c48416f85fbae Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 8 Jul 2026 12:35:46 -0400 Subject: [PATCH 1/3] feat(dashboards): overlay alert firing/recovery markers on tile charts Adds an optional "alert annotations" overlay to dashboard timeseries tiles, toggled from the dashboard menu ("Show alert annotations", off by default). Tiles with an alert draw a red "Alert" marker when it fired and a green "OK" marker when it recovered, scoped to the tile's visible time range via a new team-scoped GET /api/alerts/:id/history endpoint that returns only alert state transitions. Toggle state lives in the URL. A reusable ChartAnnotation primitive underpins the rendering for future annotation sources. --- .changeset/dashboard-alert-annotations.md | 16 ++ .../__tests__/alertHistory.test.ts | 186 ++++++++++++++++++ packages/api/src/controllers/alertHistory.ts | 73 +++++++ .../src/routers/api/__tests__/alerts.test.ts | 85 ++++++++ packages/api/src/routers/api/alerts.ts | 41 ++++ packages/app/src/DBDashboardPage.tsx | 101 +++++++--- packages/app/src/api.ts | 25 +++ .../__tests__/chartAnnotations.test.tsx | 46 +++++ .../components/charts/chartAnnotations.tsx | 55 ++++++ .../__tests__/useAlertAnnotations.test.tsx | 39 ++++ .../app/src/hooks/useAlertAnnotations.tsx | 53 +++++ packages/common-utils/src/types.ts | 18 ++ 12 files changed, 708 insertions(+), 30 deletions(-) create mode 100644 .changeset/dashboard-alert-annotations.md create mode 100644 packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx create mode 100644 packages/app/src/components/charts/chartAnnotations.tsx create mode 100644 packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx create mode 100644 packages/app/src/hooks/useAlertAnnotations.tsx 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..e616a4154e 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,189 @@ 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 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..1b581b25b5 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,75 @@ 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; + + for (const evalWindow of windows) { + const isAlert = + groupStateToOverallState(evalWindow.states) === AlertState.ALERT; + const inRange = evalWindow._id >= startTime; + + // On entry into the range, pin a firing marker to startTime if the alert + // was already firing (carried in from before the range). + if (inRange && !enteredRange) { + enteredRange = true; + if (prevIsAlert) { + transitions.push({ + createdAt: startTime.toISOString(), + state: AlertState.ALERT, + }); + } + } + + if (inRange && isAlert !== prevIsAlert) { + transitions.push({ + createdAt: evalWindow._id.toISOString(), + state: isAlert ? AlertState.ALERT : AlertState.OK, + }); + } + + prevIsAlert = isAlert; + } + + 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..a8cc4cc489 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -877,6 +877,91 @@ 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); + }); + }); + 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..8906ca7369 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,45 @@ router.get( }, ); +// Alert firing/recovery transitions within a time range, used to draw +// annotations on dashboard charts (startTime/endTime are epoch milliseconds). +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(), + }), + }), + 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. + const alert = await getAlertEnhanced(req.params.id, teamId); + if (!alert) { + return res.sendStatus(404); + } + + const data = await getAlertTransitionsInRange({ + alertId: new ObjectId(alert._id), + interval: alert.interval, + startTime: new Date(req.query.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 138b04c963..f95c61ac10 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'; @@ -365,6 +372,7 @@ const Tile = forwardRef( granularity, onTimeRangeSelect, filters, + showAlertAnnotations, // Properties forwarded by grid layout className, @@ -390,6 +398,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; @@ -620,6 +630,14 @@ const Tile = forwardRef( return tooltip; }, [alert]); + // Firing/recovery markers for this tile's alert, scoped to the tile's + // visible time range (off unless the dashboard toggle is on). + const alertAnnotationReferenceLines = useAlertAnnotations( + alert?.id, + dateRange, + showAlertAnnotations, + ); + const filterWarning = useMemo(() => { const doFiltersExist = !!filters?.filter( f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), @@ -1055,6 +1073,7 @@ const Tile = forwardRef( showDisplaySwitcher={true} enabled={chartEnabled} config={effectiveQueriedConfig} + referenceLines={alertAnnotationReferenceLines} onTimeRangeSelect={ isFullscreenView ? (start, end) => setFullscreenDateRange([start, end]) @@ -1199,6 +1218,7 @@ const Tile = forwardRef( isSourceMissing, isSourceUnset, hasBeenVisible, + alertAnnotationReferenceLines, ], ); @@ -1590,6 +1610,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); @@ -2044,6 +2070,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ...getFilterQueriesForSource(tileSourceId), ]} onTimeRangeSelect={onTimeRangeSelect} + showAlertAnnotations={showAlertAnnotations} isHighlighted={highlightedTileId === chart.id} onUpdateChart={newChart => { if (!dashboard) return; @@ -2133,6 +2160,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { where, whereLanguage, onTimeRangeSelect, + showAlertAnnotations, getFilterQueriesForSource, moveTargetContainers, handleMoveTileToGroup, @@ -2529,40 +2557,53 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - {containers.length > 0 && ( + {hasTiles && ( <> 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" + leftSection={} + onClick={() => setShowAlertAnnotations(v => !v)} + data-testid="toggle-alert-annotations-menu-item" > - Expand all sections + {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..f7629bc27a 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,30 @@ 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. + useAlertHistory( + alertId: string | undefined, + dateRange: [Date, Date], + { enabled = true }: { enabled?: boolean } = {}, + ) { + const startTime = dateRange[0].getTime(); + const endTime = dateRange[1].getTime(); + 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..d1179c4bc8 --- /dev/null +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -0,0 +1,46 @@ +import { ReactElement } from 'react'; + +import { getAnnotationReferenceLines } 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('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..3f723c49d0 --- /dev/null +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -0,0 +1,55 @@ +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; +}; + +/** + * 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. + */ +export function getAnnotationReferenceLines( + annotations: ChartAnnotation[], +): ReactElement[] { + return annotations.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..9d18a7a826 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx @@ -0,0 +1,39 @@ +import { AlertState, AlertTransition } from '@hyperdx/common-utils/dist/types'; + +import { alertTransitionsToAnnotations } 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); + }); +}); 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 067ab8f267..90f52d43d7 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -640,6 +640,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 // -------------------------- @@ -2092,6 +2102,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), From 576b10caa8650219f3eac38e18c4d60f975d77b4 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 8 Jul 2026 12:50:45 -0400 Subject: [PATCH 2/3] fix(alerts): pin carry-in marker with no in-range window; validate history range Addresses PR review feedback: - getAlertTransitionsInRange dropped the carry-in firing marker when the alert interval is wider than the dashboard window (no evaluation lands in-range); now pinned via a post-loop guard. - GET /alerts/:id/history rejects startTime >= endTime with 400 instead of silently returning an empty result. --- .../__tests__/alertHistory.test.ts | 19 ++++++++++++++ packages/api/src/controllers/alertHistory.ts | 26 +++++++++++++------ .../src/routers/api/__tests__/alerts.test.ts | 9 +++++++ packages/api/src/routers/api/alerts.ts | 12 ++++++--- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/api/src/controllers/__tests__/alertHistory.test.ts b/packages/api/src/controllers/__tests__/alertHistory.test.ts index e616a4154e..1539410493 100644 --- a/packages/api/src/controllers/__tests__/alertHistory.test.ts +++ b/packages/api/src/controllers/__tests__/alertHistory.test.ts @@ -639,6 +639,25 @@ describe('alertHistory controller', () => { 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. diff --git a/packages/api/src/controllers/alertHistory.ts b/packages/api/src/controllers/alertHistory.ts index 1b581b25b5..68caff4749 100644 --- a/packages/api/src/controllers/alertHistory.ts +++ b/packages/api/src/controllers/alertHistory.ts @@ -173,21 +173,25 @@ export async function getAlertTransitionsInRange({ 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; - // On entry into the range, pin a firing marker to startTime if the alert - // was already firing (carried in from before the range). if (inRange && !enteredRange) { enteredRange = true; - if (prevIsAlert) { - transitions.push({ - createdAt: startTime.toISOString(), - state: AlertState.ALERT, - }); - } + pinCarryInIfFiring(); } if (inRange && isAlert !== prevIsAlert) { @@ -200,5 +204,11 @@ export async function getAlertTransitionsInRange({ 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 a8cc4cc489..e2059e418d 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -960,6 +960,15 @@ describe('alerts router', () => { 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); + }); }); describe('errors propagation', () => { diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 8906ca7369..68fe7ced87 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -162,10 +162,14 @@ router.get( '/:id/history', processRequest({ params: z.object({ id: objectIdSchema }), - query: z.object({ - startTime: z.coerce.number().int(), - endTime: z.coerce.number().int(), - }), + 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 { From 15de093357d1e8d42911ab6d2a8985f86234ff19 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Wed, 8 Jul 2026 14:34:03 -0400 Subject: [PATCH 3/3] fix(alerts): address PR review round 2 - history route: use populate-free getAlertById (drops 4 populate queries) and clamp the queried span to the ~30d retention window - annotations: use the fullscreen date range while the fullscreen view is open so markers track the visible window - dashboard menu: gate TOC/collapse-all on containers independently of tiles (fixes a regression from the earlier View-section regating) - useAlertHistory: quantize query-key epochs to the minute to avoid per-tick refetch churn on live dashboards - cap rendered annotation markers (MAX_ANNOTATION_MARKERS) against flapping alerts - add useAlertAnnotations hook tests plus marker-cap and wide-range route tests --- .../src/routers/api/__tests__/alerts.test.ts | 8 +++ packages/api/src/routers/api/alerts.ts | 17 +++++-- packages/app/src/DBDashboardPage.tsx | 29 ++++++----- packages/app/src/api.ts | 10 ++-- .../__tests__/chartAnnotations.test.tsx | 15 +++++- .../components/charts/chartAnnotations.tsx | 10 +++- .../__tests__/useAlertAnnotations.test.tsx | 49 ++++++++++++++++++- 7 files changed, 115 insertions(+), 23 deletions(-) diff --git a/packages/api/src/routers/api/__tests__/alerts.test.ts b/packages/api/src/routers/api/__tests__/alerts.test.ts index e2059e418d..fc6c71c65b 100644 --- a/packages/api/src/routers/api/__tests__/alerts.test.ts +++ b/packages/api/src/routers/api/__tests__/alerts.test.ts @@ -969,6 +969,14 @@ describe('alerts router', () => { .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', () => { diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 68fe7ced87..1ffe35bbb4 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -157,6 +157,9 @@ 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', @@ -178,16 +181,24 @@ router.get( return res.sendStatus(403); } - // Scope to the caller's team; 404 for alerts they can't see. - const alert = await getAlertEnhanced(req.params.id, teamId); + // 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(req.query.startTime), + startTime: new Date(startTime), endTime: new Date(req.query.endTime), }); diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index f95c61ac10..c363d34cd6 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -630,11 +630,12 @@ const Tile = forwardRef( return tooltip; }, [alert]); - // Firing/recovery markers for this tile's alert, scoped to the tile's - // visible time range (off unless the dashboard toggle is on). + // 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, - dateRange, + isFullscreen ? fullscreenDateRange : dateRange, showAlertAnnotations, ); @@ -2557,18 +2558,20 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - {hasTiles && ( + {(hasTiles || containers.length > 0) && ( <> View - } - onClick={() => setShowAlertAnnotations(v => !v)} - data-testid="toggle-alert-annotations-menu-item" - > - {showAlertAnnotations - ? 'Hide alert annotations' - : 'Show alert annotations'} - + {hasTiles && ( + } + onClick={() => setShowAlertAnnotations(v => !v)} + data-testid="toggle-alert-annotations-menu-item" + > + {showAlertAnnotations + ? 'Hide alert annotations' + : 'Show alert annotations'} + + )} {containers.length > 0 && ( <> ['alertHistory', alertId, startTime, endTime] as const, // Fetches alert firing/recovery transitions within a time range, for drawing - // annotations on dashboard charts. + // 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 startTime = dateRange[0].getTime(); - const endTime = dateRange[1].getTime(); + 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: () => diff --git a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx index d1179c4bc8..90b58e2525 100644 --- a/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx +++ b/packages/app/src/components/charts/__tests__/chartAnnotations.test.tsx @@ -1,6 +1,9 @@ import { ReactElement } from 'react'; -import { getAnnotationReferenceLines } from '@/components/charts/chartAnnotations'; +import { + getAnnotationReferenceLines, + MAX_ANNOTATION_MARKERS, +} from '@/components/charts/chartAnnotations'; // ReferenceLine element props are typed as `unknown`; narrow for assertions. const lineProps = (el: ReactElement) => @@ -33,6 +36,16 @@ describe('getAnnotationReferenceLines', () => { 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' }, diff --git a/packages/app/src/components/charts/chartAnnotations.tsx b/packages/app/src/components/charts/chartAnnotations.tsx index 3f723c49d0..62837fb612 100644 --- a/packages/app/src/components/charts/chartAnnotations.tsx +++ b/packages/app/src/components/charts/chartAnnotations.tsx @@ -17,16 +17,22 @@ export type ChartAnnotation = { 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. + * 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.map((annotation, i) => { + 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 ( diff --git a/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx b/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx index 9d18a7a826..f82113a605 100644 --- a/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx +++ b/packages/app/src/hooks/__tests__/useAlertAnnotations.test.tsx @@ -1,6 +1,11 @@ import { AlertState, AlertTransition } from '@hyperdx/common-utils/dist/types'; +import { renderHook } from '@testing-library/react'; -import { alertTransitionsToAnnotations } from '@/hooks/useAlertAnnotations'; +import api from '@/api'; +import { + alertTransitionsToAnnotations, + useAlertAnnotations, +} from '@/hooks/useAlertAnnotations'; import { getChartColorError, getChartColorSuccess } from '@/utils'; const makeTransition = ( @@ -37,3 +42,45 @@ describe('alertTransitionsToAnnotations', () => { 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); + }); +});