+ {/* 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 && (
+
+ {linkTitle}
+
+ )}
);
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.
+
+
+ Retry
+
+
+
+ ) : (
+
+ ))}
+ >
+ );
+}
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() {