diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b9e77e82f5..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 @@ -69,6 +87,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..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,29 @@ ## 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, +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/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-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/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/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__/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__/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/__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() ?? ''; 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 {