From c37dec157ecb99890be96162b46b2ca5e3fd5417 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:48:00 +0100 Subject: [PATCH] Keep deferred filesystem detail values distinct from current health Label retained guest usage beside each mount and remove current utilization bars and threshold colours until a fresh read. Do not promote numeric carriers on failed or explicitly unavailable reads; retain identity and known capacity, current/LXC controls and the existing safe read explanation. Production drawer browser proof, mounted regressions and pure builder controls preserve the local request budget. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 18 +++ .../subsystems/performance-and-scalability.md | 9 ++ .../guest-filesystem-provenance.cjs | 136 ++++++++++++++++++ .../1014496b3e63eb6a.json | 60 ++++++++ .../Workloads/GuestDrawerOverview.tsx | 20 ++- .../Workloads/__tests__/DiskList.test.tsx | 32 +++++ ...werOverview.filesystem-provenance.test.tsx | 12 ++ .../GuestDrawerOverview.filesystems.test.tsx | 3 + .../src/components/Workloads/diskListModel.ts | 23 ++- 9 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-filesystem-provenance.cjs create mode 100644 frontend-modern/browser-verification/1014496b3e63eb6a.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bd2fbe8a17..443f3bb4df 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -249,6 +249,24 @@ Fresh linked Pulse-agent disk evidence with no reason remains unmarked, and non-VM rows do not inherit QGA guidance. The cue adds no action or tab stop; touch on it continues through the existing row disclosure to the full reason. +The opened guest Overview applies the same admission to every filesystem, not +only the aggregate row or its status sentence. A valid `prev-` value is labelled +**Last known** beside its mount identity and used/capacity bytes; retained values +do not receive current utilization bars or threshold colours. Other VM read +reasons and explicit disk telemetry unavailability show **Usage unavailable**, +retaining identity and known capacity without promoting old used bytes. Missing +or invalid retained usage is unavailable, not an empty healthy filesystem. The +read explanation precedes the filesystem breakdown and remains visible before +any sample. Clearing a lock alone cannot clear a still-supplied reason. A fresh +same-guest read restores the normal accessible utilization bar; independent LXC +and linked-agent readings with no VM reason retain their existing semantics. + +Verification: `GuestDrawerOverview.filesystem-provenance.test.tsx`, the existing +filesystem regressions and builder controls in `DiskList.test.tsx`, plus +`browser-tests/guest-filesystem-provenance.cjs` using the full production guest +drawer on desktop/light/dark and touch WebKit. These are synthetic presentation +and input proofs, not native backup/thaw, covered-filesystem writes or resumption. + Verification: mounted `GuestRow.test.tsx` and `MetricMiniSparkline.test.tsx` regressions and `browser-tests/guest-disk-provenance.cjs` exercise retained and pre-sample states, display/lens changes, current-value semantics and same-row 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 5315612235..4d6f1ac82f 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1671,6 +1671,15 @@ without contention or cross-delivery. lifecycle. Request handlers may reuse the cached manager but must not reopen or remigrate SQLite on each role or assignment request, and shutdown must close the owned manager set. +8. Guest Overview filesystem provenance is local presentation over the already + supplied snapshot: no additional query, timer, poll, History fetch or guest + command may be added to establish it. Its existing linear filesystem mapping + may label retained values and omit current utilization bars; it must preserve + ordinary current-value builder behaviour and must not mutate the snapshot. + `DiskList.test.tsx` verifies the pure builder's retained/unavailable/current + controls; mounted drawer regressions and the full-drawer browser proof cover + the shipped path. No performance or native workload improvement follows from + the presentation proof alone. ## Current State diff --git a/frontend-modern/browser-tests/guest-filesystem-provenance.cjs b/frontend-modern/browser-tests/guest-filesystem-provenance.cjs new file mode 100644 index 0000000000..4494b27263 --- /dev/null +++ b/frontend-modern/browser-tests/guest-filesystem-provenance.cjs @@ -0,0 +1,136 @@ +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-read-provenance/browser-final'; +const origin = 'http://127.0.0.1:5324'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', + 'frontend-modern/src/components/Workloads/diskListModel.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: [], cleanup: { browser: false, server: false }, + limits: 'Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter 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: 5324, strictPort: true }}); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop-light', chromium, 1365, false], + ['chromium-desktop-dark', chromium, 1365, true], + ['webkit-phone-dark', webkit, 390, true], + ['webkit-narrow-light', webkit, 320, false], + ]) { + browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const context = await browser.newContext({viewport: { width, height: width <= 768 ? 844 : 900 }, isMobile: width <= 768, hasTouch: width <= 768, locale: 'en-GB', timezoneId: 'UTC'}); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + const record = {name, browser: browser.version(), width, dark, errors: [], requests: [], checks: [], states: []}; + result.cases.push(record); + page.on('pageerror', error => record.errors.push(error.message)); + await page.route('**/*', route => { + const request = route.request(); const url = new URL(request.url()); + if (url.origin !== origin) { record.requests.push({ method: request.method(), path: url.pathname, disposition: 'off-origin blocked' }); return route.abort(); } + if (url.pathname.startsWith('/api/')) { + record.requests.push({ method: request.method(), path: url.pathname, disposition: 'synthetic' }); + return route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: [], alerts: [], anomalies: [], config: null })}); + } + return route.continue(); + }); + await page.addInitScript(isDark => { + document.addEventListener('DOMContentLoaded', () => { if (isDark) document.documentElement.classList.add('dark'); }); + window.__trustedInput = []; + document.addEventListener('click', event => window.__trustedInput.push({ type: 'click', trusted: event.isTrusted }), true); + document.addEventListener('keydown', event => window.__trustedInput.push({ type: event.key, trusted: event.isTrusted }), true); + }, dark); + await page.goto(`${origin}/browser-tests/guest-disk-provenance.html`); + await page.waitForFunction(() => window.__guestDiskProvenance); + const apply = async next => { await page.evaluate(next => window.__guestDiskProvenance.apply(next), next); await settle(page); }; + await apply({ reason: 'prev-vm-locked', data: true, usage: 90, lock: 'backup', kind: 'qemu' }); + const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]'); + const disclosure = row.locator('[data-row-action]'); + if (width <= 768) await row.locator('[data-workload-disk-read-status]').tap(); + else { await disclosure.focus(); await page.keyboard.press('Enter'); } + const drawer = page.getByRole('region', {name: 'Guest details'}); + await drawer.waitFor({state: 'visible'}); + const technical = drawer.getByTestId('guest-technical-details'); + const filesystem = technical.getByText('/data', {exact: true}).locator('xpath=ancestor::tr'); + const progress = filesystem.getByRole('progressbar'); + const text = async () => (await filesystem.innerText()).replace(/\s+/g, ' ').trim(); + const capture = async state => { + await technical.scrollIntoViewIfNeeded(); await settle(page); + const file = `${name}-${state}.png`; + await technical.screenshot({path: path.join(output, file)}); + result.screenshots.push({file, sha256: hash(path.join(output, file)), name, state}); + }; + assert.match(await text(), /Last known 90% · 9\.00 GB\/10\.0 GB · EXT4/); + assert.equal(await progress.count(), 0); + record.checks.push('Retained quantitative values are labelled last known beside their filesystem identity, with no current utilization bar.'); + record.states.push({state: 'retained', text: await text()}); + await capture('retained'); + await apply({ lock: '' }); + assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0); + record.checks.push('Clearing only the operation lock cannot promote a deferred reading.'); + const reasons = await page.evaluate(() => window.__guestDiskProvenance.deferrals.map(item => item[0])); + for (const reason of reasons) { + await apply({reason: 'prev-' + reason}); + assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0); + await apply({reason}); + assert.match(await text(), /Usage unavailable · \?\/10\.0 GB · EXT4/); + assert.doesNotMatch(await text(), /90%|9\.00 GB/); assert.equal(await progress.count(), 0); + } + record.checks.push('All eight fixed read reasons distinguish retained data from unavailable numeric carriers.'); + record.states.push({state: 'unavailable', text: await text()}); + await capture('unavailable'); + await apply({reason: '', usage: 30, lock: ''}); + assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/); + assert.equal(await progress.getAttribute('aria-valuenow'), '30'); + assert.equal(await progress.getAttribute('aria-label'), 'Filesystem /data utilization'); + assert.equal(await drawer.isVisible(), true); + record.checks.push('A same-guest fresh read restores the correct numeric value and accessible utilization bar without closing the drawer.'); + record.states.push({state: 'fresh', text: await text()}); + await capture('fresh'); + await apply({reason: 'prev-vm-locked', kind: 'lxc'}); + assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/); assert.equal(await progress.getAttribute('aria-valuenow'), '30'); + record.checks.push('Independent LXC filesystem evidence does not inherit a VM-only reason.'); + await apply({kind: 'qemu', data: false, reason: 'vm-locked'}); + assert.equal(await technical.getByText('/data', {exact: true}).count(), 0); + assert.match(await technical.innerText(), /Guest reads paused while Proxmox reports a VM operation lock/); + record.checks.push('With no prior filesystem sample, the readable explanation remains and no filesystem value is invented.'); + const overflow = await technical.evaluate(el => ({width: el.clientWidth, scrollWidth: el.scrollWidth, documentWidth: document.documentElement.clientWidth, documentScroll: document.documentElement.scrollWidth})); + assert.ok(overflow.scrollWidth <= overflow.width + 1, JSON.stringify(overflow)); + record.layout = overflow; + await drawer.getByRole('button', {name: 'Collapse backup-guest details'}).click(); + assert.equal(await drawer.count(), 0); + record.input = await page.evaluate(() => window.__trustedInput); + assert.ok(record.input.some(event => event.trusted)); + assert.deepEqual(record.errors, []); + assert.equal(record.requests.filter(request => !['GET','HEAD'].includes(request.method)).length, 0); + record.checks.push('Trusted keyboard/touch disclosure and close work; no uncaught page error, overflow or mutating request.'); + await context.close(); await browser.close(); browser = null; + } + result.result = 'passed'; + } catch (error) { + result.error = String(error.stack || error); throw error; + } finally { + if (browser) await browser.close(); result.cleanup.browser = true; + await server.close(); result.cleanup.server = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-verification/1014496b3e63eb6a.json b/frontend-modern/browser-verification/1014496b3e63eb6a.json new file mode 100644 index 0000000000..e10fb62980 --- /dev/null +++ b/frontend-modern/browser-verification/1014496b3e63eb6a.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "664b29f930b19f91b74f673914c85e3b806b54f7", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/diskListModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "16ccfd4c8a865c02d4ab3580d7872518cb599715245fb10ebbe3dfbe375cb104", + "frontend-modern/src/components/Workloads/diskListModel.ts": "8b43eeb3e08dff191039ee19ea74baa89703d6b8f89d801a5ff0f8cdef09dffc" + }, + "routes": [ + "/browser-tests/guest-disk-provenance.html \u2014 production Workloads GuestRow/full GuestDrawer; synthetic APIs, not the full App route" + ], + "states": [ + "Retained filesystem usage with each of eight prev- reasons: labelled last known, no current bars/threshold colours", + "Same eight non-retained read failures: unavailable used bytes, identity/capacity retained", + "Lock-only clearance retains the label; same-guest fresh read restores accessible 30% utilization", + "LXC independent reading and no-sample readable VM status" + ], + "interactions": [ + "Trusted desktop Enter disclosure and close", + "Trusted WebKit phone/narrow touch on the row provenance cue and close", + "Synthetic same-guest poll replacements while keeping the drawer open", + "Inspected every retained/unavailable/fresh screenshot in all four browser/theme/width groups" + ], + "viewports": [ + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-light", + "theme": "light" + }, + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-dark", + "theme": "dark" + }, + { + "width": 390, + "height": 844, + "browser": "webkit-phone-dark", + "theme": "dark" + }, + { + "width": 320, + "height": 844, + "browser": "webkit-narrow-light", + "theme": "light" + } + ], + "verified_at": "2026-10-04T21:47:04Z", + "playwright_version": "1.56.1", + "checks": 28, + "screenshots_inspected": 12, + "script": "frontend-modern/browser-tests/guest-filesystem-provenance.cjs", + "limits": "Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter or release acceptance." +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index d03798cb7c..8ad1d0c370 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -138,12 +138,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.guest.vmware?.datacenterName || props.guest.vmware?.clusterName, ); + const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined; + const diskReadState = + props.guest.telemetryAvailability?.disk === false + ? 'unavailable' + : diskReason + ? diskReason.startsWith('prev-') + ? 'last-known' + : 'unavailable' + : 'current'; const diskRows = (props.guest.disks ?? []).map((disk, index) => - buildWorkloadsDiskPresentation(disk, index, props.diskThresholds), + buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState), ); - const diskStatusMessage = - isGuestDrawerVM(props.guest) && props.guest.diskStatusReason - ? getWorkloadGuestDiskStatusMessage(props.guest.diskStatusReason) + const diskStatusMessage = diskReason + ? getWorkloadGuestDiskStatusMessage(diskReason) + : diskReadState === 'unavailable' + ? 'Filesystem usage is unavailable.' : null; return compactDetailSections([ @@ -217,6 +227,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { ? { label: 'Filesystems', rows: compactDetailRows([ + makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }), ...diskRows.map((disk) => makeDetailRow( disk.label, @@ -238,7 +249,6 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { }, ), ), - makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }), ]), } : null, diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx index 441f7b36e7..5dcbdba685 100644 --- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx @@ -28,6 +28,38 @@ afterEach(() => { cleanup(); }); +describe('filesystem presentation read admission', () => { + it.each(['last-known', 'unavailable'] as const)( + 'keeps %s readings out of threshold-coloured utilization', + (state) => { + const disk = makeDisk(); + const reading = buildWorkloadsDiskPresentation(disk, 0, undefined, state); + expect(reading.progressValue).toBeNull(); + expect(reading.progressWidth).toBe('0%'); + expect(reading.progressClass).toBe('bg-surface-hover'); + expect(reading.label).toBe('/'); + expect(reading.typeLabel).toBe('EXT4'); + expect(reading.usagePercentLabel).toBe( + state === 'last-known' ? 'Last known 50%' : 'Usage unavailable', + ); + expect(reading.usageText).toBe(state === 'last-known' ? '50.0 GB/100 GB' : '?/100 GB'); + expect(disk.used).toBe(53687091200); + }, + ); + + it('does not infer a retained value from capacity or free bytes', () => { + const reading = buildWorkloadsDiskPresentation( + makeDisk({ used: undefined }), + 0, + undefined, + 'last-known', + ); + expect(reading.usagePercentLabel).toBe('Usage unavailable'); + expect(reading.usageText).toBe('?/100 GB'); + expect(reading.progressValue).toBeNull(); + }); +}); + describe('DiskList', () => { describe('fallback when no disks', () => { it('renders fallback "-" when disks array is empty', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystem-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystem-provenance.test.tsx index f94d28b0fb..9311373466 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystem-provenance.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystem-provenance.test.tsx @@ -111,6 +111,18 @@ describe('filesystem details do not promote retained usage to current health', ( expect(within(filesystemRow()).queryByRole('progressbar')).not.toBeInTheDocument(); }); + it('explains explicit unavailability even before any filesystem sample', () => { + render(() => ( + + )); + expect(screen.getByText('Filesystems')).toBeVisible(); + expect(screen.getByText('Filesystem usage is unavailable.')).toBeVisible(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + it('keeps retained values until the reason clears, then resumes the same drawer', () => { const [value, setValue] = createSignal( guest({ diskStatusReason: 'prev-vm-locked', lock: 'backup', backupInProgress: true }), diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx index 820a69d0c4..c0deca4921 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx @@ -85,6 +85,9 @@ describe('GuestDrawerOverview filesystem labels', () => { ).toHaveAttribute('colspan', '2'); expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument(); for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument(); + expect(screen.getByText('Last known 50% · 5.00 GB/10.0 GB · MP0')).toBeVisible(); + expect(screen.getByText('Usage unavailable · ?/10.0 GB · MP1')).toBeVisible(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); setGuest({ ...guest(), diskStatusReason: '', diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts index 61ba4c3d28..d1e5c77d5a 100644 --- a/frontend-modern/src/components/Workloads/diskListModel.ts +++ b/frontend-modern/src/components/Workloads/diskListModel.ts @@ -92,18 +92,31 @@ export const buildWorkloadsDiskPresentation = ( disk: Disk, index: number, thresholds?: MetricDisplayThresholds | null, + readState: 'current' | 'last-known' | 'unavailable' = 'current', ): WorkloadsDiskPresentation => { const label = getWorkloadsDiskLabel(disk); + // Retention preserves identity and capacity, not a current utilization + // measurement. Only an explicitly retained read may display old used bytes. + const reading = readState === 'unavailable' ? { ...disk, used: undefined } : disk; + const percentLabel = getWorkloadsDiskUsagePercentLabel(reading); return { key: `${disk.mountpoint ?? ''}:${disk.device ?? ''}:${index}`, label, labelTitle: getWorkloadsDiskLabelTitle(label), - progressClass: getWorkloadsDiskProgressClass(disk, thresholds), - progressValue: getWorkloadsDiskProgressValue(disk), - progressWidth: getWorkloadsDiskProgressWidth(disk), + progressClass: + readState === 'current' + ? getWorkloadsDiskProgressClass(reading, thresholds) + : 'bg-surface-hover', + progressValue: readState === 'current' ? getWorkloadsDiskProgressValue(reading) : null, + progressWidth: readState === 'current' ? getWorkloadsDiskProgressWidth(reading) : '0%', typeLabel: getWorkloadsDiskTypeLabel(disk), - usageText: getWorkloadsDiskUsageText(disk), - usagePercentLabel: getWorkloadsDiskUsagePercentLabel(disk), + usageText: getWorkloadsDiskUsageText(reading), + usagePercentLabel: + readState === 'current' + ? percentLabel + : percentLabel === '—' + ? 'Usage unavailable' + : `Last known ${percentLabel}`, }; };