diff --git a/packages/app/pages/alerts/[alertId].tsx b/packages/app/pages/alerts/[alertId].tsx new file mode 100644 index 0000000000..1b0dc50f9a --- /dev/null +++ b/packages/app/pages/alerts/[alertId].tsx @@ -0,0 +1,3 @@ +import AlertDetailPage from '@/AlertDetailPage'; + +export default AlertDetailPage; diff --git a/packages/app/pages/alerts.tsx b/packages/app/pages/alerts/index.tsx similarity index 100% rename from packages/app/pages/alerts.tsx rename to packages/app/pages/alerts/index.tsx diff --git a/packages/app/src/AlertDetailPage.tsx b/packages/app/src/AlertDetailPage.tsx new file mode 100644 index 0000000000..34e696cdee --- /dev/null +++ b/packages/app/src/AlertDetailPage.tsx @@ -0,0 +1,269 @@ +import * as React from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { + AlertSource, + isRangeThresholdType, +} from '@hyperdx/common-utils/dist/types'; +import { + Anchor, + Breadcrumbs, + Button, + Container, + Group, + Skeleton, + Stack, + Text, +} from '@mantine/core'; +import { IconExternalLink } from '@tabler/icons-react'; + +import { AckAlert } from '@/components/alerts/AckAlert'; +import { AlertDetailChart } from '@/components/alerts/AlertDetailChart'; +import { + AlertEvaluationsTable, + AlertStateBadge, +} from '@/components/alerts/AlertEvaluationsTable'; +import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards'; +import EmptyState from '@/components/EmptyState'; +import { PageHeader } from '@/components/PageHeader'; +import { TimePicker } from '@/components/TimePicker'; + +import { useBrandDisplayName } from './theme/ThemeProvider'; +import { + extendDateRangeToInterval, + TILE_ALERT_THRESHOLD_TYPE_OPTIONS, +} from './utils/alerts'; +import { getWebhookChannelIcon } from './utils/webhookIcons'; +import { + AlertNote, + getAlertDisplayName, + getAlertSourceUrl, +} from './AlertsPage'; +import api from './api'; +import { withAppNav } from './layout'; +import { parseTimeQuery, useNewTimeQuery } from './timeQuery'; +import type { AlertsPageItem } from './types'; + +import styles from '@styles/AlertsPage.module.scss'; + +const DEFAULT_TIME_RANGE_LABEL = 'Past 12h'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parseTimeQuery always resolves a fixed relative label +const defaultTimeRange = parseTimeQuery(DEFAULT_TIME_RANGE_LABEL, false) as [ + Date, + Date, +]; + +// Number of evaluation windows in the timeline strip — wider than the +// alerts-page strip so failure/firing patterns over time are visible. +const TIMELINE_ITEMS = 60; + +function AlertProperties({ alert }: { alert: AlertsPageItem }) { + const thresholdLabel = + TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ?? + alert.thresholdType; + + return ( + +
+ + If value {thresholdLabel}{' '} + {alert.threshold} + {isRangeThresholdType(alert.thresholdType) && ( + <> + {' '} + and {alert.thresholdMax ?? '-'} + + )} + + · + Evaluates every {alert.interval} + {alert.numConsecutiveWindows != null && + alert.numConsecutiveWindows > 1 && ( + <> + · + + Fires after {alert.numConsecutiveWindows} consecutive windows + + + )} + · + + Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook + + {alert.createdBy && ( + <> + · + + Created by {alert.createdBy.name || alert.createdBy.email} + + + )} +
+ {alert.note && } +
+ ); +} + +function AlertDetailBody({ alert }: { alert: AlertsPageItem }) { + const alertUrl = getAlertSourceUrl(alert); + + const [displayedTimeInputValue, setDisplayedTimeInputValue] = React.useState( + DEFAULT_TIME_RANGE_LABEL, + ); + const { searchedTimeRange, onSearch } = useNewTimeQuery({ + initialDisplayValue: DEFAULT_TIME_RANGE_LABEL, + initialTimeRange: defaultTimeRange, + setDisplayedTimeInputValue, + }); + + // Ensure the chart always spans at least a useful number of evaluation + // windows for the alert's interval (e.g. a 1d alert widens past 12h). + const chartDateRange = React.useMemo( + () => extendDateRangeToInterval(searchedTimeRange, alert.interval), + [searchedTimeRange, alert.interval], + ); + + const { + data: evaluationsData, + isLoading: isEvaluationsLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = api.useAlertEvaluations(alert._id); + + const evaluations = React.useMemo( + () => evaluationsData?.pages.flatMap(page => page.data) ?? [], + [evaluationsData], + ); + + return ( + <> + + + Alerts + + + {getAlertDisplayName(alert) || 'Alert'} + + + } + leading={ + + {alert.state != null && } + {getAlertDisplayName(alert)} + + } + actions={ + + + {alertUrl && ( + + )} + + + } + /> +
+ + + + +
+ + Evaluation History + + + fetchNextPage()} + /> +
+
+
+
+ + ); +} + +export default function AlertDetailPage() { + const brandName = useBrandDisplayName(); + const router = useRouter(); + const alertId = + typeof router.query.alertId === 'string' ? router.query.alertId : undefined; + + const { data, isLoading, isError } = api.useAlert(alertId); + const alert = data?.data; + + return ( +
+ + + {alert ? `${getAlertDisplayName(alert)} - Alerts` : 'Alerts'} -{' '} + {brandName} + + + {isLoading && ( + + + + + + + + )} + {!isLoading && (isError || !alert) && ( + + + Back to alerts + + } + /> + + )} + {!isLoading && alert && } +
+ ); +} + +AlertDetailPage.getLayout = withAppNav; diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx index 1d1345c0f3..78d67e6a11 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -12,6 +12,7 @@ import { Alert, Anchor, Badge, + Button, Collapse, Container, Flex, @@ -51,7 +52,7 @@ import type { AlertsPageItem } from './types'; import styles from '@styles/AlertsPage.module.scss'; -function getAlertDisplayName(alert: AlertsPageItem): string { +export function getAlertDisplayName(alert: AlertsPageItem): string { if (alert.source === AlertSource.TILE && alert.dashboard) { const tile = alert.dashboard.tiles.find(t => t.id === alert.tileId); const tileName = tile?.config.name || 'Tile'; @@ -63,6 +64,17 @@ function getAlertDisplayName(alert: AlertsPageItem): string { return ''; } +/** URL of the saved search / dashboard tile the alert is watching. */ +export function getAlertSourceUrl(alert: AlertsPageItem): string { + if (alert.source === AlertSource.TILE && alert.dashboard) { + return `/dashboards/${alert.dashboardId}?highlightedTileId=${alert.tileId}`; + } + if (alert.source === AlertSource.SAVED_SEARCH && alert.savedSearch) { + return `/search/${alert.savedSearchId}`; + } + return ''; +} + function getAlertTags(alert: AlertsPageItem): string[] { return alert.dashboard?.tags ?? alert.savedSearch?.tags ?? []; } @@ -72,7 +84,7 @@ function getAlertCreatorLabel(alert: AlertsPageItem): string | undefined { return alert.createdBy.name || alert.createdBy.email; } -function AlertNote({ note }: { note: string }) { +export function AlertNote({ note }: { note: string }) { const [opened, { toggle }] = useDisclosure(false); return ( @@ -147,15 +159,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { return '–'; }, [alert]); - const alertUrl = React.useMemo(() => { - if (alert.source === AlertSource.TILE && alert.dashboard) { - return `/dashboards/${alert.dashboardId}?highlightedTileId=${alert.tileId}`; - } - if (alert.source === AlertSource.SAVED_SEARCH && alert.savedSearch) { - return `/search/${alert.savedSearchId}`; - } - return ''; - }, [alert]); + const alertUrl = React.useMemo(() => getAlertSourceUrl(alert), [alert]); const alertIcon = (() => { switch (alert.source) { @@ -268,6 +272,15 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { + ); diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 21facd9c05..4d86b5263a 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, + AlertEvaluationsApiResponse, AlertHistoryRangeApiResponse, AlertsApiResponse, InstallationApiResponse, @@ -22,7 +23,7 @@ import type { WebhookTestApiResponse, WebhookUpdateApiResponse, } from '@hyperdx/common-utils/dist/types'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'; import { IS_LOCAL_MODE } from './config'; import { getLocalDashboardTags } from './dashboard'; @@ -218,6 +219,37 @@ const api = { enabled: enabled && alertId != null, }); }, + getAlertEvaluationsQueryKey: (alertId: string | undefined, limit?: number) => + ['alertEvaluations', alertId, limit] as const, + // Paginated evaluation history for the alert detail page: one entry per + // evaluation window (newest first), including errors recorded for it. + // Older pages are keyed off the last window's createdAt (`before`). + useAlertEvaluations( + alertId: string | undefined, + { limit }: { limit?: number } = {}, + ) { + return useInfiniteQuery({ + queryKey: api.getAlertEvaluationsQueryKey(alertId, limit), + queryFn: ({ pageParam }) => + hdxServer(`alerts/${alertId}/evaluations`, { + method: 'GET', + searchParams: { + ...(limit != null && { limit }), + ...(pageParam != null && { before: pageParam }), + }, + }).json(), + initialPageParam: undefined as number | undefined, + getNextPageParam: lastPage => { + if (!lastPage.hasMore || lastPage.data.length === 0) { + return undefined; + } + return new Date( + lastPage.data[lastPage.data.length - 1].createdAt, + ).getTime(); + }, + enabled: alertId != null, + }); + }, useServices() { return useQuery({ queryKey: [`services`], diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index 3165ef17af..03a96990f2 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -18,6 +18,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { Paper } from '@mantine/core'; +import { ChartAnnotation } from '@/components/charts/chartAnnotations'; import { DBTimeChart } from '@/components/DBTimeChart'; import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig'; import { intervalToDateRange, intervalToGranularity } from '@/utils/alerts'; @@ -35,6 +36,11 @@ type AlertPreviewChartProps = { threshold: number; thresholdMax?: number; select?: string | null; + /** Override the interval-derived date range (e.g. alert detail page). */ + dateRange?: [Date, Date]; + /** Firing/recovery markers to draw on the chart. */ + annotations?: ChartAnnotation[]; + height?: number; }; export const AlertPreviewChart = ({ @@ -48,6 +54,9 @@ export const AlertPreviewChart = ({ thresholdMax, thresholdType, select, + dateRange, + annotations, + height = 200, }: AlertPreviewChartProps) => { const resolvedSelect = (select && select.trim().length > 0 @@ -86,11 +95,20 @@ export const AlertPreviewChart = ({ groupBy, select: ALERT_COUNT_DEFAULT_SELECT, displayType: DisplayType.Line, - dateRange: intervalToDateRange(interval), + dateRange: dateRange ?? intervalToDateRange(interval), granularity: intervalToGranularity(interval), }) as ChartConfigWithDateRange; return { ...chartConfig, with: aliasWith }; - }, [source, where, whereLanguage, filters, groupBy, interval, aliasWith]); + }, [ + source, + where, + whereLanguage, + filters, + groupBy, + interval, + aliasWith, + dateRange, + ]); const referenceLines = useMemo( () => @@ -103,13 +121,14 @@ export const AlertPreviewChart = ({ ); return ( - + diff --git a/packages/app/src/components/alerts/AlertDetailChart.tsx b/packages/app/src/components/alerts/AlertDetailChart.tsx new file mode 100644 index 0000000000..d9270ab33e --- /dev/null +++ b/packages/app/src/components/alerts/AlertDetailChart.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import Link from 'next/link'; +import { pick } from 'lodash'; +import { isTimeSeriesDisplayType } from '@hyperdx/common-utils/dist/core/utils'; +import { + isPromqlSavedChartConfig, + isRawSqlSavedChartConfig, +} from '@hyperdx/common-utils/dist/guards'; +import { + AlertSource, + ChartConfigWithDateRange, + DisplayType, + getSampleWeightExpression, + isLogSource, + isTraceSource, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { Anchor, Center, Paper, Skeleton, Text } from '@mantine/core'; + +import { AlertPreviewChart } from '@/components/AlertPreviewChart'; +import { getAlertReferenceLines } from '@/components/Alerts'; +import { DBTimeChart } from '@/components/DBTimeChart'; +import { useDashboards } from '@/dashboard'; +import { useAlertAnnotations } from '@/hooks/useAlertAnnotations'; +import { useSavedSearch } from '@/savedSearch'; +import { useSource } from '@/source'; +import type { AlertsPageItem } from '@/types'; +import { getMetricTableName } from '@/utils'; +import { intervalToGranularity } from '@/utils/alerts'; + +const CHART_HEIGHT = 280; + +function ChartShell({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ChartFallback({ + alertUrl, + message, +}: { + alertUrl?: string; + message: string; +}) { + return ( + +
+ + {message} + {alertUrl && ( + <> + {' '} + + Open source + + + )} + +
+
+ ); +} + +function SavedSearchAlertChart({ + alert, + dateRange, +}: { + alert: AlertsPageItem; + dateRange: [Date, Date]; +}) { + const annotations = useAlertAnnotations(alert._id, dateRange, true); + const { data: savedSearch, isLoading: isSavedSearchLoading } = useSavedSearch( + { id: alert.savedSearchId ?? '' }, + { enabled: alert.savedSearchId != null }, + ); + const { data: source, isLoading: isSourceLoading } = useSource({ + id: savedSearch?.source, + }); + + if (isSavedSearchLoading || (savedSearch != null && isSourceLoading)) { + return ; + } + + if (!savedSearch || !source) { + return ( + + ); + } + + return ( + + ); +} + +function TileAlertChart({ + alert, + dateRange, + alertUrl, +}: { + alert: AlertsPageItem; + dateRange: [Date, Date]; + alertUrl?: string; +}) { + const annotations = useAlertAnnotations(alert._id, dateRange, true); + const { data: dashboards, isLoading: isDashboardsLoading } = useDashboards(); + const dashboard = dashboards?.find(d => d.id === alert.dashboardId); + const tile = dashboard?.tiles?.find(t => t.id === alert.tileId); + + const tileSourceId = + tile != null && !isPromqlSavedChartConfig(tile.config) + ? tile.config.source + : undefined; + const { data: source, isLoading: isSourceLoading } = useSource({ + id: tileSourceId, + }); + + const granularity = intervalToGranularity(alert.interval); + const config = React.useMemo(() => { + if (!tile || isPromqlSavedChartConfig(tile.config)) { + return undefined; + } + + // Raw SQL tiles: only time-series display types can be charted over the + // alert window (mirrors what the alert task evaluates as a time series). + if (isRawSqlSavedChartConfig(tile.config)) { + if (!isTimeSeriesDisplayType(tile.config.displayType)) { + return undefined; + } + if (!tile.config.source) { + return { ...tile.config, dateRange, granularity }; + } + if (!source) { + return undefined; + } + return { + ...tile.config, + ...pick(source, [ + 'implicitColumnExpression', + 'useTextIndexForImplicitColumn', + 'from', + 'metricTables', + ]), + ...(isLogSource(source) + ? { bodyExpression: source.bodyExpression } + : {}), + sampleWeightExpression: getSampleWeightExpression(source), + dateRange, + granularity, + }; + } + + // Builder tiles (mirrors the dashboard Tile's config assembly). Number + // tiles are rendered as a line chart here — the alert task evaluates them + // as a time series, and the threshold-over-time view is what matters. + if (!source?.connection) { + return undefined; + } + const isMetricSource = source.kind === SourceKind.Metric; + const firstSelect = tile.config.select[0]; + const metricType = + isMetricSource && typeof firstSelect !== 'string' + ? firstSelect?.metricType + : undefined; + const tableName = getMetricTableName(source, metricType); + return { + ...tile.config, + displayType: + tile.config.displayType === DisplayType.Number + ? DisplayType.Line + : tile.config.displayType, + connection: source.connection, + dateRange, + granularity, + timestampValueExpression: source.timestampValueExpression, + from: { + databaseName: source.from?.databaseName || 'default', + tableName: tableName || '', + }, + implicitColumnExpression: + isLogSource(source) || isTraceSource(source) + ? source.implicitColumnExpression + : undefined, + useTextIndexForImplicitColumn: + isLogSource(source) || isTraceSource(source) + ? source.useTextIndexForImplicitColumn + : undefined, + bodyExpression: isLogSource(source) ? source.bodyExpression : undefined, + sampleWeightExpression: getSampleWeightExpression(source), + metricTables: isMetricSource ? source.metricTables : undefined, + }; + }, [tile, source, dateRange, granularity]); + + const referenceLines = React.useMemo( + () => + getAlertReferenceLines({ + threshold: alert.threshold, + thresholdMax: alert.thresholdMax, + thresholdType: alert.thresholdType, + }), + [alert.threshold, alert.thresholdMax, alert.thresholdType], + ); + + if (isDashboardsLoading || (tileSourceId != null && isSourceLoading)) { + return ; + } + + if (!config) { + return ( + + ); + } + + return ( + + + + ); +} + +/** + * The alert's underlying query charted over the selected time range, with + * threshold reference lines and firing/recovery annotations. + */ +export function AlertDetailChart({ + alert, + dateRange, + alertUrl, +}: { + alert: AlertsPageItem; + dateRange: [Date, Date]; + alertUrl?: string; +}) { + if (alert.source === AlertSource.SAVED_SEARCH) { + return ; + } + if (alert.source === AlertSource.TILE) { + return ( + + ); + } + return ( + + ); +} diff --git a/packages/app/src/components/alerts/AlertEvaluationsTable.tsx b/packages/app/src/components/alerts/AlertEvaluationsTable.tsx new file mode 100644 index 0000000000..85ac07cbd1 --- /dev/null +++ b/packages/app/src/components/alerts/AlertEvaluationsTable.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { AlertHistory, AlertState } from '@hyperdx/common-utils/dist/types'; +import { + Badge, + Button, + Center, + Group, + Skeleton, + Table, + Text, + UnstyledButton, +} from '@mantine/core'; +import { IconChevronDown } from '@tabler/icons-react'; + +import { + ALERT_ERROR_TYPE_LABELS, + AlertErrorsContent, +} from '@/components/alerts/AlertHistoryCards'; +import { FormatTime } from '@/useFormatTime'; + +export function AlertStateBadge({ state }: { state: AlertState }) { + return stateBadge(state); +} + +function stateBadge(state: AlertState) { + switch (state) { + case AlertState.ALERT: + return ( + + Alert + + ); + case AlertState.PENDING: + return ( + + Pending + + ); + case AlertState.ERROR: + return ( + + Error + + ); + case AlertState.OK: + return Ok; + default: + return ( + + {state} + + ); + } +} + +function latestValue(history: AlertHistory): number | undefined { + const lastValues = history.lastValues; + if (!lastValues || lastValues.length === 0) { + return undefined; + } + return lastValues[lastValues.length - 1].count; +} + +function EvaluationRow({ history }: { history: AlertHistory }) { + const [expanded, setExpanded] = React.useState(false); + const errors = history.errors ?? []; + const hasErrors = errors.length > 0; + const value = latestValue(history); + const errorTypes = Array.from(new Set(errors.map(e => e.type))); + + return ( + <> + setExpanded(v => !v) : undefined} + style={hasErrors ? { cursor: 'pointer' } : undefined} + > + + + + {stateBadge(history.state)} + {value != null ? value : '–'} + {history.counts > 0 ? history.counts : '–'} + + {hasErrors ? ( + { + e.stopPropagation(); + setExpanded(v => !v); + }} + aria-label="Toggle error details" + > + + + {errorTypes + .map(type => ALERT_ERROR_TYPE_LABELS[type]) + .join(', ')} + + + + + ) : ( + '–' + )} + + + {expanded && hasErrors && ( + + + + + + )} + + ); +} + +/** + * Datadog-style evaluation event stream: one row per evaluation window, + * newest first, with expandable error details for failed evaluations. + */ +export function AlertEvaluationsTable({ + evaluations, + isLoading, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: { + evaluations: AlertHistory[]; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}) { + if (isLoading) { + return ; + } + + if (evaluations.length === 0) { + return ( +
+ + No evaluations recorded yet. Evaluations appear here after the alert + runs. + +
+ ); + } + + return ( + <> + + + + Evaluation Window + State + Latest Value + Breaches + Errors + + + + {evaluations.map(history => ( + + ))} + +
+ {hasNextPage && ( +
+ +
+ )} + + ); +} diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx index bd9470e130..1b3df3b37d 100644 --- a/packages/app/src/components/alerts/AlertHistoryCards.tsx +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -26,7 +26,7 @@ import styles from '@styles/AlertsPage.module.scss'; const HISTORY_ITEMS = 18; -const ALERT_ERROR_TYPE_LABELS: Record = { +export const ALERT_ERROR_TYPE_LABELS: Record = { [AlertErrorType.INVALID_ALERT]: 'Invalid Configuration', [AlertErrorType.QUERY_ERROR]: 'Query Error', [AlertErrorType.QUERY_TIMEOUT]: 'Query Timeout', @@ -47,7 +47,7 @@ function stateToBgColorClass(state: AlertState) { } } -function AlertErrorsContent({ errors }: { errors: AlertError[] }) { +export function AlertErrorsContent({ errors }: { errors: AlertError[] }) { return ( {errors.map((error, idx) => ( diff --git a/packages/app/src/components/alerts/__tests__/AlertEvaluationsTable.test.tsx b/packages/app/src/components/alerts/__tests__/AlertEvaluationsTable.test.tsx new file mode 100644 index 0000000000..cb6f943247 --- /dev/null +++ b/packages/app/src/components/alerts/__tests__/AlertEvaluationsTable.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { + AlertErrorType, + AlertHistory, + AlertState, +} from '@hyperdx/common-utils/dist/types'; +import { fireEvent, screen } from '@testing-library/react'; + +import { AlertEvaluationsTable } from '@/components/alerts/AlertEvaluationsTable'; + +const okWindow: AlertHistory = { + counts: 0, + createdAt: '2026-04-17T12:05:00.000Z', + lastValues: [{ startTime: '2026-04-17T12:00:00.000Z', count: 3 }], + state: AlertState.OK, +}; + +const errorWindow: AlertHistory = { + counts: 0, + createdAt: '2026-04-17T12:10:00.000Z', + lastValues: [], + state: AlertState.ERROR, + errors: [ + { + timestamp: '2026-04-17T12:11:00.000Z', + type: AlertErrorType.QUERY_TIMEOUT, + message: + 'Alert query did not complete within the 300s evaluation timeout.', + }, + ], +}; + +const renderTable = ( + props: Partial> = {}, +) => + renderWithMantine( + , + ); + +describe('AlertEvaluationsTable', () => { + it('renders one row per evaluation window with state badges', () => { + renderTable(); + + const rows = screen.getAllByTestId('alert-evaluation-row'); + expect(rows).toHaveLength(2); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Ok')).toBeInTheDocument(); + // OK row shows the latest value + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('shows the error type label and expands to the full message', () => { + renderTable(); + + expect(screen.getByText('Query Timeout')).toBeInTheDocument(); + expect( + screen.queryByText(/did not complete within the 300s/), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Query Timeout')); + + expect( + screen.getByText(/did not complete within the 300s/), + ).toBeInTheDocument(); + }); + + it('renders a load-more button when older pages exist', () => { + const onLoadMore = jest.fn(); + renderTable({ hasNextPage: true, onLoadMore }); + + const button = screen.getByRole('button', { + name: /Load older evaluations/, + }); + fireEvent.click(button); + expect(onLoadMore).toHaveBeenCalled(); + }); + + it('renders an empty state when there are no evaluations', () => { + renderTable({ evaluations: [] }); + expect(screen.getByText(/No evaluations recorded yet/)).toBeInTheDocument(); + }); +}); diff --git a/packages/app/tests/e2e/features/alerts.spec.ts b/packages/app/tests/e2e/features/alerts.spec.ts index 7f86b586ec..29a33c00d2 100644 --- a/packages/app/tests/e2e/features/alerts.spec.ts +++ b/packages/app/tests/e2e/features/alerts.spec.ts @@ -704,6 +704,56 @@ test.describe( SEEDED_ERROR_ALERT.errorMessage, ); }); + + test('shows an errored evaluation in the history strip with details on click', async () => { + const seededCard = alertsPage.getAlertCardByName( + SEEDED_ERROR_ALERT.savedSearchName, + ); + await expect(seededCard).toBeVisible({ timeout: 10000 }); + + // The seeded ERROR evaluation window renders as a clickable segment + const errorSegment = alertsPage.getErrorHistorySegments(seededCard); + await expect(errorSegment).toHaveCount(1); + + await errorSegment.click(); + await expect(alertsPage.evaluationErrorModal).toBeVisible(); + await expect(alertsPage.evaluationErrorModal).toContainText( + 'Query Timeout', + ); + await expect( + alertsPage.evaluationErrorModal.locator('pre'), + ).toContainText(SEEDED_ERROR_ALERT.historyErrorMessage); + }); + + test('navigates to the alert detail page and shows the evaluation history', async () => { + const seededCard = alertsPage.getAlertCardByName( + SEEDED_ERROR_ALERT.savedSearchName, + ); + await expect(seededCard).toBeVisible({ timeout: 10000 }); + + await alertsPage.getDetailsLinkForAlertCard(seededCard).click(); + + // Generous timeout: in dev mode the first hit compiles the new route, + // which can take tens of seconds before the navigation completes. + await alertsPage.page.waitForURL(/\/alerts\/[a-f0-9]{24}/, { + timeout: 30000, + }); + await expect(alertsPage.detailPageContainer).toBeVisible({ + timeout: 15000, + }); + + // The event stream lists the errored evaluation with its type label + await expect(alertsPage.evaluationsTable).toBeVisible({ + timeout: 10000, + }); + await expect(alertsPage.evaluationsTable).toContainText('Query Timeout'); + // ...and the OK window seeded alongside it + await expect( + alertsPage.evaluationsTable.locator( + '[data-testid="alert-evaluation-row"]', + ), + ).toHaveCount(2); + }); }, ); diff --git a/packages/app/tests/e2e/global-setup-fullstack.ts b/packages/app/tests/e2e/global-setup-fullstack.ts index 866a3cd6f2..9c196b6900 100644 --- a/packages/app/tests/e2e/global-setup-fullstack.ts +++ b/packages/app/tests/e2e/global-setup-fullstack.ts @@ -56,6 +56,10 @@ export const SEEDED_ERROR_ALERT = { errorType: 'QUERY_ERROR', errorMessage: 'ClickHouse returned 500: DB::Exception: Timeout exceeded: elapsed 30s, maximum: 30s while executing query.', + // Message on the seeded ERROR-state AlertHistory row (evaluation history) + historyErrorType: 'QUERY_TIMEOUT', + historyErrorMessage: + 'Alert query did not complete within the 300s evaluation timeout. The evaluation is retried on every check, but the alert will not fire until the query completes in time.', }; /** @@ -379,6 +383,13 @@ async function seedAlertWithErrors( // check-alerts job is the only code that writes this field in normal // operation, so we write it here to avoid having to run that job during // setup. + // Evaluation windows aligned to the alert's 5m interval: one OK window + // followed by an ERROR window (a failed evaluation), so the alerts page + // history strip and the alert detail page have data to render. + const windowMs = 5 * 60 * 1000; + const errorWindowStart = Math.floor(Date.now() / windowMs) * windowMs; + const okWindowStart = errorWindowStart - windowMs; + const patchScript = ` use('hyperdx-e2e'); db.alerts.updateOne( @@ -396,6 +407,30 @@ db.alerts.updateOne( } } ); +db.alerthistories.deleteMany({ alert: ObjectId(${JSON.stringify(alertId)}) }); +db.alerthistories.insertMany([ + { + alert: ObjectId(${JSON.stringify(alertId)}), + createdAt: new Date(${okWindowStart}), + state: 'OK', + counts: 0, + lastValues: [{ startTime: new Date(${okWindowStart - windowMs}), count: 0 }] + }, + { + alert: ObjectId(${JSON.stringify(alertId)}), + createdAt: new Date(${errorWindowStart}), + state: 'ERROR', + counts: 0, + lastValues: [], + errors: [ + { + timestamp: new Date(), + type: ${JSON.stringify(SEEDED_ERROR_ALERT.historyErrorType)}, + message: ${JSON.stringify(SEEDED_ERROR_ALERT.historyErrorMessage)} + } + ] + } +]); `; try { diff --git a/packages/app/tests/e2e/page-objects/AlertsPage.ts b/packages/app/tests/e2e/page-objects/AlertsPage.ts index ab375c1e81..b82d4b76bf 100644 --- a/packages/app/tests/e2e/page-objects/AlertsPage.ts +++ b/packages/app/tests/e2e/page-objects/AlertsPage.ts @@ -115,6 +115,43 @@ export class AlertsPage { await icon.click(); } + /** + * Get the errored-evaluation segment(s) in an alert card's history strip. + * Rendered as clickable buttons (unlike normal segments which are links). + */ + getErrorHistorySegments(alertCard: Locator) { + return alertCard.getByRole('button', { name: 'View evaluation errors' }); + } + + /** + * The per-evaluation error details modal (opened by clicking an errored + * history segment). + */ + get evaluationErrorModal() { + return this.page.getByRole('dialog', { name: /Evaluation Errors/ }); + } + + /** + * Get the "Details" link for a given alert card (navigates to /alerts/:id). + */ + getDetailsLinkForAlertCard(alertCard: Locator) { + return alertCard.locator('[data-testid^="alert-details-link-"]'); + } + + /** + * The alert detail page root. + */ + get detailPageContainer() { + return this.page.locator('[data-testid="alert-detail-page"]'); + } + + /** + * The evaluation event-stream table on the alert detail page. + */ + get evaluationsTable() { + return this.page.locator('[data-testid="alert-evaluations-table"]'); + } + // --- Filter interactions --- get filters() {