From 28bb0fb51abde8733987cf46fd0390c3fb93d783 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:29:15 +0100 Subject: [PATCH] Keep paused guest filesystem readings truthful in History Separate current and explicitly retained disk evidence in the existing guest drawer. Show the fixed safe read reason beside History, preserve dated observations and access withdrawal, and clear retained provenance only with fresh source evidence. Record desktop and touch browser verification; native backup thaw and release delivery remain separate. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 21 ++ .../subsystems/performance-and-scalability.md | 13 + .../guest-history-provenance.cjs | 340 ++++++++++++++++++ .../guest-history-provenance.html | 12 + .../guest-history-provenance.tsx | 85 +++++ .../d5b903be5d5f5368.json | 44 +++ .../components/Workloads/GuestDrawer.test.tsx | 53 +++ .../src/components/Workloads/GuestDrawer.tsx | 8 +- .../Workloads/GuestDrawerHistory.tsx | 46 ++- ...uestDrawerHistory.disk-provenance.test.tsx | 261 ++++++++++++++ .../GuestDrawerHistory.sparse.test.tsx | 24 ++ .../components/Workloads/guestDrawerModel.ts | 38 +- 12 files changed, 928 insertions(+), 17 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-history-provenance.cjs create mode 100644 frontend-modern/browser-tests/guest-history-provenance.html create mode 100644 frontend-modern/browser-tests/guest-history-provenance.tsx create mode 100644 frontend-modern/browser-verification/d5b903be5d5f5368.json create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b9e77e82f5..b2359b7bbf 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -69,6 +69,27 @@ The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a shipped caller. Its empty-state title is not evidence of readable touch guidance; the shipped guest Overview supplies the readable pre-sample explanation. +Guest History applies the same provenance to its live legend fallback. A VM +filesystem reason withdraws the `current` disk fallback; only a `prev-` reason +with a valid retained value supplies a separately labelled `last known` value. +Explicit telemetry unavailability and unknown/non-finite values stay unavailable, +not zero. The readable fixed-reason notice remains beside the utilization group +even when stored disk observations exist. Its wording identifies a live read, +not the date or provenance of those stored points. Legend assistive descriptions +reference that notice. CPU, memory and I/O evidence remain independent. + +Deferred live evidence never supplies a chart point, timestamp or dated +inspection value. Keyboard/pointer inspection continues to show only the +selected stored observations, and denied History withdraws the whole chart +including its deferred evidence. Fresh same-VM evidence clears the notice; +lock clearance without a new read does not. `GuestDrawer.test.tsx`, +`GuestDrawerHistory.disk-provenance.test.tsx` and `GuestDrawerHistory.sparse.test.tsx` +cover the model, actual drawer plumbing, shared fallback precedence, retained +zero, pre-sample status, dated inspection, access denial and resumption. +`browser-tests/guest-history-provenance.cjs` uses the production full drawer +and History API client at desktop and touch widths with synthetic responses. +These checks do not establish native guest thaw, collection or release availability. + ### Completed guest backup evidence versus current activity The guest Overview keeps the last completed backup age and its existing 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 bc2298fb1b..4f62c67d56 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,19 @@ ## Purpose +### Guest History live filesystem provenance + +The guest drawer derives deferred filesystem context from its current snapshot, +separately from current CPU, memory and I/O. A retained `prev-` value may populate +only the labelled last-known legend, never the current fallback or chart geometry. +The added presentation input does not create a polling timer, trigger a guest +read, or change History target/range ownership. Stable-target context updates +reuse the mounted chart and existing query; the drawer's pre-existing target +recomputation on guest-snapshot changes is unchanged. Mounted regressions in +`GuestDrawer.test.tsx` and `GuestDrawerHistory.disk-provenance.test.tsx` verify +the actual fallback/resumption path and request counts. This is presentation +proof, not a native CPU improvement, QGA/thaw result or delivery acceptance. + ### Guest filesystem provenance preserves dense row geometry Visible retained/unavailable filesystem cues must fit the existing dense row diff --git a/frontend-modern/browser-tests/guest-history-provenance.cjs b/frontend-modern/browser-tests/guest-history-provenance.cjs new file mode 100644 index 0000000000..66a428810b --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.cjs @@ -0,0 +1,340 @@ +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-history-provenance'; +const origin = 'http://127.0.0.1:5316'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const settle = (page) => + page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])), + cases: [], + screenshots: [], + limits: + 'Production full GuestDrawer/History, API client and CSS with synthetic local responses. Not native QGA/thaw, provider collection, physical-phone, installed recovery 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', + server: { host: '127.0.0.1', port: 5316, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop', chromium, 1365, false], + ['chromium-phone', chromium, 390, false], + ['webkit-phone', webkit, 360, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + 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 mode = 'empty'; + const record = { + name, + width, + dark, + browser: browser.version(), + errors: [], + requests: [], + states: [], + checks: [], + }; + result.cases.push(record); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== origin) { + record.requests.push({ origin: url.origin, blocked: true }); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + record.requests.push({ path: url.pathname, method: route.request().method(), mode }); + if (url.pathname === '/api/metrics-store/history') { + if (mode === '403' || mode === '503') + return route.fulfill({ + status: Number(mode), + json: { error: 'fixture-private-error-body' }, + }); + const start = Date.UTC(2026, 9, 4, 2), + end = start + 60 * 60_000; + const point = (timestamp, value) => ({ timestamp, value, min: value, max: value }); + const metrics = + mode === 'stored' + ? { + cpu: [point(start, 10), point(end, 15)], + disk: [point(start, 20), point(end, 30)], + } + : mode === 'cpu-only' + ? { cpu: [point(start, 10), point(end, 15)] } + : {}; + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: url.searchParams.get('resourceId'), + range: url.searchParams.get('range'), + start, + end, + metrics, + 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( + (isDark) => + document.addEventListener('DOMContentLoaded', () => { + if (isDark) document.documentElement.classList.add('dark'); + }), + dark, + ); + await page.goto(origin + '/browser-tests/guest-history-provenance.html'); + await page.waitForFunction(() => window.__guestHistoryProvenance, undefined, { + timeout: 60000, + }); + const activate = (locator) => (width < 768 ? locator.tap() : locator.click()); + await activate(page.getByRole('tab', { name: 'History', exact: true })); + const chart = page.locator('[data-history-group="utilization"]'); + const notice = chart.locator('[data-history-deferred="disk"]'); + const waitRead = async () => { + await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]')); + await settle(page); + }; + const observe = async (next) => { + await page.evaluate((value) => window.__guestHistoryProvenance.observe(value), next); + await waitRead(); + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: output + '/' + file, + fullPage: true, + animations: 'disabled', + }); + result.screenshots.push({ file, sha256: hash(output + '/' + file), state, name }); + }; + const checkGeometry = async () => { + const geometry = await notice.evaluate((el) => { + const rect = el.getBoundingClientRect(), + group = el.closest('section').getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(el); + return { + left: rect.left, + right: rect.right, + bottom: rect.bottom, + groupRight: group.right, + textRight: Math.max(...Array.from(range.getClientRects(), (r) => r.right)), + plotTop: el.nextElementSibling.getBoundingClientRect().top, + overflow: document.documentElement.scrollWidth > innerWidth, + }; + }); + assert.equal(geometry.overflow, false); + assert.ok( + geometry.left >= 0 && + geometry.right <= width && + geometry.textRight <= geometry.groupRight + 1, + ); + assert.ok(geometry.bottom <= geometry.plotTop + 1); + return geometry; + }; + await waitRead(); + const reasons = await page.evaluate(() => window.__guestHistoryProvenance.deferrals); + for (const [reason, message] of reasons) { + for (const retained of [true, false]) { + await observe({ + reason: (retained ? 'prev-' : '') + reason, + usage: 50, + lock: reason === 'vm-locked' ? 'backup' : '', + }); + assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0); + assert.equal( + await chart.locator('[data-history-last-known="disk"]').count(), + retained ? 1 : 0, + ); + assert.equal((await notice.innerText()).includes(message), true); + if (retained) { + const legend = chart.locator('[data-history-last-known="disk"]'); + assert.match(await legend.innerText(), /50\.0%[\s\S]*last known/); + assert.equal( + await legend.evaluate((el) => + document + .getElementById(el.getAttribute('aria-describedby')) + .textContent.includes('Using last known'), + ), + true, + ); + } + assert.equal(await chart.locator('path').count(), 0); + assert.equal(await chart.locator('[data-history-observation]').count(), 0); + assert.equal(await chart.locator('[data-history-current="cpu"]').count(), 1); + record.states.push({ reason, retained, geometry: await checkGeometry() }); + } + } + await observe({ reason: 'prev-vm-locked', usage: 0 }); + assert.match( + await chart.locator('[data-history-last-known="disk"]').innerText(), + /0\.0%[\s\S]*last known/, + ); + record.checks.push('Retained zero is not lost or plotted.'); + await observe({ reason: 'prev-vm-locked', usage: 50, lock: 'backup' }); + await screenshot('last-known'); + await observe({ reason: 'prev-agent-busy', lock: '' }); + assert.match(await notice.innerText(), /earlier guest request is still in progress/); + assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0); + record.checks.push('Lock clearance alone does not restore current evidence.'); + await observe({ reason: 'vm-locked', usage: -1 }); + assert.equal(await chart.locator('[data-history-last-known]').count(), 0); + await screenshot('unavailable'); + await observe({ reason: 'prev-agent-timeout', usage: 50 }); + mode = 'stored'; + await activate(page.getByRole('button', { name: 'Refresh history', exact: true })); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-group="utilization"] path').length === 2, + ); + await settle(page); + const paths = await chart + .locator('path') + .evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d'))); + assert.equal(await chart.locator('[data-history-last-known]').count(), 0); + assert.match( + await notice.innerText(), + /Completion is uncertain\. Do not restart the guest agent during a backup/, + ); + const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' }); + if (width < 768) await slider.tap({ position: { x: 3, y: 20 } }); + else { + await slider.focus(); + await slider.press('Home'); + } + await settle(page); + assert.match(await slider.getAttribute('aria-valuetext'), /Disk 20\.0%/); + assert.equal(await chart.locator('[data-history-current]').count(), 0); + assert.deepEqual( + await chart.locator('path').evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d'))), + paths, + ); + await screenshot('stored-inspection'); + await slider.blur(); + record.checks.push( + 'Keyboard/touch inspection uses actual stored 20% at its own date, not retained 50%; paths remain unchanged.', + ); + mode = 'cpu-only'; + await activate(page.getByRole('button', { name: 'Refresh history', exact: true })); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-group="utilization"] path').length === 1, + ); + await settle(page); + await slider.focus(); + await slider.press('Home'); + assert.match(await slider.getAttribute('aria-valuetext'), /Disk no observation/); + assert.equal(await chart.locator('[data-history-last-known]').count(), 0); + await slider.blur(); + assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1); + record.checks.push('A dated CPU observation cannot borrow the retained filesystem value.'); + mode = '503'; + await activate(page.getByRole('button', { name: 'Refresh history', exact: true })); + await page.getByRole('button', { name: 'Retry history', exact: true }).waitFor(); + assert.equal(await chart.locator('path').count(), 1); + assert.equal(await notice.count(), 1); + assert.equal( + (await page.locator('main').innerText()).includes('fixture-private-error-body'), + false, + ); + mode = '403'; + await activate(page.getByRole('button', { name: 'Retry history', exact: true })); + await page.waitForFunction(() => !document.querySelector('[data-history-group]')); + assert.equal(await notice.count(), 0); + assert.equal(await page.locator('[data-history-last-known]').count(), 0); + record.checks.push( + 'Transient failure keeps prior stored data with warning; final access denial withdraws charts, fallback and notices.', + ); + mode = 'empty'; + await activate(page.getByRole('button', { name: 'Retry history', exact: true })); + await notice.waitFor(); + await waitRead(); + await observe({ reason: '', usage: 75, lock: '' }); + assert.equal(await notice.count(), 0); + assert.equal(await chart.locator('[data-history-last-known]').count(), 0); + assert.match( + await chart.locator('[data-history-current="disk"]').innerText(), + /75\.0%[\s\S]*current/, + ); + assert.equal(await chart.locator('path').count(), 0); + await screenshot('fresh-resumption'); + record.checks.push( + 'Successful read and fresh same-VM data restore current 75%, clear provenance, and create no history points.', + ); + await observe({ reason: 'prev-vm-locked', kind: 'lxc', usage: 50 }); + assert.equal(await notice.count(), 0); + assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1); + assert.equal( + record.requests.every((request) => request.method === 'GET' && !request.blocked), + true, + ); + assert.deepEqual(record.errors, []); + await page.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + } catch (error) { + result.error = { name: error.name, message: error.message }; + throw error; + } finally { + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + process.stderr.write(error.stack + '\n'); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-history-provenance.html b/frontend-modern/browser-tests/guest-history-provenance.html new file mode 100644 index 0000000000..f4fad04d47 --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.html @@ -0,0 +1,12 @@ + + + + + + Guest History filesystem provenance verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-history-provenance.tsx b/frontend-modern/browser-tests/guest-history-provenance.tsx new file mode 100644 index 0000000000..60400436e4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-history-provenance.tsx @@ -0,0 +1,85 @@ +// Full production drawer/CSS and client; observations and API responses are +// synthetic. No backup, guest-agent or native recovery operation is performed. +import { createMemo, createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { WorkloadGuest } from '../src/types/workloads'; +import { guestDiskDeferrals } from '../src/components/Workloads/__fixtures__/guestDiskDeferrals'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const [observation, setObservation] = createSignal({ + reason: 'prev-vm-locked', + usage: 50, + lock: 'backup', + kind: 'qemu', + }); + const guest = createMemo(() => ({ + id: 'fixture-pve:pve-a:101', + vmid: 101, + name: 'backup-guest', + node: 'pve-a', + instance: 'fixture-pve', + status: 'running', + type: observation().kind, + cpu: 0.1, + cpus: 2, + memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 }, + disk: { + total: 10 * 1024 ** 3, + used: (observation().usage / 100) * 10 * 1024 ** 3, + usage: observation().usage, + }, + diskStatusReason: observation().reason, + lock: observation().lock, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + backupInProgress: Boolean(observation().lock), + tags: [], + lastSeen: '2026-10-04T03:00:00Z', + })); + (window as unknown as Record).__guestHistoryProvenance = { + observe: (next: Partial>) => + setObservation((prev) => ({ ...prev, ...next })), + deferrals: guestDiskDeferrals, + }; + return ( +
+

Guest History filesystem provenance

+

+ Synthetic observations. No backup or guest-agent command is sent. +

+
+ {}} /> +
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/d5b903be5d5f5368.json b/frontend-modern/browser-verification/d5b903be5d5f5368.json new file mode 100644 index 0000000000..edab379a9c --- /dev/null +++ b/frontend-modern/browser-verification/d5b903be5d5f5368.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "d77f56bee4e2e79c9c41f1244f9a1b3e65b60dbb", + "verified_at": "2026-10-04T03:28:54Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawer.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "8713b06fd7a8ec67d111b1b97ff84c9421e9bd738b769b28c09d5217dd41f50a", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "7422c574ca1dc1ce8ffc9d28b176656a9f9be951f319f1fa8ef38e8a9b4aec8c", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "eb057472aa06df1aa2f687a6d99f6409ecf849817fc70fad1e4c23e623bb1d9f" + }, + "routes": [ + "/browser-tests/guest-history-provenance.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production full GuestDrawer, History renderer, model, API client and CSS with synthetic transport/observations; desktop/phone Chromium and dark phone WebKit. Playwright 1.56.1 matches integration lock. All twelve final screenshots opened.", + "48 fixed-deferral states: eight reasons x retained/unavailable x three contexts, visible safe reason with fitting geometry, independently current CPU/memory and never a current disk during deferral. Retained zero and pre-sample unavailable controls pass.", + "Stored disk 20/30% remains dated chart evidence, independent from retained live 50%; dated CPU-only inspection reports no disk observation. Fallbacks never become plotted points.", + "No physical phone, native QGA/backup/thaw/collection, installed recovery, release publication or customer acceptance established. Earlier failed fixture assertions/startup/tab attempts retained separately; only corrected fixture is accepted." + ], + "interactions": [ + "Real mouse/phone touch opens the existing full drawer History tab and native refresh; desktop keyboard Home and phone touch range inspection use actual stored 20% at its own date without altering paths.", + "Lock clearance with an in-flight read reason keeps the live note and last-known context. Successful fresh same-VM 75% withdraws provenance, updates current fallback and creates no history. Non-VM control is unaffected.", + "Real same-origin synthetic 503 retains stored observations with warning; final 403 withdraws chart, fallback and deferral note until successful recovery. Private fixture error body stays hidden. All API requests are GET and no page errors occurred." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 47d2960bba..a357439143 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -220,6 +220,59 @@ afterEach(() => { // ── Tests ────────────────────────────────────────────────────────────── describe('GuestDrawer', () => { + it('keeps deferred filesystem fallback last-known through History and fresh resumption', async () => { + chartsApiMocks.getMetricsHistory.mockResolvedValue({ + resourceType: 'vm', + resourceId: 'inst1:node1:100', + range: '24h', + start: 1, + end: 3, + metrics: {}, + source: 'store', + }); + const [guest, setGuest] = createSignal( + makeGuest({ + disk: { total: 100, used: 50, usage: 50 }, + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + }), + ); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const history = screen.getAllByTestId('guest-history-group-chart')[0]; + expect(history.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + 'Disk50.0%last known', + ); + expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'Guest reads paused while Proxmox reports a VM operation lock', + ); + setGuest({ ...guest(), lock: '', diskStatusReason: 'prev-agent-busy' }); + expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'earlier guest request is still in progress', + ); + 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-current="disk"]')).toHaveTextContent( + 'Disk75.0%current', + ); + expect(history.querySelector('path')).toBeNull(); + // Existing drawer target recomputation follows each guest snapshot. + // This presentation repair does not change that request ownership. + for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) { + expect(request).toMatchObject({ + resourceType: 'vm', + resourceId: 'inst1:node1:100', + range: '24h', + }); + } + }); + it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => { expect(guestDrawerSource).toContain('DiscoveryLoadingFallback'); expect(guestDrawerSource).not.toContain( diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx index 291b30525e..1439f7a212 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx @@ -6,7 +6,11 @@ import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessB import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader'; import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs'; import { getSimpleStatusIndicator } from '@/utils/status'; -import { getGuestDrawerCurrentMetrics, type GuestDrawerProps } from './guestDrawerModel'; +import { + getGuestDrawerCurrentMetrics, + getGuestDrawerDeferredMetrics, + type GuestDrawerProps, +} from './guestDrawerModel'; import { useGuestDrawerState } from './useGuestDrawerState'; import { GuestDrawerHistory, GuestDrawerHistoryRangeSelect } from './GuestDrawerHistory'; import { GuestDrawerOverview } from './GuestDrawerOverview'; @@ -49,6 +53,7 @@ export const GuestDrawer: Component = (props) => { } = useGuestDrawerState(props); const headingId = () => `guest-drawer-heading-${guestId()}`; const historyCurrentMetrics = createMemo(() => getGuestDrawerCurrentMetrics(props.guest)); + const historyDeferredMetrics = createMemo(() => getGuestDrawerDeferredMetrics(props.guest)); const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.guest.status)); @@ -132,6 +137,7 @@ export const GuestDrawer: Component = (props) => { target={historyTarget()} range={historyRange()} currentMetrics={historyCurrentMetrics()} + deferredMetrics={historyDeferredMetrics()} /> )} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index bf2b5246bc..c8881b9407 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -41,12 +41,14 @@ import { getGuestDrawerHistoryValueLabel, normalizeGuestDrawerHistoryPoints, type GuestDrawerHistoryGroupConfig, + type GuestDrawerHistoryDeferredMetric, type GuestDrawerHistoryTarget, type GuestDrawerHistoryTimeBounds, } from './guestDrawerModel'; interface GuestDrawerHistoryProps { currentMetrics?: Record; + deferredMetrics?: Record; groups?: GuestDrawerHistoryGroupConfig[]; range: HistoryTimeRange; target: GuestDrawerHistoryTarget | null; @@ -65,6 +67,7 @@ interface GuestDrawerHistoryQueryKey { interface GuestDrawerHistoryGroupChartProps { currentMetrics?: Record; + deferredMetrics?: Record; group: GuestDrawerHistoryGroupConfig; loading: boolean; metrics: Record; @@ -309,24 +312,31 @@ const GuestDrawerHistoryGroupChart: Component return byMetric; }); const displaySeries = createMemo(() => - series().map((item) => { + series().map((item, index) => { const hovered = hoveredByMetric().get(item.metric); - const currentValue = props.currentMetrics?.[item.metric]; + const deferred = props.deferredMetrics?.[item.metric]; + const fallbackValue = deferred + ? deferred.lastKnownValue + : props.currentMetrics?.[item.metric]; + const isFallback = + activeTimestamp() === null && + item.points.length === 0 && + typeof fallbackValue === 'number' && + Number.isFinite(fallbackValue); return { ...item, - isCurrent: - activeTimestamp() === null && - item.points.length === 0 && - typeof currentValue === 'number' && - Number.isFinite(currentValue), + deferred, + descriptionId: `${inspectionId}-deferred-${index}`, + isCurrent: isFallback && !deferred, + isLastKnown: isFallback && Boolean(deferred), valueLabel: hovered ? getGuestDrawerHistoryValueLabel([hovered.point], item.unit) : activeTimestamp() !== null ? '-' : item.points.length > 0 ? getGuestDrawerHistoryValueLabel(item.points, item.unit) - : typeof currentValue === 'number' && Number.isFinite(currentValue) - ? formatHistoryChartTooltipValue(currentValue, item.unit) + : typeof fallbackValue === 'number' && Number.isFinite(fallbackValue) + ? formatHistoryChartTooltipValue(fallbackValue, item.unit) : '-', }; }), @@ -382,6 +392,8 @@ const GuestDrawerHistoryGroupChart: Component current + + last known + )} + item.deferred)}> + {(item) => ( +

+ {item.label} live reading: {item.deferred?.message} +

+ )} +
+
{(tick) => ( @@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component = (props) => loading={historyQuery.loading() && !historyQuery.resolvedOnce()} metrics={metrics()} currentMetrics={props.currentMetrics} + deferredMetrics={props.deferredMetrics} range={props.range} sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`} timeBounds={timeBounds()} 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 new file mode 100644 index 0000000000..6c1bcb45e5 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-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 } from '../guestDrawerModel'; +import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 90, + isRangeLocked: () => false, +})); + +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); +}); + +const guest = (reason: string, usage = 50): WorkloadGuest => ({ + id: 'fixture:pve-a:101', + vmid: 101, + name: 'backup-guest', + node: 'pve-a', + instance: 'fixture', + type: 'qemu', + status: 'running', + cpu: 0.1, + cpus: 2, + memory: { total: 100, used: 25, free: 75, usage: 25 }, + disk: { total: 100, used: usage, usage }, + networkIn: 100, + networkOut: 200, + diskRead: 300, + diskWrite: 400, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + diskStatusReason: reason, + lastSeen: '2026-10-04T03:00:00Z', +}); +const start = Date.UTC(2026, 9, 4, 2); +const end = start + 60 * 60_000; +const response = ( + metrics: AllMetricsHistoryResponse['metrics'] = {}, +): AllMetricsHistoryResponse => ({ + resourceType: 'vm', + resourceId: 'fixture:pve-a:101', + range: '1h', + start, + end, + metrics, + source: 'store', +}); +const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value }); +const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve-a:101' }; +const mount = (value: () => WorkloadGuest) => + render(() => ( + + )); +const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0]; + +// Actual backend provenance remains authoritative. Removing a VM lock alone +// cannot turn retained readings into current data or prove native thaw. +describe('GuestDrawerHistory filesystem provenance', () => { + it.each(guestDiskDeferrals)( + 'labels retained %s as last known without creating a stored observation', + async (reason, message) => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(() => guest(`prev-${reason}`)); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const chart = utilization(); + expect(chart.querySelector('[data-history-current="disk"]')).toBeNull(); + 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}`, + ); + expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible(); + expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent( + 'CPU10.0%current', + ); + expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent( + 'Memory25.0%current', + ); + expect(chart.querySelector('path')).toBeNull(); + expect(chart.querySelector('[data-history-observation]')).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + it.each(guestDiskDeferrals)( + 'shows unavailable %s without promoting an unretained numeric summary', + async (reason, message) => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + mount(() => guest(reason)); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const chart = utilization(); + expect(chart.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known]')).toBeNull(); + expect(chart).toHaveTextContent('Disk-'); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(message); + }, + ); + + it.each([-1, NaN, Infinity, undefined])( + 'does not display unusable retained disk usage %s', + (usage) => { + const value = guest('prev-vm-locked'); + value.disk.usage = usage as number; + expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined(); + expect(getGuestDrawerDeferredMetrics(value).disk.message).toContain('Guest reads paused'); + }, + ); + + it('keeps retained zero valid and honours explicit disk telemetry unavailability', () => { + const value = guest('prev-agent-busy', 0); + expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBe(0); + value.telemetryAvailability = { + cpu: true, + memory: true, + disk: false, + networkIO: true, + diskIO: true, + uptime: true, + }; + expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined(); + expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined(); + expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({ + cpu: 10, + memory: 25, + diskread: 300, + diskwrite: 400, + }); + }); + + it('keeps non-VM data independent and maps unknown reason text without exposing it', () => { + for (const type of ['lxc', 'app-container'] as const) { + const value = { ...guest('prev-vm-locked'), type }; + expect(getGuestDrawerDeferredMetrics(value)).toEqual({}); + expect(getGuestDrawerCurrentMetrics(value).disk).toBe(50); + } + const value = guest('private transport error'); + expect(getGuestDrawerDeferredMetrics(value).disk.message).toBe( + 'Disk stats unavailable. Guest agent may not be installed.', + ); + expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined(); + }); + + it('keeps real stored points and dated keyboard inspection separate from live deferral', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ + disk: [point(start, 20), point(end, 30)], + cpu: [point(start, 10), point(end, 15)], + }), + ); + mount(() => guest('prev-agent-timeout')); + await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(2)); + const chart = utilization(); + expect(chart).toHaveTextContent('Disk30.0%'); + expect(chart.querySelector('[data-history-last-known]')).toBeNull(); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'Completion is uncertain. Do not restart the guest agent during a backup.', + ); + const slider = within(chart).getByRole('slider'); + slider.focus(); + fireEvent.input(slider, { target: { value: '0' } }); + expect(slider).toHaveAttribute( + 'aria-valuetext', + `${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk 20.0%.`, + ); + expect(chart).toHaveTextContent('Disk20.0%'); + expect(chart.querySelector('[data-history-current]')).toBeNull(); + expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("does not borrow retained live disk evidence during another metric's dated inspection", async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue( + response({ cpu: [point(start, 10), point(end, 15)] }), + ); + mount(() => guest('prev-vm-locked')); + await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument()); + const slider = screen.getByRole('slider'); + slider.focus(); + fireEvent.input(slider, { target: { value: '0' } }); + expect(utilization()).toHaveTextContent('Disk-'); + expect(utilization().querySelector('[data-history-last-known]')).toBeNull(); + expect(slider).toHaveAttribute( + 'aria-valuetext', + expect.stringContaining('Disk no observation.'), + ); + fireEvent.blur(slider); + expect(utilization().querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + '50.0%last known', + ); + }); + + it('withdraws retained context only with fresh same-source data, without refetch or remount', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response()); + const [value, setValue] = createSignal(guest('prev-vm-locked')); + mount(value); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const chart = utilization(); + setValue({ ...value(), lock: '', diskStatusReason: 'prev-agent-busy' }); + expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'still in progress', + ); + expect(chart.querySelector('[data-history-current="disk"]')).toBeNull(); + setValue(guest('', 75)); + expect(utilization()).toBe(chart); + expect(chart.querySelector('[data-history-deferred]')).toBeNull(); + expect(chart.querySelector('[data-history-last-known]')).toBeNull(); + expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent('75.0%current'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not reveal retained evidence through denied History', async () => { + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(Object.assign(new Error('private refusal'), { status: 403 })) + .mockResolvedValueOnce(response()); + mount(() => guest('prev-vm-locked')); + 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 refusal')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry history' })); + await waitFor(() => + expect(utilization().querySelector('[data-history-last-known]')).not.toBeNull(), + ); + expect(fetch).toHaveBeenCalledTimes(3); + }); +}); 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 84e17dd262..5ca01afbf3 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,30 @@ const group = (id = 'utilization') => const dots = () => document.querySelectorAll('[data-history-observation]'); describe('GuestDrawerHistory sparse observations and current provenance', () => { + it('gives explicit deferred evidence precedence over a numeric current fallback', 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-current]')).toBeNull(); + expect(group().querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + 'Disk50.0%last known', + ); + expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription( + 'Disk live reading: Guest reads paused.', + ); + expect(group().querySelector('path')).toBeNull(); + expect(dots()).toHaveLength(0); + }); + it.each([ ['cpu', 'utilization', 'CPU 0.0%'], ['netin', 'network', 'In 0 B/s'], diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 88fcafee6b..47ba99773e 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -8,7 +8,10 @@ import type { import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel'; import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format'; -import { getWorkloadsGuestBackupStatusPresentation } from '@/utils/workloadGuestPresentation'; +import { + getWorkloadGuestDiskStatusMessage, + getWorkloadsGuestBackupStatusPresentation, +} from '@/utils/workloadGuestPresentation'; import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads'; import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget'; import type { NestedWorkloadContext } from './nestedWorkloadContext'; @@ -60,6 +63,11 @@ export interface GuestDrawerHistoryTimeBounds { endTime: number; } +export interface GuestDrawerHistoryDeferredMetric { + lastKnownValue?: number; + message: string; +} + export interface GuestDrawerBackupPresentation { ageClass: string; ageLabel: string; @@ -93,6 +101,25 @@ export const getGuestDrawerAlertMessage = ( return `${alert.message} (${comparison})`; }; +const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => { + const usage = guest.telemetryAvailability?.disk === false ? undefined : guest.disk?.usage; + return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined; +}; + +export const getGuestDrawerDeferredMetrics = ( + guest: Guest, +): Record => { + if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {}; + return { + disk: { + lastKnownValue: guest.diskStatusReason.startsWith('prev-') + ? getGuestDrawerDiskUsage(guest) + : undefined, + message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason), + }, + }; +}; + // Current-value metrics displayed beside history legends while the metrics // store is still accumulating samples. These values never become chart points: // a current reading is not evidence of a historical trend. @@ -103,13 +130,10 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record= 0 - ? reportedDiskUsage - : undefined; + isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest); const finite = (value: number | undefined): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; return {