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 1/2] 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 { From 81b07e8a90d8dfe6b848945767afd5ebd8056bf8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:15:29 +0100 Subject: [PATCH 2/2] Keep incomplete filesystem readings unavailable Require finite capacity and measured used bytes in the existing filesystem list and guest Overview. Missing, invalid and sentinel readings remain unavailable rather than a healthy empty disk; measured zero and valid byte-derived readings stay visible. Preserve the exact retained History correction and add mounted, multi-filesystem and browser controls. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 18 ++ .../subsystems/performance-and-scalability.md | 10 + .../filesystem-usage-evidence.cjs | 220 ++++++++++++++++++ .../filesystem-usage-evidence.html | 12 + .../filesystem-usage-evidence.tsx | 109 +++++++++ .../d92d8a69db1a806f.json | 40 ++++ .../Workloads/__tests__/DiskList.test.tsx | 21 ++ .../diskListModel.branchcov0713.test.ts | 10 +- .../filesystemUsage.evidence.test.tsx | 164 +++++++++++++ .../src/components/Workloads/diskListModel.ts | 66 +++--- 10 files changed, 637 insertions(+), 33 deletions(-) create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.cjs create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.html create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.tsx create mode 100644 frontend-modern/browser-verification/d92d8a69db1a806f.json create mode 100644 frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.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 b2359b7bbf..ca63ff380d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,24 @@ ## Purpose +### Filesystem capacity is not measured usage + +The existing filesystem list and guest Overview require finite positive capacity +and finite non-negative measured used bytes before displaying a percentage or +utilization bar. Missing used bytes, invalid numeric fields, the negative +unknown-usage sentinel and an overflowing ratio remain unavailable, not a healthy +zero. Known capacity is still useful as `?/capacity`; filesystem identity and +type remain visible. An explicitly measured zero stays `0%`, and omitted redundant +percentage metadata does not discard valid bytes. Valid over-capacity readings +retain their percentage and severity while the visual fill stays capped at 100%. +No percentage or free-byte field is used to invent an absent used-byte reading. + +`filesystemUsage.evidence.test.tsx` covers the model and both production consumers, +including same-filesystem known → unavailable → measured-zero replacement. +`browser-tests/filesystem-usage-evidence.cjs` covers these rendered boundaries on +desktop Chromium and touch Chromium/WebKit. These synthetic observations are +not proof of appliance collection, backup thaw, installed recovery or delivery. + ### Guest disk deferral and retained observations The existing workload row, disk breakdown tooltip and guest Overview use 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 4f62c67d56..2877df4d3a 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,16 @@ ## Purpose +### Unavailable filesystem values share the existing disk model + +The filesystem list and guest Overview use the same nullable measured-byte ratio +for text, severity and progress. Per-disk validation is constant work within the +existing linear presentation pass; it adds no reads, queries, timers, remounts or +resource-identity changes. Unavailable values stay neutral rather than passing a +fabricated zero into threshold classification. Mounted filesystem evidence tests +and the production browser fixture verify replacement and rendering, not a new +latency, fleet-performance or native-collection claim. + ### Guest History live filesystem provenance The guest drawer derives deferred filesystem context from its current snapshot, diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.cjs b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs new file mode 100644 index 0000000000..7ecac9c08a --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs @@ -0,0 +1,220 @@ +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/filesystem-usage-evidence'; +const runtime = [ + 'frontend-modern/src/components/Workloads/diskListModel.ts', + 'frontend-modern/src/components/Workloads/DiskList.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', +]; +const unknown = [ + 'missing-used', + 'negative-used', + 'NaN-used', + 'infinite-used', + 'missing-total', + 'zero-total', + 'negative-total', + 'NaN-total', + 'infinite-total', + 'unknown-sentinel', + 'NaN-usage', + 'infinite-usage', + 'overflow', + 'retained-missing', +]; +const digest = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries(runtime.map((p) => [p, digest(path.join('/workspace', p))])); + 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: 5302, strictPort: true }, + }); + const results = []; + const screenshots = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, height, dark] of [ + ['chromium-desktop', chromium, 1365, 900, false], + ['chromium-phone', chromium, 390, 844, false], + ['webkit-phone', webkit, 360, 844, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const browserVersion = browser.version(); + const page = await browser.newPage({ + viewport: { width, height }, + isMobile: width <= 768, + hasTouch: width <= 768, + locale: 'en-GB', + }); + page.setDefaultTimeout(60000); + const pageErrors = []; + const apiRequests = []; + const offOrigin = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5302') { + offOrigin.push(url.origin); + return route.abort(); + } + if (url.pathname.startsWith('/api/') || url.pathname === '/ws') { + apiRequests.push(url.pathname); + return route.abort(); + } + return route.continue(); + }); + await page.goto('http://127.0.0.1:5302/browser-tests/filesystem-usage-evidence.html', { + waitUntil: 'domcontentloaded', + }); + const select = page.getByLabel('Synthetic observation'); + await select.waitFor({ state: 'visible' }); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + const list = page.getByRole('region', { name: 'Filesystem list' }); + const overview = page.getByRole('region', { name: 'Guest Overview' }); + // Overview owns a full-width guest drawer. A narrow fixture cap at an + // xl viewport would activate four columns inside an artificial 672px + // host and split the existing backup copy into single characters. + const backupBounds = await overview.getByText('No completed backup found').boundingBox(); + assert(backupBounds && backupBounds.width >= 100 && backupBounds.height <= 40); + await page.evaluate(() => { + window.__listIdentity = document.querySelector('[aria-label="Filesystem list"]'); + window.__overviewIdentity = document.querySelector( + '[data-testid="guest-technical-details"]', + ); + }); + const checks = []; + const selectState = async (state) => { + await select.selectOption(state); + assert.equal( + await page.evaluate( + () => + window.__listIdentity === document.querySelector('[aria-label="Filesystem list"]') && + window.__overviewIdentity === + document.querySelector('[data-testid="guest-technical-details"]'), + ), + true, + ); + }; + const capture = async (state) => { + const p = path.join(output, `${name}-${state}.png`); + await page.screenshot({ path: p, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', p), sha256: digest(p) }); + }; + for (const state of unknown) { + await selectState(state); + await overview.locator('[data-testid="guest-technical-details"]').waitFor(); + for (const panel of [list, overview]) { + const text = await panel.innerText(); + assert(text.includes('—'), `${state}: missing unavailable marker`); + assert(!/0%|NaN|Infinity/.test(text), `${state}: fabricated/invalid value: ${text}`); + assert(text.includes('/data') && text.includes('EXT4')); + assert.equal(await panel.getByRole('progressbar').count(), 0); + } + const listFill = list.locator('[style*="width:"]'); + assert.equal(await listFill.evaluate((element) => element.style.width), '0%'); + assert(!(await listFill.getAttribute('class')).includes('metric-normal')); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + checks.push({ state, unknown: true, numericProgress: false, noOverflow: true }); + if (['missing-used', 'infinite-total', 'retained-missing'].includes(state)) { + if (state === 'retained-missing') { + assert((await list.innerText()).includes('earlier guest request is still in progress')); + } + await capture(state); + } + } + for (const [state, percentage] of [ + ['zero', 0], + ['live', 50], + ['usage-omitted', 50], + ['over-capacity', 125], + ['retained-live', 50], + ]) { + await selectState(state); + assert((await list.innerText()).includes(`${percentage}%`)); + assert.equal( + await overview.getByRole('progressbar').getAttribute('aria-valuenow'), + String(Math.min(percentage, 100)), + ); + checks.push({ state, percentage, measured: true }); + if (state === 'zero') await capture(state); + } + // Real keyboard input on the fixture replaces the same observation; + // there is no new product control or collector action. + await selectState('missing-used'); + await select.focus(); + await page.keyboard.press('Home'); + await page.keyboard.press('Enter'); + assert.equal(await select.inputValue(), 'live'); + assert.equal(await overview.getByRole('progressbar').getAttribute('aria-valuenow'), '50'); + assert.equal(pageErrors.length, 0); + assert.equal(apiRequests.length, 0); + assert.equal(offOrigin.length, 0); + results.push({ + name, + browserVersion, + viewport: { width, height }, + dark, + checks, + pageErrors, + apiRequests, + offOrigin, + }); + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + result: 'passed', + verified_at: new Date().toISOString(), + playwrightVersion, + hashes, + results, + screenshots, + scope: + 'Production DiskList and GuestDrawerOverview under synthetic same-filesystem observations. No native collection, installed/release acceptance or full source qualification.', + }, + null, + 2, + ), + ); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failure.json'), + JSON.stringify({ error: error.message, results }, null, 2), + ); + throw error; + } finally { + 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/filesystem-usage-evidence.html b/frontend-modern/browser-tests/filesystem-usage-evidence.html new file mode 100644 index 0000000000..d05f948fed --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.html @@ -0,0 +1,12 @@ + + + + + + Filesystem usage evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.tsx b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx new file mode 100644 index 0000000000..0c6caa0395 --- /dev/null +++ b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx @@ -0,0 +1,109 @@ +// Production list and Overview, driven only by synthetic local observations. +// This fixture cannot collect guest data or establish native backup safety. +import { createMemo, createSignal, For } from 'solid-js'; +import { render } from 'solid-js/web'; +import { DiskList } from '../src/components/Workloads/DiskList'; +import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview'; +import type { Disk, VM } from '../src/types/api'; +import '../src/index.css'; + +const capacity = 10 * 1024 ** 3; +const cases: Record; reason?: string }> = { + live: { values: {} }, + 'missing-used': { values: { used: undefined } }, + 'negative-used': { values: { used: -1 } }, + 'NaN-used': { values: { used: NaN } }, + 'infinite-used': { values: { used: Infinity } }, + 'missing-total': { values: { total: undefined } }, + 'zero-total': { values: { total: 0 } }, + 'negative-total': { values: { total: -1 } }, + 'NaN-total': { values: { total: NaN } }, + 'infinite-total': { values: { total: Infinity } }, + 'unknown-sentinel': { values: { usage: -1, used: 0 } }, + 'NaN-usage': { values: { usage: NaN } }, + 'infinite-usage': { values: { usage: Infinity } }, + overflow: { values: { total: 1, used: Number.MAX_VALUE } }, + zero: { values: { used: 0, usage: undefined } }, + 'usage-omitted': { values: { usage: undefined } }, + 'over-capacity': { values: { used: capacity * 1.25, usage: 0 } }, + 'retained-live': { values: {}, reason: 'prev-vm-locked' }, + 'retained-missing': { values: { used: undefined }, reason: 'prev-agent-busy' }, +}; + +function Fixture() { + const [selected, setSelected] = createSignal('live'); + const observation = createMemo(() => ({ + mountpoint: '/data', + type: 'ext4', + total: capacity, + used: capacity / 2, + usage: 50, + ...cases[selected()].values, + })); + const guest = createMemo(() => ({ + id: 'fixture:pve-a:101', + vmid: 101, + name: 'filesystem-evidence', + node: 'pve-a', + instance: 'fixture', + status: 'running', + type: 'qemu', + cpu: 0.1, + cpus: 2, + memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 }, + disk: observation(), + disks: [observation()], + diskStatusReason: cases[selected()].reason, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: '2026-10-04T04:00:00Z', + })); + return ( +
+

Filesystem usage evidence

+ +
+ +
+
+ +
+
+ ); +} + +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification/d92d8a69db1a806f.json b/frontend-modern/browser-verification/d92d8a69db1a806f.json new file mode 100644 index 0000000000..dd08ed8a99 --- /dev/null +++ b/frontend-modern/browser-verification/d92d8a69db1a806f.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "base_sha": "ba8b9fcd32f4f4d486e33d952ab09ecdc3d3b8a7", + "verified_at": "2026-10-04T04:10:01.032Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/diskListModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/diskListModel.ts": "1afb2fa866a8c4772e56ce38861610705d43a619537c6d57e0a79b95d288394b" + }, + "routes": [ + "/browser-tests/filesystem-usage-evidence.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production DiskList and GuestDrawerOverview with actual CSS; 57 synthetic states across desktop Chromium, phone Chromium and dark phone WebKit. Playwright 1.56.1 matches the integration lock.", + "Missing, negative, non-finite and sentinel usage/capacity plus overflow never render a healthy zero, invalid percentage or numeric progress. Known capacity, mount identity and type remain visible.", + "Explicit measured zero, ordinary bytes, omitted redundant usage, over-capacity and valid retained values preserve their labels and capped visual fill. Twelve final bitmap contents were inspected; eight phone bitmaps match already opened captures exactly.", + "No native collection, backup thaw, installed recovery, physical phone, publication or release acceptance; full types/build qualification remains unavailable." + ], + "interactions": [ + "Replaced each same-filesystem synthetic observation through the fixture select without remounting either production panel; list/Overview agree on unavailable versus measured values.", + "Real fixture keyboard Home/Enter restores a live observation after unavailable. There is no new product action, route, tab, network call or collector change.", + "No page errors, API requests or off-origin requests; unknown-state layout fits the document, and full-width drawer copy remains readable." + ] +} diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx index b11f2534e1..441f7b36e7 100644 --- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx @@ -168,6 +168,27 @@ describe('DiskList', () => { }); describe('usage calculation', () => { + it('withdraws one incomplete filesystem reading without losing another mount or inventing zero', () => { + const stable = makeDisk({ mountpoint: '/stable', used: 26843545600 }); + const changing = makeDisk({ mountpoint: '/changing', device: '/dev/sda2' }); + const [disks, update] = createSignal([stable, changing]); + render(() => ); + expect(screen.getByText('25%')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + + update([stable, { ...changing, used: undefined }]); + expect(screen.getByText('25%')).toBeInTheDocument(); + expect(screen.getByText('/changing').parentElement).toHaveTextContent('?/100 GB'); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.queryByText('0%')).not.toBeInTheDocument(); + expect(screen.queryByText('50%')).not.toBeInTheDocument(); + + update([stable, { ...changing, used: 0, usage: undefined }]); + expect(screen.getByText('25%')).toBeInTheDocument(); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.queryByText('—')).not.toBeInTheDocument(); + }); + it('derives usage percent through the canonical disk-list model', () => { const disk = makeDisk({ used: 26843545600, total: 107374182400 }); expect(getWorkloadsDiskUsagePercent(disk)).toBe(25); diff --git a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts index a5d0882717..f112dc6a29 100644 --- a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts @@ -60,8 +60,8 @@ describe('diskListModel (branch coverage 0713)', () => { }); describe('getWorkloadsDiskUsagePercent', () => { - it('returns 0 when the disk has no capacity (false arm of the guard)', () => { - expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBe(0); + it('returns null when the disk has no capacity rather than a measured zero', () => { + expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBeNull(); }); it('returns (used / total) * 100 for a normal ratio (true arm of the guard)', () => { @@ -142,8 +142,8 @@ describe('diskListModel (branch coverage 0713)', () => { ); }); - it('maps a no-capacity disk (percent 0) to the normal class', () => { - expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe(NORMAL_CLASS); + it('keeps a no-capacity disk neutral rather than classifying an unmeasured zero', () => { + expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe('bg-surface-hover'); }); it('falls back to the default disk thresholds when thresholds is null (nullish arm)', () => { @@ -280,7 +280,7 @@ describe('diskListModel (branch coverage 0713)', () => { key: '/foo:/dev/sdb:5', label: '/foo', labelTitle: '/foo', - progressClass: NORMAL_CLASS, + progressClass: 'bg-surface-hover', progressValue: null, progressWidth: '0%', typeLabel: 'EXT4', diff --git a/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx new file mode 100644 index 0000000000..4ffbe725b5 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; + +import type { Disk, VM } from '@/types/api'; +import { DiskList } from '../DiskList'; +import { GuestDrawerOverview } from '../GuestDrawerOverview'; +import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel'; + +const capacity = 10 * 1024 ** 3; +const disk = (overrides: Partial = {}): Disk => ({ + mountpoint: '/data', + type: 'ext4', + total: capacity, + used: capacity / 2, + usage: 50, + ...overrides, +}); + +const unavailable: [string, Partial][] = [ + ['missing used bytes', { used: undefined }], + ['negative used bytes', { used: -1 }], + ['NaN used bytes', { used: NaN }], + ['infinite used bytes', { used: Infinity }], + ['negative infinite used bytes', { used: -Infinity }], + ['missing capacity', { total: undefined }], + ['zero capacity', { total: 0 }], + ['negative capacity', { total: -1 }], + ['NaN capacity', { total: NaN }], + ['infinite capacity', { total: Infinity }], + ['negative infinite capacity', { total: -Infinity }], + ['unknown usage sentinel', { usage: -1, used: 0 }], + ['NaN usage', { usage: NaN }], + ['infinite usage', { usage: Infinity }], + ['negative infinite usage', { usage: -Infinity }], + ['overflowing derived percentage', { total: 1, used: Number.MAX_VALUE }], +]; + +afterEach(cleanup); + +describe('filesystem readings need measured bytes, not zero defaults', () => { + it.each(unavailable)('withdraws a numeric reading for %s', (_name, values) => { + const input = disk(values); + const presentation = buildWorkloadsDiskPresentation(input, 0); + expect(getWorkloadsDiskUsagePercent(input)).toBeNull(); + expect(presentation.progressValue).toBeNull(); + expect(presentation.progressWidth).toBe('0%'); + expect(presentation.progressClass).toBe('bg-surface-hover'); + expect(presentation.usagePercentLabel).toBe('—'); + expect(presentation.usageText).not.toMatch(/NaN|Infinity|^0 B\//); + expect(presentation.label).toBe('/data'); + expect(presentation.typeLabel).toBe('EXT4'); + }); + + it('retains known capacity without inventing used bytes from a percentage or free bytes', () => { + const input = disk({ used: undefined, usage: 50, free: capacity / 2 }); + expect(buildWorkloadsDiskPresentation(input, 0).usageText).toBe('?/10.0 GB'); + expect(getWorkloadsDiskUsagePercent(input)).toBeNull(); + }); + + it('keeps an explicit measured zero, including when the redundant usage field is omitted', () => { + const presentation = buildWorkloadsDiskPresentation(disk({ used: 0, usage: undefined }), 0); + expect(presentation.progressValue).toBe(0); + expect(presentation.usagePercentLabel).toBe('0%'); + expect(presentation.usageText).toBe('0 B/10.0 GB'); + expect(presentation.progressClass).toContain('metric-normal'); + }); + + it('derives ordinary and over-capacity percentages from the measured bytes', () => { + const input = disk({ used: capacity * 1.25, usage: 0 }); + const presentation = buildWorkloadsDiskPresentation(input, 0); + expect(getWorkloadsDiskUsagePercent(disk({ usage: undefined }))).toBe(50); + expect(presentation.progressValue).toBe(125); + expect(presentation.usagePercentLabel).toBe('125%'); + expect(presentation.progressWidth).toBe('100%'); + expect(presentation.progressClass).toContain('metric-critical'); + }); +}); + +function renderPanels(initial: Disk) { + const [observation, setObservation] = createSignal(initial); + const guest = (): VM => ({ + id: 'fixture:pve-a:101', + vmid: 101, + node: 'pve-a', + instance: 'fixture', + name: 'filesystem-evidence', + status: 'running', + type: 'qemu', + cpu: 0.1, + cpus: 2, + memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 }, + disk: observation(), + disks: [observation()], + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: '', + lastSeen: '2026-10-04T04:00:00Z', + }); + render(() => ( + <> +
+ +
+ + + )); + return setObservation; +} + +describe('both filesystem panels preserve unavailable versus measured zero', () => { + it.each([ + ['missing used bytes', { used: undefined }], + ['invalid used bytes', { used: NaN }], + ['invalid capacity', { total: Infinity }], + ['unknown usage sentinel', { usage: -1, used: 0 }], + ] satisfies [string, Partial][])( + 'does not show an empty healthy disk for %s', + (_name, values) => { + renderPanels(disk(values)); + for (const id of ['disk-list-panel', 'guest-technical-details']) { + const panel = screen.getByTestId(id); + expect(panel.textContent).toContain('—'); + expect(panel.textContent).not.toMatch(/0%|NaN|Infinity/); + expect(within(panel).queryByRole('progressbar')).not.toBeInTheDocument(); + } + }, + ); + + it('withdraws a prior measurement and restores zero only after explicit same-filesystem evidence', () => { + const update = renderPanels(disk()); + const details = screen.getByTestId('guest-technical-details'); + expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50'); + update(disk({ used: undefined })); + expect(within(details).queryByRole('progressbar')).not.toBeInTheDocument(); + expect(details).toHaveTextContent('— · ?/10.0 GB · EXT4'); + update(disk({ used: 0, usage: undefined })); + expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0'); + expect(details).toHaveTextContent('0% · 0 B/10.0 GB · EXT4'); + }); +}); diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts index 15da466a7d..61ba4c3d28 100644 --- a/frontend-modern/src/components/Workloads/diskListModel.ts +++ b/frontend-modern/src/components/Workloads/diskListModel.ts @@ -22,21 +22,32 @@ export interface WorkloadsDiskPresentation { usagePercentLabel: string; } -export const hasWorkloadsDiskCapacity = (disk: Disk): boolean => - typeof disk.total === 'number' && disk.total > 0; +export const hasWorkloadsDiskCapacity = (disk: Disk): disk is Disk & { total: number } => + typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0; // The poller reports usage -1 for mounts it can only see in the container // config (capacity may be known, live usage is not). export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean => - typeof disk.usage === 'number' && disk.usage < 0; + typeof disk.used !== 'number' || + !Number.isFinite(disk.used) || + disk.used < 0 || + (typeof disk.usage === 'number' && (!Number.isFinite(disk.usage) || disk.usage < 0)); -export const getWorkloadsDiskUsagePercent = (disk: Disk): number => { - const total = disk.total ?? 0; - if (total <= 0) { - return 0; +// Capacity alone is not a measurement. In particular, an omitted `used` +// field must not become a healthy empty filesystem, even if usage/free exist. +export const getWorkloadsDiskUsagePercent = (disk: Disk): number | null => { + const { total, used } = disk; + if ( + !hasWorkloadsDiskCapacity(disk) || + isWorkloadsDiskUsageUnknown(disk) || + typeof total !== 'number' || + typeof used !== 'number' + ) { + return null; } - return ((disk.used ?? 0) / total) * 100; + const percent = (used / total) * 100; + return Number.isFinite(percent) ? percent : null; }; export const getWorkloadsDiskLabel = (disk: Disk): string => @@ -46,35 +57,34 @@ export const getWorkloadsDiskLabelTitle = (label: string): string | undefined => label !== 'Unknown' ? label : undefined; export const getWorkloadsDiskUsageText = (disk: Disk): string => { - if (isWorkloadsDiskUsageUnknown(disk)) { - return hasWorkloadsDiskCapacity(disk) - ? `?/${formatBytes(disk.total ?? 0)}` - : 'Usage unavailable'; - } - return hasWorkloadsDiskCapacity(disk) - ? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}` - : 'Usage unavailable'; + if (!hasWorkloadsDiskCapacity(disk)) return 'Usage unavailable'; + const used = + getWorkloadsDiskUsagePercent(disk) === null || typeof disk.used !== 'number' + ? '?' + : formatBytes(disk.used); + return `${used}/${formatBytes(disk.total)}`; }; -export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => - hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk) - ? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%` - : '—'; +export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => { + const percent = getWorkloadsDiskUsagePercent(disk); + return percent === null ? '—' : `${percent.toFixed(0)}%`; +}; export const getWorkloadsDiskProgressClass = ( disk: Disk, thresholds?: MetricDisplayThresholds | null, -): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds); +): string => { + const percent = getWorkloadsDiskUsagePercent(disk); + return percent === null ? 'bg-surface-hover' : getMetricColorClass(percent, 'disk', thresholds); +}; export const getWorkloadsDiskProgressValue = (disk: Disk): number | null => - hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk) - ? getWorkloadsDiskUsagePercent(disk) - : null; + getWorkloadsDiskUsagePercent(disk); -export const getWorkloadsDiskProgressWidth = (disk: Disk): string => - isWorkloadsDiskUsageUnknown(disk) - ? '0%' - : `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`; +export const getWorkloadsDiskProgressWidth = (disk: Disk): string => { + const percent = getWorkloadsDiskUsagePercent(disk); + return percent === null ? '0%' : `${Math.min(percent, 100)}%`; +}; export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';