From c9d4cdebf04194662d5b367a5cda6cc253533008 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:06:37 +0100 Subject: [PATCH 1/3] Read a silent in-guest agent's guest drawer readings as last known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Pulse agent linked into a VM keeps its last report on the VM resource after it stops reporting: the registry copies the agent's whole facet, agent.stale included, onto the guest. The guest drawer never read that flag, so a quiet agent's RAID kept its live colours and "Rebuild: 42% · 118M/sec", its network rows and version read as current, and nothing said the agent had gone. - useWorkloads maps agent.stale onto the row as agentStale, and sets disksFromAgent when Proxmox reported no guest filesystems and the agent's own disks filled in. - The guest drawer passes the stopped-reporting reason to RaidCard, puts a status line over muted network rows, marks the Pulse Agent row as stopped reporting, and reads agent-supplied filesystem rows as last known. - Agent-supplied filesystem rows follow the agent's freshness instead of the Proxmox guest-read reason. A VM with a live linked agent but no qemu-guest-agent showed the agent's filesystems as "Usage unavailable" with advice to install qemu-guest-agent. A stopped VM keeps the Proxmox reason, and Proxmox's own guest filesystems keep their read state. The workload table's disk cell, the drawer History, and the agent's OS, addresses and physical disk health still carry no stale cue. --- .../subsystems/performance-and-scalability.md | 18 +++ .../d4fbbd15ad939e02.json | 47 ++++++ .../components/Workloads/GuestDrawer.test.tsx | 151 ++++++++++++++++++ .../Workloads/GuestDrawerOverview.tsx | 71 ++++++-- .../src/hooks/__tests__/useWorkloads.test.ts | 48 ++++++ frontend-modern/src/hooks/useWorkloads.ts | 5 + frontend-modern/src/types/workloads.ts | 10 ++ 7 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 frontend-modern/browser-verification/d4fbbd15ad939e02.json diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 55a9ba06fa..b339107a3e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2732,6 +2732,24 @@ must remain distinguishable without hover. Workloads supplies this row metadata, not local table CSS or a duplicate renderer; non-filesystem rows keep the compact default. Verification covers the opt-in at this composition boundary and actual long-path wrapping in the shared renderer's browser proof. +A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the +VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`, +and sets `disksFromAgent` when Proxmox reported no guest filesystems and the +agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx` +reads the Guest RAID card as last known through the shared `RaidCard` +`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status +line over muted Network rows, reads agent-supplied Filesystems rows through the +existing `last-known` disk read state, and marks the Pulse Agent row as stopped +reporting. Agent-supplied filesystem rows follow the agent's freshness rather +than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads, +so a live agent's rows keep their usage and bars. Those rows read as +unavailable once the guest is stopped, whether its status or the Proxmox +`vm-stopped` reason says so, and Proxmox's own guest filesystems keep their +Proxmox read state. The two flags ride the existing row mapping and +add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin +both sources. The workload table's disk cell and the drawer History still read +the aggregate disk metric and the Proxmox reason, and the agent's OS, addresses +and physical disk health carry no stale cue yet. Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency boundary for collapse: Workloads owns which inline row is selected and the close handler, while diff --git a/frontend-modern/browser-verification/d4fbbd15ad939e02.json b/frontend-modern/browser-verification/d4fbbd15ad939e02.json new file mode 100644 index 0000000000..28d656c133 --- /dev/null +++ b/frontend-modern/browser-verification/d4fbbd15ad939e02.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "base_sha": "43a80dafd91e92d3079a039cdb08929281812eef", + "verified_at": "2026-10-07T13:06:25Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f984547722c304cd3c80610a3e5ef743b3e6ad268493016ddc649c858e54e6de", + "frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d", + "frontend-modern/src/types/workloads.ts": "4eebb6b801a056900e7fc309679cdca891b48bdd2672e40dbb58eaffce4ca0c6" + }, + "routes": [ + "/proxmox?q=analytics-batch-310", + "/proxmox?q=analytics-batch-118", + "/proxmox?q=analytics-batch-58", + "/proxmox?q=analytics-batch-262" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from origin/main 43a80dafd9 plus this change, with a local-only go build -overlay of internal/mock/generator.go: host agents apollo-114 and meridian-731 held past their reporting lease (offline, LastSeen frozen 14 minutes back, readings retained); all four standalone agents seeded with md0 raid1 clean and md1 raid5 'clean, degraded, recovering' at 42.4% / 118M/sec (sdd1 faulty, sde1 spare rebuilding); analytics-batch-310 and analytics-batch-58 forced to report no Proxmox guest filesystems with diskStatusReason agent-not-running", + "Agents linked into VMs through the real POST /api/resources/{vm}/link: apollo-114 -> analytics-batch-118, meridian-731 -> analytics-batch-310 (both silent), spectrum-709 -> analytics-batch-262 and meridian-848 -> analytics-batch-58 (both live). /api/resources serves agent.stale true with the agent's raid, disks and networkInterfaces on the two silent VMs and no stale flag on the two live ones", + "Before this change (same stack, PR 2652 head, real browser run): silent VMs showed Guest RAID with emerald 'clean', amber 'clean, degraded, recovering', 'Rebuild: 42% \u00b7 118M/sec' and coloured member badges, network rows and 'Pulse 0.2.0-alpha' as current; the live analytics-batch-58 showed its agent filesystems as 'Usage unavailable \u00b7 ?/328 GB' under 'Guest agent not running. Install and start qemu-guest-agent in the VM.'", + "After, silent analytics-batch-310 (agent filesystems) at 1440 and 390: Pulse Agent row 'Pulse 0.2.0-alpha \u00b7 stopped reporting' in amber with title 'Pulse Agent 0.2.0-alpha has stopped reporting. Its readings here are its last report.'; Filesystems status 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' with 'Last known 75%' / 'Last known 42%' rows and no bars; Network status 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' over a muted eth0 row; both RAID arrays data-raid-reading=last-known, '(last known)' states in muted text titled 'Last known reading, not current: host agent stopped reporting', 'Rebuild was at 42%', neutral badges 'sdd1 \u00b7 faulty' and 'sde1 \u00b7 spare rebuilding'", + "After, silent analytics-batch-118 with Proxmox guest filesystems (earlier run of the same change): RAID, network and agent row read as above while the Proxmox filesystem rows kept their usage and bars", + "After, live analytics-batch-58 (agent filesystems, Proxmox agent-not-running): '/ 65%' and '/data 28%' with progress bars, no status line, no qemu-guest-agent advice; RAID current with emerald/amber states and 'Rebuild: 42% \u00b7 118M/sec'", + "After, live control analytics-batch-262 (Proxmox filesystems): unchanged at both widths", + "No horizontal page overflow at either width; no page errors. Stopped guests were not exercised in the browser: their agent filesystems read 'Usage unavailable' under the stopped-VM message whether the guest status or the Proxmox vm-stopped reason says stopped, and the stale flip on an open drawer, are covered by GuestDrawer.test.tsx. The final stopped-status gate changes only stopped guests, so the running-guest states above were observed on this code path" + ], + "interactions": [ + "Opened each guest's inline drawer from the Proxmox workloads table filtered by name (name cell click at 1440, first visible cell tap at 390)", + "Scrolled the phone drawer to the Network section and Guest RAID card and captured both", + "Read computed text colour, titles and data-raid-reading of every Filesystems, Network, System and RAID row via Playwright" + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 5f094a8215..2e4f080e38 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -1602,6 +1602,157 @@ describe('GuestDrawer', () => { }); }); + // ── In-guest Pulse Agent readings ── + + describe('In-guest Pulse Agent readings', () => { + // Proxmox read no guest filesystems, so these rows came from the agent. + const makeAgentGuest = (overrides: Partial = {}) => + makeGuest({ + agentVersion: '6.4.5', + agentKind: 'pulse', + disksFromAgent: true, + diskStatusReason: 'agent-not-running', + disks: [{ total: 100, used: 75, free: 25, usage: 75, mountpoint: '/' }], + networkInterfaces: [{ name: 'eth0', addresses: ['192.168.1.5'], rxBytes: 1024 }], + agentRaid: [ + { + device: '/dev/md1', + name: 'data', + level: 'raid5', + state: 'clean, degraded, recovering', + totalDevices: 3, + activeDevices: 2, + workingDevices: 3, + failedDevices: 1, + spareDevices: 0, + devices: [{ device: 'sdd1', state: 'faulty', slot: 1 }], + rebuildPercent: 42, + rebuildSpeed: '118M/sec', + }, + ], + ...overrides, + }); + + it("reads a silent in-guest agent's RAID, network and filesystems as last known", () => { + render(() => ); + + expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toHaveAttribute( + 'title', + 'Pulse Agent 6.4.5 has stopped reporting. Its readings here are its last report.', + ); + expect( + technicalDetails().getByText( + 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + ), + ).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument(); + expect( + technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toBeNull(); + expect( + technicalDetails().getByText( + 'Using last known interface details. The Pulse Agent in this guest stopped reporting.', + ), + ).toBeInTheDocument(); + expect( + technicalDetails() + .getByText(/^192\.168\.1\.5/) + .closest('td'), + ).toHaveClass('text-muted'); + // Proxmox's guest-read reason does not describe rows the agent supplied. + expect(technicalDetails().queryByText(/qemu-guest-agent/)).toBeNull(); + + const array = screen.getByTestId('raid-card-array'); + expect(array).toHaveAttribute('data-raid-reading', 'last-known'); + expect(within(array).getByTestId('raid-card-state')).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent( + 'Rebuild was at 42%', + ); + }); + + it('keeps a live in-guest agent current when Proxmox has no guest reads', () => { + render(() => ); + + expect(technicalDetails().getByText('Pulse 6.4.5')).toBeInTheDocument(); + expect( + technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toHaveAttribute('aria-valuenow', '75'); + expect(technicalDetails().queryByText(/Last known|qemu-guest-agent/)).toBeNull(); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + expect(screen.getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild: 42% · 118M/sec'); + }); + + it("keeps Proxmox's own guest filesystems current under a silent agent", () => { + render(() => ( + + )); + + expect( + technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toHaveAttribute('aria-valuenow', '75'); + expect(technicalDetails().queryByText(/Using last known disk stats/)).toBeNull(); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute( + 'data-raid-reading', + 'last-known', + ); + }); + + // Proxmox gives vm-stopped only for a guest with allocated disk, so the + // guest's own stopped status must hold the rows too. + it.each([ + ['with the Proxmox reason', 'vm-stopped'], + ['without a Proxmox reason', undefined], + ])( + "reads a stopped guest's agent filesystems as unavailable before the lease lapses, %s", + (_label, diskStatusReason) => { + render(() => ( + + )); + + expect( + technicalDetails().getByText( + 'Guest filesystem stats unavailable while the VM is stopped.', + ), + ).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Usage unavailable/)).toBeInTheDocument(); + expect( + technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toBeNull(); + }, + ); + + it('follows the agent between silent and reporting while the drawer stays open', () => { + const [guest, setGuest] = createSignal(makeAgentGuest()); + render(() => ); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + + setGuest({ ...guest(), agentStale: true }); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute( + 'data-raid-reading', + 'last-known', + ); + expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument(); + + setGuest({ ...guest(), agentStale: undefined }); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + expect(technicalDetails().queryByText(/stopped reporting|Last known/)).toBeNull(); + }); + }); + // ── WebInterfaceUrlField ── describe('WebInterfaceUrlField', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 072c4c3e4a..7218755137 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -10,6 +10,7 @@ import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDis import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection'; import type { Alert } from '@/types/api'; import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard'; +import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers'; import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; @@ -79,6 +80,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { ageClass: props.backupPresentation?.ageClass, }); const guestReadPresentation = () => getGuestDrawerGuestReadPresentation(props.guest); + // A linked Pulse agent past its reporting lease leaves its last report on + // the guest, so its RAID, network and filesystem rows read as last known. + const agentLastKnownReason = () => + props.guest.agentStale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined; const coverageLabel = () => { if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL; if (props.hasAgentInfo) return `${props.agentHeading} observed`; @@ -147,9 +152,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.guest.vmware?.datacenterName || props.guest.vmware?.clusterName, ); - const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined; - const diskReadState = - props.guest.telemetryAvailability?.disk === false + // Rows the linked Pulse agent supplied follow that agent's freshness, not + // the Proxmox reason for its own guest reads. A stopped guest has no + // current filesystems from either source, with or without the Proxmox + // vm-stopped reason, which it gives only for a guest with allocated disk. + const vmDiskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined; + const agentDisks = props.guest.disksFromAgent === true; + const agentDisksStopped = + agentDisks && (props.guest.status === 'stopped' || vmDiskReason === 'vm-stopped'); + const diskReason = agentDisks ? undefined : vmDiskReason; + const diskReadState = agentDisks + ? agentDisksStopped + ? 'unavailable' + : agentLastKnownReason() + ? 'last-known' + : 'current' + : props.guest.telemetryAvailability?.disk === false ? 'unavailable' : diskReason ? diskReason.startsWith('prev-') @@ -159,11 +177,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { const diskRows = (props.guest.disks ?? []).map((disk, index) => buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState), ); - const diskStatusMessage = diskReason - ? getWorkloadGuestDiskStatusMessage(diskReason) - : diskReadState === 'unavailable' - ? 'Filesystem usage is unavailable.' - : null; + const diskStatusMessage = agentDisks + ? agentDisksStopped + ? isGuestDrawerVM(props.guest) + ? getWorkloadGuestDiskStatusMessage('vm-stopped') + : 'Filesystem usage is unavailable.' + : agentLastKnownReason() + ? 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' + : null + : diskReason + ? getWorkloadGuestDiskStatusMessage(diskReason) + : diskReadState === 'unavailable' + ? 'Filesystem usage is unavailable.' + : null; return compactDetailSections([ discovery @@ -186,7 +212,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { rows: compactDetailRows([ props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null, props.hasAgentInfo - ? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle }) + ? agentLastKnownReason() + ? makeDetailRow(props.agentHeading, `${props.agentLabel} · stopped reporting`, { + title: `${props.agentTitle} has stopped reporting. Its readings here are its last report.`, + tone: 'warning', + wrap: true, + }) + : makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle }) : null, ]), }, @@ -272,8 +304,16 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.hasNetworkInterfaces ? { label: 'Network', - rows: compactDetailRows( - props.networkInterfaces.slice(0, 4).map((iface, index) => { + rows: compactDetailRows([ + // Only the linked Pulse agent reports guest interfaces. + makeDetailRow( + 'Status', + agentLastKnownReason() + ? 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' + : null, + { layout: 'stacked', wrap: true }, + ), + ...props.networkInterfaces.slice(0, 4).map((iface, index) => { const addresses = iface.addresses ?? []; const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0; const detail = [ @@ -291,11 +331,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { iface.name || `Interface ${index + 1}`, detail || 'No details', { + tone: agentLastKnownReason() ? 'muted' : undefined, wrap: true, }, ); }), - ), + ]), } : null, ]); @@ -338,7 +379,11 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { - +
diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 8a19338644..1549c6c930 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -142,6 +142,54 @@ describe('useWorkloads', () => { } }); + it('marks a silent linked agent and the guest filesystems it supplied', async () => { + const disk = (mountpoint: string) => ({ + mountpoint, + type: 'ext4', + total: 100, + used: 40, + free: 60, + usage: 40, + }); + const [snapshot] = createSignal([ + // Proxmox read no guest filesystems, so the agent's own report fills in. + { + ...sampleResource, + id: 'vm-agent-disks', + vmid: 102, + sources: ['proxmox', 'agent'], + agent: { agentVersion: '6.4.5', stale: true, disks: [disk('/agent')] }, + }, + // Proxmox guest filesystems win over a live agent's. + { + ...sampleResource, + id: 'vm-platform-disks', + vmid: 103, + sources: ['proxmox', 'agent'], + proxmox: { disks: [disk('/platform')] }, + agent: { agentVersion: '6.4.5', stale: false, disks: [disk('/agent')] }, + }, + ] as any); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + try { + await flushAsync(); + const byId = (id: string) => + result!.workloads().find((workload) => workload.canonicalResourceId === id); + expect(byId('vm-agent-disks')).toMatchObject({ agentStale: true, disksFromAgent: true }); + expect(byId('vm-agent-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/agent']); + expect(byId('vm-platform-disks')?.agentStale).toBeUndefined(); + expect(byId('vm-platform-disks')?.disksFromAgent).toBeUndefined(); + expect(byId('vm-platform-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/platform']); + } finally { + dispose(); + } + }); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 35f238e8cb..f8d45872eb 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -146,6 +146,7 @@ type APIResource = { networkInterfaces?: APINetworkInterface[]; disks?: APIDiskInfo[]; raid?: HostRAIDArray[]; + stale?: boolean; }; docker?: { containerId?: string; @@ -592,6 +593,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { })(), disk: buildMetric(resource.metrics?.disk), disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks), + disksFromAgent: (resource.proxmox?.disks == null && resource.agent?.disks != null) || undefined, // Both the API and owning platform snapshot pass through this mapper. // A retained numeric metric must not lose the provider's read deferral. diskStatusReason: resource.proxmox?.diskStatusReason, @@ -612,6 +614,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { agentId: resource.agent?.agentId, agentRaid: resource.agent?.raid, networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces), + // A linked agent past its reporting lease keeps its last report on the + // resource, so its RAID, network and filesystem rows are not current. + agentStale: resource.agent?.stale === true || undefined, networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0, networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0, diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index 411d3c3777..b4bb88c54e 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -60,6 +60,16 @@ export type WorkloadGuest = (VM | Container) & { agentId?: string; /** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */ agentRaid?: HostRAIDArray[]; + /** + * Set when the linked Pulse agent is past its reporting lease. Its RAID, + * network and filesystem readings are then its last report, not current. + */ + agentStale?: boolean; + /** + * Set when `disks` came from the linked Pulse agent because the platform + * reported no guest filesystems, so the rows follow that agent's freshness. + */ + disksFromAgent?: boolean; updateStatus?: DockerContainerUpdateStatus; // Server-evaluated capability refusals from the unified resource. The // update button reads this to disable itself with the refusal reason From 7a8376c055a4ccd8da954a39cac631f3b1066427 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:54:59 +0100 Subject: [PATCH 2/3] Keep platform metric alerts through Proxmox node cleanup CleanupAlertsForNodes runs on every Proxmox node poll and removes alerts whose node is not a current Proxmox node or PBS instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or platform host as their node and a display label such as "TrueNAS Disk" in metadata.resourceType, which the preserve list's platform words never matched. So in an estate with Proxmox and one of those platforms, every poll deleted these alerts silently while their reducer incident stayed firing, and the next evaluation re-created each one and handed it to the alert callback again as a new alert. Keep alerts that carry metadata.platformType, or whose label maps to a Kubernetes, TrueNAS or vSphere type. A guest alert on a removed Proxmox node is still removed. --- .../v6/internal/subsystems/alerts.md | 17 +++++ internal/alerts/active_cleanup.go | 10 +++ internal/alerts/alerts_test.go | 76 +++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 2690c5f5ea..3509cfddf1 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1842,6 +1842,23 @@ well as its alerts. `internal/alerts/host_unraid_lifecycle_test.go` pins the clear, the holds, the restarted count and the pending-run and count cleanup. +### Proxmox node cleanup keeps platform metric alerts + +`CleanupAlertsForNodes` runs on every Proxmox node poll (and every mock alert +tick) and removes alerts whose `Node` is not a current Proxmox node or PBS +instance. Kubernetes, TrueNAS and vSphere metric alerts carry a cluster or +platform host in `Node` and a display label such as `TrueNAS Disk` or +`Kubernetes Pod` in `metadata.resourceType`, which the preserve list's +platform words never matched. The cleanup used to delete them silently on +each poll while their reducer incident stayed firing, so the next evaluation +re-created each one and handed it to the alert callback again as a new +alert. `shouldPreserveAlertOutsideNodeCleanup` now keeps any alert carrying +`metadata.platformType`, or whose label maps through +`CanonicalAlertResourceType` to a Kubernetes, TrueNAS or vSphere type. +`TestCleanupAlertsForNodesKeepsPlatformMetricAlerts` in +`internal/alerts/alerts_test.go` pins both signals, and its +sibling pins that a guest alert on a removed Proxmox node is still removed. + ### Configured flapping thresholds remain reachable Every accepted positive `FlappingThreshold`, including values above ten, must diff --git a/internal/alerts/active_cleanup.go b/internal/alerts/active_cleanup.go index 4da24fa05d..2fdfcd34fc 100644 --- a/internal/alerts/active_cleanup.go +++ b/internal/alerts/active_cleanup.go @@ -4,6 +4,7 @@ import ( "strings" "time" + alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config" alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" @@ -264,6 +265,15 @@ func shouldPreserveAlertOutsideNodeCleanup(alertID string, alert *Alert) bool { if alert.CanonicalKind == string(alertspecs.AlertSpecKindProviderIncident) { return true } + // Kubernetes, TrueNAS and vSphere metric alerts carry a display label such + // as "TrueNAS Disk" or "Kubernetes Pod" in resourceType, which the platform + // words below never matched, and a cluster or host in Node rather than a + // Proxmox node. Every Proxmox poll used to delete them silently, and the + // next evaluation raised them again as new alerts. + if metadataStringValue(alert.Metadata, alertPlatformTypeKey) != "" || + isUnifiedModernPlatformAlertType(alertconfig.CanonicalAlertResourceType(alertMetadataResourceType(alert))) { + return true + } switch alertMetadataResourceType(alert) { case string(unifiedresources.ResourceTypeAgent), diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index f4c4463c5e..f0ad060e0f 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -12628,6 +12628,82 @@ func TestCleanupAlertsForNodes(t *testing.T) { }) } +// TestCleanupAlertsForNodesKeepsPlatformMetricAlerts pins that the Proxmox +// node cleanup, which runs on every Proxmox poll, leaves Kubernetes, TrueNAS +// and vSphere metric alerts alone. Their Node is a cluster or platform host, +// never a Proxmox node, and their resourceType is a display label. +func TestCleanupAlertsForNodesKeepsPlatformMetricAlerts(t *testing.T) { + m := newTestManager(t) + configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig()) + + inputs := []*UnifiedResourceInput{ + {ID: "physical-disk:truenas-main/sda", Type: "truenas-disk", Name: "sda", Node: "truenas-main", Instance: "TrueNAS", Temperature: &UnifiedResourceMetric{Value: 80, Percent: 80}}, + {ID: "storage:truenas-main/pool:tank", Type: "truenas-pool", Name: "tank", Node: "truenas-main", Instance: "TrueNAS", Disk: &UnifiedResourceMetric{Percent: 95}}, + {ID: "k8s:prod:pod:api", Type: "pod", Name: "api", Node: "k8s-node-1", Instance: "prod", CPU: &UnifiedResourceMetric{Percent: 99}}, + {ID: "vmware:vc:vm:app-01", Type: "vmware-vm", Name: "app-01", Node: "esxi-01", Instance: "vc", CPU: &UnifiedResourceMetric{Percent: 99}}, + } + for _, input := range inputs { + m.CheckUnifiedResource(input) + } + platformAlerts := alertKeys(m) + if len(platformAlerts) != len(inputs) { + t.Fatalf("expected one alert per platform resource, got %v", platformAlerts) + } + + m.CleanupAlertsForNodes(map[string]bool{"pve1": true}) + + for _, alertID := range platformAlerts { + if _, exists := testLookupActiveAlert(t, m, alertID); !exists { + t.Fatalf("Proxmox node cleanup removed platform alert %q; remaining %v", alertID, alertKeys(m)) + } + } + + // The platform id alone keeps an alert whose label is not recognised. + m.mu.Lock() + labels := make(map[*Alert]interface{}, len(m.activeAlerts)) + for _, alert := range m.activeAlerts { + labels[alert] = alert.Metadata["resourceType"] + alert.Metadata["resourceType"] = "Unrecognised" + } + m.mu.Unlock() + m.CleanupAlertsForNodes(map[string]bool{"pve1": true}) + if got := alertKeys(m); len(got) != len(platformAlerts) { + t.Fatalf("platform alerts removed despite their platformType: %v", got) + } + m.mu.Lock() + for alert, label := range labels { + alert.Metadata["resourceType"] = label + } + m.mu.Unlock() + + // A legacy-shaped alert whose metadata predates platformType is still kept + // by its display label. + m.mu.Lock() + for _, alert := range m.activeAlerts { + delete(alert.Metadata, alertPlatformTypeKey) + } + m.mu.Unlock() + m.CleanupAlertsForNodes(map[string]bool{"pve1": true}) + if got := alertKeys(m); len(got) != len(platformAlerts) { + t.Fatalf("display-label platform alerts removed without platformType: %v", got) + } +} + +// The cleanup still removes a Proxmox guest alert whose node left the estate. +func TestCleanupAlertsForNodesStillRemovesMissingProxmoxNodeAlerts(t *testing.T) { + m := newTestManager(t) + configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig()) + + m.CheckUnifiedResource(&UnifiedResourceInput{ID: "pve-gone:node-gone:101", Type: "vm", Name: "vm-101", Node: "node-gone", Instance: "pve-gone", CPU: &UnifiedResourceMetric{Percent: 99}}) + if len(alertKeys(m)) != 1 { + t.Fatalf("expected the guest CPU alert, got %v", alertKeys(m)) + } + m.CleanupAlertsForNodes(map[string]bool{"pve1": true}) + if got := alertKeys(m); len(got) != 0 { + t.Fatalf("guest alert on a removed node survived cleanup: %v", got) + } +} + func TestCheckZFSPoolHealth(t *testing.T) { // t.Parallel() poolStateAlertID := buildCanonicalStateID("local-zfs/zfs-pool:rpool", "local-zfs/zfs-pool:rpool-state") From 8676ec687ff902c8fa856363273494b7e535aecd Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:03:02 +0100 Subject: [PATCH 3/3] Read the shared wall clock on Alerts overview cards The overview hook kept its own minute signal and handed its value to each card as "now". A card that mounted between ticks measured from up to a minute earlier: an alert raised 70 seconds ago read "this minute", a reading already past the 10-minute stale cut-off led the card as the live one, a just-raised alert stayed out of Triggered (24h) until the next tick, and the stale flip and 24h drop landed up to a minute late. The card now reads useRelativeTimeNow for its started age and the live reading's stale cut-off, and the hook reads it for the 24h count, so they measure from the wall clock and move with the shared 30-second tick. The minute interval stays only for the delivery diagnoses refresh, a server read that should not double its rate. --- .../v6/internal/subsystems/alerts.md | 16 ++ .../subsystems/frontend-primitives.md | 6 + .../6324f927820d6ea5.json | 37 +++++ .../alerts/AlertOverviewAlertCard.tsx | 10 +- .../AlertOverviewAlertCard.clock.test.tsx | 152 ++++++++++++++++++ ...ertOverviewAlertCard.system-scope.test.tsx | 1 - .../__tests__/useAlertOverviewState.test.tsx | 17 ++ .../features/alerts/useAlertOverviewState.ts | 22 +-- 8 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 frontend-modern/browser-verification/6324f927820d6ea5.json create mode 100644 frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.clock.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 9d7946179e..6bf31e686c 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2745,6 +2745,22 @@ derived alert read-model and Last 24 Hours stat refresh for shared acknowledgement owner instead of keeping its own alert mutation fork. Future overview action behavior should extend that shared acknowledgement hook instead of putting acknowledge mutations back into render shells. +Overview ages and bands read the shared clock. `AlertOverviewAlertCard.tsx` +passes its own `useRelativeTimeNow` reading as `now` to +`formatAlertOverviewStartedAgo` and `getMetricAlertPresentation`, and the Last +24 Hours count in `useAlertOverviewState.ts` reads the same clock, so all three +measure from the wall clock. The hook no longer exposes a minute `tick`: read +as "now", that tick measured a card mounted between ticks from up to a minute +earlier, so an alert raised 70 seconds ago read "this minute", an alert +raised since the last tick stayed out of the 24h count until the next one, a +reading already past the 10-minute stale cut-off led the card as live, and the +stale flip and 24h drop landed up to a minute late. A mounted card now turns stale, +and an alert leaves the 24h count, within one 30-second tick with no data +change. The delivery diagnoses refresh is a server read and keeps its own +minute interval rather than following the 30-second clock. +`AlertOverviewAlertCard.clock.test.tsx` pins the mid-tick mount, the stale flip +and the 24h drop under fake timers, and `useAlertOverviewState.test.tsx` pins +the diagnoses cadence. Render-heavy alert overview ownership now routes through `frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`, `frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a5fa98dd52..f6598abcd8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7177,6 +7177,12 @@ shell stays in `frontend-modern/src/features/alerts/OverviewTab.tsx`, while alert stats, filtered ordering, and single/bulk acknowledge runtime behavior. Future overview control flow should extend that hook rather than restoring action timers or acknowledge mutations to the tab shell. +The overview follows the shared-clock rule for relative ages: the alert card +reads `useRelativeTimeNow` for its started age and the live reading's stale +cut-off, and the hook reads it for the Last 24 Hours count. A hook-local +minute signal used as "now" is a table-local interval under that rule; the +hook keeps a minute interval only to refresh delivery diagnoses, a server read +whose cadence is not tied to the age clock. Render-heavy overview ownership now lives in `frontend-modern/src/features/alerts/AlertOverviewStatsCards.tsx`, `frontend-modern/src/features/alerts/AlertOverviewActiveAlertsSection.tsx`, diff --git a/frontend-modern/browser-verification/6324f927820d6ea5.json b/frontend-modern/browser-verification/6324f927820d6ea5.json new file mode 100644 index 0000000000..2170db9557 --- /dev/null +++ b/frontend-modern/browser-verification/6324f927820d6ea5.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "bab38a25b92684c978c4273200765152040624ed", + "verified_at": "2026-10-07T14:02:51Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "bb98293f7a7b7b857b40ec71a38e1e32163aa937405278dd07af2a8c9dac1118", + "frontend-modern/src/features/alerts/useAlertOverviewState.ts": "3e2c91ba930a1d3faf3965a5fd68f5bb44e27465a88cbb06c23aabe500d25668" + }, + "routes": [ + "/alerts/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (backend :7697, Vite :5217 serving this tree on base bab38a25b9) with Playwright page.clock installed before load and an init script recording when each setInterval starts, so the shared 30s clock and the overview hook's minute interval have known phases", + "Three producer-shaped copies of a live disk threshold alert (metricStatus breaching) sent in one websocket frame 15s after a shared tick and 45s after the hook's minute, then /ws data frames held (data-free pong per step) and /api reads stalled, so only the clock moves", + "Late-mounted card (raised 70s ago, reading 10m05s old) reads '1 min. ago' and 'Last reading: Disk 92%, 10 mins ago' at mount; held card (reading goes stale 5s after the hook's next minute) is live at mount and 1s before the cut-off, and reads 'Last reading: ..., 10 mins ago' 31s after it; Triggered (24h) drops 85 -> 84 31s after the third copy turns 24h old; no page errors; same at 1440 and 390", + "Same-session control with both runtime files swapped to main under the running Vite: the late card read 'this minute' and led with the old reading as live, the held card was still live 31s after the cut-off, and the 24h count rose 84 -> 85 because the just-raised copy read as future; fix restored and diff compared byte-for-byte", + "tsc clean; src/features/alerts, src/components/Alerts and src/utils suites pass except the Mac-only agentInstallCommand lifecycle tests" + ], + "interactions": [ + "Loaded /alerts/overview, injected the alert copies between ticks, held the stream and stepped the page clock past the stale cut-off and the 24h boundary, at both viewports, before and after the fix" + ] +} diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 870d095c84..cd8487f0c7 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -36,6 +36,7 @@ import { AlertSnoozeAction } from './AlertSnoozeAction'; import { isAlertSnoozed } from './useAlertSnoozeState'; import { isPulseSystemAlert } from '@/utils/alertScope'; import { formatTemperature } from '@/utils/temperature'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import { normalizeSourcePlatformQueryValue, resolvePlatformTypeFromSources, @@ -110,6 +111,9 @@ const platformPagePath = (platform: string | undefined): string | undefined => { }; export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { + // The started age and the live reading's stale cut-off and ages measure + // from the wall clock, so a card that mounts between ticks is current. + const now = useRelativeTimeNow(); const alertKey = () => getCanonicalAlertId(props.alert); const hasResource = () => !isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim()); @@ -138,9 +142,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { // A threshold alert can stay open below its trigger, so the card leads with // the reading Pulse is evaluating now; the message keeps the last breach. const metricPresentation = () => - isPulseSystemAlert(props.alert) - ? null - : getMetricAlertPresentation(props.alert, props.state.tick()); + isPulseSystemAlert(props.alert) ? null : getMetricAlertPresentation(props.alert, now()); const alertLevels = (): { alert: string; clear?: string } | null => { const presentation = metricPresentation(); if (presentation) { @@ -318,7 +320,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { new Date(props.alert.startTime).toLocaleString(), )} > - {formatAlertOverviewStartedAgo(props.alert.startTime, props.state.tick())} + {formatAlertOverviewStartedAgo(props.alert.startTime, now())}
({ + useLocation: () => ({ hash: '', pathname: '/alerts', search: '', query: {} }), + A: (props: Record) => props.children, +})); + +const getDeliveryDiagnoses = vi.fn<() => Promise>(); + +vi.mock('@/api/alerts', () => ({ + AlertsAPI: { + get getDeliveryDiagnoses() { + return getDeliveryDiagnoses; + }, + }, +})); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock('@/utils/logger', () => ({ + logger: { error: vi.fn() }, +})); + +vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ + InvestigateAlertButton: () => null, +})); + +import { OverviewTab } from '../OverviewTab'; + +const OPENED_AT = Date.parse('2026-10-07T12:00:00Z'); +// Between the shared clock's first and second ticks, and before the overview's +// own minute. +const BETWEEN_TICKS_MS = RELATIVE_TIME_TICK_MS + 15_000; + +function cpuAlert(id: string, startTime: number, observedAt: number): Alert { + return { + id, + resourceId: `vm-${id}`, + resourceName: `VM ${id}`, + type: 'cpu', + level: 'warning', + message: `CPU on VM ${id} at 92%`, + value: 92, + threshold: 80, + startTime: new Date(startTime).toISOString(), + lastSeen: new Date(observedAt).toISOString(), + acknowledged: false, + node: 'node1', + metricStatus: { + phase: 'breaching', + value: 91, + unit: '%', + observedAt: new Date(observedAt).toISOString(), + trigger: 80, + recovery: 75, + }, + } as Alert; +} + +function renderOverview() { + const [activeAlerts, setActiveAlerts] = createSignal>({}); + render(() => ( + false} + dismissQuickTip={vi.fn()} + showAcknowledged={() => true} + setShowAcknowledged={vi.fn()} + alertsDisabled={() => false} + /> + )); + return setActiveAlerts; +} + +const triggered24h = () => + screen + .getByText('Triggered (24h)') + .closest('[data-alert-overview-stat]') + ?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent; + +describe('Alert overview card clock', () => { + beforeEach(() => { + setActiveLocale(DEFAULT_LOCALE); + getDeliveryDiagnoses.mockReset(); + getDeliveryDiagnoses.mockResolvedValue([]); + vi.useFakeTimers(); + vi.setSystemTime(OPENED_AT); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('measures a card that mounts between ticks from the wall clock', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + // Raised 70 seconds ago; last evaluated just past the stale cut-off. Read + // from the time of the last tick, the card would call it "this minute" + // and lead with the old reading as the live one. + const now = Date.now(); + setActiveAlerts({ + late: cpuAlert('late', now - 70_000, now - METRIC_ALERT_STATUS_STALE_MS - 5_000), + }); + + expect(screen.getByText('1 min. ago')).toBeInTheDocument(); + expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument(); + }); + + it('turns stale at the next shared tick after the cut-off with no data change', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + // The reading goes stale 5 seconds after the shared clock's second tick. + const staleAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000; + const now = Date.now(); + setActiveAlerts({ held: cpuAlert('held', now, staleAt - METRIC_ALERT_STATUS_STALE_MS) }); + expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument(); + + vi.advanceTimersByTime(OPENED_AT + 2 * RELATIVE_TIME_TICK_MS - Date.now()); + expect(screen.getByText('CPU 91%, above the 80% alert level')).toBeInTheDocument(); + + // The next tick lands within one shared tick of the cut-off, not on the + // overview's next minute. + vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS); + expect(screen.queryByText('CPU 91%, above the 80% alert level')).toBeNull(); + expect(screen.getByText('Last reading: CPU 91%, 10 mins ago')).toBeInTheDocument(); + }); + + it('drops an alert from the 24h count at the next shared tick after it turns 24h old', () => { + const setActiveAlerts = renderOverview(); + vi.advanceTimersByTime(BETWEEN_TICKS_MS); + + const leavesWindowAt = OPENED_AT + 2 * RELATIVE_TIME_TICK_MS + 5_000; + setActiveAlerts({ old: cpuAlert('old', leavesWindowAt - 86_400_000, Date.now()) }); + expect(triggered24h()).toBe('1'); + + vi.advanceTimersByTime(OPENED_AT + 3 * RELATIVE_TIME_TICK_MS - Date.now()); + expect(triggered24h()).toBe('0'); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx index a3dddd8e8f..aae6143db3 100644 --- a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx @@ -26,7 +26,6 @@ import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard'; const acknowledge = vi.fn(); const toggleTimeline = vi.fn(); const state = { - tick: () => Date.now(), processingAlerts: () => new Set(), snoozeProcessingAlerts: () => new Set(), deliveryDiagnoses: () => ({}), diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx index 80e0cd2034..6c854746b4 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertOverviewState.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AlertsAPI } from '@/api/alerts'; import { notificationStore } from '@/stores/notifications'; import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; +import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock'; import { useAlertOverviewState } from '../useAlertOverviewState'; @@ -118,6 +119,22 @@ describe('useAlertOverviewState', () => { expect(result.deliveryDiagnoses()).toEqual({ a1: retained }); }); + it('refreshes diagnoses once a minute, not on the 30-second age clock', async () => { + renderHook(() => + useAlertOverviewState({ + activeAlerts: () => ({ a1: makeAlert('a1', new Date().toISOString()) }), + overrides: () => [], + showAcknowledged: () => true, + updateAlert: vi.fn(), + }), + ); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(RELATIVE_TIME_TICK_MS); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(60_000 - RELATIVE_TIME_TICK_MS); + expect(AlertsAPI.getDeliveryDiagnoses).toHaveBeenCalledTimes(2); + }); + it('ignores pending responses and stops periodic reads after disposal', async () => { let finish!: (value: AlertDeliveryDiagnosis[]) => void; vi.mocked(AlertsAPI.getDeliveryDiagnoses).mockReturnValueOnce( diff --git a/frontend-modern/src/features/alerts/useAlertOverviewState.ts b/frontend-modern/src/features/alerts/useAlertOverviewState.ts index 583bb9163b..0424aba4da 100644 --- a/frontend-modern/src/features/alerts/useAlertOverviewState.ts +++ b/frontend-modern/src/features/alerts/useAlertOverviewState.ts @@ -3,6 +3,7 @@ import type { Accessor } from 'solid-js'; import { AlertsAPI } from '@/api/alerts'; import type { Alert, AlertDeliveryDiagnosis } from '@/types/api'; +import { useRelativeTimeNow } from '@/utils/relativeTimeClock'; import type { Override } from './types'; import { useAlertAcknowledgementState } from './useAlertAcknowledgementState'; import { useAlertSnoozeState } from './useAlertSnoozeState'; @@ -34,8 +35,7 @@ export interface UseAlertOverviewStateProps { } export function useAlertOverviewState(props: UseAlertOverviewStateProps) { - const [tick, setTick] = createSignal(Date.now()); - const tickInterval = setInterval(() => setTick(Date.now()), 60_000); + const now = useRelativeTimeNow(); const activeAlerts = createMemo(() => Object.values(props.activeAlerts())); const { unacknowledgedAlerts, @@ -56,10 +56,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { handleUnsnooze, } = useAlertSnoozeState({ alerts: activeAlerts, updateAlert: props.updateAlert }); - onCleanup(() => { - clearInterval(tickInterval); - }); - // Delivery diagnoses answer "did/will this alert notify?" per card. One // bulk request covers every active alert; a failed refresh keeps the last // snapshot and the card simply shows no delivery line for unknown alerts. @@ -98,19 +94,24 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { .join('\n'), ); createEffect(() => { - // Refresh when the active alert set changes and on the shared minute - // tick, so time-based holds (cooldown, quiet hours) stay current. + // Refresh when the active alert set changes. activeAlertIdsKey(); - tick(); void refreshDeliveryDiagnoses(); }); + // And once a minute, so time-based holds (cooldown, quiet hours) stay + // current. This is a server read, so it keeps its own minute rather than + // following the 30-second clock the ages read. + const diagnosisInterval = setInterval(() => void refreshDeliveryDiagnoses(), 60_000); + onCleanup(() => { + clearInterval(diagnosisInterval); + }); const alertStats = createMemo(() => { const alerts = effectiveAlerts(); const recent = alerts.filter((alert) => { const ts = new Date(alert.startTime).getTime(); if (Number.isNaN(ts)) return true; - const age = tick() - ts; + const age = now() - ts; return age >= 0 && age < 86_400_000; }); const unacknowledged = alerts.filter((alert) => !alert.acknowledged); @@ -169,7 +170,6 @@ export function useAlertOverviewState(props: UseAlertOverviewStateProps) { }); return { - tick, alertStats, filteredAlerts, groupedAlerts,