mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Lead open History rows with the live reading of held alerts (#2638)
Builds on #2637 (alert card live reading), so this branch carries its commits until that PR merges. ## Why Alerts > History is a log of occurrences, but its open row is the one occurrence still in progress: it shows an Active badge and a running duration. Beside them, the breach message "Node temperature at 95.0°C" reads as the reading now, while the node sits at 78°C under its 80°C trigger and the alert is held until it clears. With #2637 the Alerts card one tab away says "Temperature 78°C now, back under the 80°C alert level", so the two pages disagreed about the same alert (the confusion in #2068). ## What changes - An active History row for a threshold alert with a usable live status leads with the same presentation summary as the card (`getMetricAlertPresentation`, via a new `getAlertHistoryRowCopy` in `alertHistoryModel.ts`). - Desktop: the clear rule and the last breach move into the message cell's hover. Phone: they show as muted lines under the reading, since a phone has no hover. - The row's `description` stays the recorded message, so search ("95.0" still finds the held row) and the Assistant handoff read the record. - Closed rows, and open rows without a live status (non-threshold alerts, Pulse system alerts, the moments after a restart), are unchanged. - The reading rechecks on the shared relative-time clock, read only for open rows, so it turns stale ("Last reading: …, 12 mins ago") when evaluations stop without re-rendering closed rows on each tick. ## Verification - Private mock stack built from this tree with a local-only generator overlay pinning pve2's CPU temperature: walked 95 → 78 → 72 °C at 1440×900 and 390×844, light and dark; earlier pass also covered breaching, °F, and the alert resolving out of the list. - New tests: model copy and fallbacks (`alertHistoryModel.test.ts`), the phone card (`AlertHistoryMobileList.test.tsx`), and the desktop row following a live store update and turning stale on the clock (`AlertHistoryTableAlertRow.test.tsx`). - Receipt: `frontend-modern/browser-verification/9c0e53268f0d014f.json`. Refs #2068
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user