+ {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 ? (
+
)}
+ 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/__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..b82d4b76bf 100644
--- a/packages/app/tests/e2e/page-objects/AlertsPage.ts
+++ b/packages/app/tests/e2e/page-objects/AlertsPage.ts
@@ -115,6 +115,43 @@ export class AlertsPage {
await icon.click();
}
+ /**
+ * Get the errored-evaluation segment(s) in an alert card's history strip.
+ * Rendered as clickable buttons (unlike normal segments which are links).
+ */
+ getErrorHistorySegments(alertCard: Locator) {
+ return alertCard.getByRole('button', { name: 'View evaluation errors' });
+ }
+
+ /**
+ * The per-evaluation error details modal (opened by clicking an errored
+ * history segment).
+ */
+ get evaluationErrorModal() {
+ return this.page.getByRole('dialog', { name: /Evaluation Errors/ });
+ }
+
+ /**
+ * Get the "Details" link for a given alert card (navigates to /alerts/:id).
+ */
+ getDetailsLinkForAlertCard(alertCard: Locator) {
+ return alertCard.locator('[data-testid^="alert-details-link-"]');
+ }
+
+ /**
+ * The alert detail page root.
+ */
+ get detailPageContainer() {
+ return this.page.locator('[data-testid="alert-detail-page"]');
+ }
+
+ /**
+ * The evaluation event-stream table on the alert detail page.
+ */
+ get evaluationsTable() {
+ return this.page.locator('[data-testid="alert-evaluations-table"]');
+ }
+
// --- Filter interactions ---
get filters() {
From 61d7c8f8cb6c9f2e95016283a336ce7c6bcad4c8 Mon Sep 17 00:00:00 2001
From: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:13:02 -0700
Subject: [PATCH 2/5] refactor(app): split AlertEvaluationsTable into focused
components
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The evaluations table combined row rendering, expansion behavior, error
details, and infinite-scroll states in one 396-line file, over the repo's
300-line component guideline. Split by responsibility with no functionality
change:
- AlertStateBadge.tsx — the state badge (also folds the stateBadge()
indirection into the component; it was aliased for export anyway)
- AlertEvaluationRow.tsx — the expandable per-window parent row, per-group
child rows, and row-level formatting helpers
- AlertEvaluationsTable.tsx — the table shell, loading/empty/error states,
and the infinite-scroll sentinel/retry affordance
AlertDetailPage now imports AlertStateBadge from its own module. Existing
unit tests exercise everything through the public AlertEvaluationsTable
component and pass unchanged.
---
packages/app/src/AlertDetailPage.tsx | 6 +-
.../components/alerts/AlertEvaluationRow.tsx | 228 +++++++++++++++
.../alerts/AlertEvaluationsTable.tsx | 276 ++----------------
.../src/components/alerts/AlertStateBadge.tsx | 38 +++
4 files changed, 286 insertions(+), 262 deletions(-)
create mode 100644 packages/app/src/components/alerts/AlertEvaluationRow.tsx
create mode 100644 packages/app/src/components/alerts/AlertStateBadge.tsx
diff --git a/packages/app/src/AlertDetailPage.tsx b/packages/app/src/AlertDetailPage.tsx
index 07a9396684..032c02dc93 100644
--- a/packages/app/src/AlertDetailPage.tsx
+++ b/packages/app/src/AlertDetailPage.tsx
@@ -20,11 +20,9 @@ import { IconExternalLink } from '@tabler/icons-react';
import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertDetailChart } from '@/components/alerts/AlertDetailChart';
-import {
- AlertEvaluationsTable,
- AlertStateBadge,
-} from '@/components/alerts/AlertEvaluationsTable';
+import { AlertEvaluationsTable } from '@/components/alerts/AlertEvaluationsTable';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
+import { AlertStateBadge } from '@/components/alerts/AlertStateBadge';
import EmptyState from '@/components/EmptyState';
import { PageHeader } from '@/components/PageHeader';
import { TimePicker } from '@/components/TimePicker';
diff --git a/packages/app/src/components/alerts/AlertEvaluationRow.tsx b/packages/app/src/components/alerts/AlertEvaluationRow.tsx
new file mode 100644
index 0000000000..25eb63a257
--- /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. */
+export 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
index 851fd2459e..934d03c2dc 100644
--- a/packages/app/src/components/alerts/AlertEvaluationsTable.tsx
+++ b/packages/app/src/components/alerts/AlertEvaluationsTable.tsx
@@ -1,264 +1,25 @@
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 {
- Badge,
Button,
Center,
Group,
Loader,
Skeleton,
- Stack,
Table,
Text,
- Tooltip,
} from '@mantine/core';
import { useInViewport } from '@mantine/hooks';
-import { IconChevronDown, IconChevronRight } from '@tabler/icons-react';
-import {
- ALERT_ERROR_TYPE_LABELS,
- AlertErrorsContent,
-} from '@/components/alerts/AlertHistoryCards';
-import { FormatTime } from '@/useFormatTime';
-import { formatDurationMs } from '@/utils';
-
-import styles from '@styles/AlertsPage.module.scss';
+import { AlertEvaluationRow } from '@/components/alerts/AlertEvaluationRow';
-const TABLE_COLUMNS = 8;
-
-export function AlertStateBadge({ state }: { state: AlertState }) {
- return stateBadge(state);
-}
-
-function stateBadge(state: AlertState) {
- switch (state) {
- case AlertState.ALERT:
- return (
-
- Alert
-
- );
- case AlertState.PENDING:
- return (
-
- Pending
-
- );
- case AlertState.ERROR:
- return (
-
- Error
-
- );
- case AlertState.OK:
- return Ok;
- default:
- return (
-
- {state}
-
- );
- }
-}
-
-function latestValue(history: AlertEvaluation): number | undefined {
- const lastValues = history.lastValues;
- if (!lastValues || lastValues.length === 0) {
- return undefined;
- }
- return lastValues[lastValues.length - 1].count;
-}
-
-/** Child row: one group's result within the parent evaluation window. */
-function GroupRow({ group }: { group: AlertEvaluationGroup }) {
- return (
-
-
-
-
- └
-
-
- {group.group}
-
-
-
- {stateBadge(group.state)}
-
- {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) : '–';
-}
-
-function EvaluationRow({
- history,
- interval,
-}: {
- history: AlertEvaluation;
- interval: AlertInterval;
-}) {
- 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"
- >
-
-
-
-
-
-
-
-
- {stateBadge(history.state)}
- {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 && (
-
-
-
-
-
-
-
- )}
- >
- )}
- >
- );
-}
+type LoadMoreSentinelProps = {
+ isFetchingNextPage: boolean;
+ onLoadMore: () => void;
+};
/**
* Sentinel row at the bottom of the event stream: when scrolled into view
@@ -268,10 +29,7 @@ function EvaluationRow({
function LoadMoreSentinel({
isFetchingNextPage,
onLoadMore,
-}: {
- isFetchingNextPage: boolean;
- onLoadMore: () => void;
-}) {
+}: LoadMoreSentinelProps) {
const { ref, inViewport } = useInViewport();
React.useEffect(() => {
@@ -292,6 +50,16 @@ function LoadMoreSentinel({
);
}
+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
@@ -306,15 +74,7 @@ export function AlertEvaluationsTable({
hasNextPage,
isFetchingNextPage,
onLoadMore,
-}: {
- evaluations: AlertEvaluation[];
- interval: AlertInterval;
- isLoading: boolean;
- isError: boolean;
- hasNextPage: boolean;
- isFetchingNextPage: boolean;
- onLoadMore: () => void;
-}) {
+}: AlertEvaluationsTableProps) {
if (isLoading) {
return ;
}
@@ -356,7 +116,7 @@ export function AlertEvaluationsTable({
{evaluations.map(history => (
-
+ Alert
+
+ );
+ case AlertState.PENDING:
+ return (
+
+ Pending
+
+ );
+ case AlertState.ERROR:
+ return (
+
+ Error
+
+ );
+ case AlertState.OK:
+ return Ok;
+ default:
+ return (
+
+ {state}
+
+ );
+ }
+}
From 22893d1ce95223946fdd1779f46b16a2a4b53027 Mon Sep 17 00:00:00 2001
From: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:22:04 -0700
Subject: [PATCH 3/5] fix(app): unexport TABLE_COLUMNS (knip: unused export)
---
packages/app/src/components/alerts/AlertEvaluationRow.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/app/src/components/alerts/AlertEvaluationRow.tsx b/packages/app/src/components/alerts/AlertEvaluationRow.tsx
index 25eb63a257..d87ab766ee 100644
--- a/packages/app/src/components/alerts/AlertEvaluationRow.tsx
+++ b/packages/app/src/components/alerts/AlertEvaluationRow.tsx
@@ -21,7 +21,7 @@ import { formatDurationMs } from '@/utils';
import styles from '@styles/AlertsPage.module.scss';
/** Mirrors the header column count in AlertEvaluationsTable. */
-export const TABLE_COLUMNS = 8;
+const TABLE_COLUMNS = 8;
function latestValue(history: AlertEvaluation): number | undefined {
const lastValues = history.lastValues;
From 9eb84c4c69adc1b3ffeb7cefc8340806e34480d2 Mon Sep 17 00:00:00 2001
From: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Date: Tue, 11 Aug 2026 18:42:00 -0700
Subject: [PATCH 4/5] feat(app): scale the alert detail page's default time
range with the interval
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fixed 'Past 12h' default renders only a bucket or two for long-interval
alerts — the chart buckets at the alert's evaluation interval, so for 12h/1d
intervals granularity >= duration and the chart looks broken. Derive the
default from the interval instead (12h for <=15m intervals up to 30d for 1d
intervals), always showing at least ~24 evaluation windows. An explicit
from/to in the URL still takes precedence.
---
packages/app/src/AlertDetailPage.tsx | 42 +++++++++++++++++++++++-----
1 file changed, 35 insertions(+), 7 deletions(-)
diff --git a/packages/app/src/AlertDetailPage.tsx b/packages/app/src/AlertDetailPage.tsx
index 032c02dc93..97b00cee82 100644
--- a/packages/app/src/AlertDetailPage.tsx
+++ b/packages/app/src/AlertDetailPage.tsx
@@ -3,6 +3,7 @@ import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
+ AlertInterval,
AlertSource,
isRangeThresholdType,
} from '@hyperdx/common-utils/dist/types';
@@ -43,11 +44,30 @@ import type { AlertsPageItem } from './types';
import styles from '@styles/AlertsPage.module.scss';
-const DEFAULT_TIME_RANGE_LABEL = 'Past 12h';
-const defaultTimeRange = parseTimeQuery(DEFAULT_TIME_RANGE_LABEL, false) as [
- Date,
- Date,
-];
+/**
+ * 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.
@@ -103,11 +123,19 @@ function AlertProperties({ alert }: { alert: AlertsPageItem }) {
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(
- DEFAULT_TIME_RANGE_LABEL,
+ defaultTimeRangeLabel,
);
const { searchedTimeRange, onSearch } = useNewTimeQuery({
- initialDisplayValue: DEFAULT_TIME_RANGE_LABEL,
+ initialDisplayValue: defaultTimeRangeLabel,
initialTimeRange: defaultTimeRange,
setDisplayedTimeInputValue,
});
From cc24784c458b94f43d571719626d866adb38e747 Mon Sep 17 00:00:00 2001
From: Warren Lee <5959690+wrn14897@users.noreply.github.com>
Date: Tue, 11 Aug 2026 19:05:34 -0700
Subject: [PATCH 5/5] refactor(app): share alert properties summary; alert name
links to details
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Address review feedback on the alerts list:
- Extract AlertPropertiesSummary (threshold condition, optional evaluation
schedule, notification channel, creator) — previously duplicated between
the alerts-page rows and the detail page header. The detail page opts into
the schedule segment (interval + consecutive windows); the list rows keep
the shorter line.
- With alert details enabled, the alert name now links to /alerts/:id — the
status page is the natural landing spot for an alert — and the source
(dashboard tile / saved search) moves to a secondary right-side link,
replacing the Details button. Flag off keeps the previous behavior
(name -> source, no extra link).
---
packages/app/src/AlertDetailPage.tsx | 49 +------------
packages/app/src/AlertsPage.tsx | 66 ++++-------------
.../alerts/AlertPropertiesSummary.tsx | 73 +++++++++++++++++++
.../app/tests/e2e/page-objects/AlertsPage.ts | 6 +-
4 files changed, 94 insertions(+), 100 deletions(-)
create mode 100644 packages/app/src/components/alerts/AlertPropertiesSummary.tsx
diff --git a/packages/app/src/AlertDetailPage.tsx b/packages/app/src/AlertDetailPage.tsx
index 97b00cee82..c87719a462 100644
--- a/packages/app/src/AlertDetailPage.tsx
+++ b/packages/app/src/AlertDetailPage.tsx
@@ -2,11 +2,7 @@ import * as React from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
-import {
- AlertInterval,
- AlertSource,
- isRangeThresholdType,
-} from '@hyperdx/common-utils/dist/types';
+import { AlertInterval, AlertSource } from '@hyperdx/common-utils/dist/types';
import {
Anchor,
Breadcrumbs,
@@ -23,6 +19,7 @@ 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';
@@ -30,8 +27,6 @@ import { TimePicker } from '@/components/TimePicker';
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 {
AlertNote,
getAlertDisplayName,
@@ -74,47 +69,9 @@ function getDefaultTimeRangeLabel(interval: AlertInterval): string {
const TIMELINE_ITEMS = 60;
function AlertProperties({ alert }: { alert: AlertsPageItem }) {
- const thresholdLabel =
- TILE_ALERT_THRESHOLD_TYPE_OPTIONS[alert.thresholdType] ??
- alert.thresholdType;
-
return (
-
-
- If value {thresholdLabel}{' '}
- {alert.threshold}
- {isRangeThresholdType(alert.thresholdType) && (
- <>
- {' '}
- and {alert.thresholdMax ?? '-'}
- >
- )}
-
- ·
- Evaluates every {alert.interval}
- {alert.numConsecutiveWindows != null &&
- alert.numConsecutiveWindows > 1 && (
- <>
- ·
-
- Fires after {alert.numConsecutiveWindows} consecutive windows
-
- >
- )}
- ·
-
- Notify via {getWebhookChannelIcon(alert.channel.type)} Webhook
-
- {alert.createdBy && (
- <>
- ·
-
- Created by {alert.createdBy.name || alert.createdBy.email}
-
- >
- )}
-
+
{alert.note && }
);
diff --git a/packages/app/src/AlertsPage.tsx b/packages/app/src/AlertsPage.tsx
index 8edbe3ded2..f3338656dc 100644
--- a/packages/app/src/AlertsPage.tsx
+++ b/packages/app/src/AlertsPage.tsx
@@ -4,11 +4,7 @@ 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,
@@ -41,14 +37,13 @@ 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';
@@ -175,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:
@@ -235,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}
@@ -247,18 +220,7 @@ function AlertDetails({ alert }: { alert: AlertsPageItem }) {
+ );
+}
diff --git a/packages/app/tests/e2e/page-objects/AlertsPage.ts b/packages/app/tests/e2e/page-objects/AlertsPage.ts
index b82d4b76bf..685b9d6483 100644
--- a/packages/app/tests/e2e/page-objects/AlertsPage.ts
+++ b/packages/app/tests/e2e/page-objects/AlertsPage.ts
@@ -132,10 +132,12 @@ export class AlertsPage {
}
/**
- * Get the "Details" link for a given alert card (navigates to /alerts/:id).
+ * 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-details-link-"]');
+ return alertCard.locator('[data-testid^="alert-link-"]');
}
/**