From 5feda4836c74939b50d05a13fc485152137b3e46 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:54:49 +0100 Subject: [PATCH] Lead open History rows with the live reading of held alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts > History is a log of occurrences, but its open row is the one still in progress: it shows an Active badge and a running duration, so "Node temperature at 95.0°C" beside them reads as the reading now while the node sits at 78°C under its 80°C trigger. The Alerts card one tab away already says "78°C now, back under the 80°C alert level", so the two pages disagreed about the same alert. An active row for a threshold alert with a usable live status now leads with the same presentation summary. The desktop row keeps the clear rule and the last breach in its hover; the phone card shows them as muted lines because a phone has no hover. The row's description stays the recorded message, so search and the Assistant handoff still read the record, and closed rows and open rows without a live status are unchanged. The reading rechecks on the shared clock, read only for open rows, so it turns stale when evaluations stop without re-rendering every closed row on each tick. Refs #2068 --- .../v6/internal/subsystems/alerts.md | 13 ++ .../subsystems/frontend-primitives.md | 9 ++ .../9c0e53268f0d014f.json | 45 +++++++ .../alerts/AlertHistoryMobileList.tsx | 20 ++- .../alerts/AlertHistoryTableAlertRow.tsx | 10 +- .../__tests__/AlertHistoryMobileList.test.tsx | 58 +++++++++ .../AlertHistoryTableAlertRow.test.tsx | 122 ++++++++++++++++++ .../__tests__/alertHistoryModel.test.ts | 114 +++++++++++++++- .../src/features/alerts/alertHistoryModel.ts | 41 ++++++ .../pages/__tests__/Alerts.helpers.test.ts | 4 + 10 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/browser-verification/9c0e53268f0d014f.json create mode 100644 frontend-modern/src/features/alerts/__tests__/AlertHistoryTableAlertRow.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index e00fd188b7..6a811a308c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -49,6 +49,19 @@ recorded message. Every disk alert on a guest carries the guest's name, so the presentation names the disk from `metadata.label`, as the per-disk message does. +### History leads its open row with the live reading — issue #2068 + +Alerts > History is a log of occurrences, but its open row is the one +occurrence still in progress: it already shows an Active badge and a running +duration, so a breach message beside them reads as the reading now. An active +history row for a threshold alert with a usable live status leads with the +presentation's summary; the desktop row carries the clear rule and last +breach in its hover, and the phone card shows them as muted lines because it +has no hover. `buildAlertHistoryItems` keeps `description` as the recorded +message, so search and the Assistant handoff still read the record, and +attaches the source alert as `liveAlert` only to active, non-system rows. +Closed rows, and open ones without a live status, keep their recorded message. + ### Retained guest observations are not alert evidence Guest memory marked `last-known`, `unavailable` or an unknown observation state diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 53b15f10dd..feda622ea2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -43,6 +43,15 @@ card's left column (status icon and text) keeps a 16rem floor and the text breaks long words, so the action buttons wrap instead of squeezing the reading to its longest word beside them. +### History rows read held alerts through the same helper — issue #2068 + +`AlertHistoryTableAlertRow` and `AlertHistoryMobileList` take their message +copy from `getAlertHistoryRowCopy` in `features/alerts/alertHistoryModel.ts`, +which wraps the same `metricAlertPresentation.ts` helper for an active row's +`liveAlert`. It reads the `useRelativeTimeNow` clock only for rows with a live +alert, so a reading that stops updating turns stale on screen while closed +rows never re-render on the tick. + ### Canonical drawer History preserves guest read provenance The shared resource drawer passes selected memory observation state/source/time diff --git a/frontend-modern/browser-verification/9c0e53268f0d014f.json b/frontend-modern/browser-verification/9c0e53268f0d014f.json new file mode 100644 index 0000000000..fb11cc38ea --- /dev/null +++ b/frontend-modern/browser-verification/9c0e53268f0d014f.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "a570d9f335cf8cd13a9df00a123a52234ec04a23", + "verified_at": "2026-10-07T07:58:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx", + "frontend-modern/src/features/alerts/alertHistoryModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "71cf89c4a4bba27a8efdf3332260d033273185359c45eb01a6dcf7317cf1b53f", + "frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx": "b2b4199c76b0ccee79fad658bf91cb44174d5dbe5491942e846dc196af06e608", + "frontend-modern/src/features/alerts/alertHistoryModel.ts": "5e81adf1ad8c42224f250fbf9dc5e9b13fbfa0ed92b96802bb1af56cd139cbd6" + }, + "routes": [ + "/alerts/history?q=pve2", + "/alerts/history?q=95.0" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from this tree (main bea0cec8b9 + alert-card follow-up #2637 + this change), with an uncommitted generator overlay pinning pve2 CPU temperature; node temperature alert level 80\u00b0C, clear level 75\u00b0C, 5 minute recovery delay", + "History desktop row, held at 78\u00b0C: \"Temperature 78\u00b0C now, back under the 80\u00b0C alert level\", hover adds \"Stays open until it reaches 75\u00b0C or lower and stays there for 5 minutes.\" and \"Last reading at or above 80\u00b0C: 95\u00b0C\"; the breach message \"Node temperature at 95.0\u00b0C\" is not shown; light and dark", + "History phone card at 390px, held: live summary, clear rule and last breach as muted lines under it, no horizontal scroll, light and dark", + "History desktop row and phone card, recovering at 72\u00b0C: \"Temperature 72\u00b0C now, recovering\" with \"Clears after 5 minutes at 75\u00b0C or lower, 1 minute so far.\"", + "Search q=95.0 finds the held pve2 row through its recorded message while the row reads 72\u00b0C", + "Active non-threshold row (powered off) and resolved/acknowledged rows keep their recorded messages; no console errors on either route", + "Earlier pass on the pre-merge content (identical row code; the phone card now also carries main's moved-close line for closed rows): breaching at 95\u00b0C, Fahrenheit preference (172\u00b0F now, 176\u00b0F alert level, 203\u00b0F breach), resolved alert leaving the list" + ], + "interactions": [ + "Walked pve2 temperature 95 -> 78 -> 72 via the control file", + "Read description cell text and hover titles (desktop) and card paragraphs (phone) with Playwright in each phase", + "Loaded the q=pve2 and q=95.0 search URLs and collected console errors", + "Captured light and dark screenshots at 1440x900 and 390x844" + ] +} diff --git a/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx index 0cffed81e0..beee22a902 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx @@ -1,5 +1,6 @@ import { Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { aiChatStore } from '@/stores/aiChat'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { getAlertHistoryResourceTypeBadgeClass } from '@/utils/alertHistoryPresentation'; import { @@ -8,6 +9,7 @@ import { } from '@/utils/alertIncidentPresentation'; import { AlertHistoryItemActions } from './AlertHistoryItemActions'; +import { getAlertHistoryRowCopy } from './alertHistoryModel'; import { PlatformWindowedList } from '@/features/platformPage/PlatformWindowedList'; import { getGroupSummaryLabel } from './AlertHistoryTableGroupRow'; import { @@ -30,6 +32,9 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) { const [investigation, setInvestigation] = createSignal( null, ); + // Open threshold alerts lead with their live reading, which turns stale when + // evaluations stop, so it rechecks on the shared clock. + const now = useRelativeTimeNow(); const mobileItems = createMemo(() => { const items: AlertHistoryMobileItem[] = []; @@ -199,6 +204,7 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) { const alert = item.alert; const rowKey = () => props.state.getIncidentRowKey(alert); const status = () => getAlertHistoryStatusPresentation(alert.status); + const descriptionCopy = () => getAlertHistoryRowCopy(alert, now); return (
-

{alert.description}

+

+ {descriptionCopy().text} +

+ {/* A phone has no hover, so what clears the alert and its last + breach stay visible under the live reading. */} + + {(detail) =>

{detail()}

} +
+ + {(lastBreach) => ( +

{lastBreach()}

+ )} +
{(detail) =>

{detail()}

}
diff --git a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx index 13d1b3b797..b760d3ae24 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx @@ -8,8 +8,10 @@ import { getAlertHistoryStatusPresentation, getAlertIncidentLevelBadgeClass, } from '@/utils/alertIncidentPresentation'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { AlertHistoryItemActions } from './AlertHistoryItemActions'; +import { getAlertHistoryRowCopy } from './alertHistoryModel'; import { AlertResourceIncidentsPanel } from './AlertResourceIncidentsPanel'; import type { AlertHistoryState } from './useAlertHistoryState'; @@ -23,6 +25,10 @@ interface AlertHistoryTableAlertRowProps { export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) { const rowKey = () => props.state.getIncidentRowKey(props.alert); const historyStatusPresentation = () => getAlertHistoryStatusPresentation(props.alert.status); + // An open threshold alert's live reading turns stale when evaluations stop, + // so it rechecks on the shared clock; the last breach moves to the hover. + const now = useRelativeTimeNow(); + const descriptionCopy = () => getAlertHistoryRowCopy(props.alert, now); return ( <> @@ -62,9 +68,9 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps) - {props.alert.description} + {descriptionCopy().text} ({ + InvestigateAlertButton: () => null, +})); + function createState() { const alert = { id: 'alert-1', @@ -138,6 +143,59 @@ describe('AlertHistoryMobileList', () => { expect(screen.getAllByRole('heading', { name: 'Resource incidents' })).toHaveLength(1); }); + it('leads an open threshold alert with its live reading and keeps the breach visible', () => { + const { state, setGroupedAlerts } = createState(); + const observedAt = new Date(Date.now() - 10_000).toISOString(); + setGroupedAlerts([ + { + label: 'Today (August 4th)', + fullLabel: 'Today, August 4th 2026', + alerts: [ + { + id: 'minipc::metric-threshold:temperature', + source: 'alert', + resourceId: 'node-1', + resourceName: 'minipc', + resourceType: 'node', + title: 'Temperature', + description: 'Node temperature at 95.0°C', + severity: 'warning', + status: 'active', + duration: '30m', + startTime: '2026-08-04T14:30:00.000Z', + node: 'minipc', + nodeDisplayName: 'minipc', + rawAlertType: 'temperature', + liveAlert: { + type: 'temperature', + value: 95, + lastSeen: new Date(Date.now() - 20 * 60 * 1000).toISOString(), + metricStatus: { + phase: 'latched', + value: 78, + unit: '°C', + observedAt, + trigger: 80, + recovery: 75, + recoveryDelaySeconds: 300, + }, + }, + } as never, + ], + }, + ]); + + render(() => ); + + expect(screen.getByText('Temperature 78°C now, back under the 80°C alert level')).toBeVisible(); + expect( + screen.getByText('Stays open until it reaches 75°C or lower and stays there for 5 minutes.'), + ).toBeVisible(); + // A phone has no hover, so the last breach stays on the card. + expect(screen.getByText(/^Last reading at or above 80°C: 95°C, /)).toBeVisible(); + expect(screen.queryByText('Node temperature at 95.0°C')).toBeNull(); + }); + it('keeps resource investigation scrolling outside the virtualized history row', async () => { const { state } = createState(); diff --git a/frontend-modern/src/features/alerts/__tests__/AlertHistoryTableAlertRow.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertHistoryTableAlertRow.test.tsx new file mode 100644 index 0000000000..03f55e75db --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/AlertHistoryTableAlertRow.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { createStore } from 'solid-js/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Alert, MetricAlertStatus } from '@/types/api'; + +import { AlertHistoryTableAlertRow } from '../AlertHistoryTableAlertRow'; +import type { HistoryItem } from '../alertHistoryModel'; +import type { AlertHistoryState } from '../useAlertHistoryState'; + +// Open rows offer the Assistant investigation, which needs the router. +vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ + InvestigateAlertButton: () => null, +})); + +const NOW = Date.parse('2026-10-06T19:00:00Z'); + +function createState() { + const [expandedIncidents] = createSignal(new Set()); + const [resourceIncidentPanel] = createSignal(null); + return { + getIncidentRowKey: (alert: HistoryItem) => `${alert.id}-row`, + expandedIncidents, + resourceIncidentPanel, + formatAlertRowTime: () => '18:30', + formatAlertRowTimestamp: () => 'Tuesday, 6 October 2026 at 18:30:00', + } as unknown as AlertHistoryState; +} + +const historyItem = (overrides: Partial): HistoryItem => ({ + id: 'minipc::metric-threshold:temperature', + source: 'alert', + status: 'active', + startTime: '2026-10-06T18:30:00Z', + duration: '30m', + resourceName: 'minipc', + resourceType: 'Node', + resourceId: 'minipc', + node: 'minipc', + severity: 'warning', + title: 'Temperature', + rawAlertType: 'temperature', + description: 'Node temperature at 95.0°C', + ...overrides, +}); + +const renderRow = (alert: HistoryItem) => + render(() => ( + + + + +
+ )); + +describe('AlertHistoryTableAlertRow', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + // minipc on 2026-10-06: the node read 78°C under its 80°C trigger and the + // open row still said "Node temperature at 95.0°C". + it('leads an open threshold alert with the reading now and follows it live', () => { + const [liveAlert, setLiveAlert] = createStore< + Pick + >({ + type: 'temperature', + value: 95, + lastSeen: '2026-10-06T18:40:00Z', + metricStatus: { + phase: 'latched', + value: 78, + unit: '°C', + observedAt: new Date(NOW - 10_000).toISOString(), + trigger: 80, + recovery: 75, + recoveryDelaySeconds: 300, + }, + }); + renderRow(historyItem({ liveAlert })); + + const cell = screen.getByText('Temperature 78°C now, back under the 80°C alert level'); + expect(cell.getAttribute('title')?.split('\n')).toEqual([ + 'Temperature 78°C now, back under the 80°C alert level', + 'Stays open until it reaches 75°C or lower and stays there for 5 minutes.', + 'Last reading at or above 80°C: 95°C, 20 mins ago', + ]); + expect(screen.queryByText('Node temperature at 95.0°C')).toBeNull(); + + // The next evaluation reaches the clear level; the row follows it. + setLiveAlert('metricStatus', (status) => ({ + ...(status as MetricAlertStatus), + phase: 'recovering', + value: 72, + observedAt: new Date(NOW).toISOString(), + })); + expect(cell.textContent).toBe('Temperature 72°C now, recovering'); + + // Evaluations stop: on the shared clock the reading stops being "now". + vi.advanceTimersByTime(15 * 60 * 1000); + expect(cell.textContent).toMatch(/^Last reading: Temperature 72°C, /); + }); + + it('keeps the recorded message on a resolved row', () => { + renderRow( + historyItem({ + status: 'resolved', + endTime: '2026-10-06T18:50:00Z', + description: 'Resolved: Node temperature at 74.0°C', + }), + ); + expect(screen.getByText('Resolved: Node temperature at 74.0°C')).toHaveAttribute( + 'title', + 'Resolved: Node temperature at 74.0°C', + ); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts index 0a9bc19072..58aa21e381 100644 --- a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { Alert } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -11,6 +11,7 @@ import { buildSelectedBucketDetails, formatAlertHistoryDuration, getAlertBucketDurationLabel, + getAlertHistoryRowCopy, type HistoryItem, } from '../alertHistoryModel'; @@ -120,3 +121,114 @@ describe('alertHistoryModel', () => { expect(sorted.map((item) => item.id)).toEqual(['newest', 'alpha', 'mid', 'zeta']); }); }); + +// minipc on 2026-10-06: the node temperature alert opened at 95°C, the node +// then read 78°C under its 80°C trigger, and the History row for the open +// alert still said "Node temperature at 95.0°C". +describe('alert history rows for held threshold alerts', () => { + const NOW = Date.parse('2026-10-06T19:00:00Z'); + const heldAlert = (status?: Partial>): Alert => + ({ + id: 'homelab-minipc::metric-threshold:temperature', + type: 'temperature', + level: 'warning', + resourceId: 'homelab-minipc', + resourceName: 'minipc', + node: 'minipc', + instance: 'homelab', + message: 'Node temperature at 95.0°C', + value: 95, + threshold: 80, + startTime: '2026-10-06T18:30:00Z', + lastSeen: '2026-10-06T18:40:00Z', + acknowledged: false, + metricStatus: status + ? { + phase: 'latched', + value: 78, + unit: '°C', + observedAt: new Date(NOW - 10_000).toISOString(), + trigger: 80, + recovery: 75, + recoveryDelaySeconds: 300, + ...status, + } + : undefined, + }) as Alert; + const build = (activeAlerts: Record, alertHistory: Alert[] = []) => + buildAlertHistoryItems({ + activeAlerts, + alertHistory, + getResource: () => undefined, + allResources: [], + now: NOW, + }); + + it('leads the open row with the live reading and keeps the breach for hover', () => { + const alert = heldAlert({ phase: 'latched', value: 78 }); + const [item] = build({ [alert.id]: alert }); + + // The record itself is unchanged: search and the Assistant handoff read it. + expect(item.description).toBe('Node temperature at 95.0°C'); + const copy = getAlertHistoryRowCopy(item, () => NOW); + expect(copy.text).toBe('Temperature 78°C now, back under the 80°C alert level'); + expect(copy.detail).toBe( + 'Stays open until it reaches 75°C or lower and stays there for 5 minutes.', + ); + expect(copy.lastBreach).toMatch(/^Last reading at or above 80°C: 95°C, /); + expect(copy.title.split('\n')).toEqual([copy.text, copy.detail, copy.lastBreach]); + }); + + it('says a recovering open row is recovering, with its progress', () => { + const alert = heldAlert({ phase: 'recovering', value: 72, recoveryElapsedSeconds: 130 }); + const copy = getAlertHistoryRowCopy(build({ [alert.id]: alert })[0], () => NOW); + expect(copy.text).toBe('Temperature 72°C now, recovering'); + expect(copy.detail).toBe('Clears after 5 minutes at 75°C or lower, 2 minutes so far.'); + }); + + it('stops calling a reading "now" once evaluations stop', () => { + const alert = heldAlert({ phase: 'latched', value: 78 }); + const [item] = build({ [alert.id]: alert }); + const copy = getAlertHistoryRowCopy(item, () => NOW + 15 * 60 * 1000); + expect(copy.text).toMatch(/^Last reading: Temperature 78°C, /); + expect(copy.text).not.toContain('now'); + }); + + it('keeps the recorded message on closed rows and open rows without a live status', () => { + const open = heldAlert(); + const closed = { + ...heldAlert({ phase: 'latched', value: 78 }), + id: 'homelab-minipc::metric-threshold:cpu', + message: 'Resolved: Node temperature at 74.0°C', + }; + const clock = vi.fn(() => NOW); + const items = build({ [open.id]: open }, [closed]); + const openItem = items.find((item) => item.id === open.id)!; + const closedItem = items.find((item) => item.id === closed.id)!; + + expect(closedItem.status).toBe('resolved'); + expect(closedItem.liveAlert).toBeUndefined(); + expect(getAlertHistoryRowCopy(closedItem, clock)).toEqual({ + text: 'Resolved: Node temperature at 74.0°C', + title: 'Resolved: Node temperature at 74.0°C', + }); + // Closed rows never read the clock, so they do not re-render on its tick. + expect(clock).not.toHaveBeenCalled(); + expect(getAlertHistoryRowCopy(openItem, clock)).toEqual({ + text: 'Node temperature at 95.0°C', + title: 'Node temperature at 95.0°C', + }); + }); + + it('gives Pulse system alerts no live reading', () => { + const alert = { + ...heldAlert({ phase: 'latched', value: 78 }), + id: 'pulse-system::storage', + metadata: { systemAlert: true }, + } as Alert; + const [item] = build({ [alert.id]: alert }); + expect(item.systemAlert).toBe(true); + expect(item.liveAlert).toBeUndefined(); + expect(getAlertHistoryRowCopy(item, () => NOW).text).toBe('Node temperature at 95.0°C'); + }); +}); diff --git a/frontend-modern/src/features/alerts/alertHistoryModel.ts b/frontend-modern/src/features/alerts/alertHistoryModel.ts index 097a560da5..0be5a89f2b 100644 --- a/frontend-modern/src/features/alerts/alertHistoryModel.ts +++ b/frontend-modern/src/features/alerts/alertHistoryModel.ts @@ -7,6 +7,7 @@ import { import { isPulseSystemAlert } from '@/utils/alertScope'; import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers'; +import { getMetricAlertPresentation } from './metricAlertPresentation'; export const MS_PER_HOUR = 60 * 60 * 1000; @@ -33,6 +34,22 @@ export interface HistoryItem { closeDetail?: string; acknowledged?: boolean; systemAlert?: boolean; + /** + * The open alert behind an active row, so the row can describe a threshold + * alert held below its trigger from its live reading. Closed rows have none. + */ + liveAlert?: Pick; +} + +export interface AlertHistoryRowCopy { + /** What the row leads with. */ + text: string; + /** What clears an open threshold alert from here. */ + detail?: string; + /** The last reading that met the trigger, once the live one differs. */ + lastBreach?: string; + /** The full copy, for hover where the row truncates. */ + title: string; } export interface AlertTrendSeries { @@ -205,6 +222,7 @@ export function buildAlertHistoryItems({ rawAlertType: alert.type, description: alert.message, acknowledged: false, + ...(systemScoped ? {} : { liveAlert: alert }), }); }); @@ -251,6 +269,29 @@ export function buildAlertHistoryItems({ return items; } +// The open row is the one occurrence still in progress. A threshold alert can +// stay open below its trigger while its message keeps the last breach, so the +// row leads with the reading Pulse is evaluating now, as the Alerts card does. +// Closed rows, and open ones without a live status, keep the recorded message, +// which search and the Assistant handoff also read. The clock is read only for +// open rows, so closed rows do not re-render on every tick. +export function getAlertHistoryRowCopy( + item: Pick, + now: () => number = Date.now, +): AlertHistoryRowCopy { + const recorded = item.description ?? ''; + const presentation = item.liveAlert ? getMetricAlertPresentation(item.liveAlert, now()) : null; + if (!presentation) return { text: recorded, title: recorded }; + return { + text: presentation.summary, + detail: presentation.detail, + lastBreach: presentation.lastBreach, + title: [presentation.summary, presentation.detail, presentation.lastBreach] + .filter(Boolean) + .join('\n'), + }; +} + export function filterAlertHistoryItems( items: HistoryItem[], severityFilter: AlertSeverityFilter, diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 31016b5670..ab56ce918e 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -549,6 +549,10 @@ describe('tab path helpers', () => { expect(alertHistoryTableAlertRowSource).toContain('export function AlertHistoryTableAlertRow'); expect(alertHistoryTableAlertRowSource).toContain('IncidentTimelinePanel'); expect(alertHistoryTableAlertRowSource).toContain('AlertHistoryItemActions'); + // An open threshold alert's row leads with its live reading, not the + // breach message it opened with (#2068). + expect(alertHistoryTableAlertRowSource).toContain('getAlertHistoryRowCopy(props.alert, now)'); + expect(alertHistoryTableAlertRowSource).not.toContain('{props.alert.description}'); expect(alertHistoryItemActionsSource).toContain('InvestigateAlertButton'); expect(alertHistoryAdministrationCardSource).toContain( 'export function AlertHistoryAdministrationCard',