Lead open History rows with the live reading of held alerts

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
This commit is contained in:
rcourtman
2026-10-07 08:54:49 +01:00
parent a570d9f335
commit 5feda4836c
10 changed files with 432 additions and 4 deletions
@@ -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
@@ -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
@@ -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"
]
}
@@ -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<MobileAlertHistoryInvestigation | null>(
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<AlertHistoryMobileItem[]>(() => {
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 (
<article
@@ -228,7 +234,19 @@ export function AlertHistoryMobileList(props: AlertHistoryMobileListProps) {
</div>
</div>
<p class="mt-2 text-xs leading-relaxed text-base-content">{alert.description}</p>
<p class="mt-2 text-xs leading-relaxed text-base-content">
{descriptionCopy().text}
</p>
{/* A phone has no hover, so what clears the alert and its last
breach stay visible under the live reading. */}
<Show when={descriptionCopy().detail}>
{(detail) => <p class="mt-0.5 text-xs leading-relaxed text-muted">{detail()}</p>}
</Show>
<Show when={descriptionCopy().lastBreach}>
{(lastBreach) => (
<p class="mt-0.5 text-xs leading-relaxed text-muted">{lastBreach()}</p>
)}
</Show>
<Show when={alert.closeDetail}>
{(detail) => <p class="mt-1 text-xs leading-relaxed text-muted">{detail()}</p>}
</Show>
@@ -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)
<TableCell
class={`${getPlatformTableCellClassForKind('text')} max-w-[300px] truncate text-base-content`}
title={props.alert.description}
title={descriptionCopy().title}
>
{props.alert.description}
{descriptionCopy().text}
</TableCell>
<TableCell
@@ -6,6 +6,11 @@ import { aiChatStore } from '@/stores/aiChat';
import { AlertHistoryMobileList } from '../AlertHistoryMobileList';
import type { AlertHistoryState } from '../useAlertHistoryState';
// Open rows offer the Assistant investigation, which needs the router.
vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({
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(() => <AlertHistoryMobileList state={state} />);
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();
@@ -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<string>());
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>): 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(() => (
<table>
<tbody>
<AlertHistoryTableAlertRow alert={alert} state={createState()} />
</tbody>
</table>
));
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<Alert, 'type' | 'value' | 'lastSeen' | 'metricStatus'>
>({
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',
);
});
});
@@ -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<NonNullable<Alert['metricStatus']>>): 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<string, Alert>, 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');
});
});
@@ -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<Alert, 'type' | 'value' | 'lastSeen' | 'metricStatus' | 'metadata'>;
}
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<HistoryItem, 'description' | 'liveAlert'>,
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,
@@ -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',