From 63443e8712dbb6d3866bdc5fce9f2b89c13be51c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:15:49 +0100 Subject: [PATCH 1/6] Reproduce misleading Pulse system-alert scope Exercise real overview, history and Assistant paths with canonical system warning payloads and monitored-resource controls. Retain backup evaluation and existing notification/watchdog conditions. Contract-Neutral: Test-only reproduction of the existing system-alert contract; no runtime, API, identity, policy or notification delta. Change-source: pulse-maintainer --- .../__tests__/InvestigateAlertButton.test.tsx | 17 ++++ .../alertAssistantHandoffModel.test.ts | 34 +++++++ .../alerts/__fixtures__/systemAlerts.ts | 35 ++++++++ ...ertOverviewAlertCard.system-scope.test.tsx | 88 +++++++++++++++++++ .../alertHistoryModel.system-scope.test.ts | 64 ++++++++++++++ 5 files changed, 238 insertions(+) create mode 100644 frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts create mode 100644 frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx create mode 100644 frontend-modern/src/features/alerts/__tests__/alertHistoryModel.system-scope.test.ts diff --git a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx index a7194f6f5b..516df51d18 100644 --- a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n'; import type { Alert } from '@/types/api'; +import { makeSystemAlert } from '@/features/alerts/__fixtures__/systemAlerts'; import { getPublicPricingUrl } from '@/utils/pricingHandoff'; // --------------------------------------------------------------------------- @@ -155,6 +156,22 @@ beforeEach(() => { notificationStoreMock.info.mockReset(); }); +describe('system-alert investigation', () => { + it.each([undefined, { systemAlert: true, resourceType: 'vm' }])( + 'opens explanation, never resource Patrol, for a system alert with metadata %j', + (metadata) => { + const alert = makeSystemAlert('backup-evaluation', { resourceId: 'vm-wrong', metadata }); + render(() => ); + fireEvent.click(screen.getByRole('button', { name: 'Ask Pulse Assistant about this alert' })); + expect(explainMock).toHaveBeenCalledTimes(1); + expect(triggerPatrolRunMock).not.toHaveBeenCalled(); + expect(openedContext().targetId).toBeUndefined(); + expect(openedContext().handoffResources).toEqual([]); + expect(formatAlertValueMock).not.toHaveBeenCalled(); + }, + ); +}); + // --------------------------------------------------------------------------- // Rendering / visibility // --------------------------------------------------------------------------- diff --git a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts index 3d0e532922..9f23bdbe9a 100644 --- a/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts +++ b/frontend-modern/src/components/Alerts/__tests__/alertAssistantHandoffModel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Alert } from '@/types/api'; import { buildAlertAssistantHandoff } from '../alertAssistantHandoffModel'; +import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '@/features/alerts/__fixtures__/systemAlerts'; function makeAlert(overrides: Partial = {}): Alert { return { @@ -22,6 +23,39 @@ function makeAlert(overrides: Partial = {}): Alert { } describe('alertAssistantHandoffModel', () => { + it.each(SYSTEM_ALERT_TYPES)( + 'explains Pulse %s without fabricated metric or resource context', + (type) => { + const alert = makeSystemAlert(type); + const { context } = buildAlertAssistantHandoff({ alert, resourceType: 'vm', vmid: 101 }); + expect(context.targetType).toBeUndefined(); + expect(context.targetId).toBeUndefined(); + expect(context.handoffResources).toEqual([]); + expect(context.autonomousMode).toBe(false); + expect(context.handoffContext).toContain('Scope: Pulse itself (not a monitored resource)'); + expect(context.handoffContext).toContain(alert.message); + expect(context.handoffContext).toContain(`Alert Identifier: ${alert.id}`); + expect(context.handoffContext).toContain('Operator Boundary:'); + expect(context.handoffContext).not.toMatch(/Current Value:|Threshold:|Resource ID:|Node:/); + expect(context.context?.guestName).toBeUndefined(); + expect(context.context?.vmid).toBeUndefined(); + expect(context.briefing?.detailLines?.join('\n')).not.toMatch(/Current value|threshold/); + }, + ); + + it('retains system scope for future types and legacy IDs carrying system metadata', () => { + const { context } = buildAlertAssistantHandoff({ + alert: makeSystemAlert('future-condition', { + id: 'legacy-system-id', + resourceId: 'vm-wrong', + node: 'wrong-node', + metadata: { systemAlert: true, resourceType: 'vm' }, + }), + }); + expect(context.targetId).toBeUndefined(); + expect(context.handoffResources).toEqual([]); + expect(context.handoffContext).not.toMatch(/wrong-node|vm-wrong/); + }); it('builds a visible approval-required alert handoff without command payloads', () => { const handoff = buildAlertAssistantHandoff({ alert: makeAlert(), diff --git a/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts b/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts new file mode 100644 index 0000000000..6f2f9086a1 --- /dev/null +++ b/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts @@ -0,0 +1,35 @@ +import type { Alert } from '@/types/api'; + +// Synthetic payloads shaped like Manager.RaiseSystemAlert. No native services. +export const SYSTEM_ALERT_TYPES = [ + 'backup-evaluation', + 'notification-delivery', + 'deadman-delivery', + 'deadman-monitoring-stalled', + 'deadman-interruption', + 'deadman-state', +] as const; + +export function makeSystemAlert( + type: string = 'backup-evaluation', + overrides: Partial = {}, +): Alert { + return { + id: `pulse-system-${type}`, + type, + resourceId: '', + resourceName: 'Pulse', + node: '', + instance: '', + level: 'warning', + message: + "Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept; check Pulse's logs.", + value: 0, + threshold: 0, + startTime: '2026-10-04T15:00:00Z', + lastSeen: '2026-10-04T15:01:00Z', + acknowledged: false, + metadata: { systemAlert: true, systemAlertType: type }, + ...overrides, + }; +} 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 new file mode 100644 index 0000000000..b150134fa8 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import type { AlertOverviewState } from '../useAlertOverviewState'; +import type { AlertIncidentTimelineState } from '../useAlertIncidentTimelineState'; +import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '../__fixtures__/systemAlerts'; + +vi.mock('@solidjs/router', () => ({ + A: (props: any) => ( + + {props.children} + + ), +})); +vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({ + InvestigateAlertButton: () => null, +})); +vi.mock('../ResourceMonitoringPolicyAction', () => ({ + ResourceMonitoringPolicyAction: () => , +})); +vi.mock('../AlertSnoozeAction', () => ({ AlertSnoozeAction: () => null })); +import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard'; + +const acknowledge = vi.fn(); +const toggleTimeline = vi.fn(); +const state = { + processingAlerts: () => new Set(), + snoozeProcessingAlerts: () => new Set(), + deliveryDiagnoses: () => ({}), + handleAlertAcknowledgement: acknowledge, +} as unknown as AlertOverviewState; +const timelineState = { + expandedIncidents: () => new Set(), + toggleIncidentTimeline: toggleTimeline, +} as unknown as AlertIncidentTimelineState; +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('Pulse system-alert overview scope', () => { + it.each(SYSTEM_ALERT_TYPES)('does not link %s to a fictional Proxmox resource', (type) => { + const alert = makeSystemAlert(type); + render(() => ( + + )); + expect(screen.getByText('Pulse').closest('a')).toBeNull(); + expect(screen.getByText(alert.message)).toBeInTheDocument(); + expect(screen.queryByText('Resource monitoring policy')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Acknowledge' })); + expect(acknowledge).toHaveBeenCalledWith(alert); + fireEvent.click(screen.getByRole('button', { name: 'Timeline' })); + expect(toggleTimeline).toHaveBeenCalledWith(alert.id, alert.id, alert.startTime); + }); + + it('ignores conflicting metric and resource hints on a marked system alert', () => { + const alert = makeSystemAlert('future-system-condition', { + id: 'legacy-id', + resourceId: 'vm-wrong', + threshold: 80, + metadata: { systemAlert: true, resourceType: 'vm' }, + }); + render(() => ( + + )); + expect(screen.getByText('Pulse').closest('a')).toBeNull(); + expect(screen.queryByText(/limit:/)).toBeNull(); + expect(screen.queryByText('Resource monitoring policy')).toBeNull(); + }); + + it('preserves the actual link and metric limit for a monitored resource called Pulse', () => { + const alert = makeSystemAlert('cpu', { + id: 'resource-cpu', + resourceId: 'vm-pulse', + value: 92, + threshold: 80, + metadata: undefined, + }); + render(() => ( + + )); + expect(screen.getByRole('link', { name: 'Pulse' })).toHaveAttribute( + 'href', + '/proxmox/overview', + ); + expect(screen.getByText('limit: 80%')).toBeInTheDocument(); + expect(screen.getByText('Resource monitoring policy')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.system-scope.test.ts b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.system-scope.test.ts new file mode 100644 index 0000000000..df82138c8c --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.system-scope.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { buildAlertHistoryItems } from '../alertHistoryModel'; +import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '../__fixtures__/systemAlerts'; + +const pulseVM = { id: 'vm-pulse', name: 'Pulse', type: 'vm' } as Resource; + +describe('Pulse system-alert history scope', () => { + it.each(SYSTEM_ALERT_TYPES)( + 'keeps active and resolved %s separate from a resource named Pulse', + (type) => { + const getResource = vi.fn(() => pulseVM); + const active = makeSystemAlert(type); + const resolved = makeSystemAlert(type, { id: `${active.id}-older`, acknowledged: true }); + const items = buildAlertHistoryItems({ + activeAlerts: { [active.id]: active }, + alertHistory: [active, resolved], + getResource, + allResources: [pulseVM], + }); + expect(items).toHaveLength(2); + for (const item of items) { + expect(item.resourceType).toBe('Pulse'); + expect(item).toHaveProperty('systemAlert', true); + expect(item.resourceId).toBe(''); + } + expect(getResource).not.toHaveBeenCalled(); + }, + ); + + it('prioritises explicit system metadata over conflicting resource hints', () => { + const alert = makeSystemAlert('future-system-condition', { + id: 'legacy-system-id', + resourceId: pulseVM.id, + metadata: { systemAlert: true, resourceType: 'vm' }, + }); + const [item] = buildAlertHistoryItems({ + activeAlerts: { [alert.id]: alert }, + alertHistory: [], + getResource: () => pulseVM, + allResources: [pulseVM], + }); + expect(item.resourceType).toBe('Pulse'); + expect(item.resourceId).toBe(''); + expect(item).toHaveProperty('systemAlert', true); + }); + + it('does not turn a monitored VM called Pulse into a system alert', () => { + const alert = makeSystemAlert('cpu', { + id: 'resource-cpu', + resourceId: pulseVM.id, + metadata: { systemAlert: false }, + }); + const [item] = buildAlertHistoryItems({ + activeAlerts: { [alert.id]: alert }, + alertHistory: [], + getResource: () => pulseVM, + allResources: [pulseVM], + }); + expect(item.resourceType).toBe('VM'); + expect(item.resourceId).toBe(pulseVM.id); + expect(item.systemAlert).not.toBe(true); + }); +}); From 813846485825aab066890e00b1c1b13615e59b4f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:33:53 +0100 Subject: [PATCH 2/6] Keep Pulse system alerts separate from monitored resources Present backup-evaluation and existing service warnings without a fictional resource link, policy action, Patrol target or zero-value metric. Preserve service scope through active and retained History explanations; keep exact warning identity, message, acknowledgement and timeline, with ordinary resource controls and narrow keyboard/touch browser proof. Contract-Neutral: Frontend enforcement of existing system-alert scope only; no API, schema, resource identity, monitoring policy, operator permission or notification contract delta. Semantic subsystem contracts and connected verification are included. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 19 + .../subsystems/frontend-primitives.md | 10 + .../browser-tests/system-alert-scope.cjs | 378 ++++++++++++++++++ .../browser-tests/system-alert-scope.html | 12 + .../browser-tests/system-alert-scope.tsx | 107 +++++ .../4c16797de9c711c2.json | 54 +++ .../Alerts/InvestigateAlertButton.tsx | 8 +- .../__tests__/InvestigateAlertButton.test.tsx | 22 + .../Alerts/alertAssistantHandoffModel.ts | 60 +-- .../alerts/AlertHistoryItemActions.tsx | 1 + .../alerts/AlertOverviewAlertCard.tsx | 30 +- .../src/features/alerts/alertHistoryModel.ts | 23 +- .../src/utils/__tests__/alertScope.test.ts | 17 + frontend-modern/src/utils/alertScope.ts | 10 + 14 files changed, 709 insertions(+), 42 deletions(-) create mode 100644 frontend-modern/browser-tests/system-alert-scope.cjs create mode 100644 frontend-modern/browser-tests/system-alert-scope.html create mode 100644 frontend-modern/browser-tests/system-alert-scope.tsx create mode 100644 frontend-modern/browser-verification/4c16797de9c711c2.json create mode 100644 frontend-modern/src/utils/__tests__/alertScope.test.ts create mode 100644 frontend-modern/src/utils/alertScope.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index c0943f501d..3c89b76df0 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,25 @@ ## Purpose +### Pulse system alerts retain their service scope in the existing UI + +The `pulse-system-` identity prefix or explicit `systemAlert: true` metadata +marks a Pulse service condition, not a monitored resource. Overview retains +the warning, acknowledgement, snooze and occurrence timeline but offers no +resource link or monitoring-policy action. History preserves this scope for +active and retained conditions before any resource-name fallback: a machine +named Pulse is not evidence that it owns the service warning. Ordinary resource +alerts, including those called Pulse, retain their existing target affordances. + +The Assistant explanation keeps the exact alert identity, message and +approval-required boundary, without invented metric values, a guest target or +resource Patrol. Reconstructed History explanations preserve the system marker; +new system types need no frontend allowlist. Connected overview/history/handoff +and investigation-button controls verify both authoritative markers, legacy +metadata, name collisions and ordinary resource controls. Browser acceptance +checks the actual components on desktop and narrow touch views. This changes +presentation only, not alert evaluation, delivery, API or operator permissions. + ### Backup-age evaluation failures use the existing system-alert lifecycle `pulse-system-backup-evaluation` is a warning about Pulse's inability to read diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c6951e64ab..e12228086b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,16 @@ ## Purpose +Pulse system-alert presentation uses the existing reserved ID/metadata markers, +not the displayed name or a platform fallback. Overview and both History layouts +keep service warnings, occurrence timelines and acknowledgement usable without +a false resource link/policy action. History retains the system marker when +reconstructing Assistant context; the explanation has no fabricated metric, +guest target or targeted Patrol. Ordinary monitored resources named Pulse keep +their original affordances. The shared scope helper, mounted alert controls and +desktop/narrow-touch browser fixture cover this boundary. No navigation entry, +page, API, alert policy or operator permission is introduced. + ### Guest-agent coverage is not inferred from metadata The existing workload drawer distinguishes assigned Pulse Agent action ownership, diff --git a/frontend-modern/browser-tests/system-alert-scope.cjs b/frontend-modern/browser-tests/system-alert-scope.cjs new file mode 100644 index 0000000000..a91ae3d078 --- /dev/null +++ b/frontend-modern/browser-tests/system-alert-scope.cjs @@ -0,0 +1,378 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/system-alert-scope-proof'; +const runtime = [ + 'frontend-modern/src/utils/alertScope.ts', + 'frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts', + 'frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx', + 'frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx', + 'frontend-modern/src/features/alerts/alertHistoryModel.ts', + 'frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx', +]; +const systemTypes = [ + 'backup-evaluation', + 'notification-delivery', + 'deadman-delivery', + 'deadman-monitoring-stalled', + 'deadman-interruption', + 'deadman-state', +]; +const legacy = { + id: 'retained-legacy-system-id', + type: 'notification-delivery', + level: 'warning', + resourceId: 'vm-wrong', + resourceName: 'Pulse', + node: 'wrong-node', + metadata: { systemAlert: true, resourceType: 'vm' }, + message: 'Synthetic retained notification delivery outage.', + startTime: '2026-10-04T14:00:00Z', + lastSeen: '2026-10-04T14:30:00Z', + acknowledged: true, + value: 0, + threshold: 0, +}; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const version = require('playwright/package.json').version; + assert.equal( + version, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries( + runtime.map((file) => [file, hash(path.join('/workspace', file))]), + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null }, + }); + const origin = 'http://127.0.0.1:5297'; + const results = [], + screenshots = []; + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1365, false], + ['chromium-desktop-dark', chromium, 1365, true], + ['webkit-phone-dark', webkit, 390, true], + ['webkit-narrow-light', webkit, 320, false], + ]) { + phase = name; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(10000); + page.setDefaultNavigationTimeout(60000); + const errors = [], + requests = [], + offOrigin = [], + writes = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.addInitScript((dark) => { + localStorage.clear(); + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + await page.routeWebSocket(/.*/, (socket) => socket.close()); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, query: url.search, method: req.method() }); + if (req.method() !== 'GET') { + writes.push({ path: url.pathname, method: req.method(), body: req.postDataJSON() }); + assert.ok( + ['/api/alerts/acknowledge', '/api/alerts/unacknowledge'].includes(url.pathname), + 'unexpected write', + ); + assert.equal(req.method(), 'POST'); + assert.deepEqual(req.postDataJSON(), { + alertIdentifier: 'pulse-system-backup-evaluation', + }); + return route.fulfill({ json: { success: true } }); + } + if (url.pathname === '/api/alerts/history') return route.fulfill({ json: [legacy] }); + if (url.pathname === '/api/alerts/incidents') return route.fulfill({ json: null }); + if (url.pathname === '/api/notifications/health') + return route.fulfill({ + json: { + overall_healthy: true, + queue: { + healthy: true, + status: 'healthy', + pending: 0, + sending: 0, + sent: 0, + failed: 0, + dlq: 0, + attention_required: 0, + reason_codes: [], + completed_retention_days: 7, + dead_letter_retention_days: 30, + counts_are_retention_bounded: true, + retry_attempts_affect_health: false, + terminal_failures_affect_health: true, + failure_classes_7d: { + authentication: 0, + rate_limited: 0, + connectivity: 0, + tls: 0, + configuration: 0, + rejected: 0, + server_error: 0, + unknown: 0, + }, + failure_class_window_days: 7, + failure_classes_available: true, + }, + }, + }); + if (url.pathname.includes('license')) + return route.fulfill({ + json: { capabilities: [], entitlements: [], limits: [], max_history_days: 90 }, + }); + if (url.pathname === '/api/security/status') + return route.fulfill({ + json: { + hasAuthentication: true, + requiresAuth: false, + sessionCapabilities: { assistantEnabled: true }, + }, + }); + return route.fulfill({ json: [] }); + }); + const check = async (label, action) => { + phase = `${name}: ${label}`; + await action(); + checks.push({ label, passed: true }); + }; + const capture = async (locator, state) => { + const file = `${name}-${state}.png`; + await locator.screenshot({ path: path.join(output, file) }); + screenshots.push({ file, state, width, dark, sha256: hash(path.join(output, file)) }); + }; + const assertSystemContext = async (id) => { + const context = await page.evaluate(() => window.__systemAlertScope.context()); + assert.equal(context.targetId, undefined); + assert.equal(context.targetType, undefined); + assert.deepEqual(context.handoffResources, []); + assert.equal(context.autonomousMode, false); + assert.equal(context.context.guestName, undefined); + assert.equal(context.context.node, undefined); + assert.match(context.handoffContext, /Scope: Pulse itself \(not a monitored resource\)/); + assert.ok(context.handoffContext.includes(`Alert Identifier: ${id}`)); + assert.ok(context.handoffContext.includes('Operator Boundary:')); + assert.doesNotMatch( + context.handoffContext, + /Current Value:|Threshold:|wrong-node|vm-wrong/, + ); + assert.doesNotMatch(context.briefing.detailLines.join('\n'), /Current value|threshold/); + await page.evaluate(() => window.__systemAlertScope.closeExplanation()); + }; + try { + await page.goto(`${origin}/browser-tests/system-alert-scope.html`); + const overview = page.getByRole('region', { name: 'Existing alert overview' }); + const history = page.getByRole('region', { name: 'Existing alert history' }); + const backupCard = page.locator('#alert-pulse-system-backup-evaluation'); + await check( + 'canonical service warning has no false resource link or policy action', + async () => { + await backupCard.waitFor({ state: 'visible' }); + assert.equal( + await overview + .getByText('Notification delivery status is unavailable', { exact: true }) + .count(), + 0, + ); + assert.equal(await backupCard.getByRole('link').count(), 0); + assert.equal( + await backupCard.getByRole('button', { name: 'Have Patrol investigate' }).count(), + 0, + ); + assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/); + await page.waitForFunction(() => window.__systemAlertScope.historyRows().length === 3); + const rows = await page.evaluate(() => window.__systemAlertScope.historyRows()); + for (const row of rows.filter((r) => r.systemAlert)) { + assert.equal(row.resourceType, 'Pulse'); + assert.equal(row.resourceId, ''); + } + assert.equal(rows.find((r) => r.id === 'resource-cpu').resourceType, 'vm'); + }, + ); + await capture(overview, 'backup-warning'); + await check('acknowledgement remains active, not recovery', async () => { + await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).click(); + await backupCard.getByText('Acknowledged', { exact: true }).waitFor(); + assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/); + await backupCard.getByRole('button', { name: 'Unacknowledge', exact: true }).click(); + await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).waitFor(); + }); + await check('occurrence timeline keeps exact service identity', async () => { + await backupCard.getByRole('button', { name: 'Timeline', exact: true }).click(); + await page.waitForFunction(() => + document + .querySelector('#alert-pulse-system-backup-evaluation') + ?.textContent.includes('No incident'), + ); + const incident = requests.find((r) => r.path === '/api/alerts/incidents'); + assert.ok(incident); + const query = new URLSearchParams(incident.query); + assert.equal(query.get('alertIdentifier'), 'pulse-system-backup-evaluation'); + assert.equal(query.get('started_at'), '2026-10-04T15:00:00Z'); + await backupCard.getByRole('button', { name: 'Hide Timeline', exact: true }).click(); + }); + for (const type of systemTypes) { + await check(`${type} explanation is service-scoped and model-only`, async () => { + await page.evaluate((type) => window.__systemAlertScope.updateSystem(type), type); + const card = page.locator(`#alert-pulse-system-${type}`); + await card.waitFor({ state: 'visible' }); + assert.equal(await card.getByRole('link').count(), 0); + const button = card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true }); + if (width > 500) { + await button.focus(); + await page.keyboard.press('Enter'); + } else await button.tap(); + await assertSystemContext(`pulse-system-${type}`); + }); + } + await check( + 'legacy system metadata wins over conflicting resource/metric hints', + async () => { + await page.evaluate(() => + window.__systemAlertScope.updateSystem('future-condition', true), + ); + const card = page.locator('#alert-legacy-system-id'); + await card.waitFor({ state: 'visible' }); + assert.equal(await card.getByRole('link').count(), 0); + assert.doesNotMatch(await card.textContent(), /limit:|wrong-node/); + await card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true }).click(); + await assertSystemContext('legacy-system-id'); + }, + ); + await check( + 'desktop and phone History reconstruct the retained service marker', + async () => { + const row = + width < 500 + ? history + .locator('[data-alert-history-row-key]') + .filter({ hasText: legacy.message }) + : history.getByRole('row').filter({ hasText: legacy.message }); + assert.equal(await row.locator('[data-alert-history-action="resource"]').count(), 0); + await row.getByRole('button', { name: 'Ask Pulse Assistant about this alert' }).click(); + await assertSystemContext(legacy.id); + }, + ); + await capture(history, 'retained-history'); + await check( + 'monitored VM called Pulse retains its link and targeted affordances', + async () => { + const card = page.locator('#alert-resource-cpu'); + assert.equal( + await card.getByRole('link', { name: 'Pulse', exact: true }).getAttribute('href'), + '/proxmox/overview', + ); + assert.match(await card.textContent(), /limit: 80%/); + await card + .getByRole('button', { name: 'Have Patrol investigate' }) + .waitFor({ state: 'visible' }); + }, + ); + await check( + 'authoritative recovery withdraws only the current service warning', + async () => { + await page.evaluate(() => window.__systemAlertScope.recover()); + assert.equal(await overview.locator('[id^="alert-legacy"]').count(), 0); + assert.equal(await overview.locator('#alert-resource-cpu').count(), 1); + assert.ok( + (await page.evaluate(() => window.__systemAlertScope.historyRows())).some( + (r) => r.id === legacy.id, + ), + ); + }, + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.equal(writes.length, 2); + assert.ok( + !requests.some((r) => /patrol\/run|execute|diagnostic|ai\/chat|guest-agent/.test(r.path)), + ); + results.push({ + name, + width, + dark, + browser_version: browser.version(), + checks, + requests, + writes, + errors, + offOrigin, + }); + } finally { + await browser.close(); + browser = null; + } + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { passed: true, playwright_version: version, content_sha256: hashes, results, screenshots }, + null, + 2, + ) + '\n', + ); + console.log( + JSON.stringify({ + passed: true, + groups: results.length, + checks: results.reduce((n, r) => n + r.checks.length, 0), + screenshots: screenshots.length, + playwright_version: version, + }), + ); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failed.json'), + JSON.stringify( + { passed: false, phase, error: error.message, results, screenshots }, + null, + 2, + ) + '\n', + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/system-alert-scope.html b/frontend-modern/browser-tests/system-alert-scope.html new file mode 100644 index 0000000000..5538407dca --- /dev/null +++ b/frontend-modern/browser-tests/system-alert-scope.html @@ -0,0 +1,12 @@ + + + + + + System-alert scope verification + + +
+ + + diff --git a/frontend-modern/browser-tests/system-alert-scope.tsx b/frontend-modern/browser-tests/system-alert-scope.tsx new file mode 100644 index 0000000000..ca02744e78 --- /dev/null +++ b/frontend-modern/browser-tests/system-alert-scope.tsx @@ -0,0 +1,107 @@ +// Production alert components/state/router and real Assistant handoff store. +// Synthetic API evidence; no native service, model, diagnostic or command. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { OverviewTab } from '../src/features/alerts/OverviewTab'; +import { AlertHistoryTableSection } from '../src/features/alerts/AlertHistoryTableSection'; +import { useAlertHistoryState } from '../src/features/alerts/useAlertHistoryState'; +import { makeSystemAlert } from '../src/features/alerts/__fixtures__/systemAlerts'; +import { aiChatStore } from '../src/stores/aiChat'; +import type { Alert } from '../src/types/api'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +aiChatStore.setEnabled(true); +const pulseVM = { id: 'vm-pulse', name: 'Pulse', type: 'vm' } as Resource; +const control = makeSystemAlert('cpu', { + id: 'resource-cpu', + resourceId: pulseVM.id, + metadata: { resourceType: 'vm' }, + value: 92, + threshold: 80, + message: 'Synthetic CPU reading on the monitored VM called Pulse.', +}); + +function Fixture() { + const initial = makeSystemAlert(); + const [alerts, setAlerts] = createSignal>({ + [initial.id]: initial, + [control.id]: control, + }); + const [showAcknowledged, setShowAcknowledged] = createSignal(true); + const history = useAlertHistoryState({ + activeAlerts: alerts, + getResource: () => pulseVM, + allResources: () => [pulseVM], + }); + (window as any).__systemAlertScope = { + updateSystem: (type: string, legacy = false) => { + const next = makeSystemAlert( + type, + legacy + ? { + id: 'legacy-system-id', + resourceId: 'vm-wrong', + node: 'wrong-node', + metadata: { systemAlert: true, resourceType: 'vm' }, + threshold: 80, + } + : {}, + ); + setAlerts({ [next.id]: next, [control.id]: control }); + }, + recover: () => setAlerts({ [control.id]: control }), + alerts, + historyRows: history.alertData, + context: () => aiChatStore.context, + closeExplanation: () => aiChatStore.close(), + }; + return ( +
+

Pulse system-alert evidence

+

+ Synthetic observations. No workload, diagnostic or model is contacted. +

+
+ + setAlerts((previous) => ({ ...previous, [id]: { ...previous[id], ...updates } })) + } + showAcknowledged={showAcknowledged} + setShowAcknowledged={setShowAcknowledged} + showQuickTip={() => false} + dismissQuickTip={() => {}} + alertsDisabled={() => false} + /> +
+
+

History

+ +
+ +
+

+ Test capture of the real explanation store; no Assistant request is executed. +

+
+            {JSON.stringify(aiChatStore.context, null, 2)}
+          
+
+
+
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/4c16797de9c711c2.json b/frontend-modern/browser-verification/4c16797de9c711c2.json new file mode 100644 index 0000000000..196b87c2f8 --- /dev/null +++ b/frontend-modern/browser-verification/4c16797de9c711c2.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "base_sha": "63443e8712dbb6d3866bdc5fce9f2b89c13be51c", + "verified_at": "2026-10-04T16:33:49Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx", + "frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts", + "frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/alerts/alertHistoryModel.ts", + "frontend-modern/src/utils/alertScope.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx": "f68c7c887abd83ee4b8c022874951b6be06d5b9975bb2a78a0f782d3d5419001", + "frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts": "01eaea04cf8730fca4342d12b2be4e2fefd6e31b530ab1e416a937557cb4d664", + "frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx": "9e7bf38b1f3e57f4a8b178ee48770561e5eef7f93440c190d3001ed95526e765", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "198eecb8d3f94aaf14020686c494f343db687d49c0933589005adb795b35bc8b", + "frontend-modern/src/features/alerts/alertHistoryModel.ts": "69e754d21e4b700bd8e7308e72082786373b1f25f0e2d4ea4434a2d4ba804a1a", + "frontend-modern/src/utils/alertScope.ts": "b10844bcf4a3a2a829d49c385369a0559102477b84688f27c311de02ce931065" + }, + "routes": ["/browser-tests/system-alert-scope.html"], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Production Overview and desktop/mobile History with synthetic canonical system-alert payloads; no native service or full-App acceptance.", + "Backup evaluation unavailable, all six current system types, metadata-only future/legacy type with conflicting resource/metric hints.", + "Monitored VM named Pulse remains resource scoped; independent synthetic notification queue health is complete and healthy.", + "Acknowledged service warning remains active; explicit source recovery removes only current service warning and retains historical occurrence.", + "Light/dark desktop and 390px/320px WebKit touch layouts; eight final screenshots inspected." + ], + "interactions": [ + "Acknowledgement and unacknowledgement retain exact canonical service alert identifier and do not resolve its condition.", + "Occurrence timeline fetch preserves service identifier and original start timestamp.", + "Six real explanation-button handoffs use keyboard Enter on desktop and touch tap on phones; no metric values, resource targets, Patrol, model or diagnostic calls.", + "History explanation reconstructs metadata-only system scope; resource link/Monitoring/Patrol remain only on actual VM control." + ], + "browser_command": "pulse-worker-browser frontend-modern/browser-tests/system-alert-scope.cjs", + "playwright_version": "1.56.1", + "passed_groups": 4, + "passed_checks": 52 +} diff --git a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx index e7098f7af4..6834affd70 100644 --- a/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx +++ b/frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx @@ -7,6 +7,7 @@ import { notificationStore } from '@/stores/notifications'; import { triggerPatrolRun } from '@/api/patrol'; import { t } from '@/i18n'; import type { Alert } from '@/types/api'; +import { isPulseSystemAlert } from '@/utils/alertScope'; import { useUpgradeNavigation } from '@/components/shared/useUpgradeNavigation'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; @@ -54,7 +55,10 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) { } const showPatrolOption = () => - props.patrolOption === true && !isLocked() && !!(props.alert.resourceId || '').trim(); + props.patrolOption === true && + !isLocked() && + !isPulseSystemAlert(props.alert) && + !!(props.alert.resourceId || '').trim(); const usesPatrolPrimary = () => showPatrolOption() && props.variant !== 'icon'; const buttonTitle = () => isLocked() @@ -97,7 +101,7 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) { const startPatrolInvestigation = async () => { const resourceId = (props.alert.resourceId || '').trim(); - if (!resourceId) { + if (isPulseSystemAlert(props.alert) || !resourceId) { notificationStore.warning(t('alerts.assistant.patrol.noResource')); return; } diff --git a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx index 516df51d18..bc93b32485 100644 --- a/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/InvestigateAlertButton.test.tsx @@ -157,6 +157,28 @@ beforeEach(() => { }); describe('system-alert investigation', () => { + it('retains resource Patrol for a monitored VM named Pulse', async () => { + const alert = makeAlert({ resourceName: 'Pulse' }); + render(() => ); + fireEvent.click(screen.getByRole('button', { name: 'Have Patrol investigate' })); + await waitFor(() => expect(triggerPatrolRunMock).toHaveBeenCalledTimes(1)); + expect(triggerPatrolRunMock).toHaveBeenCalledWith({ + resource_ids: ['vm-101'], + alert_identifier: alert.id, + alert_type: 'cpu', + }); + expect(explainMock).not.toHaveBeenCalled(); + }); + + it('retains metadata-only system scope on a historical ID', () => { + const alert = makeSystemAlert('future-condition', { id: 'legacy-id', resourceId: 'vm-wrong' }); + render(() => ); + fireEvent.click(screen.getByRole('button', { name: 'Ask Pulse Assistant about this alert' })); + expect(triggerPatrolRunMock).not.toHaveBeenCalled(); + expect(openedContext().targetId).toBeUndefined(); + expect(openedContext().handoffResources).toEqual([]); + }); + it.each([undefined, { systemAlert: true, resourceType: 'vm' }])( 'opens explanation, never resource Patrol, for a system alert with metadata %j', (metadata) => { diff --git a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts index a006e379af..3a6a08b9c7 100644 --- a/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts +++ b/frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts @@ -4,6 +4,7 @@ import { DEFAULT_LOCALE, t, type SupportedLocale } from '@/i18n'; import { getCanonicalAlertId } from '@/features/alerts/identity'; import { formatAlertValue } from '@/utils/alertFormatters'; import { isMetricAlertType } from '@/utils/alerts'; +import { isPulseSystemAlert } from '@/utils/alertScope'; import { resolveAlertTargetType } from '@/utils/alertTargetTypes'; interface BuildAlertAssistantHandoffInput { @@ -32,23 +33,27 @@ export function buildAlertAssistantHandoff({ // threshold 0.0%" in operator-facing copy is misleading default-zero // noise. Suppress those fields and rely on alert.type + alert.message // to convey what's wrong. - const hasMetricValues = isMetricAlertType(alert.type); + const systemScoped = isPulseSystemAlert(alert); + const hasMetricValues = !systemScoped && isMetricAlertType(alert.type); const currentValue = hasMetricValues ? formatAlertValue(alert.value, alert.type) : ''; const thresholdValue = hasMetricValues ? formatAlertValue(alert.threshold, alert.type) : ''; - const nodeLabel = alert.node ? alert.nodeDisplayName || alert.node : ''; + const nodeLabel = !systemScoped && alert.node ? alert.nodeDisplayName || alert.node : ''; const levelLabel = formatAlertLevel(alert.level); const modelLevelLabel = formatAlertLevel(alert.level, DEFAULT_LOCALE); - const targetType = resolveAlertTargetType({ - alertType: alert.type, - resourceType, - metadataResourceType: - typeof alert.metadata?.resourceType === 'string' - ? (alert.metadata.resourceType as string) - : undefined, - resourceId: alert.resourceId, - }); + const targetType = systemScoped + ? undefined + : resolveAlertTargetType({ + alertType: alert.type, + resourceType, + metadataResourceType: + typeof alert.metadata?.resourceType === 'string' + ? (alert.metadata.resourceType as string) + : undefined, + resourceId: alert.resourceId, + }); const handoffContext = buildAlertAssistantModelContext({ alert, + systemScoped, alertIdentifier, currentValue, thresholdValue, @@ -60,17 +65,19 @@ export function buildAlertAssistantHandoff({ return { context: { targetType, - targetId: alert.resourceId, + targetId: systemScoped ? undefined : alert.resourceId, autonomousMode: false, handoffContext, - handoffResources: [ - { - id: alert.resourceId, - name: alert.resourceName, - type: targetType, - node: alert.node, - }, - ], + handoffResources: systemScoped + ? [] + : [ + { + id: alert.resourceId, + name: alert.resourceName, + type: targetType, + node: alert.node, + }, + ], briefing: { sourceLabel: t('alerts.assistant.sourceLabel'), title: t('alerts.assistant.title'), @@ -103,9 +110,9 @@ export function buildAlertAssistantHandoff({ alertType: alert.type, alertLevel: alert.level, alertMessage: alert.message, - guestName: alert.resourceName, - node: alert.node, - vmid, + guestName: systemScoped ? undefined : alert.resourceName, + node: systemScoped ? undefined : alert.node, + vmid: systemScoped ? undefined : vmid, }, }, }; @@ -113,6 +120,7 @@ export function buildAlertAssistantHandoff({ function buildAlertAssistantModelContext({ alert, + systemScoped, alertIdentifier, currentValue, thresholdValue, @@ -121,6 +129,7 @@ function buildAlertAssistantModelContext({ levelLabel, }: { alert: Alert; + systemScoped: boolean; alertIdentifier: string; currentValue: string; thresholdValue: string; @@ -135,8 +144,9 @@ function buildAlertAssistantModelContext({ formatContextLine('Alert Type', alert.type), formatContextLine('Alert Level', levelLabel), formatContextLine('Alert Status', 'active'), - formatContextLine('Resource', alert.resourceName), - formatContextLine('Resource ID', alert.resourceId), + systemScoped ? 'Scope: Pulse itself (not a monitored resource)' : undefined, + formatContextLine(systemScoped ? 'Service' : 'Resource', alert.resourceName), + systemScoped ? undefined : formatContextLine('Resource ID', alert.resourceId), formatContextLine('Current Value', currentValue), formatContextLine('Threshold', thresholdValue), formatContextLine('Duration', durationText), diff --git a/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx index 4532b62297..74697f961e 100644 --- a/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx +++ b/frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx @@ -91,6 +91,7 @@ export function AlertHistoryItemActions(props: AlertHistoryItemActionsProps) { startTime: props.alert.startTime, lastSeen: props.alert.startTime, acknowledged: props.alert.status === 'acknowledged', + metadata: props.alert.systemAlert ? { systemAlert: true } : undefined, }} resourceType={props.alert.resourceType} variant="icon" diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 2510d165ed..77b59924a6 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -31,6 +31,7 @@ import type { AlertOverviewState } from './useAlertOverviewState'; import { ResourceMonitoringPolicyAction } from './ResourceMonitoringPolicyAction'; import { AlertSnoozeAction } from './AlertSnoozeAction'; import { isAlertSnoozed } from './useAlertSnoozeState'; +import { isPulseSystemAlert } from '@/utils/alertScope'; interface AlertOverviewAlertCardProps { alert: Alert; @@ -40,6 +41,8 @@ interface AlertOverviewAlertCardProps { export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { const alertKey = () => getCanonicalAlertId(props.alert); + const hasResource = () => + !isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim()); const processing = () => props.state.processingAlerts().has(alertKey()) || props.state.snoozeProcessingAlerts().has(alertKey()); @@ -124,20 +127,29 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
- + {props.alert.resourceName} + + } > - {props.alert.resourceName} - + + {props.alert.resourceName} + + ({alertTypeDisplayLabel(props.alert.type)}) {formatAlertSeverityLabel(props.alert.level)} - + {getAlertOverviewNodeLabel(props.alert.nodeDisplayName || props.alert.node)} @@ -158,7 +170,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {

{getAlertOverviewStartedAtLabel(new Date(props.alert.startTime).toLocaleString())}

- 0}> + 0}> limit: {props.alert.threshold} {props.alert.type === 'temperature' || props.alert.type === 'diskTemperature' @@ -231,7 +243,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { props.timelineState.expandedIncidents().has(alertKey()), )} - + ; resourceId?: string; + alertId?: string; getResource: (resourceId: string) => Resource | undefined; allResources: Resource[]; }) { + if (isPulseSystemAlert({ id: alertId || '', metadata })) return 'Pulse'; const metadataType = typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined; if (metadataType && metadataType.trim().length > 0) { @@ -169,6 +174,7 @@ export function buildAlertHistoryItems({ const items: HistoryItem[] = []; Object.values(activeAlerts).forEach((alert) => { + const systemScoped = isPulseSystemAlert(alert); items.push({ id: alert.id, source: 'alert', @@ -177,15 +183,17 @@ export function buildAlertHistoryItems({ duration: formatAlertHistoryDuration(alert.startTime, undefined, now), resourceName: alert.resourceName, resourceType: resolveAlertHistoryResourceType({ + alertId: alert.id, resourceName: alert.resourceName, metadata: alert.metadata, resourceId: alert.resourceId, getResource, allResources, }), - resourceId: alert.resourceId, - node: alert.node, - nodeDisplayName: alert.nodeDisplayName, + resourceId: systemScoped ? '' : alert.resourceId, + node: systemScoped ? undefined : alert.node, + nodeDisplayName: systemScoped ? undefined : alert.nodeDisplayName, + ...(systemScoped ? { systemAlert: true } : {}), severity: alert.level, title: alertTypeDisplayLabel(alert.type), rawAlertType: alert.type, @@ -197,6 +205,7 @@ export function buildAlertHistoryItems({ const activeAlertIds = new Set(Object.keys(activeAlerts)); alertHistory.forEach((alert) => { if (activeAlertIds.has(alert.id)) return; + const systemScoped = isPulseSystemAlert(alert); items.push({ id: alert.id, @@ -207,15 +216,17 @@ export function buildAlertHistoryItems({ duration: formatAlertHistoryDuration(alert.startTime, alert.lastSeen, now), resourceName: alert.resourceName, resourceType: resolveAlertHistoryResourceType({ + alertId: alert.id, resourceName: alert.resourceName, metadata: alert.metadata, resourceId: alert.resourceId, getResource, allResources, }), - resourceId: alert.resourceId, - node: alert.node, - nodeDisplayName: alert.nodeDisplayName, + resourceId: systemScoped ? '' : alert.resourceId, + node: systemScoped ? undefined : alert.node, + nodeDisplayName: systemScoped ? undefined : alert.nodeDisplayName, + ...(systemScoped ? { systemAlert: true } : {}), severity: alert.level, title: alertTypeDisplayLabel(alert.type), rawAlertType: alert.type, diff --git a/frontend-modern/src/utils/__tests__/alertScope.test.ts b/frontend-modern/src/utils/__tests__/alertScope.test.ts new file mode 100644 index 0000000000..6337a4d201 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertScope.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isPulseSystemAlert } from '../alertScope'; + +describe('Pulse system-alert scope markers', () => { + it('accepts the canonical ID even when metadata is absent', () => { + expect(isPulseSystemAlert({ id: 'pulse-system-future-condition' })).toBe(true); + }); + it('accepts explicit metadata on a retained legacy ID', () => { + expect(isPulseSystemAlert({ id: 'legacy-id', metadata: { systemAlert: true } })).toBe(true); + }); + it.each([undefined, false, 'true', 1])( + 'does not infer system scope from metadata %j', + (marker) => { + expect(isPulseSystemAlert({ id: 'vm-pulse', metadata: { systemAlert: marker } })).toBe(false); + }, + ); +}); diff --git a/frontend-modern/src/utils/alertScope.ts b/frontend-modern/src/utils/alertScope.ts new file mode 100644 index 0000000000..1eafe470d6 --- /dev/null +++ b/frontend-modern/src/utils/alertScope.ts @@ -0,0 +1,10 @@ +import type { Alert } from '@/types/api'; + +/** + * Mirror the existing system-alert identity/metadata contract. Names and alert + * types are not scope evidence: a monitored machine can also be called Pulse, + * and future system conditions must not need a frontend type allowlist. + */ +export function isPulseSystemAlert(alert: Pick): boolean { + return alert.id.startsWith('pulse-system-') || alert.metadata?.systemAlert === true; +} From c6e1b71cb6b44ae9e067e10a3a83edd594918b11 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:13:19 +0100 Subject: [PATCH 3/6] Complete retained system-alert fixture copy correction Replace the synthetic backup-warning semicolon with separate sentences. Keep the exact original reproduction and service-alert runtime ancestry and unchanged production content; complete the previously failed copy audit additively. Contract-Neutral: Test-fixture punctuation only. No runtime, API, identity, monitoring, notification or frontend contract delta. Change-source: pulse-maintainer --- .../src/features/alerts/__fixtures__/systemAlerts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts b/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts index 6f2f9086a1..0772af47db 100644 --- a/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts +++ b/frontend-modern/src/features/alerts/__fixtures__/systemAlerts.ts @@ -23,7 +23,7 @@ export function makeSystemAlert( instance: '', level: 'warning', message: - "Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept; check Pulse's logs.", + "Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept. Check Pulse's logs.", value: 0, threshold: 0, startTime: '2026-10-04T15:00:00Z', From 3d846f76d724b1853a3ea58201cfc6eb7e25b084 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:20:18 +0100 Subject: [PATCH 4/6] Reproduce guest memory freshness lost at frontend consumers Exercise original-age and selected-source provenance through both owned projections and mounted History. Pin retained, unavailable and unknown values, same-guest transitions, independent live readings, dated inspection and denied History without starting diagnostics. Contract-Neutral: Test-only controls for the existing optional guest-memory observation contract. No runtime, route, selection, history or permission change. Change-source: pulse-maintainer --- ...stDrawerHistory.memory-provenance.test.tsx | 249 ++++++++++++++++++ .../__tests__/useUnifiedResources.test.ts | 30 +++ .../src/hooks/__tests__/useWorkloads.test.ts | 56 ++++ .../__tests__/resourceStateAdapters.test.ts | 22 ++ 4 files changed, 357 insertions(+) create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx new file mode 100644 index 0000000000..918c6cce6c --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx @@ -0,0 +1,249 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import type { WorkloadGuest } from '@/types/workloads'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import { + getGuestDrawerCurrentMetrics, + getGuestDrawerDeferredMetrics, + getGuestDrawerMemoryRows, +} from '../guestDrawerModel'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 90, + isRangeLocked: () => false, +})); +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); +}); +const original = '2026-10-04T14:00:00Z'; +const later = '2026-10-04T17:00:00Z'; +const annotation = ( + state: string, + observedAt: string | undefined = original, + source = 'guest-agent-meminfo', +) => ({ state, source, ...(observedAt === undefined ? {} : { observedAt }) }); +const guest = (observation?: ReturnType, usage = 25): WorkloadGuest => { + const memory = { + total: 100, + used: usage, + free: 100 - usage, + usage, + ...(observation ? { observation } : {}), + }; + return { + id: 'fixture:pve1:101', + vmid: 101, + name: 'backup-guest', + node: 'pve1', + instance: 'fixture', + type: 'qemu', + status: 'running', + cpu: 0.1, + cpus: 2, + memory, + disk: { total: 100, used: 50, usage: 50 }, + networkIn: 100, + networkOut: 200, + diskRead: 300, + diskWrite: 400, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: later, + }; +}; +const response = ( + metrics: AllMetricsHistoryResponse['metrics'] = {}, +): AllMetricsHistoryResponse => ({ + resourceType: 'vm', + resourceId: 'fixture:pve1:101', + range: '1h', + start: Date.parse(original), + end: Date.parse(later), + source: 'store', + metrics, +}); +const mount = (value: () => WorkloadGuest) => + render(() => ( + + )); +const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0]; + +describe('guest memory original-observation provenance', () => { + it.each(['qemu', 'lxc'] as const)( + 'retains %s memory as evidence without inventing current freshness', + (type) => { + const value = { ...guest(annotation('last-known')), type }; + expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(value).memory).toMatchObject({ + lastKnownValue: 25, + message: expect.stringContaining('2026-10-04 14:00:00 UTC'), + }); + expect(getGuestDrawerMemoryRows(value)).toContainEqual({ + label: 'Reading', + value: expect.stringContaining('Last known'), + }); + expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({ cpu: 10, disk: 50, netin: 100 }); + }, + ); + + it('never borrows disk deferral or refreshed guest LastSeen for memory provenance', () => { + const retained = { + ...guest(annotation('last-known')), + diskStatusReason: undefined, + lock: '', + lastSeen: later, + }; + expect(getGuestDrawerCurrentMetrics(retained).memory).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(retained).memory.message).toContain('14:00:00 UTC'); + expect(getGuestDrawerDeferredMetrics(retained).memory.message).not.toContain('17:00:00'); + const pve = { + ...guest(annotation('current', original, 'status-mem')), + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + }; + expect(getGuestDrawerCurrentMetrics(pve).memory).toBe(25); + expect(getGuestDrawerDeferredMetrics(pve).memory).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(pve).disk.lastKnownValue).toBe(50); + }); + + it('keeps unavailable numbers out of current and retained legends and usage breakdown', () => { + const value = guest(annotation('unavailable'), 0); + expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(value).memory.lastKnownValue).toBeUndefined(); + expect(getGuestDrawerMemoryRows(value)).toContainEqual({ + label: 'Usage', + value: 'Unavailable', + }); + expect(getGuestDrawerMemoryRows(value).some((row) => row.label === 'Free')).toBe(false); + expect(getGuestDrawerMemoryRows(value)).toContainEqual({ label: 'Total', value: '100 B' }); + }); + + it.each([ + undefined, + annotation('private raw state'), + annotation('current', 'invalid'), + annotation('current', '0001-01-01T00:00:00Z'), + annotation('current', '2999-01-01T00:00:00Z'), + ])('does not promote unknown legacy or unusable memory provenance %#', (observation) => { + const value = guest(observation); + expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(value).memory).toMatchObject({ + lastKnownValue: 25, + valueLabel: 'freshness unknown', + }); + expect(getGuestDrawerDeferredMetrics(value).memory.message).not.toMatch( + /private raw state|2999|0001/, + ); + }); + + it.each([-1, NaN, Infinity, 101])('does not expose invalid retained memory %s', (usage) => { + expect( + getGuestDrawerDeferredMetrics(guest(annotation('last-known'), usage)).memory.lastKnownValue, + ).toBeUndefined(); + }); + + it('retains measured zero and leaves unannotated non-Proxmox data unchanged', () => { + expect( + getGuestDrawerDeferredMetrics(guest(annotation('last-known'), 0)).memory.lastKnownValue, + ).toBe(0); + const vsphere = { + ...guest(), + vmid: 0, + instance: '', + node: '', + platformScopes: ['vmware-vsphere'], + }; + expect(getGuestDrawerCurrentMetrics(vsphere).memory).toBe(25); + expect(getGuestDrawerDeferredMetrics(vsphere)).toEqual({}); + }); + + it('updates same-guest retained, unavailable and independent live source without remount or refetch', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + const [value, setValue] = createSignal(guest(annotation('last-known'))); + mount(value); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const chart = utilization(); + expect(chart.querySelector('[data-history-current="memory"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known="memory"]')).toHaveTextContent( + 'Memory25.0%last known', + ); + expect(chart.querySelector('[data-history-deferred="memory"]')).toHaveTextContent( + '14:00:00 UTC', + ); + setValue({ ...value(), lastSeen: later, lock: 'backup', diskStatusReason: 'prev-vm-locked' }); + expect(chart.querySelector('[data-history-deferred="memory"]')).not.toHaveTextContent( + '17:00:00', + ); + setValue(guest(annotation('unavailable'), 0)); + expect(chart.querySelector('[data-history-current="memory"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known="memory"]')).toBeNull(); + expect(chart).toHaveTextContent('Memory-'); + setValue(guest(annotation('current', later, 'agent'), 35)); + expect(utilization()).toBe(chart); + expect(chart.querySelector('[data-history-deferred="memory"]')).toBeNull(); + expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent( + 'Memory35.0%current', + ); + expect(chart.querySelector('path')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('keeps stored points and dated inspection distinct from retained live memory', async () => { + const point = (timestamp: string, value: number) => ({ + timestamp: Date.parse(timestamp), + value, + min: value, + max: value, + }); + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ memory: [point(original, 20), point(later, 30)] }), + ); + mount(() => guest(annotation('last-known'), 25)); + await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(1)); + const chart = utilization(); + expect(chart).toHaveTextContent('Memory30.0%'); + expect(chart.querySelector('[data-history-current="memory"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known="memory"]')).toBeNull(); + const slider = within(chart).getByRole('slider'); + slider.focus(); + fireEvent.input(slider, { target: { value: '0' } }); + expect(chart).toHaveTextContent('Memory20.0%'); + expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('Memory 20.0%.')); + }); + + it('withdraws retained annotations on denied History without leaking raw errors', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(Object.assign(new Error('private backend refusal'), { status: 403 })); + mount(() => guest(annotation('last-known'))); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute( + 'aria-busy', + 'false', + ), + ); + fireEvent.click(screen.getByRole('button', { name: 'Refresh history' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(), + ); + expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument(); + expect(document.querySelector('[data-history-deferred]')).toBeNull(); + expect(screen.queryByText('private backend refusal')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index ef2dfae357..4f87635c71 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -155,6 +155,36 @@ describe('useUnifiedResources', () => { let resolveIncrementalResourcePatchIndices: UseUnifiedResourcesModule['resolveIncrementalResourcePatchIndices']; let eventBus: (typeof import('@/stores/events'))['eventBus']; + it('keeps API-selected memory annotation through the canonical normalizer', async () => { + setWsConnected(false); + setWsState('resources', []); + const observation = { + state: 'last-known', + source: 'guest-agent-meminfo', + observedAt: '2026-10-04T14:00:00Z', + }; + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + ...v2Resource, + metrics: { ...v2Resource.metrics, memory: { ...v2Resource.metrics.memory, observation } }, + }, + ]), + ); + let dispose = () => {}; + let result!: ReturnType; + createRoot((d) => { + dispose = d; + result = useUnifiedResources(); + }); + try { + await waitForResourceCount(() => result.resources().length); + expect(result.resources()[0].memory).toMatchObject({ current: 50, observation }); + } finally { + dispose(); + } + }); + beforeEach(async () => { vi.useFakeTimers(); vi.resetModules(); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 48bdb54cc2..f5c088f43f 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -489,6 +489,62 @@ describe('useWorkloads', () => { dispose(); }); + it.each(['api', 'snapshot'])( + 'keeps selected memory provenance through %s without borrowing a raw facet', + async (transport) => { + const original = { + state: 'last-known', + source: 'guest-agent-meminfo', + observedAt: '2026-10-04T14:00:00Z', + }; + const raw = { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' }; + const resource = { + ...sampleResource, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'cluster-a', + memory: { observation: raw }, + }, + metrics: { + ...sampleResource.metrics, + memory: { ...sampleResource.metrics.memory, observation: original }, + }, + memory: { current: 50, total: 4096, used: 2048, observation: original }, + }; + apiFetchJSONMock.mockResolvedValue({ data: [resource], meta: { totalPages: 1 } }); + const [snapshot, setSnapshot] = createSignal([resource]); + let dispose = () => {}; + let result!: ReturnType; + createRoot((d) => { + dispose = d; + result = useWorkloads( + () => true, + transport === 'snapshot' ? { resourceSnapshot: snapshot as any } : {}, + ); + }); + try { + await waitForWorkloadCount(() => result.workloads().length); + expect(result.workloads()[0].memory).toMatchObject({ usage: 50, observation: original }); + const legacy = { + ...resource, + metrics: { ...resource.metrics, memory: sampleResource.metrics.memory }, + memory: { current: 50, total: 4096, used: 2048 }, + }; + if (transport === 'snapshot') setSnapshot([legacy] as any); + else { + apiFetchJSONMock.mockResolvedValue({ data: [legacy], meta: { totalPages: 1 } }); + await result.refetch(); + } + await flushAsync(); + expect(result.workloads()[0].memory).not.toHaveProperty('observation'); + expect(result.workloads()[0].memory.usage).toBe(50); + } finally { + dispose(); + } + }, + ); + it('tracks canonical hybrid VM memory without substituting nested agent readings', async () => { // #1962: synthetic snapshots exercise Web reactivity, not poller recovery. const buildVM = (instance: string, percent?: number) => diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index d38b96df74..0014cc7aaa 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -52,6 +52,28 @@ const createServiceResource = ( }) as Resource; describe('resourceStateAdapters nodeFromResource', () => { + it('keeps selected memory provenance independent of a different raw platform reading', () => { + const observation = { + state: 'last-known', + source: 'guest-agent-meminfo', + observedAt: '2026-10-04T14:00:00Z', + }; + const raw = { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' }; + const resource = { + ...createNodeResource({}), + memory: { current: 25, total: 1024, used: 256, observation }, + proxmox: { memory: { total: 1024, used: 768, usage: 75, observation: raw } }, + } as Resource; + expect(nodeFromResource(resource)?.memory).toMatchObject({ usage: 25, observation }); + expect( + nodeFromResource({ ...resource, memory: { current: 25, total: 1024, used: 256 } })?.memory, + ).not.toHaveProperty('observation'); + expect(nodeFromResource({ ...resource, memory: undefined })?.memory).toMatchObject({ + usage: 75, + observation: raw, + }); + }); + it('keeps Proxmox metric coordinates without inventing an agent from discovery routing', () => { const resource = { ...createNodeResource({ proxmox: { nodeName: 'pve-node-1' } }), From 835f88576f1975b7c98f05468968de6b3d17fa25 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:17:37 +0100 Subject: [PATCH 5/6] Keep retained guest memory distinct from current observations Preserve the existing authoritative selected-memory annotation through raw, API, unified and canonical frontend projections. Qualify current, last-known, unknown and unavailable drawer context from its own original source/time, not disk deferral or renewed guest LastSeen. Keep retained numbers separate from current legends and stored points, including measured zero, denied history and independent live readings. Memoize History reads by actual target/range coordinates so same-guest snapshot replacements update without extra reads or chart remounts. Complete connected source controls and owning subsystem contracts; record the inspected 24-case Chromium/WebKit desktop and narrow light/dark proof against this commit's own parent. No route, collector, threshold, retention, permission or native recovery claim. Change-source: pulse-maintainer --- .../v6/internal/subsystems/api-contracts.md | 10 + .../subsystems/frontend-primitives.md | 30 +- .../subsystems/performance-and-scalability.md | 12 + .../internal/subsystems/storage-recovery.md | 12 + .../internal/subsystems/unified-resources.md | 26 ++ .../browser-tests/guest-memory-provenance.cjs | 418 ++++++++++++++++++ .../guest-memory-provenance.html | 12 + .../browser-tests/guest-memory-provenance.tsx | 213 +++++++++ .../aca8311c65fac54f.json | 77 ++++ .../Workloads/GuestDrawerHistory.tsx | 36 +- .../Workloads/GuestDrawerOverview.tsx | 4 + ...uestDrawerHistory.disk-provenance.test.tsx | 10 +- ...stDrawerHistory.memory-provenance.test.tsx | 20 +- .../GuestDrawerHistory.sparse.test.tsx | 61 ++- .../guestDrawerModel.branchcov2.test.ts | 12 +- .../components/Workloads/guestDrawerModel.ts | 114 ++++- .../__tests__/useUnifiedResources.test.ts | 17 + .../src/hooks/__tests__/useWorkloads.test.ts | 15 + .../src/hooks/useUnifiedResources.ts | 5 + frontend-modern/src/hooks/useWorkloads.ts | 6 + frontend-modern/src/types/api.ts | 10 + frontend-modern/src/types/resource.ts | 4 +- .../__tests__/resourceStateAdapters.test.ts | 9 + .../src/utils/memoryObservation.ts | 13 + .../src/utils/resourceStateAdapters.ts | 5 + 25 files changed, 1125 insertions(+), 26 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-memory-provenance.cjs create mode 100644 frontend-modern/browser-tests/guest-memory-provenance.html create mode 100644 frontend-modern/browser-tests/guest-memory-provenance.tsx create mode 100644 frontend-modern/browser-verification/aca8311c65fac54f.json create mode 100644 frontend-modern/src/utils/memoryObservation.ts diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cba4da2477..aa67fa3c82 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -20,6 +20,16 @@ ## Purpose +### Optional guest-memory observation reaches existing frontend consumers + +Frontend Memory and ResourceMetric model the existing optional server-owned +`observation` fields (state, source, original observedAt). The shared wire +reader selects only those fields and never invents an annotation for a legacy +metric. API/canonical workload mapping preserves the selected annotation, not +that of a conflicting platform facet. Unknown states/times stay non-current in +the Proxmox guest drawer. Numeric selection, permissions and existing endpoint +shape remain unchanged; hook/adapter/History controls verify this consumer. + ### Platform connection alerts share the constructed runtime owners The default monitor's connection-alert snapshot callback is installed only after diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e12228086b..bd2fbe8a17 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,32 @@ ## Purpose +### Guest drawers consume selected memory provenance + +Existing guest drawers consume optional server-owned `memory.observation` and +selected canonical/API metric annotations without renewing their original +observation time from resource LastSeen, disk-read deferral or another platform +facet. `useWorkloads`, `useUnifiedResources` and the legacy resource adapter +preserve the selected annotation. A selected unannotated metric cannot borrow a +conflicting raw platform annotation. Raw-memory fallback retains its own +annotation only when its numeric reading is actually selected. + +Proxmox VM/LXC Overview states current, last known, unavailable or freshness +unknown with the original known UTC time and an allowlisted source label. +Missing, unrecognised or unusable provenance is not current. Unavailable usage +keeps known capacity but never invents zero used/free/swap measurements. History +keeps retained/unknown numeric context separate from current legends and stored +points, dated inspection and denied source access. Same-guest source changes +update without resetting the drawer or refetching History. Independent valid +PVE/Pulse Agent readings remain current even while QGA disk reads are deferred; +unannotated unrelated platforms keep existing presentation. + +Connected original-source/normalizer/adapter and mounted History controls plus +`browser-tests/guest-memory-provenance.cjs` exercise these boundaries. They do +not establish native thaw, covered-filesystem writes, liveness, reporter relief, +publication or an installed containing release. No route, collection, numeric +selection, retention, alert policy or diagnostic permission is added. + Pulse system-alert presentation uses the existing reserved ID/metadata markers, not the displayed name or a platform fallback. Overview and both History layouts keep service warnings, occurrence timelines and acknowledgement usable without @@ -49,8 +75,8 @@ requires independent thaw and writes to covered filesystems. Running power state, an OK backup or reported agent availability establishes none of those. Fresh same-guest evidence withdraws the precaution without a new diagnostic or collector request. Existing filesystem provenance, metric values, History, -Discovery/action permissions and monitoring policy are unchanged. No disk-read -reason is used to invent memory provenance that the workload API does not expose. +Discovery/action permissions and monitoring policy are unchanged. Memory qualification uses its own authoritative observation; disk-read state +cannot qualify a different metric or renew its observation time. Verification: `GuestDrawerOverview.agent-state.test.tsx` covers each current state with/without cached version, unknown private fields, action ownership, platform 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 0ce5bfa2c4..5315612235 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -15,6 +15,18 @@ ## Purpose +### Memory presentation does not collect or renew observations + +Guest-memory provenance is projected from the already-owned snapshot in the +existing drawer. A same-guest provenance change updates the Overview/History +label without refetching metrics History, resetting the drawer, creating chart +points or running a diagnostic. Fixed source labels and the original known +time replace fabricated current freshness; there is no new timer, collector or +fleet-wide query. Connected hook and mounted sparse/provenance controls pin +unchanged numeric selection, source isolation and no-extra-History-read +behaviour. Browser comparisons are source/render acceptance, not native CPU, +RSS, backup safety or installed relief measurements. + ### Guest read-state display does not start another read The existing workload drawer's guest-read state and backup precaution derive diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bf835e0494..0a0168e91a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,18 @@ ## Purpose +### Guest-memory read state stays independent of backup disk state + +The existing guest drawer retains Core's selected memory provenance through +both resources-API and canonical snapshot consumers. Retained memory remains +last-known at its original observation time, even after a new poll or cleared +backup lock. Missing provenance is unknown, unavailable memory is not zero, +and an independent current Proxmox/Pulse Agent memory reading remains current +while guest-agent filesystem reads are deferred. History samples and its +access-denial boundary remain authoritative. This client repair does not run +backup/QGA operations or establish independent thaw, covered-filesystem writes, +liveness, resumption or delivery. Existing outage precautions remain effective. + ### Memory provenance does not imply recoverability Canonical metric values may carry optional selected guest memory observation diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4ad877c1d1..ae04260b9a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,32 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Guest drawers consume selected memory provenance + +Existing guest drawers consume optional server-owned `memory.observation` and +selected canonical/API metric annotations without renewing their original +observation time from resource LastSeen, disk-read deferral or another platform +facet. `useWorkloads`, `useUnifiedResources` and the legacy resource adapter +preserve the selected annotation. A selected unannotated metric cannot borrow a +conflicting raw platform annotation. Raw-memory fallback retains its own +annotation only when its numeric reading is actually selected. + +Proxmox VM/LXC Overview states current, last known, unavailable or freshness +unknown with the original known UTC time and an allowlisted source label. +Missing, unrecognised or unusable provenance is not current. Unavailable usage +keeps known capacity but never invents zero used/free/swap measurements. History +keeps retained/unknown numeric context separate from current legends and stored +points, dated inspection and denied source access. Same-guest source changes +update without resetting the drawer or refetching History. Independent valid +PVE/Pulse Agent readings remain current even while QGA disk reads are deferred; +unannotated unrelated platforms keep existing presentation. + +Connected original-source/normalizer/adapter and mounted History controls plus +`browser-tests/guest-memory-provenance.cjs` exercise these boundaries. They do +not establish native thaw, covered-filesystem writes, liveness, reporter relief, +publication or an installed containing release. No route, collection, numeric +selection, retention, alert policy or diagnostic permission is added. + ### Guest memory provenance follows metric selection Raw Proxmox `memory.observation` preserves the guest builder's state, source diff --git a/frontend-modern/browser-tests/guest-memory-provenance.cjs b/frontend-modern/browser-tests/guest-memory-provenance.cjs new file mode 100644 index 0000000000..16f2b1b63b --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-provenance.cjs @@ -0,0 +1,418 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/guest-memory-provenance'; +const origin = 'http://127.0.0.1:5329'; +const runtime = [ + 'frontend-modern/src/types/api.ts', + 'frontend-modern/src/types/resource.ts', + 'frontend-modern/src/utils/memoryObservation.ts', + 'frontend-modern/src/utils/resourceStateAdapters.ts', + 'frontend-modern/src/hooks/useUnifiedResources.ts', + 'frontend-modern/src/hooks/useWorkloads.ts', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx', +]; +const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const initial = () => ({ + state: 'last-known', + source: 'guest-agent-meminfo', + observedAt: '2026-10-04T14:00:00Z', + usage: 25, +}); +const resource = (read) => { + const capacity = 4 * 1024 ** 3; + return { + id: read.peer ? 'fixture-b-pve1-101' : 'fixture-a-pve1-101', + type: 'vm', + name: read.peer ? 'peer-guest' : 'backup-guest', + status: 'online', + lastSeen: read.lastSeen ?? '2026-10-04T17:00:00Z', + sources: ['proxmox', 'agent'], + platformScopes: ['proxmox-pve'], + metrics: { + cpu: { percent: 10 }, + ...(read.state === 'unavailable' + ? {} + : { + memory: { + percent: read.usage, + used: (capacity * read.usage) / 100, + total: capacity, + ...(read.state === undefined + ? {} + : { + observation: { + state: read.state, + source: read.source ?? '', + ...(read.observedAt ? { observedAt: read.observedAt } : {}), + }, + }), + }, + }), + disk: { percent: 50, total: capacity, used: capacity / 2 }, + }, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: read.peer ? 'fixture-b' : 'fixture-a', + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + guestAgentExpected: true, + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + memory: { + total: capacity, + usageUnavailable: read.state === 'unavailable', + observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' }, + }, + }, + }; +}; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])), + cases: [], + screenshots: [], + limits: + 'Production drawer/history/model with real raw, API, unified API and canonical full/delta/fast consumers. Synthetic source payloads, History replies and disconnected websocket metadata. No full App, native QGA/thaw, filesystem writes, physical phone, reporter, installed or publication acceptance.', + }; + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: output + '/cache', + server: { host: '127.0.0.1', port: 5329, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop-light', chromium, 1365, false], + ['desktop-dark', chromium, 1365, true], + ['phone-dark', webkit, 390, true], + ['narrow-light', webkit, 320, false], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const transport of [ + 'raw', + 'api', + 'unified-api', + 'canonical-full', + 'canonical-delta', + 'canonical-fast', + ]) { + const page = await browser.newPage({ + viewport: { width, height: width < 768 ? 844 : 900 }, + isMobile: width < 768, + hasTouch: width < 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + let read = initial(), + mode = 'empty'; + const record = { + name, + transport, + width, + dark, + browser: browser.version(), + errors: [], + requests: [], + states: [], + }; + result.cases.push(record); + page.on('pageerror', (e) => record.errors.push(e.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== origin) { + record.requests.push({ blocked: true, origin: url.origin }); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + record.requests.push({ path: url.pathname, method: route.request().method() }); + assert.equal(route.request().method(), 'GET'); + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: [resource(read)], meta: { totalPages: 1 } } }); + if (url.pathname === '/api/metrics-store/history') { + if (mode === 'denied') + return route.fulfill({ status: 403, json: { error: 'private fixture refusal' } }); + const start = Date.UTC(2026, 9, 4, 14), + end = Date.UTC(2026, 9, 4, 17), + point = (timestamp, value) => ({ timestamp, value, min: value, max: value }); + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: url.searchParams.get('resourceId'), + range: url.searchParams.get('range'), + start, + end, + metrics: + mode === 'stored' + ? { + memory: [point(start, 20), point(end, 30)], + cpu: [point(start, 10), point(end, 15)], + } + : {}, + source: 'store', + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { success: true, data: [], alerts: [], anomalies: [], config: null }, + }); + }); + await page.addInitScript( + (value) => + document.addEventListener('DOMContentLoaded', () => { + if (value) document.documentElement.classList.add('dark'); + }), + dark, + ); + await page.goto( + origin + '/browser-tests/guest-memory-provenance.html?transport=' + transport, + ); + const drawer = page.getByRole('region', { name: 'Guest details' }); + await drawer.getByText('Memory reading', { exact: true }).waitFor(); + const overview = drawer.getByRole('tab', { name: 'Overview', exact: true }); + const history = drawer.getByRole('tab', { name: 'History', exact: true }); + const activate = async (control) => { + if (width < 768) await control.tap(); + else { + await control.focus(); + await page.keyboard.press('Enter'); + } + }; + // ObjectDrawerHeader deliberately extends its collapse hit area by + // four pixels into the surrounding padding. Its region scrollWidth is + // not a text-clipping measure. Check the actual reading cell and the + // page viewport instead, including retained and unknown long labels. + const checkReadingLayout = async (state) => { + const row = drawer.getByText('Memory reading', { exact: true }).locator('..'); + const cell = row.locator('td').nth(1); + const layout = await cell.evaluate((element) => { + const text = element.querySelector('span'); + const bounds = element.getBoundingClientRect(); + const valueBounds = text.getBoundingClientRect(); + const style = getComputedStyle(text); + return { + documentWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + cell: { left: bounds.left, right: bounds.right }, + value: { + left: valueBounds.left, + right: valueBounds.right, + width: text.clientWidth, + scrollWidth: text.scrollWidth, + whiteSpace: style.whiteSpace, + textOverflow: style.textOverflow, + }, + }; + }); + (record.layouts ??= []).push({ state, ...layout }); + assert(layout.documentScrollWidth <= layout.documentWidth + 1); + assert(layout.value.scrollWidth <= layout.value.width + 1); + assert(layout.value.left >= layout.cell.left - 1); + assert(layout.value.right <= layout.cell.right + 1); + assert.equal(layout.value.whiteSpace, 'normal'); + assert.notEqual(layout.value.textOverflow, 'ellipsis'); + }; + const capture = async (state) => { + if (transport !== 'unified-api') return; + const file = `${name}-${state}.png`; + await drawer.screenshot({ path: output + '/' + file }); + result.screenshots.push({ + file, + sha256: hash(output + '/' + file), + name, + transport, + state, + }); + }; + const note = async (state) => + record.states.push({ + state, + evidence: await page.evaluate(() => window.__guestMemory.evidence()), + }); + const update = async (next) => { + read = next; + await page.evaluate((value) => window.__guestMemory.update(value), next); + }; + assert.match( + await drawer.innerText(), + /Last known · QEMU guest agent · 2026-10-04 14:00:00 UTC/, + ); + await checkReadingLayout('retained-overview'); + await capture('retained-overview'); + await activate(history); + const chart = drawer.locator('[data-history-group="utilization"]'); + await chart.locator('[data-history-last-known="memory"]').waitFor(); + assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0); + assert.match( + await chart.locator('[data-history-last-known="memory"]').innerText(), + /25\.0%.*last known/s, + ); + assert.match( + await chart.locator('[data-history-deferred="memory"]').innerText(), + /14:00:00 UTC/, + ); + assert.equal(await chart.locator('path').count(), 0); + const historyCalls = record.requests.filter( + (r) => r.path === '/api/metrics-store/history', + ).length; + await page.evaluate(() => { + window.__chartIdentity = document.querySelector('[data-history-group="utilization"]'); + }); + await note('retained'); + await capture('retained-history'); + await update({ ...initial(), lastSeen: '2026-10-04T18:00:00Z' }); + assert.doesNotMatch( + await chart.locator('[data-history-deferred="memory"]').innerText(), + /18:00:00/, + ); + await update({ + state: 'current', + source: 'status-mem', + observedAt: '2026-10-04T17:00:00Z', + usage: 35, + }); + await chart.locator('[data-history-current="memory"]').waitFor(); + assert.match( + await chart.locator('[data-history-current="memory"]').innerText(), + /35\.0%.*current/s, + ); + assert.equal(await chart.locator('[data-history-deferred="memory"]').count(), 0); + assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1); + await note('independent-current-pve'); + await update({ state: 'unavailable', source: 'unavailable', usage: 0 }); + assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0); + assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0); + assert.match(await chart.innerText(), /Memory\s*-/); + await note('unavailable'); + await update({ usage: 40 }); + await chart.locator('[data-history-unknown="memory"]').waitFor(); + assert.match( + await chart.locator('[data-history-unknown="memory"]').innerText(), + /40\.0%.*freshness unknown/s, + ); + assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0); + assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0); + assert.equal( + await page.evaluate( + () => + window.__chartIdentity === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + assert.equal( + record.requests.filter((r) => r.path === '/api/metrics-store/history').length, + historyCalls, + ); + await activate(overview); + assert.match(await drawer.innerText(), /Freshness unknown · Unknown source · time unknown/); + await checkReadingLayout('unknown-overview'); + await capture('unknown-overview'); + await note('legacy-unknown'); + await activate(history); + await update({ state: 'last-known', source: 'private provider detail', usage: 0 }); + await chart.locator('[data-history-last-known="memory"]').waitFor(); + assert.match( + await chart.locator('[data-history-last-known="memory"]').innerText(), + /0\.0%.*last known/s, + ); + assert.match( + await chart.locator('[data-history-deferred="memory"]').innerText(), + /Observation time unknown/, + ); + assert.doesNotMatch(await drawer.innerText(), /private provider detail/); + await note('retained-zero-unknown-age'); + mode = 'stored'; + await activate(drawer.getByRole('button', { name: 'Refresh history', exact: true })); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-group="utilization"] path').length === 2, + ); + assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0); + assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0); + assert.match(await chart.innerText(), /Memory\s*30\.0%/); + const slider = chart.getByRole('slider'); + await slider.focus(); + await page.keyboard.press('Home'); + assert.match(await slider.getAttribute('aria-valuetext'), /Memory 20\.0%/); + await note('stored-keyboard-inspection'); + mode = 'denied'; + await activate(drawer.getByRole('button', { name: 'Refresh history', exact: true })); + await drawer.getByRole('button', { name: 'Retry history', exact: true }).waitFor(); + assert.equal(await drawer.locator('[data-history-deferred]').count(), 0); + assert.equal(await drawer.locator('[data-history-group]').count(), 0); + assert.doesNotMatch(await drawer.innerText(), /private fixture refusal/); + mode = 'empty'; + await activate(drawer.getByRole('button', { name: 'Retry history', exact: true })); + await chart.locator('[data-history-last-known="memory"]').waitFor(); + await note('denied-and-recovered'); + await update({ + peer: true, + state: 'current', + source: 'agent', + observedAt: '2026-10-04T16:00:00Z', + usage: 70, + }); + await drawer.getByText('peer-guest', { exact: true }).waitFor(); + assert.equal(await overview.getAttribute('aria-selected'), 'true'); + assert.match(await drawer.innerText(), /Current · Pulse Agent · 2026-10-04 16:00:00 UTC/); + assert.doesNotMatch(await drawer.innerText(), /14:00:00 UTC/); + await note('peer-identity-replacement'); + await checkReadingLayout('peer-overview'); + assert.deepEqual(record.errors, []); + assert(record.requests.every((r) => !r.blocked && r.method === 'GET')); + await page.close(); + } + await browser.close(); + browser = undefined; + } + result.result = 'passed'; + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + process.stdout.write( + `Guest memory provenance: ${result.cases.length} transport/layout cases passed.\n`, + ); + } catch (error) { + fs.writeFileSync( + output + '/failure.json', + JSON.stringify({ error: String(error), stack: error.stack, result }, null, 2) + '\n', + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})(); diff --git a/frontend-modern/browser-tests/guest-memory-provenance.html b/frontend-modern/browser-tests/guest-memory-provenance.html new file mode 100644 index 0000000000..7a8916a1c3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-provenance.html @@ -0,0 +1,12 @@ + + + + + + Guest memory provenance evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-memory-provenance.tsx b/frontend-modern/browser-tests/guest-memory-provenance.tsx new file mode 100644 index 0000000000..a33edaeac4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-provenance.tsx @@ -0,0 +1,213 @@ +// Production hook/adapter/drawer consumers, synthetic source snapshots only. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { useUnifiedResources } from '../src/hooks/useUnifiedResources'; +import { + mergeCanonicalResourceSnapshot, + mergeCanonicalResourceDeltaSnapshot, +} from '../src/utils/resourceStateAdapters'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource } from '../src/types/resource'; +import type { MemoryObservation } from '../src/types/api'; +import type { WorkloadGuest } from '../src/types/workloads'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); +interface Reading { + state?: string; + source?: string; + observedAt?: string; + usage: number; + lastSeen?: string; + peer?: boolean; +} +const initial: Reading = { + state: 'last-known', + source: 'guest-agent-meminfo', + observedAt: '2026-10-04T14:00:00Z', + usage: 25, +}; +const observation = (read: Reading): MemoryObservation | undefined => + read.state === undefined + ? undefined + : { + state: read.state, + source: read.source ?? '', + ...(read.observedAt ? { observedAt: read.observedAt } : {}), + }; +const capacity = 4 * 1024 ** 3; +const id = (read: Reading) => (read.peer ? 'fixture-b-pve1-101' : 'fixture-a-pve1-101'); +const nativeResource = (read: Reading): Resource => ({ + id: id(read), + type: 'vm', + name: read.peer ? 'peer-guest' : 'backup-guest', + displayName: read.peer ? 'peer-guest' : 'backup-guest', + status: 'online', + sources: ['proxmox', 'agent'], + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + sourceType: 'hybrid', + lastSeen: Date.parse(read.lastSeen ?? '2026-10-04T17:00:00Z'), + cpu: { current: 10 }, + memory: + read.state === 'unavailable' + ? undefined + : { + current: read.usage, + used: (capacity * read.usage) / 100, + total: capacity, + ...(observation(read) ? { observation: observation(read) } : {}), + }, + disk: { current: 50, used: capacity / 2, total: capacity }, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: read.peer ? 'fixture-b' : 'fixture-a', + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + guestAgentExpected: true, + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + memory: { + total: capacity, + used: capacity / 2, + free: capacity / 2, + usage: 50, + usageUnavailable: read.state === 'unavailable', + observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' }, + }, + }, +}); +const rawGuest = (read: Reading): WorkloadGuest => ({ + id: id(read), + vmid: 101, + name: read.peer ? 'peer-guest' : 'backup-guest', + instance: read.peer ? 'fixture-b' : 'fixture-a', + node: 'pve1', + type: 'qemu', + status: 'running', + cpu: 0.1, + cpus: 2, + memory: { + total: capacity, + used: (capacity * read.usage) / 100, + free: (capacity * (100 - read.usage)) / 100, + usage: read.usage, + usageUnavailable: read.state === 'unavailable', + ...(observation(read) ? { observation: observation(read) } : {}), + }, + disk: { usage: 50, total: capacity, used: capacity / 2 }, + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + guestAgentStatus: 'deferred', + guestAgentExpected: true, + networkIn: 10, + networkOut: 20, + diskRead: 30, + diskWrite: 40, + uptime: 3600, + template: false, + lastBackup: 0, + lastSeen: read.lastSeen ?? '2026-10-04T17:00:00Z', + tags: [], +}); +// Only the websocket transport is synthetic. API conversion and owned hooks +// remain production code, with real requests fulfilled by the bounded script. +window.__pulseWsStore = { + state: { resources: [], activeAlerts: [], recentlyResolved: [] }, + connected: () => false, + initialDataReceived: () => true, + resourceSnapshotReceived: () => false, + resourceChange: () => ({ version: 0, changedIds: null, changedKeys: null }), + changedResourceMetaSince: () => null, + changedResourceIdsSince: () => null, + shutdown: () => {}, +} as unknown as NonNullable; +function Fixture() { + const transport = new URLSearchParams(window.location.search).get('transport') ?? 'api'; + const [read, setRead] = createSignal(initial); + const [snapshot, setSnapshot] = createSignal([nativeResource(initial)]); + const unified = + transport === 'unified-api' + ? useUnifiedResources({ query: 'type=vm', realtimeEnabled: () => false }) + : null; + const workloads = + transport === 'raw' + ? null + : useWorkloads( + () => true, + transport === 'api' + ? {} + : { resourceSnapshot: unified ? () => unified.resources() : snapshot }, + ); + const guest = () => (transport === 'raw' ? rawGuest(read()) : workloads!.workloads()[0]); + (window as any).__guestMemory = { + update: async (next: Reading) => { + setRead(next); + if (transport === 'raw') return; + if (transport === 'api') { + await workloads!.refetch(); + return; + } + if (unified) { + await unified.refetch(); + return; + } + const incoming = nativeResource(next); + setSnapshot((previous) => + transport === 'canonical-full' || next.peer + ? mergeCanonicalResourceSnapshot([incoming], previous) + : mergeCanonicalResourceDeltaSnapshot( + [incoming], + previous, + new Set([incoming.id]), + transport === 'canonical-fast' + ? new Map([[incoming.id, ['memory', 'proxmox', 'lastSeen']]]) + : undefined, + ), + ); + }, + evidence: () => ({ + id: guest()?.id, + observation: guest()?.memory.observation, + usage: guest()?.memory.usage, + unavailable: guest()?.memory.usageUnavailable, + }), + }; + return ( +
+

Guest memory evidence

+

+ Synthetic {transport} observations. No guest checks are sent. +

+ + {() => ( +
+ {}} /> +
+ )} +
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/aca8311c65fac54f.json b/frontend-modern/browser-verification/aca8311c65fac54f.json new file mode 100644 index 0000000000..6beb5131a5 --- /dev/null +++ b/frontend-modern/browser-verification/aca8311c65fac54f.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "3d846f76d724b1853a3ea58201cfc6eb7e25b084", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/hooks/useUnifiedResources.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/api.ts", + "frontend-modern/src/types/resource.ts", + "frontend-modern/src/utils/memoryObservation.ts", + "frontend-modern/src/utils/resourceStateAdapters.ts" + ], + "content_sha256": { + "frontend-modern/src/types/api.ts": "f5805a599793431a45c5a88341cc8af4ad1a05787ce51d75b9fbb75f5dcf5606", + "frontend-modern/src/types/resource.ts": "daffee073bb11699518d9c45aeb8075d4e53535ee6f385b0dffb8d06a05c670d", + "frontend-modern/src/utils/memoryObservation.ts": "a9ef178ff58cf9ab3e622af3a6cceb9e9da9f42e03b2d246fe73ab22e8a3bf1e", + "frontend-modern/src/utils/resourceStateAdapters.ts": "f55c5ff10daafad9b6c577f68777696329c69e0b0bb2c13ec662c7c7d59e4e29", + "frontend-modern/src/hooks/useUnifiedResources.ts": "e4ea180cd2e372eab77414ae96d9c180ad706b67b2619a67f29bb046e38a55ef", + "frontend-modern/src/hooks/useWorkloads.ts": "0915943dd0cbb65940ab75a3e1143a80eda53c32db485181d56948e640f9e66d", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "c4e4d5f2ecd0d65cba73b14d400af0f8eacae10965d1ad9b499dfc1b03ac658d", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "2c72e3b40e311c91ee789724ac72cde01ebe36166c1f23a462a320d1a7f03add", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "5d592a9c3422a20e4596d25dc673f894219663c80392240ea1094ccd282e0848" + }, + "routes": [ + "/browser-tests/guest-memory-provenance.html?transport=raw", + "/browser-tests/guest-memory-provenance.html?transport=api", + "/browser-tests/guest-memory-provenance.html?transport=unified-api", + "/browser-tests/guest-memory-provenance.html?transport=canonical-full", + "/browser-tests/guest-memory-provenance.html?transport=canonical-delta", + "/browser-tests/guest-memory-provenance.html?transport=canonical-fast" + ], + "states": [ + "retained QGA memory with original observation time", + "repeated deferral without renewing age or History reads", + "independent current PVE memory despite disk deferral", + "unavailable memory without fabricated zero/free measurement", + "legacy unknown memory without borrowing conflicting platform provenance", + "retained measured zero with unknown source/time", + "stored points and dated keyboard inspection", + "403 concealment followed by permitted recovery", + "same-guest replacement without chart remount; peer identity replacement resets drawer" + ], + "interactions": [ + "keyboard Enter and touch select real drawer tabs and Refresh/Retry", + "Home key inspects stored history through the accessible slider", + "production raw/API/unified/canonical full/delta/fast consumer updates", + "reading-cell wrapping and page-width checks at all viewports", + "only bounded synthetic GET responses; no mutations or guest diagnostics", + "twelve captured and inspected light/dark Overview/History images, including 390px and 320px" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "verified_at": "2026-10-04T19:16:25Z", + "browsers": [ + "141.0.7390.37", + "26.0" + ], + "playwright_version": "1.56.1", + "proof_script": "frontend-modern/browser-tests/guest-memory-provenance.cjs", + "proof_cases": 24, + "limits": "Production drawer/history/model with real raw, API, unified API and canonical full/delta/fast consumers. Synthetic source payloads, History replies and disconnected websocket metadata. No full App, native QGA/thaw, filesystem writes, physical phone, reporter, installed or publication acceptance." +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index c8881b9407..1f1ace5910 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -328,7 +328,8 @@ const GuestDrawerHistoryGroupChart: Component deferred, descriptionId: `${inspectionId}-deferred-${index}`, isCurrent: isFallback && !deferred, - isLastKnown: isFallback && Boolean(deferred), + isLastKnown: isFallback && Boolean(deferred) && !deferred?.valueLabel, + isUnknown: isFallback && deferred?.valueLabel === 'freshness unknown', valueLabel: hovered ? getGuestDrawerHistoryValueLabel([hovered.point], item.unit) : activeTimestamp() !== null @@ -393,6 +394,7 @@ const GuestDrawerHistoryGroupChart: Component class="inline-flex items-center gap-1" data-history-current={item.isCurrent ? item.metric : undefined} data-history-last-known={item.isLastKnown ? item.metric : undefined} + data-history-unknown={item.isUnknown ? item.metric : undefined} aria-describedby={item.deferred ? item.descriptionId : undefined} > current
+ + freshness unknown + last known @@ -419,7 +424,7 @@ const GuestDrawerHistoryGroupChart: Component class="mb-2 text-xs leading-relaxed text-muted" data-history-deferred={item.metric} > - {item.label} live reading: {item.deferred?.message} + {item.label} reading: {item.deferred?.message}

)} @@ -637,11 +642,10 @@ export const GuestDrawerHistory: Component = (props) => }); const locked = createMemo(() => isRangeLocked(props.range)); - const historyQuery = createNonSuspendingQuery< - AllMetricsHistoryResponse, - GuestDrawerHistoryQueryKey - >({ - source: () => { + // Snapshot replacement often creates a new target object with unchanged + // coordinates. Only a real target/range/access change owns a new read. + const historySource = createMemo( + () => { const target = props.target; if (!target || locked()) return null; return { @@ -650,6 +654,24 @@ export const GuestDrawerHistory: Component = (props) => range: props.range, }; }, + null, + { + equals: (previous, next) => + previous === next || + Boolean( + previous && + next && + previous.resourceType === next.resourceType && + previous.resourceId === next.resourceId && + previous.range === next.range, + ), + }, + ); + const historyQuery = createNonSuspendingQuery< + AllMetricsHistoryResponse, + GuestDrawerHistoryQueryKey + >({ + source: historySource, fetcher: async (key, signal) => normalizeHistoryResponse( await ChartsAPI.getMetricsHistory({ diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index a118a6cd1e..d03798cb7c 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -23,6 +23,7 @@ import { buildWorkloadsDiskPresentation } from './diskListModel'; import { getGuestDrawerAlertMessage, getGuestDrawerMemoryRows, + getGuestDrawerMemoryReading, getGuestDrawerGuestReadPresentation, isGuestDrawerVM, } from './guestDrawerModel'; @@ -94,6 +95,9 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { tone: guestReadPresentation()?.tone, wrap: true, }), + makeDetailRow('Memory reading', getGuestDrawerMemoryReading(props.guest)?.summary, { + wrap: true, + }), makeDetailRow('Primary IP', props.ipAddresses[0]), // Starting or ending an operation is not evidence of a completed // backup. Keep its activity separate from the last completed age. diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx index 6c1bcb45e5..238a40f0df 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx @@ -30,7 +30,13 @@ const guest = (reason: string, usage = 50): WorkloadGuest => ({ status: 'running', cpu: 0.1, cpus: 2, - memory: { total: 100, used: 25, free: 75, usage: 25 }, + memory: { + total: 100, + used: 25, + free: 75, + usage: 25, + observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T03:00:00Z' }, + }, disk: { total: 100, used: usage, usage }, networkIn: 100, networkOut: 200, @@ -86,7 +92,7 @@ describe('GuestDrawerHistory filesystem provenance', () => { const retained = chart.querySelector('[data-history-last-known="disk"]')!; expect(retained).toHaveTextContent('Disk50.0%last known'); expect(retained).toHaveAccessibleDescription( - `Disk live reading: Using last known disk stats. ${message}`, + `Disk reading: Using last known disk stats. ${message}`, ); expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible(); expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent( diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx index 918c6cce6c..86f106771d 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx @@ -9,6 +9,7 @@ import { getGuestDrawerCurrentMetrics, getGuestDrawerDeferredMetrics, getGuestDrawerMemoryRows, + getGuestDrawerMemoryReading, } from '../guestDrawerModel'; vi.mock('@/stores/license', () => ({ @@ -92,10 +93,7 @@ describe('guest memory original-observation provenance', () => { lastKnownValue: 25, message: expect.stringContaining('2026-10-04 14:00:00 UTC'), }); - expect(getGuestDrawerMemoryRows(value)).toContainEqual({ - label: 'Reading', - value: expect.stringContaining('Last known'), - }); + expect(getGuestDrawerMemoryReading(value)?.summary).toContain('Last known'); expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({ cpu: 10, disk: 50, netin: 100 }); }, ); @@ -156,7 +154,21 @@ describe('guest memory original-observation provenance', () => { ).toBeUndefined(); }); + it('retains unknown original age without exposing a raw source string', () => { + const value = guest({ state: 'last-known', source: 'private provider detail' }); + expect(getGuestDrawerMemoryReading(value)?.summary).toBe( + 'Last known · Unknown source · time unknown', + ); + expect(getGuestDrawerDeferredMetrics(value).memory.message).toContain( + 'Observation time unknown.', + ); + expect(getGuestDrawerDeferredMetrics(value).memory.message).not.toContain( + 'private provider detail', + ); + }); + it('retains measured zero and leaves unannotated non-Proxmox data unchanged', () => { + expect(getGuestDrawerDeferredMetrics(guest(annotation('last-known'), 0)).memory).toBeDefined(); expect( getGuestDrawerDeferredMetrics(guest(annotation('last-known'), 0)).memory.lastKnownValue, ).toBe(0); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx index 5ca01afbf3..2d428eca9e 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx @@ -49,6 +49,65 @@ const group = (id = 'utilization') => const dots = () => document.querySelectorAll('[data-history-observation]'); describe('GuestDrawerHistory sparse observations and current provenance', () => { + it('reads again only for changed coordinates, not a fresh same-target snapshot object', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + const [source, setSource] = createSignal(target); + const [usage, setUsage] = createSignal(25); + render(() => ( + + )); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute( + 'aria-busy', + 'false', + ), + ); + const chart = group(); + setSource({ ...target }); + setUsage(35); + await waitFor(() => + expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent( + '35.0%current', + ), + ); + expect(fetch).toHaveBeenCalledTimes(1); + expect(group()).toBe(chart); + setSource({ ...target, resourceId: 'peer-host' }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(fetch.mock.calls[1][0]).toMatchObject({ resourceId: 'peer-host' }); + }); + + it('keeps unknown freshness distinct from current, last-known and stored readings', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + render(() => ( + + )); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + expect(group().querySelector('[data-history-unknown="memory"]')).toHaveTextContent( + 'Memory25.0%freshness unknown', + ); + expect(group().querySelector('[data-history-unknown="memory"]')).toHaveAccessibleDescription( + 'Memory reading: Observation time unknown. Not a current measurement.', + ); + expect(group().querySelector('[data-history-current]')).toBeNull(); + expect(group().querySelector('[data-history-last-known]')).toBeNull(); + expect(group().querySelector('path')).toBeNull(); + expect(dots()).toHaveLength(0); + }); + it('gives explicit deferred evidence precedence over a numeric current fallback', async () => { vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); render(() => ( @@ -67,7 +126,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () => 'Disk50.0%last known', ); expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription( - 'Disk live reading: Guest reads paused.', + 'Disk reading: Guest reads paused.', ); expect(group().querySelector('path')).toBeNull(); expect(dots()).toHaveLength(0); diff --git a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts index c0e1fbd806..2ffd5318e2 100644 --- a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts @@ -80,7 +80,17 @@ describe('guestDrawerModel (branch coverage)', () => { it('scales the canonical workload cpu ratio by 100 and returns finite metric values', () => { const guest = makeGuest({ cpu: 1.0, - memory: { total: 100, used: 40, free: 60, usage: 0.4 }, + memory: { + total: 100, + used: 40, + free: 60, + usage: 0.4, + observation: { + state: 'current', + source: 'status-mem', + observedAt: '2026-10-04T03:00:00Z', + }, + }, disk: { total: 100, used: 30, free: 70, usage: 0.3 }, networkIn: 100, networkOut: 200, diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index a48cf69ec0..e511545781 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -65,6 +65,7 @@ export interface GuestDrawerHistoryTimeBounds { export interface GuestDrawerHistoryDeferredMetric { lastKnownValue?: number; + valueLabel?: 'freshness unknown'; message: string; } @@ -106,18 +107,106 @@ const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => { return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined; }; +export interface GuestDrawerMemoryReading { + state: 'current' | 'last-known' | 'unavailable' | 'unknown'; + summary: string; + message: string; +} + +const memorySourceLabels: Record = { + 'guest-agent-meminfo': 'QEMU guest agent', + 'guest-agent-meminfo-derived': 'QEMU guest agent', + agent: 'Pulse Agent', + 'available-field': 'Proxmox', + 'derived-free-buffers-cached': 'Proxmox', + 'derived-total-minus-used': 'Proxmox', + 'status-mem': 'Proxmox', + 'status-freemem': 'Proxmox', + 'cluster-resources': 'Proxmox', + 'previous-snapshot': 'Previous snapshot', +}; + +const getGuestDrawerMemoryUsage = (guest: Guest): number | undefined => { + const usage = guest.memory?.usage; + return guest.telemetryAvailability?.memory !== false && + !guest.memory?.usageUnavailable && + typeof usage === 'number' && + Number.isFinite(usage) && + usage >= 0 && + usage <= 100 + ? usage + : undefined; +}; + +export const getGuestDrawerMemoryReading = (guest: Guest): GuestDrawerMemoryReading | null => { + const observation = guest.memory?.observation; + const nonProxmoxVMware = + guest.platformScopes?.includes('vmware-vsphere') && + !guest.platformScopes.includes('proxmox-pve'); + const proxmoxGuest = + !nonProxmoxVMware && + (guest.type === 'qemu' || + guest.type === 'lxc' || + guest.platformScopes?.includes('proxmox-pve') || + (guest.vmid > 0 && Boolean(guest.node && guest.instance))); + // Unannotated unrelated platforms retain their existing behaviour. Legacy + // Proxmox readings lack authority to assert current freshness. + if (!proxmoxGuest && !observation) return null; + const timestamp = + typeof observation?.observedAt === 'string' ? Date.parse(observation.observedAt) : NaN; + const validTime = Number.isFinite(timestamp) && timestamp > 0 && timestamp <= Date.now(); + const observed = validTime + ? new Date(timestamp).toISOString().replace('T', ' ').replace('.000Z', 'Z').replace('Z', ' UTC') + : null; + const state = + getGuestDrawerMemoryUsage(guest) === undefined || observation?.state === 'unavailable' + ? 'unavailable' + : observation?.state === 'last-known' + ? 'last-known' + : observation?.state === 'current' && validTime + ? 'current' + : 'unknown'; + const label = + state === 'current' + ? 'Current' + : state === 'last-known' + ? 'Last known' + : state === 'unavailable' + ? 'Unavailable' + : 'Freshness unknown'; + const source = + observation && Object.hasOwn(memorySourceLabels, observation.source) + ? memorySourceLabels[observation.source] + : 'Unknown source'; + const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.'; + return { + state, + summary: `${label} · ${source}${observed ? ` · ${observed}` : ' · time unknown'}`, + message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}`, + }; +}; + export const getGuestDrawerDeferredMetrics = ( guest: Guest, ): Record => { - if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {}; - return { - disk: { + const metrics: Record = {}; + if (isGuestDrawerVM(guest) && guest.diskStatusReason) { + metrics.disk = { lastKnownValue: guest.diskStatusReason.startsWith('prev-') ? getGuestDrawerDiskUsage(guest) : undefined, message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason), - }, - }; + }; + } + const memory = getGuestDrawerMemoryReading(guest); + if (memory && memory.state !== 'current') { + metrics.memory = { + lastKnownValue: memory.state === 'unavailable' ? undefined : getGuestDrawerMemoryUsage(guest), + ...(memory.state === 'unknown' ? { valueLabel: 'freshness unknown' as const } : {}), + message: memory.message, + }; + } + return metrics; }; // Current-value metrics displayed beside history legends while the metrics @@ -128,8 +217,11 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record): boolean => availability?.[metric] ?? true; const cpuPercent = available('cpu') ? getWorkloadCPUPercent(guest.cpu) : undefined; + const memoryReading = getGuestDrawerMemoryReading(guest); const memUsage = - available('memory') && !guest.memory?.usageUnavailable ? guest.memory?.usage : undefined; + available('memory') && (!memoryReading || memoryReading.state === 'current') + ? getGuestDrawerMemoryUsage(guest) + : undefined; // A paused filesystem read is not current, even if the snapshot still // carries a numeric summary. Retained evidence has its own labelled path. const diskUsage = @@ -497,11 +589,12 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] = if (!memory) return []; const rows: GuestDrawerMemoryRow[] = []; + const reading = getGuestDrawerMemoryReading(guest); const total = memory.total ?? 0; const used = memory.used ?? 0; const cache = memory.cache ?? 0; - if (memory.usageUnavailable) { + if (memory.usageUnavailable || reading?.state === 'unavailable') { rows.push({ label: 'Usage', value: 'Unavailable' }); if (total > 0) { rows.push({ label: 'Total', value: formatBytes(total) }); @@ -524,7 +617,12 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] = rows.push({ label: 'Balloon', value: formatBytes(memory.balloon) }); } - if (memory.swapTotal && memory.swapTotal > 0) { + if ( + !memory.usageUnavailable && + reading?.state !== 'unavailable' && + memory.swapTotal && + memory.swapTotal > 0 + ) { rows.push({ label: 'Swap', value: `${formatBytes(memory.swapUsed ?? 0)} / ${formatBytes(memory.swapTotal)}`, diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 4f87635c71..3f62f9fa63 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -180,6 +180,23 @@ describe('useUnifiedResources', () => { try { await waitForResourceCount(() => result.resources().length); expect(result.resources()[0].memory).toMatchObject({ current: 50, observation }); + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + ...v2Resource, + lastSeen: '2026-10-04T17:00:00Z', + metrics: { + ...v2Resource.metrics, + memory: { ...v2Resource.metrics.memory, observation }, + }, + }, + ]), + ); + await result.refetch(); + expect(result.resources()[0].memory).toMatchObject({ current: 50, observation }); + apiFetchMock.mockResolvedValue(resourceResponse([v2Resource])); + await result.refetch(); + expect(result.resources()[0].memory).not.toHaveProperty('observation'); } finally { dispose(); } diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index f5c088f43f..e0c4a0eac0 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -539,6 +539,21 @@ describe('useWorkloads', () => { await flushAsync(); expect(result.workloads()[0].memory).not.toHaveProperty('observation'); expect(result.workloads()[0].memory.usage).toBe(50); + const live = { + ...resource, + metrics: { + ...resource.metrics, + memory: { ...resource.metrics.memory, observation: raw }, + }, + memory: { ...resource.memory, observation: raw }, + }; + if (transport === 'snapshot') setSnapshot([live]); + else { + apiFetchJSONMock.mockResolvedValue({ data: [live], meta: { totalPages: 1 } }); + await result.refetch(); + } + await flushAsync(); + expect(result.workloads()[0].memory).toMatchObject({ usage: 50, observation: raw }); } finally { dispose(); } diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index dbb5d9b3d8..489881c89c 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -1,3 +1,5 @@ +import { readMemoryObservation } from '@/utils/memoryObservation'; +import type { MemoryObservation } from '@/types/api'; import { batch, createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js'; import { createStore, reconcile, unwrap } from 'solid-js/store'; import { canonicalizeMetricsHistoryTargetType } from '@/api/charts'; @@ -69,6 +71,7 @@ const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200; const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250; type APIMetricValue = { + observation?: MemoryObservation; value?: number; used?: number; total?: number; @@ -793,9 +796,11 @@ const metricToResourceMetric = (metric?: APIMetricValue) => { const used = metric.used ?? undefined; const total = metric.total ?? undefined; const current = metric.percent ?? metric.value ?? 0; + const observation = readMemoryObservation(metric.observation); const free = total !== undefined && used !== undefined ? total - used : undefined; return { current, + ...(observation ? { observation } : {}), total, used, free, diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index b60f46937d..605418bc57 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -1,3 +1,5 @@ +import { readMemoryObservation } from '@/utils/memoryObservation'; +import type { MemoryObservation } from '@/types/api'; import { onCleanup, createEffect, createSignal, untrack, type Accessor } from 'solid-js'; import { apiFetchJSON, getOrgID } from '@/utils/apiClient'; import { normalizeOrgScope } from '@/utils/orgScope'; @@ -40,6 +42,7 @@ const WORKLOADS_MAX_PAGES = 100; const WORKLOADS_CACHE_MAX_AGE_MS = 15_000; type APIMetricValue = { + observation?: MemoryObservation; value?: number; used?: number; total?: number; @@ -559,8 +562,10 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { memory: (() => { const base = buildMetric(resource.metrics?.memory); const cache = resource.proxmox?.memoryCache ?? 0; + const observation = readMemoryObservation(resource.metrics?.memory?.observation); return { ...base, + ...(observation ? { observation } : {}), usageUnavailable: !memoryAvailable, // buildMetric derives free as total-used (available); carve the // reclaimable cache back out so free means truly-free pages. @@ -688,6 +693,7 @@ const metricToAPI = (metric?: Resource['cpu']): APIMetricValue | undefined => metric ? { percent: metric.current, + ...(metric.observation ? { observation: metric.observation } : {}), used: metric.used, total: metric.total, } diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 29e6a0ee92..5e7da6d6de 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -1170,6 +1170,15 @@ export interface PBSJobHealthEvidence { error?: string; } +/** Server-owned provenance of the selected guest-memory reading. */ +export interface MemoryObservation { + /** current, last-known or unavailable. Unrecognised/absent states are unknown. */ + state: string; + source: string; + /** Original source time, never the refreshed resource LastSeen. */ + observedAt?: string; +} + export interface Memory { total: number; used: number; @@ -1178,6 +1187,7 @@ export interface Memory { cache?: number; /** Total is known, but no cache-aware source can establish current usage. */ usageUnavailable?: boolean; + observation?: MemoryObservation; usage: number; balloon?: number; swapUsed?: number; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 36899574e6..57f305f505 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -16,6 +16,7 @@ import type { HostSensorSummary, HostRAIDArray, Memory, + MemoryObservation, DockerRuntimeCommand, PBSBackupJob, PBSGarbageJob, @@ -119,7 +120,8 @@ export interface ResourceHealth { // Metric value with optional limits export interface ResourceMetric { - current: number; // Current value (percentage or bytes) + current: number; // Selected numeric value (percentage or bytes), not a freshness assertion. + observation?: MemoryObservation; total?: number; // Total capacity (bytes) - null for percentages used?: number; // Used amount (bytes) free?: number; // Free amount (bytes) diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index 0014cc7aaa..bea2d130c6 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -72,6 +72,15 @@ describe('resourceStateAdapters nodeFromResource', () => { usage: 75, observation: raw, }); + expect( + nodeFromResource({ + ...resource, + memory: { + ...resource.memory!, + observation: { state: 'current', source: 'agent', observedAt: '2026-10-04T16:00:00Z' }, + }, + })?.memory.observation?.source, + ).toBe('agent'); }); it('keeps Proxmox metric coordinates without inventing an agent from discovery routing', () => { diff --git a/frontend-modern/src/utils/memoryObservation.ts b/frontend-modern/src/utils/memoryObservation.ts new file mode 100644 index 0000000000..f53e2ff612 --- /dev/null +++ b/frontend-modern/src/utils/memoryObservation.ts @@ -0,0 +1,13 @@ +import type { MemoryObservation } from '@/types/api'; + +// Keep only the existing server-owned wire fields. Missing/future state is not +// replaced by a resource poll time or a platform facet from another metric. +export const readMemoryObservation = (value: unknown): MemoryObservation | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + return { + state: typeof record.state === 'string' ? record.state : '', + source: typeof record.source === 'string' ? record.source : '', + ...(typeof record.observedAt === 'string' ? { observedAt: record.observedAt } : {}), + }; +}; diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 58408371bf..fef157dbc0 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -1,3 +1,4 @@ +import { readMemoryObservation } from '@/utils/memoryObservation'; import type { Disk, HostNetworkInterface, @@ -1326,6 +1327,9 @@ const buildMemory = ( proxmoxMeta?: Record, ): Memory => { const total = metric?.total ?? asNumber(fallback?.total) ?? 0; + const observation = readMemoryObservation( + metric == null ? fallback?.observation : metric.observation, + ); const usageUnavailable = metric == null && asBoolean(fallback?.usageUnavailable) === true; const used = metric?.used ?? asNumber(fallback?.used) ?? 0; const cache = asNumber(proxmoxMeta?.memoryCache) ?? asNumber(fallback?.cache) ?? 0; @@ -1344,6 +1348,7 @@ const buildMemory = ( free, usage, usageUnavailable, + ...(observation ? { observation } : {}), cache: cache > 0 ? cache : undefined, swapUsed: asNumber(proxmoxMeta?.swapUsed) ?? asNumber(fallback?.swapUsed), swapTotal: asNumber(proxmoxMeta?.swapTotal) ?? asNumber(fallback?.swapTotal), From dd2f442bac7790bf007ac60b352cdb7debd2a162 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:22:53 +0100 Subject: [PATCH 6/6] Keep disk resumption controls independent of memory freshness Scope the inherited mounted-drawer disk resumption assertions to disk. Explicitly preserve unknown legacy memory and prove unchanged coordinates retain one History read. This corrects the old all-metric assertion rather than accepting disk freshness as authority for memory. Contract-Neutral: Test-only refinement for the existing independent memory/disk provenance contract; no runtime, route, API, monitoring policy or permission delta. Change-source: pulse-maintainer --- .../components/Workloads/GuestDrawer.test.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index edbbc40bed..d7d9b65dfe 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -309,14 +309,22 @@ describe('GuestDrawer', () => { ); expect(history.querySelector('[data-history-current="disk"]')).toBeNull(); setGuest({ ...guest(), diskStatusReason: '', disk: { total: 100, used: 75, usage: 75 } }); - expect(history.querySelector('[data-history-last-known]')).toBeNull(); - expect(history.querySelector('[data-history-deferred]')).toBeNull(); + expect(history.querySelector('[data-history-last-known="disk"]')).toBeNull(); + expect(history.querySelector('[data-history-deferred="disk"]')).toBeNull(); expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent( 'Disk75.0%current', ); + // Fresh disk evidence cannot qualify the independent legacy memory value. + expect(history.querySelector('[data-history-current="memory"]')).toBeNull(); + expect(history.querySelector('[data-history-unknown="memory"]')).toHaveTextContent( + 'freshness unknown', + ); + expect(history.querySelector('[data-history-deferred="memory"]')).toHaveTextContent( + 'Not a current measurement.', + ); expect(history.querySelector('path')).toBeNull(); - // Existing drawer target recomputation follows each guest snapshot. - // This presentation repair does not change that request ownership. + // Same target/range snapshots retain the chart and original History read. + expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1); for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) { expect(request).toMatchObject({ resourceType: 'vm',