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/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 c6951e64ab..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,42 @@ ## 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 +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, @@ -39,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-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/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/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 a7194f6f5b..bc93b32485 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,44 @@ beforeEach(() => { notificationStoreMock.info.mockReset(); }); +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) => { + 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/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/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', 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 new file mode 100644 index 0000000000..86f106771d --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.memory-provenance.test.tsx @@ -0,0 +1,261 @@ +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, + getGuestDrawerMemoryReading, +} 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(getGuestDrawerMemoryReading(value)?.summary).toContain('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 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); + 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/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/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()), )} - + = {}, +): 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); + }); +}); diff --git a/frontend-modern/src/features/alerts/alertHistoryModel.ts b/frontend-modern/src/features/alerts/alertHistoryModel.ts index d224291543..bd2d89af25 100644 --- a/frontend-modern/src/features/alerts/alertHistoryModel.ts +++ b/frontend-modern/src/features/alerts/alertHistoryModel.ts @@ -1,5 +1,6 @@ import type { Alert } from '@/types/api'; import type { Resource } from '@/types/resource'; +import { isPulseSystemAlert } from '@/utils/alertScope'; import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers'; @@ -25,6 +26,7 @@ export interface HistoryItem { rawAlertType?: string; description?: string; acknowledged?: boolean; + systemAlert?: boolean; } export interface AlertTrendSeries { @@ -121,15 +123,18 @@ export function resolveAlertHistoryResourceType({ resourceName, metadata, resourceId, + alertId, getResource, allResources, }: { resourceName: string; metadata?: Record; 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/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index ef2dfae357..3f62f9fa63 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -155,6 +155,53 @@ 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 }); + 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(); + } + }); + 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..e0c4a0eac0 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -489,6 +489,77 @@ 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); + 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(); + } + }, + ); + 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/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__/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/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index d38b96df74..bea2d130c6 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -52,6 +52,37 @@ 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, + }); + 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', () => { const resource = { ...createNodeResource({ proxmox: { nodeName: 'pve-node-1' } }), 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; +} 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),