diff --git a/.changeset/alert-detail-page.md b/.changeset/alert-detail-page.md new file mode 100644 index 0000000000..2f5d237ddb --- /dev/null +++ b/.changeset/alert-detail-page.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': minor +--- + +Add an alert detail page (/alerts/:id) with the alert's query charted against +its threshold, a widened evaluation-history strip, and a paginated evaluation +event stream (per-group breakdown for group-by alerts, evaluation analytics +columns, time-range-scoped cursor pagination). The alerts page history strip +renders errored evaluation windows with per-window error details. Gated behind +NEXT_PUBLIC_ENABLE_ALERT_DETAILS (default off). diff --git a/docker-compose.yml b/docker-compose.yml index bdd7339e03..041023c827 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,8 @@ services: # Uncomment the next two lines to enable PromQL (Prometheus-compatible metrics) # ENABLE_PROMQL: 'true' # NEXT_PUBLIC_ENABLE_PROMQL: 'true' + # Uncomment to enable the alert detail page (in development) + # NEXT_PUBLIC_ENABLE_ALERT_DETAILS: 'true' DEFAULT_CONNECTIONS: '[{"name":"Local ClickHouse","host":"http://ch-server:8123","username":"default","password":""}]' diff --git a/packages/app/.env.development b/packages/app/.env.development index e8c3430161..dabe2bc79d 100644 --- a/packages/app/.env.development +++ b/packages/app/.env.development @@ -14,3 +14,4 @@ NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PO # NEXT_PUBLIC_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT= NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true +NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true 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/playwright.config.ts b/packages/app/playwright.config.ts index 0e10cfa00b..4fa2d334e3 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -105,8 +105,8 @@ export default defineConfig({ { // Full UI: Alerts + Dashboards. Not local mode; Alerts enabled; command: USE_DEV - ? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true next dev --webpack` - : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true yarn start`, + ? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true next dev --webpack` + : `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES=true NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true yarn start`, port: parseInt(APP_PORT, 10), reuseExistingServer: !process.env.CI, timeout: APP_SERVER_STARTUP_TIMEOUT_MS, diff --git a/packages/app/src/AlertDetailPage.tsx b/packages/app/src/AlertDetailPage.tsx new file mode 100644 index 0000000000..c87719a462 --- /dev/null +++ b/packages/app/src/AlertDetailPage.tsx @@ -0,0 +1,268 @@ +import * as React from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { AlertInterval, AlertSource } 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 } from '@/components/alerts/AlertEvaluationsTable'; +import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards'; +import { AlertPropertiesSummary } from '@/components/alerts/AlertPropertiesSummary'; +import { AlertStateBadge } from '@/components/alerts/AlertStateBadge'; +import EmptyState from '@/components/EmptyState'; +import { PageHeader } from '@/components/PageHeader'; +import { TimePicker } from '@/components/TimePicker'; +import { IS_ALERT_DETAILS_ENABLED } from '@/config'; + +import { useBrandDisplayName } from './theme/ThemeProvider'; +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'; + +/** + * Default chart range for the alert's evaluation interval. The chart buckets + * at the interval, so a fixed short default renders only a bucket or two for + * long-interval alerts (granularity >= duration). Each default shows at + * least ~24 evaluation windows; evaluations retention is 31d, so the 1d + * interval caps at 30d. + */ +function getDefaultTimeRangeLabel(interval: AlertInterval): string { + switch (interval) { + case '30m': + return 'Past 1d'; + case '1h': + return 'Past 2d'; + case '6h': + return 'Past 7d'; + case '12h': + return 'Past 14d'; + case '1d': + return 'Past 30d'; + default: + // 1m / 5m / 15m + return 'Past 12h'; + } +} + +// 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 }) { + return ( + + + {alert.note && } + + ); +} + +function AlertDetailBody({ alert }: { alert: AlertsPageItem }) { + const alertUrl = getAlertSourceUrl(alert); + + // Interval-dependent, but fixed for the page lifetime: the body only + // mounts once the alert has loaded, and useNewTimeQuery reads the initial + // values once (a from/to in the URL still takes precedence). + const defaultTimeRangeLabel = getDefaultTimeRangeLabel(alert.interval); + const defaultTimeRange = React.useMemo( + () => parseTimeQuery(defaultTimeRangeLabel, false) as [Date, Date], + [defaultTimeRangeLabel], + ); + const [displayedTimeInputValue, setDisplayedTimeInputValue] = React.useState( + defaultTimeRangeLabel, + ); + const { searchedTimeRange, onSearch } = useNewTimeQuery({ + initialDisplayValue: defaultTimeRangeLabel, + initialTimeRange: defaultTimeRange, + setDisplayedTimeInputValue, + }); + + // The chart, timeline strip, and event stream all reflect the exact picked + // time range. Evaluations are fetched in fixed-size pages as the user + // scrolls (each page is a hard-bounded scan server-side, so wide ranges + // never fetch unbounded history). + const { + data: evaluationsData, + isLoading: isEvaluationsLoading, + isError: isEvaluationsError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = api.useAlertEvaluations(alert._id, searchedTimeRange); + + const evaluations = React.useMemo( + () => evaluationsData?.pages.flatMap(page => page.data) ?? [], + [evaluationsData], + ); + + // Stable callback so the scroll sentinel's effect doesn't refire on every + // render. cancelRefetch:false makes overlapping triggers no-ops instead of + // restarting an in-flight page fetch. + const onLoadMore = React.useCallback(() => { + fetchNextPage({ cancelRefetch: false }); + }, [fetchNextPage]); + + return ( + <> + + + Alerts + + + {getAlertDisplayName(alert) || 'Alert'} + + + } + leading={ + + {alert.state != null && } + {getAlertDisplayName(alert)} + + } + actions={ + + + {alertUrl && ( + + )} + + + } + /> +
+ + + + +
+ + Evaluation History + + + +
+
+
+
+ + ); +} + +export default function AlertDetailPage() { + const brandName = useBrandDisplayName(); + const router = useRouter(); + const alertId = + typeof router.query.alertId === 'string' ? router.query.alertId : undefined; + + // Direct-URL guard while the feature bakes: the alerts page only renders + // Details links when the flag is on, but the route itself must bounce too. + React.useEffect(() => { + if (!IS_ALERT_DETAILS_ENABLED) { + router.replace('/alerts'); + } + }, [router]); + + const { data, isLoading, isError } = api.useAlert(alertId); + const alert = data?.data; + + if (!IS_ALERT_DETAILS_ENABLED) { + return null; + } + + 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 87e1e6aa16..f3338656dc 100644 --- a/packages/app/src/AlertsPage.tsx +++ b/packages/app/src/AlertsPage.tsx @@ -4,15 +4,12 @@ import Link from 'next/link'; import { useQueryState } from 'nuqs'; import ReactMarkdown from 'react-markdown'; import { isImportableAlert } from '@hyperdx/common-utils/dist/iac'; -import { - AlertSource, - AlertState, - isRangeThresholdType, -} from '@hyperdx/common-utils/dist/types'; +import { AlertSource, AlertState } from '@hyperdx/common-utils/dist/types'; import { Alert, Anchor, Badge, + Button, Collapse, Container, Flex, @@ -40,20 +37,20 @@ import { import { AckAlert } from '@/components/alerts/AckAlert'; import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards'; +import { AlertPropertiesSummary } from '@/components/alerts/AlertPropertiesSummary'; import EmptyState from '@/components/EmptyState'; import ResourceTerraformPopover from '@/components/Iac/ResourceTerraformPopover'; import { PageHeader } from '@/components/PageHeader'; +import { IS_ALERT_DETAILS_ENABLED } from '@/config'; import { useBrandDisplayName } from './theme/ThemeProvider'; -import { TILE_ALERT_THRESHOLD_TYPE_OPTIONS } from './utils/alerts'; -import { getWebhookChannelIcon } from './utils/webhookIcons'; import api from './api'; import { withAppNav } from './layout'; 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'; @@ -65,6 +62,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 ?? []; } @@ -74,7 +82,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 ( @@ -149,15 +157,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) { @@ -170,33 +170,6 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { } })(); - const alertType = React.useMemo(() => { - const thresholdLabel = - TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ?? - alert.thresholdType; - return ( - <> - If value {thresholdLabel}{' '} - {alert.threshold} - {isRangeThresholdType(alert.thresholdType) && ( - <> - {' '} - and {alert.thresholdMax ?? '-'} - - )} - · - - ); - }, [alert]); - - const notificationMethod = React.useMemo(() => { - return ( - - Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook - - ); - }, [alert]); - const linkTitle = React.useMemo(() => { switch (alert.source) { case AlertSource.TILE: @@ -230,11 +203,16 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
+ {/* With alert details enabled, the alert name is the entry point + to its status page; the source tile / saved search moves to a + secondary link on the right. */} {alertIcon} @@ -242,18 +220,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
-
- {alertType} - {notificationMethod} - {alert.createdBy && ( - <> - · - - Created by {alert.createdBy.name || alert.createdBy.email} - - - )} -
+ {getAlertTags(alert).length > 0 && ( {getAlertTags(alert).map(tag => ( @@ -286,6 +253,17 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) { )} + {IS_ALERT_DETAILS_ENABLED && alertUrl && ( + + )} ); diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 21facd9c05..a8dea6a600 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,39 @@ const api = { enabled: enabled && alertId != null, }); }, + getAlertEvaluationsQueryKey: ( + alertId: string | undefined, + startTime: number, + endTime: number, + ) => ['alertEvaluations', alertId, startTime, endTime] as const, + // Paginated evaluation history for the alert detail page: one entry per + // evaluation window (newest first), scoped to the given date range and + // including errors recorded for each window. Older pages are keyed off the + // server-provided `nextBefore` cursor, which advances even across gaps with + // no evaluations. Bounds are quantized to the minute so live ticks don't + // produce a new query key on every render. + useAlertEvaluations(alertId: string | undefined, dateRange: [Date, Date]) { + 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 useInfiniteQuery({ + queryKey: api.getAlertEvaluationsQueryKey(alertId, startTime, endTime), + queryFn: ({ pageParam }) => + hdxServer(`alerts/${alertId}/evaluations`, { + method: 'GET', + searchParams: { + startTime, + endTime, + ...(pageParam != null && { before: pageParam }), + }, + }).json(), + initialPageParam: undefined as number | undefined, + getNextPageParam: lastPage => + lastPage.hasMore ? lastPage.nextBefore : undefined, + enabled: alertId != null && startTime < endTime, + }); + }, 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/AlertEvaluationRow.tsx b/packages/app/src/components/alerts/AlertEvaluationRow.tsx new file mode 100644 index 0000000000..d87ab766ee --- /dev/null +++ b/packages/app/src/components/alerts/AlertEvaluationRow.tsx @@ -0,0 +1,228 @@ +import * as React from 'react'; +import { + ALERT_EVALUATION_GROUPS_LIMIT, + ALERT_INTERVAL_TO_MINUTES, + AlertEvaluation, + AlertEvaluationGroup, + AlertInterval, + AlertState, +} from '@hyperdx/common-utils/dist/types'; +import { Group, Stack, Table, Text, Tooltip } from '@mantine/core'; +import { IconChevronDown, IconChevronRight } from '@tabler/icons-react'; + +import { + ALERT_ERROR_TYPE_LABELS, + AlertErrorsContent, +} from '@/components/alerts/AlertHistoryCards'; +import { AlertStateBadge } from '@/components/alerts/AlertStateBadge'; +import { FormatTime } from '@/useFormatTime'; +import { formatDurationMs } from '@/utils'; + +import styles from '@styles/AlertsPage.module.scss'; + +/** Mirrors the header column count in AlertEvaluationsTable. */ +const TABLE_COLUMNS = 8; + +function latestValue(history: AlertEvaluation): number | undefined { + const lastValues = history.lastValues; + if (!lastValues || lastValues.length === 0) { + return undefined; + } + return lastValues[lastValues.length - 1].count; +} + +type GroupRowProps = { + group: AlertEvaluationGroup; +}; + +/** Child row: one group's result within the parent evaluation window. */ +function GroupRow({ group }: GroupRowProps) { + return ( + + + + + └ + + + {group.group} + + + + + + + + {group.lastValue != null ? group.lastValue.count : '–'} + + {group.counts > 0 ? group.counts : '–'} + + + + + + ); +} + +/** Duration cell: formatted milliseconds, or a dash when not measured. */ +function durationCell(ms: number | undefined) { + return ms != null ? formatDurationMs(ms) : '–'; +} + +type AlertEvaluationRowProps = { + history: AlertEvaluation; + interval: AlertInterval; +}; + +/** + * Parent row for one evaluation window, expandable into per-group child rows + * for group-by alerts and error details for failed evaluations. + */ +export function AlertEvaluationRow({ + history, + interval, +}: AlertEvaluationRowProps) { + const [expanded, setExpanded] = React.useState(false); + const errors = history.errors ?? []; + const groups = history.groups ?? []; + const hasErrors = errors.length > 0; + const hasGroups = groups.length > 0; + const value = hasGroups ? undefined : latestValue(history); + const errorTypes = Array.from(new Set(errors.map(e => e.type))); + + // The chart plots each bucket's value at the bucket *start*, while the + // evaluation runs at the bucket *end* (createdAt). Show the start of the + // latest evaluated bucket so the table lines up with the chart. + // `lastValues` are ascending, so the last entry is the newest bucket; + // failed evaluations have no lastValues, so fall back to + // createdAt − interval. + const lastValues = history.lastValues ?? []; + const lastBucketStart = + lastValues.length > 0 + ? lastValues[lastValues.length - 1].startTime + : new Date( + new Date(history.createdAt).getTime() - + ALERT_INTERVAL_TO_MINUTES[interval] * 60_000, + ); + const evaluatedSpanStart = lastValues[0]?.startTime ?? lastBucketStart; + + const groupsTotal = history.groupsTotal ?? groups.length; + const firingGroups = groups.filter(g => g.state === AlertState.ALERT).length; + const omittedGroups = groupsTotal - groups.length; + const backfilledBuckets = history.analytics?.backfilledBuckets ?? 0; + const expandable = hasErrors || hasGroups; + + return ( + <> + setExpanded(v => !v) : undefined} + style={expandable ? { cursor: 'pointer' } : undefined} + aria-expanded={expandable ? expanded : undefined} + > + + + {expandable ? ( + expanded ? ( + + ) : ( + + ) + ) : ( + // Keep timestamps aligned with expandable rows + + )} + + Evaluated –{' '} + + + } + withArrow + color="dark" + > + + + + + + + + + + {hasGroups && ( + + {firingGroups > 0 + ? `${firingGroups}/${groupsTotal} groups firing` + : `${groupsTotal} groups`} + + )} + + + {value != null ? value : '–'} + {history.counts > 0 ? history.counts : '–'} + + {backfilledBuckets > 0 ? ( + + + {backfilledBuckets} + + + ) : ( + '–' + )} + + {durationCell(history.analytics?.queryDurationMs)} + + {durationCell(history.analytics?.webhookDurationMs)} + + + {hasErrors ? ( + + {errorTypes.map(type => ALERT_ERROR_TYPE_LABELS[type]).join(', ')} + + ) : ( + '–' + )} + + + {expanded && ( + <> + {groups.map(group => ( + + ))} + {omittedGroups > 0 && ( + + + + Showing the top {ALERT_EVALUATION_GROUPS_LIMIT} of{' '} + {groupsTotal} groups (firing first) — additional groups + aren't fetched. + + + + )} + {hasErrors && ( + + + + + + + + )} + + )} + + ); +} diff --git a/packages/app/src/components/alerts/AlertEvaluationsTable.tsx b/packages/app/src/components/alerts/AlertEvaluationsTable.tsx new file mode 100644 index 0000000000..934d03c2dc --- /dev/null +++ b/packages/app/src/components/alerts/AlertEvaluationsTable.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { + AlertEvaluation, + AlertInterval, +} from '@hyperdx/common-utils/dist/types'; +import { + Button, + Center, + Group, + Loader, + Skeleton, + Table, + Text, +} from '@mantine/core'; +import { useInViewport } from '@mantine/hooks'; + +import { AlertEvaluationRow } from '@/components/alerts/AlertEvaluationRow'; + +type LoadMoreSentinelProps = { + isFetchingNextPage: boolean; + onLoadMore: () => void; +}; + +/** + * Sentinel row at the bottom of the event stream: when scrolled into view + * (and older pages exist), it triggers the next fetch — infinite scroll in + * `limit`-sized chunks instead of a manual load-more button. + */ +function LoadMoreSentinel({ + isFetchingNextPage, + onLoadMore, +}: LoadMoreSentinelProps) { + const { ref, inViewport } = useInViewport(); + + React.useEffect(() => { + if (inViewport && !isFetchingNextPage) { + onLoadMore(); + } + }, [inViewport, isFetchingNextPage, onLoadMore]); + + return ( +
+ + + + Loading older evaluations… + + +
+ ); +} + +type AlertEvaluationsTableProps = { + evaluations: AlertEvaluation[]; + interval: AlertInterval; + isLoading: boolean; + isError: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}; + +/** + * Datadog-style evaluation event stream: one parent row per evaluation + * window, newest first, expandable into per-group child rows for group-by + * alerts, with error details for failed evaluations. Fetches older windows + * in pages as the user scrolls to the bottom. + */ +export function AlertEvaluationsTable({ + evaluations, + interval, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}: AlertEvaluationsTableProps) { + if (isLoading) { + return ; + } + + if (evaluations.length === 0 && isError) { + return ( +
+ + Failed to load evaluations. + +
+ ); + } + + if (evaluations.length === 0 && !hasNextPage) { + return ( +
+ + No evaluations in the selected time range. + +
+ ); + } + + return ( + <> + + + + Evaluation Window + State + Latest Value + Breaches + Backfilled Buckets + Query Duration + Webhook Duration + Errors + + + + {evaluations.map(history => ( + + ))} + +
+ {hasNextPage && + // A failed page fetch must unmount the sentinel: its effect refires + // whenever isFetchingNextPage settles back to false, so leaving it + // mounted after an error would refetch in an unbounded loop. Show an + // explicit retry affordance instead. + (isError ? ( +
+ + + Failed to load older evaluations. + + + +
+ ) : ( + + ))} + + ); +} diff --git a/packages/app/src/components/alerts/AlertHistoryCards.tsx b/packages/app/src/components/alerts/AlertHistoryCards.tsx index 331ac16e1c..1b3df3b37d 100644 --- a/packages/app/src/components/alerts/AlertHistoryCards.tsx +++ b/packages/app/src/components/alerts/AlertHistoryCards.tsx @@ -26,23 +26,83 @@ import styles from '@styles/AlertsPage.module.scss'; const HISTORY_ITEMS = 18; +export const ALERT_ERROR_TYPE_LABELS: Record = { + [AlertErrorType.INVALID_ALERT]: 'Invalid Configuration', + [AlertErrorType.QUERY_ERROR]: 'Query Error', + [AlertErrorType.QUERY_TIMEOUT]: 'Query Timeout', + [AlertErrorType.WEBHOOK_ERROR]: 'Webhook Error', + [AlertErrorType.UNKNOWN]: 'Unknown Error', +}; + function stateToBgColorClass(state: AlertState) { switch (state) { case AlertState.OK: return styles.ok; case AlertState.PENDING: return styles.pending; + case AlertState.ERROR: + return styles.error; default: return styles.alarm; } } +export function AlertErrorsContent({ errors }: { errors: AlertError[] }) { + return ( + + {errors.map((error, idx) => ( + + + {ALERT_ERROR_TYPE_LABELS[error.type]} at{' '} + + + + {error.message} + + + ))} + + ); +} + +/** Dedupe errors by type+message, keeping the most recent occurrence. */ +function dedupeAlertErrors(errors: AlertError[]): AlertError[] { + const map = new Map(); + for (const error of errors) { + const key = `${error.type}||${error.message}`; + const existing = map.get(key); + if ( + !existing || + new Date(error.timestamp).getTime() > + new Date(existing.timestamp).getTime() + ) { + map.set(key, error); + } + } + return Array.from(map.values()); +} + +function errorTypeSummary(errors: AlertError[]): string { + const types = Array.from(new Set(errors.map(error => error.type))); + return types.length === 1 + ? ALERT_ERROR_TYPE_LABELS[types[0]] + : 'Multiple Errors'; +} + function AlertHistoryCard({ history, alertUrl, + onShowErrors, }: { history: AlertHistory; alertUrl?: string; + onShowErrors: (history: AlertHistory) => void; }) { const start = new Date(history.createdAt.toString()); @@ -65,25 +125,41 @@ function AlertHistoryCard({ return url.pathname + url.search; }, [history, alertUrl]); + const isError = history.state === AlertState.ERROR; + const time = formatRelative(start, today); + const content = (
); - const count = history.counts ?? 0; - const pending = history.state === AlertState.PENDING ? 'pending' : ''; - const alert = `alert${count === 0 || count > 1 ? 's' : ''}`; - const time = formatRelative(start, today); - const label = `${count} ${pending} ${alert} ${time}`; + const label = React.useMemo(() => { + if (isError) { + const summary = errorTypeSummary(history.errors ?? []); + return `Evaluation failed (${summary}) ${time}. Click for details.`; + } + const count = history.counts ?? 0; + const pending = history.state === AlertState.PENDING ? 'pending' : ''; + const alert = `alert${count === 0 || count > 1 ? 's' : ''}`; + return `${count} ${pending} ${alert} ${time}`; + }, [isError, history, time]); return ( - {href ? ( + {isError ? ( + onShowErrors(history)} + aria-label="View evaluation errors" + > + {content} + + ) : href ? ( {content} @@ -94,41 +170,17 @@ function AlertHistoryCard({ ); } -const ALERT_ERROR_TYPE_LABELS: Record = { - [AlertErrorType.INVALID_ALERT]: 'Invalid Configuration', - [AlertErrorType.QUERY_ERROR]: 'Query Error', - [AlertErrorType.QUERY_TIMEOUT]: 'Query Timeout', - [AlertErrorType.WEBHOOK_ERROR]: 'Webhook Error', - [AlertErrorType.UNKNOWN]: 'Unknown Error', -}; - function AlertErrorsIndicator({ alert }: { alert: AlertsPageItem }) { const [opened, { open, close }] = useDisclosure(false); - const { uniqueErrors, uniqueTypes } = React.useMemo(() => { - const map = new Map(); - for (const error of alert.executionErrors ?? []) { - const key = `${error.type}||${error.message}`; - const existing = map.get(key); - if ( - !existing || - new Date(error.timestamp).getTime() > - new Date(existing.timestamp).getTime() - ) { - map.set(key, error); - } - } - const errors = Array.from(map.values()); - const types = Array.from(new Set(errors.map(error => error.type))); - return { uniqueErrors: errors, uniqueTypes: types }; - }, [alert.executionErrors]); + const uniqueErrors = React.useMemo( + () => dedupeAlertErrors(alert.executionErrors ?? []), + [alert.executionErrors], + ); if (uniqueErrors.length === 0) return null; - const errorType = - uniqueTypes.length === 1 - ? ALERT_ERROR_TYPE_LABELS[uniqueTypes[0]] - : 'Multiple Errors'; + const errorType = errorTypeSummary(uniqueErrors); return ( <> @@ -160,25 +212,7 @@ function AlertErrorsIndicator({ alert }: { alert: AlertsPageItem }) { title="Alert Execution Errors" data-testid={`alert-error-modal-${alert._id}`} > - - {uniqueErrors.map((error, idx) => ( - - - {ALERT_ERROR_TYPE_LABELS[error.type]} at{' '} - - - - {error.message} - - - ))} - + ); @@ -187,28 +221,44 @@ function AlertErrorsIndicator({ alert }: { alert: AlertsPageItem }) { export function AlertHistoryCardList({ alert, alertUrl, + history: historyProp, + maxItems = HISTORY_ITEMS, + showErrorIndicator = true, }: { alert: AlertsPageItem; alertUrl?: string; + /** Evaluation windows to render; defaults to the alert's inline history. */ + history?: AlertHistory[]; + maxItems?: number; + showErrorIndicator?: boolean; }) { - const { history } = alert; + const history = historyProp ?? alert.history; + const [errorHistory, setErrorHistory] = React.useState( + null, + ); + const items = React.useMemo(() => { - if (history.length < HISTORY_ITEMS) { + if (history.length < maxItems) { return history; } - return history.slice(0, HISTORY_ITEMS); - }, [history]); + return history.slice(0, maxItems); + }, [history, maxItems]); const paddingItems = React.useMemo(() => { - if (history.length > HISTORY_ITEMS) { + if (history.length > maxItems) { return []; } - return new Array(HISTORY_ITEMS - history.length).fill(null); - }, [history]); + return new Array(maxItems - history.length).fill(null); + }, [history, maxItems]); + + const modalErrors = React.useMemo( + () => dedupeAlertErrors(errorHistory?.errors ?? []), + [errorHistory], + ); return ( - + {showErrorIndicator && } {items.length > 0 && (
{paddingItems.map((_, index) => ( @@ -224,10 +274,29 @@ export function AlertHistoryCardList({ key={index} history={history} alertUrl={alertUrl} + onShowErrors={setErrorHistory} /> ))}
)} + setErrorHistory(null)} + size="lg" + title={ + <> + Evaluation Errors + {errorHistory != null && ( + + + + )} + + } + data-testid={`alert-evaluation-error-modal-${alert._id}`} + > + +
); } diff --git a/packages/app/src/components/alerts/AlertPropertiesSummary.tsx b/packages/app/src/components/alerts/AlertPropertiesSummary.tsx new file mode 100644 index 0000000000..f8eb24e851 --- /dev/null +++ b/packages/app/src/components/alerts/AlertPropertiesSummary.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { isRangeThresholdType } from '@hyperdx/common-utils/dist/types'; +import { Group } from '@mantine/core'; + +import type { AlertsPageItem } from '@/types'; +import { TILE_ALERT_THRESHOLD_TYPE_OPTIONS } from '@/utils/alerts'; +import { getWebhookChannelIcon } from '@/utils/webhookIcons'; + +type AlertPropertiesSummaryProps = { + alert: AlertsPageItem; + /** + * Include the evaluation schedule (interval and, when configured, + * consecutive-window count). On by the detail page; the alerts-page rows + * keep the shorter line. + */ + showSchedule?: boolean; +}; + +/** + * One-line alert metadata summary: threshold condition, optional evaluation + * schedule, notification channel, and creator. Shared between the alerts + * page rows and the alert detail page header. + */ +export function AlertPropertiesSummary({ + alert, + showSchedule = false, +}: AlertPropertiesSummaryProps) { + const thresholdLabel = + TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ?? + alert.thresholdType; + + return ( +
+ + If value {thresholdLabel}{' '} + {alert.threshold} + {isRangeThresholdType(alert.thresholdType) && ( + <> + {' '} + and {alert.thresholdMax ?? '-'} + + )} + + {showSchedule && ( + <> + · + 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} + + + )} +
+ ); +} diff --git a/packages/app/src/components/alerts/AlertStateBadge.tsx b/packages/app/src/components/alerts/AlertStateBadge.tsx new file mode 100644 index 0000000000..0e6d9d9c86 --- /dev/null +++ b/packages/app/src/components/alerts/AlertStateBadge.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { AlertState } from '@hyperdx/common-utils/dist/types'; +import { Badge } from '@mantine/core'; + +type AlertStateBadgeProps = { + state: AlertState; +}; + +export function AlertStateBadge({ state }: AlertStateBadgeProps) { + 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} + + ); + } +} 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..0be06f7fe3 --- /dev/null +++ b/packages/app/src/components/alerts/__tests__/AlertEvaluationsTable.test.tsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { + AlertErrorType, + AlertEvaluation, + AlertState, +} from '@hyperdx/common-utils/dist/types'; +import { fireEvent, screen, within } from '@testing-library/react'; + +import { AlertEvaluationsTable } from '@/components/alerts/AlertEvaluationsTable'; + +// Controls whether the infinite-scroll sentinel reports itself as visible. +let mockInViewport = false; +jest.mock('@mantine/hooks', () => ({ + ...jest.requireActual('@mantine/hooks'), + useInViewport: () => ({ ref: jest.fn(), inViewport: mockInViewport }), +})); + +// Render raw ISO timestamps so time assertions don't depend on the test +// runner's timezone or the user's clock-format preference. +jest.mock('@/useFormatTime', () => ({ + FormatTime: ({ value }: { value?: number | string | Date }) => + value ? new Date(value).toISOString() : null, +})); + +const okWindow: AlertEvaluation = { + counts: 0, + createdAt: '2026-04-17T12:05:00.000Z', + lastValues: [{ startTime: '2026-04-17T12:00:00.000Z', count: 3 }], + state: AlertState.OK, +}; + +const errorWindow: AlertEvaluation = { + 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 groupedFiringWindow: AlertEvaluation = { + counts: 2, + createdAt: '2026-04-17T12:15:00.000Z', + lastValues: [ + { startTime: '2026-04-17T12:10:00.000Z', count: 1 }, + { startTime: '2026-04-17T12:10:00.000Z', count: 14 }, + ], + state: AlertState.ALERT, + groups: [ + { + group: 'ServiceName:api', + state: AlertState.ALERT, + counts: 2, + lastValue: { startTime: '2026-04-17T12:10:00.000Z', count: 14 }, + fired: true, + }, + { + group: 'ServiceName:web', + state: AlertState.OK, + counts: 0, + lastValue: { startTime: '2026-04-17T12:10:00.000Z', count: 1 }, + }, + ], + groupsTotal: 2, + analytics: { + queryDurationMs: 1200, + webhookDurationMs: 340, + backfilledBuckets: 0, + }, +}; + +const backfilledWindow: AlertEvaluation = { + counts: 0, + createdAt: '2026-04-17T12:20:00.000Z', + lastValues: [ + { startTime: '2026-04-17T12:05:00.000Z', count: 0 }, + { startTime: '2026-04-17T12:10:00.000Z', count: 0 }, + { startTime: '2026-04-17T12:15:00.000Z', count: 0 }, + ], + state: AlertState.OK, + analytics: { queryDurationMs: 800, backfilledBuckets: 2 }, +}; + +const renderTable = ( + props: Partial> = {}, +) => + renderWithMantine( + , + ); + +describe('AlertEvaluationsTable', () => { + beforeEach(() => { + mockInViewport = false; + }); + + 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('labels each row with the evaluated bucket start, matching the chart', () => { + renderTable({ + evaluations: [backfilledWindow, errorWindow, okWindow], + }); + + const rows = screen.getAllByTestId('alert-evaluation-row'); + // Backfilled window (createdAt 12:20, buckets 12:05/12:10/12:15) is + // labeled with the newest evaluated bucket, not the evaluation time + expect(rows[0]).toHaveTextContent('2026-04-17T12:15:00.000Z'); + expect(rows[0]).not.toHaveTextContent('2026-04-17T12:20:00.000Z'); + // Failed evaluation has no lastValues: falls back to createdAt − interval + expect(rows[1]).toHaveTextContent('2026-04-17T12:05:00.000Z'); + expect(rows[1]).not.toHaveTextContent('2026-04-17T12:10:00.000Z'); + // OK window (createdAt 12:05) shows its bucket start 12:00 + expect(rows[2]).toHaveTextContent('2026-04-17T12:00:00.000Z'); + expect(rows[2]).not.toHaveTextContent('2026-04-17T12:05:00.000Z'); + }); + + 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('fetches the next page when the scroll sentinel enters the viewport', () => { + mockInViewport = true; + const onLoadMore = jest.fn(); + renderTable({ hasNextPage: true, onLoadMore }); + + expect( + screen.getByTestId('alert-evaluations-load-more'), + ).toBeInTheDocument(); + expect(onLoadMore).toHaveBeenCalled(); + }); + + it('does not fetch while a page is already being fetched', () => { + mockInViewport = true; + const onLoadMore = jest.fn(); + renderTable({ hasNextPage: true, isFetchingNextPage: true, onLoadMore }); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it('does not render the sentinel when there are no older pages', () => { + renderTable({ hasNextPage: false }); + expect( + screen.queryByTestId('alert-evaluations-load-more'), + ).not.toBeInTheDocument(); + }); + + it('stops auto-fetching and offers a retry once a page fetch fails', () => { + // Without this, the sentinel effect refires each time the failed fetch + // settles (isFetchingNextPage true -> false), refetching forever. + mockInViewport = true; + const onLoadMore = jest.fn(); + renderTable({ hasNextPage: true, isError: true, onLoadMore }); + + // The sentinel is unmounted, so nothing auto-fetches + expect( + screen.queryByTestId('alert-evaluations-load-more'), + ).not.toBeInTheDocument(); + expect(onLoadMore).not.toHaveBeenCalled(); + + // An explicit retry affordance replaces it + expect( + screen.getByTestId('alert-evaluations-load-error'), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it('shows a failure message when the first page fails to load', () => { + renderTable({ evaluations: [], isError: true }); + expect(screen.getByTestId('alert-evaluations-error')).toBeInTheDocument(); + expect( + screen.queryByText(/No evaluations in the selected time range/), + ).not.toBeInTheDocument(); + }); + + it('renders an empty state when the range has no evaluations', () => { + renderTable({ evaluations: [] }); + expect( + screen.getByText(/No evaluations in the selected time range/), + ).toBeInTheDocument(); + }); + + it('renders grouped windows collapsed, expanding into per-group child rows on click', () => { + renderTable({ + evaluations: [groupedFiringWindow, errorWindow, okWindow], + }); + + // Group summary on the parent row, but children stay collapsed — + // no rows auto-expand + expect(screen.getByText('1/2 groups firing')).toBeInTheDocument(); + expect(screen.queryAllByTestId('alert-evaluation-group-row')).toHaveLength( + 0, + ); + + fireEvent.click(screen.getByText('1/2 groups firing')); + + // Expanded: per-group child rows, firing first + const groupRows = screen.getAllByTestId('alert-evaluation-group-row'); + expect(groupRows).toHaveLength(2); + expect(groupRows[0]).toHaveTextContent('ServiceName:api'); + expect(groupRows[0]).toHaveTextContent('Alert'); + expect(groupRows[0]).toHaveTextContent('14'); + expect(groupRows[1]).toHaveTextContent('ServiceName:web'); + expect(groupRows[1]).toHaveTextContent('Ok'); + + // Clicking again collapses + fireEvent.click(screen.getByText('1/2 groups firing')); + expect(screen.queryAllByTestId('alert-evaluation-group-row')).toHaveLength( + 0, + ); + }); + + it('explains the server-side group cap when groups were omitted', () => { + renderTable({ + evaluations: [{ ...groupedFiringWindow, groupsTotal: 60 }], + }); + + expect(screen.getByText('1/60 groups firing')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('1/60 groups firing')); + + expect( + screen.getByText( + /Showing the top 50 of 60 groups \(firing first\) — additional groups aren't fetched\./, + ), + ).toBeInTheDocument(); + }); + + it('hides the window-level value for grouped windows', () => { + renderTable({ evaluations: [groupedFiringWindow] }); + + const parentRow = screen.getByTestId('alert-evaluation-row'); + // Latest Value cell shows a dash; per-group values live in child rows + expect(parentRow).not.toHaveTextContent('14'); + }); + + it('shows backfilled buckets on the parent row when ticks were missed', () => { + renderTable({ evaluations: [backfilledWindow, okWindow] }); + + const rows = screen.getAllByTestId('alert-evaluation-row'); + // Backfilled Buckets is the 5th column + expect(within(rows[0]).getAllByRole('cell')[4]).toHaveTextContent('2'); + // Steady-state window shows a dash in the Backfilled Buckets column + expect(within(rows[1]).getAllByRole('cell')[4]).toHaveTextContent('–'); + }); + + it('shows query and webhook durations as columns on the parent row', () => { + renderTable({ evaluations: [groupedFiringWindow, errorWindow] }); + + const rows = screen.getAllByTestId('alert-evaluation-row'); + // Visible without expanding anything + expect(rows[0]).toHaveTextContent('1.2s'); + expect(rows[0]).toHaveTextContent('340ms'); + // Window without analytics shows dashes in the duration columns + expect(rows[1]).not.toHaveTextContent('1.2s'); + expect(rows[1]).not.toHaveTextContent('340ms'); + }); +}); diff --git a/packages/app/src/components/alerts/__tests__/AlertHistoryCards.test.tsx b/packages/app/src/components/alerts/__tests__/AlertHistoryCards.test.tsx new file mode 100644 index 0000000000..fc8b6e4677 --- /dev/null +++ b/packages/app/src/components/alerts/__tests__/AlertHistoryCards.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { + AlertErrorType, + AlertHistory, + AlertState, + AlertThresholdType, +} from '@hyperdx/common-utils/dist/types'; +import { fireEvent, screen } from '@testing-library/react'; + +import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards'; +import type { AlertsPageItem } from '@/types'; + +const makeAlert = (history: AlertHistory[]): AlertsPageItem => ({ + _id: 'alert-1', + interval: '5m', + threshold: 1, + thresholdType: AlertThresholdType.ABOVE, + channel: { type: 'webhook' }, + createdAt: '2026-04-17T00:00:00.000Z', + updatedAt: '2026-04-17T00:00:00.000Z', + history, +}); + +const okWindow: AlertHistory = { + counts: 0, + createdAt: '2026-04-17T12:05:00.000Z', + lastValues: [{ startTime: '2026-04-17T12:00:00.000Z', count: 0 }], + 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_ERROR, + message: 'clickhouse kaput', + }, + ], +}; + +describe('AlertHistoryCardList', () => { + it('renders errored evaluations as buttons that open the error details modal', async () => { + renderWithMantine( + , + ); + + const errorSegment = screen.getByRole('button', { + name: 'View evaluation errors', + }); + expect(errorSegment).toBeInTheDocument(); + expect(screen.queryByText('clickhouse kaput')).not.toBeInTheDocument(); + + fireEvent.click(errorSegment); + + // The modal content mounts asynchronously (Mantine portal + transition) + expect(await screen.findByText(/Query Error/)).toBeInTheDocument(); + expect(await screen.findByText('clickhouse kaput')).toBeInTheDocument(); + }); + + it('does not render error buttons for normal windows', () => { + renderWithMantine(); + expect( + screen.queryByRole('button', { name: 'View evaluation errors' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/config.ts b/packages/app/src/config.ts index e687a6e4f7..a282c38e5f 100644 --- a/packages/app/src/config.ts +++ b/packages/app/src/config.ts @@ -73,6 +73,10 @@ export const IS_SESSIONS_ENABLED = true; export const IS_PROMQL_ENABLED = env('NEXT_PUBLIC_ENABLE_PROMQL') === 'true'; export const IS_DASHBOARD_VARIABLES_ENABLED = env('NEXT_PUBLIC_ENABLE_DASHBOARD_VARIABLES') === 'true'; +// Alert detail page (/alerts/:id). Default off — currently enabled only in +// dev (.env.development) and CI (e2e webserver) while the feature bakes. +export const IS_ALERT_DETAILS_ENABLED = + env('NEXT_PUBLIC_ENABLE_ALERT_DETAILS') === 'true'; // Not exported: IS_IAC_EXPORT_ENABLED below is the only gate callers should // read. Leaving the raw flag importable re-opens the "forgot the local-mode // check" mistake that folding the two together was meant to close. diff --git a/packages/app/styles/AlertsPage.module.scss b/packages/app/styles/AlertsPage.module.scss index 6261985499..59fc0cf406 100644 --- a/packages/app/styles/AlertsPage.module.scss +++ b/packages/app/styles/AlertsPage.module.scss @@ -43,11 +43,29 @@ background-color: var(--color-bg-warning); } + // Errored evaluation: striped red so it reads as "bad" but is visually + // distinct from a solid firing (alarm) segment. + &.error { + background-color: var(--color-bg-danger); + background-image: repeating-linear-gradient( + -45deg, + transparent 0 2px, + rgb(0 0 0 / 45%) 2px 4px + ); + } + &.clickable { cursor: pointer; } } +// Child rows (per-group breakdown / error details) under an evaluation +// window row in the alert detail page's event stream. Muted background keeps +// parent windows visually dominant when several are expanded. +.evaluationChildRow { + background-color: var(--color-bg-muted); +} + .alertRow { display: flex; align-items: center; diff --git a/packages/app/tests/e2e/features/alerts.spec.ts b/packages/app/tests/e2e/features/alerts.spec.ts index c8bdb9e5be..464cab2827 100644 --- a/packages/app/tests/e2e/features/alerts.spec.ts +++ b/packages/app/tests/e2e/features/alerts.spec.ts @@ -718,6 +718,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..685b9d6483 100644 --- a/packages/app/tests/e2e/page-objects/AlertsPage.ts +++ b/packages/app/tests/e2e/page-objects/AlertsPage.ts @@ -115,6 +115,45 @@ 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 alert-name link for a given alert card. With alert details + * enabled it navigates to /alerts/:id; the source tile / saved search is + * a separate secondary link (alert-source-link-*). + */ + getDetailsLinkForAlertCard(alertCard: Locator) { + return alertCard.locator('[data-testid^="alert-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() {