diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 6bd98740cc..ee19fa94e5 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -38,6 +38,26 @@ covered filesystem, guest liveness and only-prior-active restoration belong to the native safety owners. These presentation checks do not set a release gate, prove a safe guest or authorise a diagnostic/restart. +## Canonical guest History filesystem ownership + +Automated: `resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts`, +`ResourceDetailDrawer.history.test.tsx`, the existing workload mapper and +source-admission controls. Use +`frontend-modern/browser-tests/linked-guest-history.cjs` to inspect the real +canonical and Workloads drawers on desktop Chromium and phone WebKit. +A live linked agent remains current despite a QEMU-only read failure; its +silent report is explicitly last known even while the platform stays online. +Check provider-list precedence (including an empty list), source transitions +on the same mounted drawer, native stopped withdrawal and fresh zero. +LXC platform storage remains usable independently of in-guest agent state. +Load stored observations, then change agent freshness and native power without +removing, relabelling as current or fabricating chart samples. + +The fixture's controls and readings are synthetic, not installed guest +recovery. Native thaw, writes to every covered filesystem, liveness, restart +and prior-active restoration remain with the native owners; no release gate +or safe-to-probe verdict follows from presentation proof. + ## How To Use This Matrix 1. Run the automated proof first. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 14eb3d24d3..fd47c81606 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -9403,3 +9403,28 @@ Verification connects `useThresholdsGuestData.test.ts`, both layouts in `ResourceTable.test.tsx`, and the production hook/table/editor browser fixture `guest-filesystem-thresholds.cjs`. Presentation proof is not native guest recovery, thaw/writes/liveness, alert delivery or installed/released acceptance. + +### Canonical History follows the filesystem producer (12 October 2026) + +On Proxmox VMs and system containers, canonical History uses the same +filesystem-ownership rule as Workloads: Proxmox's filesystem list takes +precedence (including an explicit empty list); the linked Pulse agent owns +the fallback only when that list is absent and its own list is present. +A current linked-agent reading is not deferred by an unrelated QEMU read +failure, and a silent linked agent's retained aggregate is never current +just because the platform keeps the resource online. Native stopped state +still withdraws in-guest carriers from either producer; stopped LXC platform +storage and unrelated platform metrics remain independent. + +The existing aggregate is qualified, not recalculated from filesystem rows. +Missing aggregates remain absent, measured zero remains usable, and neither +Last seen nor a QEMU recovery renews a silent agent's report. Stored History, +memory/CPU/network/IO, collection and guest commands are unchanged. + +Verification: `resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts` +and mounted `ResourceDetailDrawer.history.test.tsx` connect live/silent agents, +provider precedence, stopping/recovery and zero. The local +`linked-guest-history.cjs` browser fixture uses the production canonical drawer, +Workloads snapshot adapter/drawer, History and CSS on desktop and phone, with +synthetic GET responses. These controls do not establish native restart, thaw, +covered-filesystem writes, installed recovery or containing software. diff --git a/frontend-modern/browser-tests/linked-guest-history.cjs b/frontend-modern/browser-tests/linked-guest-history.cjs new file mode 100644 index 0000000000..d96fdda78c --- /dev/null +++ b/frontend-modern/browser-tests/linked-guest-history.cjs @@ -0,0 +1,320 @@ +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/linked-guest-history/browser-final'; +const origin = 'http://127.0.0.1:5345'; +const runtime = [ + 'frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts', + 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx', + 'frontend-modern/src/hooks/useWorkloads.ts', + 'frontend-modern/src/utils/workloadGuestPresentation.ts', +]; +const fixture = ['cjs', 'tsx', 'html'].map( + (ext) => `frontend-modern/browser-tests/linked-guest-history.${ext}`, +); +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +(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)])), + fixture_hashes: Object.fromEntries(fixture.map((p) => [p, hash('/workspace/' + p)])), + cases: [], + screenshots: [], + cleanup: { browsers: false, server: false }, + limits: + 'Production canonical drawer and Workloads snapshot adapter/drawer, History, clients and CSS with synthetic snapshots/GET responses. No native filesystem, thaw, restart, guest action, installed or release 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', + optimizeDeps: { entries: ['browser-tests/linked-guest-history.html'] }, + server: { host: '127.0.0.1', port: 5345, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [engineName, engine, width, height, dark] of [ + ['desktop-light', chromium, 1280, 900, false], + ['phone-dark', webkit, 390, 844, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const lxc of [false, true]) + for (const guest of [false, true]) { + const name = `${guest ? 'workloads' : 'canonical'}-${lxc ? 'lxc' : 'vm'}-${engineName}`; + const page = await browser.newPage({ + viewport: { width, height }, + isMobile: width < 768, + hasTouch: width < 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(12000); + page.setDefaultNavigationTimeout(45000); + const record = { + name, + width, + height, + dark, + lxc, + guest, + browser: browser.version(), + checks: [], + errors: [], + requests: [], + }; + result.cases.push(record); + let stored = false; + page.on('pageerror', (e) => record.errors.push(e.message)); + await page.route('**/*', (route) => { + const request = route.request(), + url = new URL(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(); + assert.equal(request.method(), 'GET'); + record.requests.push({ path: url.pathname, method: request.method(), stored }); + if (url.pathname === '/api/metrics-store/history') { + assert.equal(url.searchParams.get('resourceType'), lxc ? 'system-container' : 'vm'); + assert.equal(url.searchParams.get('resourceId'), 'site-a:pve1:105'); + const start = Date.parse('2026-10-12T00:00:00Z'), + end = Date.parse('2026-10-12T01:00:00Z'); + return route.fulfill({ + json: { + resourceType: lxc ? 'system-container' : 'vm', + resourceId: 'site-a:pve1:105', + range: url.searchParams.get('range'), + start, + end, + source: 'store', + metrics: stored + ? { + disk: [ + { timestamp: start, value: 12, min: 12, max: 12 }, + { timestamp: end, value: 30, min: 30, max: 30 }, + ], + } + : {}, + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 90, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { + success: true, + data: [], + alerts: [], + anomalies: [], + config: null, + capabilities: [], + relationships: [], + recentChanges: [], + available: false, + audits: [], + count: 0, + }, + }); + }); + await page.addInitScript((isDark) => { + document.addEventListener('DOMContentLoaded', () => + document.documentElement.classList.toggle('dark', isDark), + ); + }, dark); + const suffix = + '?' + + new URLSearchParams({ + ...(guest ? { guest: '1' } : {}), + ...(lxc ? { lxc: '1' } : {}), + }).toString(); + await page.goto(origin + '/browser-tests/linked-guest-history.html' + suffix); + const activate = async (locator) => { + if (width < 768) await locator.tap(); + else { + await locator.focus(); + await locator.press('Enter'); + } + }; + await activate(page.getByRole('tab', { name: 'History', exact: true })); + const chart = page.locator('[data-history-group="utilization"]'); + const current = () => chart.locator('[data-history-current="disk"]'); + const prior = () => chart.locator('[data-history-last-known="disk"]'); + const deferred = () => chart.locator('[data-history-deferred="disk"]'); + const set = (label) => activate(page.getByRole('button', { name: label, exact: true })); + const check = async (label, fn) => { + await fn(); + record.checks.push(label); + }; + const text = async (locator, expression) => { + await locator.waitFor(); + assert.match(await locator.innerText(), expression); + }; + const missing = async (locator) => assert.equal(await locator.count(), 0); + const capture = async (state) => { + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + const file = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + result.screenshots.push({ file, name, state, sha256: hash(path.join(output, file)) }); + const geometry = await page.evaluate(() => ({ + width: document.documentElement.clientWidth, + scroll: document.documentElement.scrollWidth, + })); + assert.ok(geometry.scroll <= geometry.width + 1, JSON.stringify(geometry)); + }; + await check('Live linked reading survives QGA failure', async () => { + await text(current(), /62.0%\s*current/); + await missing(deferred()); + }); + await page.evaluate(() => { + window.__chart = document.querySelector('[data-history-group="utilization"]'); + }); + await check('Silent agent is last known despite online platform', async () => { + await set('Silent agent'); + await text(prior(), /62.0%\s*last known/); + await missing(current()); + await text(deferred(), /The Pulse Agent in this guest stopped reporting/); + }); + await capture('silent'); + await check('QGA recovery and Last seen cannot renew the agent report', async () => { + await set('QGA recovered'); + await set('Advance snapshot'); + await text(prior(), /62.0%\s*last known/); + await text(deferred(), /Pulse Agent/); + }); + await check('Stopped in-guest reading is unavailable, not last known', async () => { + await set('Stopped guest'); + await missing(current()); + await missing(prior()); + await text(deferred(), /unavailable/); + }); + await capture('stopped'); + await check('Fresh linked-agent zero is current', async () => { + await set('Fresh zero'); + await text(current(), /0.0%\s*current/); + await missing(deferred()); + }); + await check('Missing aggregate is not fabricated from filesystem rows', async () => { + await set('Missing aggregate'); + await missing(current()); + await missing(prior()); + }); + await check('Platform filesystem precedence uses its own read state', async () => { + await set('Platform filesystems'); + if (lxc) { + await text(current(), /62.0%\s*current/); + await missing(deferred()); + } else { + await text(prior(), /62.0%\s*last known/); + await text(deferred(), /operation lock/); + } + }); + await check('Explicit empty platform list does not borrow agent freshness', async () => { + await set('Empty platform list'); + if (lxc) { + await text(current(), /62.0%\s*current/); + } else { + await text(prior(), /62.0%\s*last known/); + await text(deferred(), /operation lock/); + } + }); + await check('Stopped LXC platform storage stays independent', async () => { + await set('Stopped platform'); + if (lxc) { + await text(current(), /62.0%\s*current/); + await missing(deferred()); + } else { + await missing(current()); + await missing(prior()); + await text(deferred(), /VM is stopped/); + } + }); + await check( + 'Stored observations remain intact through source/power changes', + async () => { + await set('Live agent'); + stored = true; + await activate(page.getByRole('button', { name: 'Refresh history', exact: true })); + await chart.locator('path').waitFor(); + const before = await chart.locator('path').getAttribute('d'); + assert.ok(before); + await set('Silent agent'); + await text(deferred(), /Pulse Agent/); + assert.equal(await chart.locator('path').getAttribute('d'), before); + await missing(prior()); + await set('Stopped guest'); + await text(deferred(), /unavailable/); + assert.equal(await chart.locator('path').getAttribute('d'), before); + await missing(current()); + assert.match(await chart.innerText(), /30.0%/); + }, + ); + await check( + 'Same mounted chart, usable independent metrics and no fabricated samples', + async () => { + assert.equal( + await page.evaluate( + () => + window.__chart === document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + await text(chart.locator('[data-history-current="cpu"]'), /10.0%\s*current/); + await text(chart.locator('[data-history-current="memory"]'), /0.0%\s*current/); + assert.equal(await chart.locator('path').count(), 1); + assert.deepEqual(record.errors, []); + assert.ok(record.requests.every((r) => !r.blocked)); + }, + ); + await capture('stored-stopped'); + await page.close(); + } + await browser.close(); + browser = null; + } + result.result = 'passed'; + } catch (error) { + result.error = String(error); + throw error; + } finally { + if (browser) await browser.close(); + result.cleanup.browsers = true; + await server.close(); + result.cleanup.server = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/linked-guest-history.html b/frontend-modern/browser-tests/linked-guest-history.html new file mode 100644 index 0000000000..47774357a3 --- /dev/null +++ b/frontend-modern/browser-tests/linked-guest-history.html @@ -0,0 +1,12 @@ + + + + + + Linked guest filesystem History verification + + +
+ + + diff --git a/frontend-modern/browser-tests/linked-guest-history.tsx b/frontend-modern/browser-tests/linked-guest-history.tsx new file mode 100644 index 0000000000..dd634487d8 --- /dev/null +++ b/frontend-modern/browser-tests/linked-guest-history.tsx @@ -0,0 +1,146 @@ +// Only snapshots and the fixture controls are synthetic. Source mapping, +// drawers, History, clients and CSS are production code; no guest action. +import { createMemo, createSignal, For, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { DarkModeContext, WebSocketContext, type WebSocketStore } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const search = new URLSearchParams(location.search); + const type = search.has('lxc') ? 'system-container' : 'vm'; + const [mode, setMode] = createSignal('Live agent'); + const [seen, setSeen] = createSignal(Date.parse('2026-10-12T01:00:00Z')); + const disk = () => ({ + mountpoint: '/', + total: 100, + used: mode() === 'Fresh zero' ? 0 : 62, + free: 38, + }); + const stopped = () => ['Stopped guest', 'Stopped platform'].includes(mode()); + const platform = () => + ['Platform filesystems', 'Empty platform list', 'Stopped platform'].includes(mode()); + const silent = () => ['Silent agent', 'QGA recovered'].includes(mode()); + const resource = createMemo(() => ({ + id: 'site-a:pve1:105', + type, + name: 'Linked guest', + displayName: 'Linked guest', + platformId: 'site-a', + platformType: 'proxmox-pve', + sourceType: 'hybrid', + status: 'online', + lastSeen: seen(), + metricsTarget: { resourceType: type, resourceId: 'site-a:pve1:105' }, + cpu: { current: 10 }, + memory: { + current: 0, + total: 100, + used: 0, + observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' }, + }, + disk: + mode() === 'Missing aggregate' + ? undefined + : { current: disk().used, total: 100, used: disk().used }, + network: { rxBytes: 0, txBytes: 200 }, + proxmox: { + nodeName: 'pve1', + vmid: 105, + instance: 'site-a', + runtimeStatus: stopped() ? 'stopped' : 'running', + diskStatusReason: + mode() === 'QGA recovered' || stopped() + ? '' + : platform() + ? 'prev-vm-locked' + : 'agent-not-running', + disks: platform() ? (mode() === 'Empty platform list' ? [] : [disk()]) : undefined, + }, + agent: { agentId: 'in-guest', stale: silent(), disks: [disk()] }, + })); + const mapped = useWorkloads(() => true, { resourceSnapshot: () => [resource()] }); + return ( +
+

Linked guest History ยท {type}

+

+ Synthetic readings and controls. No guest command or backup runs. +

+
+ + {(value) => ( + + )} + + +
+
+ + {(guest) => {}} />} + + } + > + + +
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + true, + } as unknown as WebSocketStore + } + > + + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/f14fcf66d296e5f7.json b/frontend-modern/browser-verification/f14fcf66d296e5f7.json new file mode 100644 index 0000000000..7367f6d339 --- /dev/null +++ b/frontend-modern/browser-verification/f14fcf66d296e5f7.json @@ -0,0 +1,240 @@ +{ + "version": 1, + "base_sha": "f99c6f9e5c3aba2ad025bc24da7c6bd6c7e5a85a", + "verified_at": "2026-10-12T07:58:01.128408Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "257ccee974aeab51c6d35360117986e9f4aed92891cb2a153b656e57e71a584f" + }, + "routes": [ + "Existing canonical ResourceDetailDrawer / History", + "Existing Workloads snapshot adapter -> GuestDrawer / History (production components in local fixture)" + ], + "states": [ + "Current linked-agent VM disk aggregate survives unrelated QGA read failures", + "Silent linked-agent VM/LXC aggregate is last known despite online platform; QGA recovery/Last seen do not renew it", + "Stopped in-guest VM/LXC reading is unavailable, no last-known fallback; native stopped still wins", + "Platform filesystem lists retain source precedence, including explicit empty lists; LXC platform storage stays independent", + "Measured zero remains current, missing aggregate is not fabricated; stored paths and observations survive source/power updates", + "Independent CPU/memory/network readings and same-mounted chart remain intact on desktop and phone" + ], + "interactions": [ + "History and synthetic source controls activated with keyboard focus/Enter on desktop or touch on phone", + "Same-ID source, reporting lease, power and aggregate snapshots update without remount", + "Refresh history reads two actual synthetic stored points through production client; later source/power updates keep their path unchanged" + ], + "viewports": [ + { + "name": "desktop-light", + "width": 1280, + "height": 900, + "browser": "Chromium 141.0.7390.37" + }, + { + "name": "phone-dark", + "width": 390, + "height": 844, + "browser": "WebKit 26.0" + } + ], + "evidence": { + "command": "pulse-worker-browser frontend-modern/browser-tests/linked-guest-history.cjs", + "playwright": "1.56.1", + "integration_lock_version": "1.56.1", + "completed_checks": 88, + "cases": 8, + "fixture_sha256": { + "frontend-modern/browser-tests/linked-guest-history.cjs": "1767ebebd11d4db018b1f3bea7a89ad9fc7692e8cb084bd7c9d89fe87ef4ac0f", + "frontend-modern/browser-tests/linked-guest-history.tsx": "feb16a0b04a82ae722b53f9e71969be89e70344188c5f2b0deb2853a5f96309f", + "frontend-modern/browser-tests/linked-guest-history.html": "2a1bd63a32bc4be49d7ae9f0dc9c91932f11b3e4c3f0c8f38c7110fd0095ccae" + }, + "runtime_sha256": { + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "257ccee974aeab51c6d35360117986e9f4aed92891cb2a153b656e57e71a584f", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "9e7ee3e3cbb5f8da38cf43e0aaecd105f43333d16df7338e849d3ccaf6804d8f", + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "604deee9674aa3d97e45ccf7bc5b8c5dda27017dee2f235c107750911d2981b7", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127", + "frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "71a2d2e62411c70c0eb609e68cd4963dac483be59a5be6afcf96c1ce2191436f" + }, + "captures": [ + { + "file": "canonical-vm-desktop-light-silent.png", + "name": "canonical-vm-desktop-light", + "state": "silent", + "sha256": "1b9c34d68c8cf24be7114c06d9976188f5bd9419af9e5b461d8a8ba484d50b4f", + "viewed": true + }, + { + "file": "canonical-vm-desktop-light-stopped.png", + "name": "canonical-vm-desktop-light", + "state": "stopped", + "sha256": "45fd35767c8246a298cd18957cdacf88396bce6c41327a6494bb94cd15567d7f", + "viewed": true + }, + { + "file": "canonical-vm-desktop-light-stored-stopped.png", + "name": "canonical-vm-desktop-light", + "state": "stored-stopped", + "sha256": "c15788cb4d7f45b85c4644f6dd14578e863b0f1d7ebf507bd82e23ea3683a254", + "viewed": true + }, + { + "file": "workloads-vm-desktop-light-silent.png", + "name": "workloads-vm-desktop-light", + "state": "silent", + "sha256": "dea3ac6a77870247ae166a2f60ce3da4266b6e70f40f8b23dbef08d06d0bf2ad", + "viewed": true + }, + { + "file": "workloads-vm-desktop-light-stopped.png", + "name": "workloads-vm-desktop-light", + "state": "stopped", + "sha256": "876e1b63a462e7ff6bf9321f15c025b8709db3f101312a3f4dc8127ec8928aec", + "viewed": true + }, + { + "file": "workloads-vm-desktop-light-stored-stopped.png", + "name": "workloads-vm-desktop-light", + "state": "stored-stopped", + "sha256": "02bad262d41990992bcf008dfb5dbe4fbd97a7247fd4bbce48c2740ab8713dcc", + "viewed": true + }, + { + "file": "canonical-lxc-desktop-light-silent.png", + "name": "canonical-lxc-desktop-light", + "state": "silent", + "sha256": "4858ed050197194789d58205bfe4fa3ab54ac3bbdc7f6e041d00adb6ec2f6634", + "viewed": true + }, + { + "file": "canonical-lxc-desktop-light-stopped.png", + "name": "canonical-lxc-desktop-light", + "state": "stopped", + "sha256": "58cfcedff92ace012f61a0989ab7a0caba0a760736968da22485a6912320013d", + "viewed": true + }, + { + "file": "canonical-lxc-desktop-light-stored-stopped.png", + "name": "canonical-lxc-desktop-light", + "state": "stored-stopped", + "sha256": "7d8eb01333adafbf4ab43dd5e5097dd39a42369dd0b0de9e9d3dfab4761bacd1", + "viewed": true + }, + { + "file": "workloads-lxc-desktop-light-silent.png", + "name": "workloads-lxc-desktop-light", + "state": "silent", + "sha256": "ed66a394887732f81eb3fd0cec0e7e4e1775ad79d6a9d080c8d7bec373760bcc", + "viewed": true + }, + { + "file": "workloads-lxc-desktop-light-stopped.png", + "name": "workloads-lxc-desktop-light", + "state": "stopped", + "sha256": "7acc180629fe474edd6126d7bd9fa4d5d133de46aaebbee32b1272ab1e1556e0", + "viewed": true + }, + { + "file": "workloads-lxc-desktop-light-stored-stopped.png", + "name": "workloads-lxc-desktop-light", + "state": "stored-stopped", + "sha256": "19250fac504ee0811b84caeeea4acdc3dd4ced30d794d34c2d9a3a4b135e9189", + "viewed": true + }, + { + "file": "canonical-vm-phone-dark-silent.png", + "name": "canonical-vm-phone-dark", + "state": "silent", + "sha256": "c519511e99e6e24208e4f05a2da121756d76edeb514f209306faa4d5de5f9a3a", + "viewed": true + }, + { + "file": "canonical-vm-phone-dark-stopped.png", + "name": "canonical-vm-phone-dark", + "state": "stopped", + "sha256": "1085f3fb0a99654fcc807b3a5fb492704f2a3e150f0d996d8407c0ccd9fb173b", + "viewed": true + }, + { + "file": "canonical-vm-phone-dark-stored-stopped.png", + "name": "canonical-vm-phone-dark", + "state": "stored-stopped", + "sha256": "4eedf5fcc6fb4c54c5a4be70d4ac24d18230b15b11ee9af8c8bbdd42d956d16a", + "viewed": true + }, + { + "file": "workloads-vm-phone-dark-silent.png", + "name": "workloads-vm-phone-dark", + "state": "silent", + "sha256": "84561a90090f3d945b944c4298b9a2961550fe59e945922aa2f89951e690af79", + "viewed": true + }, + { + "file": "workloads-vm-phone-dark-stopped.png", + "name": "workloads-vm-phone-dark", + "state": "stopped", + "sha256": "188c4e63560a61da7636165903e06e6733615c22042340ef852303d3128c7f79", + "viewed": true + }, + { + "file": "workloads-vm-phone-dark-stored-stopped.png", + "name": "workloads-vm-phone-dark", + "state": "stored-stopped", + "sha256": "4899356a909fff6ffd12e2e4f1ac301e1e0fefa0f2032d61ddfa22a88459ca4c", + "viewed": true + }, + { + "file": "canonical-lxc-phone-dark-silent.png", + "name": "canonical-lxc-phone-dark", + "state": "silent", + "sha256": "1a3503060ef8060fe1165fccec8190abca8fe2c55c1866d4652c156c7d413402", + "viewed": true + }, + { + "file": "canonical-lxc-phone-dark-stopped.png", + "name": "canonical-lxc-phone-dark", + "state": "stopped", + "sha256": "6a0e50472158269e635c37c4151b63ccfd8cd4b8b8e351c2ae62c8abf420fd4a", + "viewed": true + }, + { + "file": "canonical-lxc-phone-dark-stored-stopped.png", + "name": "canonical-lxc-phone-dark", + "state": "stored-stopped", + "sha256": "9bc76bcad4019b98a739d10a3e1ab1cc38aeae3e6bb2de01faa7f623c2ca5c20", + "viewed": true + }, + { + "file": "workloads-lxc-phone-dark-silent.png", + "name": "workloads-lxc-phone-dark", + "state": "silent", + "sha256": "d6a324d28b2e2af6175bd8f09c9b1b17949e03e0e5519e9c294c90395302e5fa", + "viewed": true + }, + { + "file": "workloads-lxc-phone-dark-stopped.png", + "name": "workloads-lxc-phone-dark", + "state": "stopped", + "sha256": "ae4204af814c04e9e8ffb49895edfe592e2e628f8fa85924b86044af682e7193", + "viewed": true + }, + { + "file": "workloads-lxc-phone-dark-stored-stopped.png", + "name": "workloads-lxc-phone-dark", + "state": "stored-stopped", + "sha256": "88225624fbc638302a2f210c9421e5a12df7f99fb561b7875911a5e4d9072e13", + "viewed": true + } + ], + "cleanup": { + "browsers": true, + "server": true + }, + "result_sha256": "8c6dc1162c9191540362d1516bfc9eed33c2ef4aaec92f899c162b4b7ebd3516", + "prior_attempt": "First browser attempt had a missing synthetic WebSocketContext.Provider; zero checks, client1, captured page error, browsers/server stopped. Fixture-only provider correction; runtime/deadlines/thresholds unchanged." + }, + "limits": "Production canonical drawer and Workloads snapshot adapter/drawer, History, clients and CSS with synthetic snapshots/GET responses. No native filesystem, thaw, restart, guest action, installed or release acceptance." +} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 30ea9130be..041894d3eb 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -322,6 +322,104 @@ describe('ResourceDetailDrawer change history section', () => { } }); + it.each(['vm', 'system-container'] as const)( + 'keeps the mounted %s History on the selected filesystem producer', + async (type) => { + resetCreateNonSuspendingQueryCacheForTest(); + const disks = [{ mountpoint: '/', total: 100, used: 62, free: 38 }]; + const source = baseResource({ + id: 'linked-filesystem-guest', + type, + platformType: 'proxmox-pve', + status: 'online', + metricsTarget: { resourceType: type, resourceId: 'linked:pve:105' }, + cpu: { current: 10 }, + memory: { + current: 0, + observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' }, + }, + disk: { current: 62 }, + proxmox: { runtimeStatus: 'running', diskStatusReason: 'agent-not-running' }, + agent: { agentId: 'in-guest', stale: false, disks }, + }); + const read = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue({ + resourceType: type, + resourceId: 'linked:pve:105', + range: '24h', + start: Date.parse('2026-10-12T00:00:00Z'), + end: Date.parse('2026-10-12T01:00:00Z'), + source: 'store', + metrics: {}, + }); + const [resource, setResource] = createSignal(source); + const view = render(() => ( + + )); + try { + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); + const chart = view.container.querySelector('[data-history-group="utilization"]')!; + expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent( + '62.0%current', + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull(); + setResource({ + ...source, + lastSeen: source.lastSeen + 3600000, + agent: { ...source.agent, stale: true }, + }); + expect(chart.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + '62.0%last known', + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'The Pulse Agent in this guest stopped reporting.', + ); + setResource({ + ...source, + proxmox: { runtimeStatus: 'running', disks, diskStatusReason: 'prev-vm-locked' }, + }); + if (type === 'vm') { + expect(chart.querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + '62.0%last known', + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'VM operation lock', + ); + } else { + expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent( + '62.0%current', + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull(); + } + setResource({ ...source, proxmox: { runtimeStatus: 'stopped' } }); + expect(chart.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known="disk"]')).toBeNull(); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'unavailable', + ); + setResource({ ...source, disk: { current: 0 }, status: 'offline' }); + expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent( + '0.0%current', + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull(); + expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent( + '0.0%current', + ); + expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent( + '10.0%current', + ); + expect(view.container.querySelector('[data-history-group="utilization"]')).toBe(chart); + expect(chart.querySelector('path')).toBeNull(); + expect(read).toHaveBeenCalledTimes(1); + } finally { + view.unmount(); + read.mockRestore(); + resetCreateNonSuspendingQueryCacheForTest(); + } + }, + ); + it('keeps current readings separate from stored history samples', () => { expect(guestDrawerHistorySource).toContain('currentMetrics'); expect(guestDrawerHistorySource).toContain('No stored history in this range'); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts index 3ae5ee2af8..d90fe3717d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts @@ -95,6 +95,22 @@ describe('canonical drawer History honours selected guest observation provenance }, ); + it('retains a legitimate linked-agent filesystem zero without calling it current', () => { + const value = resource('current', 0); + value.disk = { current: 0, used: 0, total: 100 }; + value.proxmox = { runtimeStatus: 'running', diskStatusReason: '' }; + value.agent = { stale: true, disks: [{ mountpoint: '/', used: 0, total: 100, free: 100 }] }; + expect(getResourceMetricsHistoryCurrentMetrics(value)).toMatchObject({ + disk: undefined, + usage: undefined, + memory: 0, + }); + expect(getResourceMetricsHistoryDeferredMetrics(value).disk).toEqual({ + lastKnownValue: 0, + message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + }); + }); + it('keeps unavailable, true zero and independent current memory distinct from disk deferral', () => { expect( getResourceMetricsHistoryDeferredMetrics(resource('unavailable', 0)).memory.lastKnownValue, diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts new file mode 100644 index 0000000000..3da29e12c9 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; +import { + getResourceMetricsHistoryCurrentMetrics as current, + getResourceMetricsHistoryDeferredMetrics as deferred, +} from '../resourceDetailDrawerMetricsHistoryModel'; + +const agentDisk = { mountpoint: '/', total: 100, used: 62, free: 38 }; +const guest = (patch: Partial = {}): Resource => ({ + id: 'site-a:pve1:105', + type: 'vm', + name: 'Linked guest', + displayName: 'Linked guest', + platformId: 'site-a', + platformType: 'proxmox-pve', + sourceType: 'hybrid', + status: 'online', + lastSeen: Date.parse('2026-10-12T01:00:00Z'), + cpu: { current: 10 }, + memory: { + current: 0, + observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' }, + }, + disk: { current: 62, total: 100, used: 62 }, + network: { rxBytes: 0, txBytes: 200 }, + proxmox: { runtimeStatus: 'running', diskStatusReason: 'agent-not-running' }, + agent: { agentId: 'in-guest', stale: false, disks: [agentDisk] }, + ...patch, +}); + +describe('canonical History filesystem source ownership', () => { + it.each([ + 'agent-not-running', + 'agent-error', + 'permission-denied', + ...guestDiskDeferrals.map(([r]) => r), + ])('uses the live linked agent instead of the unrelated QGA %s read failure', (reason) => { + const value = guest({ proxmox: { runtimeStatus: 'running', diskStatusReason: reason } }); + expect(current(value)).toMatchObject({ disk: 62, usage: 62, cpu: 10, memory: 0, netin: 0 }); + expect(deferred(value).disk).toBeUndefined(); + value.proxmox!.diskStatusReason = `prev-${reason}`; + expect(current(value).disk).toBe(62); + expect(deferred(value).disk).toBeUndefined(); + }); + + it.each(['vm', 'system-container'] as const)( + 'qualifies a silent linked agent on an online %s', + (type) => { + const value = guest({ type, agent: { stale: true, disks: [agentDisk] } }); + expect(current(value)).toMatchObject({ + disk: undefined, + usage: undefined, + cpu: 10, + memory: 0, + }); + expect(deferred(value).disk).toEqual({ + lastKnownValue: 62, + message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + }); + value.lastSeen += 3600000; + value.proxmox!.diskStatusReason = ''; + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk.lastKnownValue).toBe(62); + }, + ); + + it.each(['stopped', ' STOPPED ', 'offline'])( + 'native %s wins over a live or silent linked agent', + (runtimeStatus) => { + for (const stale of [false, true]) { + const value = guest({ proxmox: { runtimeStatus }, agent: { stale, disks: [agentDisk] } }); + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk).toEqual({ + lastKnownValue: undefined, + message: 'Guest filesystem stats unavailable while the VM is stopped.', + }); + } + }, + ); + + it.each([{ disks: [] }, { disks: [agentDisk] }])( + 'does not borrow an agent when the platform owns disks (%j)', + ({ disks }) => { + const value = guest({ + proxmox: { runtimeStatus: 'running', disks, diskStatusReason: 'prev-vm-locked' }, + }); + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk.message).toContain('VM operation lock'); + value.agent!.stale = true; + value.proxmox!.diskStatusReason = ''; + expect(current(value).disk).toBe(62); + expect(deferred(value).disk).toBeUndefined(); + }, + ); + + it('does not infer filesystem ownership from a mere agent identity', () => { + const value = guest({ agent: { agentId: 'in-guest', stale: false } }); + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk.lastKnownValue).toBeUndefined(); + }); + + it('preserves current linked-agent zero and does not fabricate an absent aggregate', () => { + const value = guest({ disk: { current: 0, used: 0, total: 100 } }); + expect(current(value)).toMatchObject({ disk: 0, usage: 0 }); + expect(deferred(value).disk).toBeUndefined(); + value.disk = undefined; + expect(current(value).disk).toBeUndefined(); + value.agent!.stale = true; + expect(deferred(value).disk.lastKnownValue).toBeUndefined(); + }); + + it('retains LXC platform storage while stopped, but not its in-guest agent reading', () => { + const value = guest({ + type: 'system-container', + proxmox: { runtimeStatus: 'stopped', disks: [agentDisk] }, + }); + expect(current(value)).toMatchObject({ disk: 62, usage: 62 }); + expect(deferred(value).disk).toBeUndefined(); + value.proxmox!.disks = undefined; + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk).toEqual({ + lastKnownValue: undefined, + message: 'Filesystem usage is unavailable.', + }); + }); + + it.each(['vm', 'system-container'] as const)( + 'keeps a linked-only %s in Proxmox scopes qualified', + (type) => { + const value = guest({ + type, + platformType: 'agent', + platformScopes: ['agent', 'proxmox-pve'], + agent: { stale: true, disks: [agentDisk] }, + }); + expect(current(value).disk).toBeUndefined(); + expect(deferred(value).disk.lastKnownValue).toBe(62); + }, + ); + + it('does not impose QGA read metadata on a vSphere-only VM', () => { + const value = guest({ platformType: 'vmware-vsphere', platformScopes: ['vmware-vsphere'] }); + expect(current(value)).toMatchObject({ disk: 62, usage: 62 }); + expect(deferred(value).disk).toBeUndefined(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts index d0c8a50c8a..a8f78cb0a0 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts @@ -174,7 +174,7 @@ const memoryReading = (resource: Resource) => ); const guestDiskReading = (resource: Resource) => { - if (resource.type !== 'vm' || !isProxmoxGuest(resource)) return null; + if (!isProxmoxGuest(resource)) return null; // A linked source can keep the canonical resource online while Proxmox // reports the VM stopped. Judge filesystem carriers by the native state, // using the same read admission as Workloads and Alerts, not Last seen. @@ -185,8 +185,13 @@ const guestDiskReading = (resource: Resource) => { { status: status === 'offline' ? 'stopped' : status, diskStatusReason: resource.proxmox?.diskStatusReason, + // Match the Workloads projection: the linked agent owns filesystem + // readings only when Proxmox supplied no filesystem list. Its own + // reporting lease then qualifies them, not the unrelated QGA failure. + disksFromAgent: resource.proxmox?.disks == null && resource.agent?.disks != null, + agentStale: resource.agent?.stale === true, }, - true, + resource.type === 'vm', ); };