From 81b07e8a90d8dfe6b848945767afd5ebd8056bf8 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sun, 4 Oct 2026 05:15:29 +0100
Subject: [PATCH] Keep incomplete filesystem readings unavailable
Require finite capacity and measured used bytes in the existing filesystem list and guest Overview. Missing, invalid and sentinel readings remain unavailable rather than a healthy empty disk; measured zero and valid byte-derived readings stay visible. Preserve the exact retained History correction and add mounted, multi-filesystem and browser controls.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 18 ++
.../subsystems/performance-and-scalability.md | 10 +
.../filesystem-usage-evidence.cjs | 220 ++++++++++++++++++
.../filesystem-usage-evidence.html | 12 +
.../filesystem-usage-evidence.tsx | 109 +++++++++
.../d92d8a69db1a806f.json | 40 ++++
.../Workloads/__tests__/DiskList.test.tsx | 21 ++
.../diskListModel.branchcov0713.test.ts | 10 +-
.../filesystemUsage.evidence.test.tsx | 164 +++++++++++++
.../src/components/Workloads/diskListModel.ts | 66 +++---
10 files changed, 637 insertions(+), 33 deletions(-)
create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.cjs
create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.html
create mode 100644 frontend-modern/browser-tests/filesystem-usage-evidence.tsx
create mode 100644 frontend-modern/browser-verification/d92d8a69db1a806f.json
create mode 100644 frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index b2359b7bbf..ca63ff380d 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,24 @@
## Purpose
+### Filesystem capacity is not measured usage
+
+The existing filesystem list and guest Overview require finite positive capacity
+and finite non-negative measured used bytes before displaying a percentage or
+utilization bar. Missing used bytes, invalid numeric fields, the negative
+unknown-usage sentinel and an overflowing ratio remain unavailable, not a healthy
+zero. Known capacity is still useful as `?/capacity`; filesystem identity and
+type remain visible. An explicitly measured zero stays `0%`, and omitted redundant
+percentage metadata does not discard valid bytes. Valid over-capacity readings
+retain their percentage and severity while the visual fill stays capped at 100%.
+No percentage or free-byte field is used to invent an absent used-byte reading.
+
+`filesystemUsage.evidence.test.tsx` covers the model and both production consumers,
+including same-filesystem known → unavailable → measured-zero replacement.
+`browser-tests/filesystem-usage-evidence.cjs` covers these rendered boundaries on
+desktop Chromium and touch Chromium/WebKit. These synthetic observations are
+not proof of appliance collection, backup thaw, installed recovery or delivery.
+
### Guest disk deferral and retained observations
The existing workload row, disk breakdown tooltip and guest Overview use
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 4f62c67d56..2877df4d3a 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -15,6 +15,16 @@
## Purpose
+### Unavailable filesystem values share the existing disk model
+
+The filesystem list and guest Overview use the same nullable measured-byte ratio
+for text, severity and progress. Per-disk validation is constant work within the
+existing linear presentation pass; it adds no reads, queries, timers, remounts or
+resource-identity changes. Unavailable values stay neutral rather than passing a
+fabricated zero into threshold classification. Mounted filesystem evidence tests
+and the production browser fixture verify replacement and rendering, not a new
+latency, fleet-performance or native-collection claim.
+
### Guest History live filesystem provenance
The guest drawer derives deferred filesystem context from its current snapshot,
diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.cjs b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs
new file mode 100644
index 0000000000..7ecac9c08a
--- /dev/null
+++ b/frontend-modern/browser-tests/filesystem-usage-evidence.cjs
@@ -0,0 +1,220 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+const root = '/workspace/frontend-modern';
+const output = '/workspace/tmp/filesystem-usage-evidence';
+const runtime = [
+ 'frontend-modern/src/components/Workloads/diskListModel.ts',
+ 'frontend-modern/src/components/Workloads/DiskList.tsx',
+ 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
+];
+const unknown = [
+ 'missing-used',
+ 'negative-used',
+ 'NaN-used',
+ 'infinite-used',
+ 'missing-total',
+ 'zero-total',
+ 'negative-total',
+ 'NaN-total',
+ 'infinite-total',
+ 'unknown-sentinel',
+ 'NaN-usage',
+ 'infinite-usage',
+ 'overflow',
+ 'retained-missing',
+];
+const digest = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const playwrightVersion = require('playwright/package.json').version;
+ assert.equal(
+ playwrightVersion,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const hashes = Object.fromEntries(runtime.map((p) => [p, digest(path.join('/workspace', p))]));
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5302, strictPort: true },
+ });
+ const results = [];
+ const screenshots = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, height, dark] of [
+ ['chromium-desktop', chromium, 1365, 900, false],
+ ['chromium-phone', chromium, 390, 844, false],
+ ['webkit-phone', webkit, 360, 844, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const browserVersion = browser.version();
+ const page = await browser.newPage({
+ viewport: { width, height },
+ isMobile: width <= 768,
+ hasTouch: width <= 768,
+ locale: 'en-GB',
+ });
+ page.setDefaultTimeout(60000);
+ const pageErrors = [];
+ const apiRequests = [];
+ const offOrigin = [];
+ page.on('pageerror', (error) => pageErrors.push(error.message));
+ await page.route('**/*', (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5302') {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (url.pathname.startsWith('/api/') || url.pathname === '/ws') {
+ apiRequests.push(url.pathname);
+ return route.abort();
+ }
+ return route.continue();
+ });
+ await page.goto('http://127.0.0.1:5302/browser-tests/filesystem-usage-evidence.html', {
+ waitUntil: 'domcontentloaded',
+ });
+ const select = page.getByLabel('Synthetic observation');
+ await select.waitFor({ state: 'visible' });
+ if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ const list = page.getByRole('region', { name: 'Filesystem list' });
+ const overview = page.getByRole('region', { name: 'Guest Overview' });
+ // Overview owns a full-width guest drawer. A narrow fixture cap at an
+ // xl viewport would activate four columns inside an artificial 672px
+ // host and split the existing backup copy into single characters.
+ const backupBounds = await overview.getByText('No completed backup found').boundingBox();
+ assert(backupBounds && backupBounds.width >= 100 && backupBounds.height <= 40);
+ await page.evaluate(() => {
+ window.__listIdentity = document.querySelector('[aria-label="Filesystem list"]');
+ window.__overviewIdentity = document.querySelector(
+ '[data-testid="guest-technical-details"]',
+ );
+ });
+ const checks = [];
+ const selectState = async (state) => {
+ await select.selectOption(state);
+ assert.equal(
+ await page.evaluate(
+ () =>
+ window.__listIdentity === document.querySelector('[aria-label="Filesystem list"]') &&
+ window.__overviewIdentity ===
+ document.querySelector('[data-testid="guest-technical-details"]'),
+ ),
+ true,
+ );
+ };
+ const capture = async (state) => {
+ const p = path.join(output, `${name}-${state}.png`);
+ await page.screenshot({ path: p, fullPage: true });
+ screenshots.push({ path: path.relative('/workspace', p), sha256: digest(p) });
+ };
+ for (const state of unknown) {
+ await selectState(state);
+ await overview.locator('[data-testid="guest-technical-details"]').waitFor();
+ for (const panel of [list, overview]) {
+ const text = await panel.innerText();
+ assert(text.includes('—'), `${state}: missing unavailable marker`);
+ assert(!/0%|NaN|Infinity/.test(text), `${state}: fabricated/invalid value: ${text}`);
+ assert(text.includes('/data') && text.includes('EXT4'));
+ assert.equal(await panel.getByRole('progressbar').count(), 0);
+ }
+ const listFill = list.locator('[style*="width:"]');
+ assert.equal(await listFill.evaluate((element) => element.style.width), '0%');
+ assert(!(await listFill.getAttribute('class')).includes('metric-normal'));
+ assert.equal(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ true,
+ );
+ checks.push({ state, unknown: true, numericProgress: false, noOverflow: true });
+ if (['missing-used', 'infinite-total', 'retained-missing'].includes(state)) {
+ if (state === 'retained-missing') {
+ assert((await list.innerText()).includes('earlier guest request is still in progress'));
+ }
+ await capture(state);
+ }
+ }
+ for (const [state, percentage] of [
+ ['zero', 0],
+ ['live', 50],
+ ['usage-omitted', 50],
+ ['over-capacity', 125],
+ ['retained-live', 50],
+ ]) {
+ await selectState(state);
+ assert((await list.innerText()).includes(`${percentage}%`));
+ assert.equal(
+ await overview.getByRole('progressbar').getAttribute('aria-valuenow'),
+ String(Math.min(percentage, 100)),
+ );
+ checks.push({ state, percentage, measured: true });
+ if (state === 'zero') await capture(state);
+ }
+ // Real keyboard input on the fixture replaces the same observation;
+ // there is no new product control or collector action.
+ await selectState('missing-used');
+ await select.focus();
+ await page.keyboard.press('Home');
+ await page.keyboard.press('Enter');
+ assert.equal(await select.inputValue(), 'live');
+ assert.equal(await overview.getByRole('progressbar').getAttribute('aria-valuenow'), '50');
+ assert.equal(pageErrors.length, 0);
+ assert.equal(apiRequests.length, 0);
+ assert.equal(offOrigin.length, 0);
+ results.push({
+ name,
+ browserVersion,
+ viewport: { width, height },
+ dark,
+ checks,
+ pageErrors,
+ apiRequests,
+ offOrigin,
+ });
+ await browser.close();
+ browser = undefined;
+ }
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ result: 'passed',
+ verified_at: new Date().toISOString(),
+ playwrightVersion,
+ hashes,
+ results,
+ screenshots,
+ scope:
+ 'Production DiskList and GuestDrawerOverview under synthetic same-filesystem observations. No native collection, installed/release acceptance or full source qualification.',
+ },
+ null,
+ 2,
+ ),
+ );
+ } catch (error) {
+ fs.writeFileSync(
+ path.join(output, 'failure.json'),
+ JSON.stringify({ error: error.message, results }, null, 2),
+ );
+ throw error;
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ process.stderr.write(`${error.stack}\n`);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.html b/frontend-modern/browser-tests/filesystem-usage-evidence.html
new file mode 100644
index 0000000000..d05f948fed
--- /dev/null
+++ b/frontend-modern/browser-tests/filesystem-usage-evidence.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Filesystem usage evidence
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/filesystem-usage-evidence.tsx b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx
new file mode 100644
index 0000000000..0c6caa0395
--- /dev/null
+++ b/frontend-modern/browser-tests/filesystem-usage-evidence.tsx
@@ -0,0 +1,109 @@
+// Production list and Overview, driven only by synthetic local observations.
+// This fixture cannot collect guest data or establish native backup safety.
+import { createMemo, createSignal, For } from 'solid-js';
+import { render } from 'solid-js/web';
+import { DiskList } from '../src/components/Workloads/DiskList';
+import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
+import type { Disk, VM } from '../src/types/api';
+import '../src/index.css';
+
+const capacity = 10 * 1024 ** 3;
+const cases: Record; reason?: string }> = {
+ live: { values: {} },
+ 'missing-used': { values: { used: undefined } },
+ 'negative-used': { values: { used: -1 } },
+ 'NaN-used': { values: { used: NaN } },
+ 'infinite-used': { values: { used: Infinity } },
+ 'missing-total': { values: { total: undefined } },
+ 'zero-total': { values: { total: 0 } },
+ 'negative-total': { values: { total: -1 } },
+ 'NaN-total': { values: { total: NaN } },
+ 'infinite-total': { values: { total: Infinity } },
+ 'unknown-sentinel': { values: { usage: -1, used: 0 } },
+ 'NaN-usage': { values: { usage: NaN } },
+ 'infinite-usage': { values: { usage: Infinity } },
+ overflow: { values: { total: 1, used: Number.MAX_VALUE } },
+ zero: { values: { used: 0, usage: undefined } },
+ 'usage-omitted': { values: { usage: undefined } },
+ 'over-capacity': { values: { used: capacity * 1.25, usage: 0 } },
+ 'retained-live': { values: {}, reason: 'prev-vm-locked' },
+ 'retained-missing': { values: { used: undefined }, reason: 'prev-agent-busy' },
+};
+
+function Fixture() {
+ const [selected, setSelected] = createSignal('live');
+ const observation = createMemo(() => ({
+ mountpoint: '/data',
+ type: 'ext4',
+ total: capacity,
+ used: capacity / 2,
+ usage: 50,
+ ...cases[selected()].values,
+ }));
+ const guest = createMemo(() => ({
+ id: 'fixture:pve-a:101',
+ vmid: 101,
+ name: 'filesystem-evidence',
+ node: 'pve-a',
+ instance: 'fixture',
+ status: 'running',
+ type: 'qemu',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
+ disk: observation(),
+ disks: [observation()],
+ diskStatusReason: cases[selected()].reason,
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ tags: [],
+ lock: '',
+ lastSeen: '2026-10-04T04:00:00Z',
+ }));
+ return (
+
+ Filesystem usage evidence
+
+
+
+
+ );
+}
+
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification/d92d8a69db1a806f.json b/frontend-modern/browser-verification/d92d8a69db1a806f.json
new file mode 100644
index 0000000000..dd08ed8a99
--- /dev/null
+++ b/frontend-modern/browser-verification/d92d8a69db1a806f.json
@@ -0,0 +1,40 @@
+{
+ "version": 1,
+ "base_sha": "ba8b9fcd32f4f4d486e33d952ab09ecdc3d3b8a7",
+ "verified_at": "2026-10-04T04:10:01.032Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/diskListModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/diskListModel.ts": "1afb2fa866a8c4772e56ce38861610705d43a619537c6d57e0a79b95d288394b"
+ },
+ "routes": [
+ "/browser-tests/filesystem-usage-evidence.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production DiskList and GuestDrawerOverview with actual CSS; 57 synthetic states across desktop Chromium, phone Chromium and dark phone WebKit. Playwright 1.56.1 matches the integration lock.",
+ "Missing, negative, non-finite and sentinel usage/capacity plus overflow never render a healthy zero, invalid percentage or numeric progress. Known capacity, mount identity and type remain visible.",
+ "Explicit measured zero, ordinary bytes, omitted redundant usage, over-capacity and valid retained values preserve their labels and capped visual fill. Twelve final bitmap contents were inspected; eight phone bitmaps match already opened captures exactly.",
+ "No native collection, backup thaw, installed recovery, physical phone, publication or release acceptance; full types/build qualification remains unavailable."
+ ],
+ "interactions": [
+ "Replaced each same-filesystem synthetic observation through the fixture select without remounting either production panel; list/Overview agree on unavailable versus measured values.",
+ "Real fixture keyboard Home/Enter restores a live observation after unavailable. There is no new product action, route, tab, network call or collector change.",
+ "No page errors, API requests or off-origin requests; unknown-state layout fits the document, and full-width drawer copy remains readable."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
index b11f2534e1..441f7b36e7 100644
--- a/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/DiskList.test.tsx
@@ -168,6 +168,27 @@ describe('DiskList', () => {
});
describe('usage calculation', () => {
+ it('withdraws one incomplete filesystem reading without losing another mount or inventing zero', () => {
+ const stable = makeDisk({ mountpoint: '/stable', used: 26843545600 });
+ const changing = makeDisk({ mountpoint: '/changing', device: '/dev/sda2' });
+ const [disks, update] = createSignal([stable, changing]);
+ render(() => );
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('50%')).toBeInTheDocument();
+
+ update([stable, { ...changing, used: undefined }]);
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('/changing').parentElement).toHaveTextContent('?/100 GB');
+ expect(screen.getByText('—')).toBeInTheDocument();
+ expect(screen.queryByText('0%')).not.toBeInTheDocument();
+ expect(screen.queryByText('50%')).not.toBeInTheDocument();
+
+ update([stable, { ...changing, used: 0, usage: undefined }]);
+ expect(screen.getByText('25%')).toBeInTheDocument();
+ expect(screen.getByText('0%')).toBeInTheDocument();
+ expect(screen.queryByText('—')).not.toBeInTheDocument();
+ });
+
it('derives usage percent through the canonical disk-list model', () => {
const disk = makeDisk({ used: 26843545600, total: 107374182400 });
expect(getWorkloadsDiskUsagePercent(disk)).toBe(25);
diff --git a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
index a5d0882717..f112dc6a29 100644
--- a/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/diskListModel.branchcov0713.test.ts
@@ -60,8 +60,8 @@ describe('diskListModel (branch coverage 0713)', () => {
});
describe('getWorkloadsDiskUsagePercent', () => {
- it('returns 0 when the disk has no capacity (false arm of the guard)', () => {
- expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBe(0);
+ it('returns null when the disk has no capacity rather than a measured zero', () => {
+ expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBeNull();
});
it('returns (used / total) * 100 for a normal ratio (true arm of the guard)', () => {
@@ -142,8 +142,8 @@ describe('diskListModel (branch coverage 0713)', () => {
);
});
- it('maps a no-capacity disk (percent 0) to the normal class', () => {
- expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe(NORMAL_CLASS);
+ it('keeps a no-capacity disk neutral rather than classifying an unmeasured zero', () => {
+ expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe('bg-surface-hover');
});
it('falls back to the default disk thresholds when thresholds is null (nullish arm)', () => {
@@ -280,7 +280,7 @@ describe('diskListModel (branch coverage 0713)', () => {
key: '/foo:/dev/sdb:5',
label: '/foo',
labelTitle: '/foo',
- progressClass: NORMAL_CLASS,
+ progressClass: 'bg-surface-hover',
progressValue: null,
progressWidth: '0%',
typeLabel: 'EXT4',
diff --git a/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx
new file mode 100644
index 0000000000..4ffbe725b5
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/filesystemUsage.evidence.test.tsx
@@ -0,0 +1,164 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render, screen, within } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+
+import type { Disk, VM } from '@/types/api';
+import { DiskList } from '../DiskList';
+import { GuestDrawerOverview } from '../GuestDrawerOverview';
+import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
+
+const capacity = 10 * 1024 ** 3;
+const disk = (overrides: Partial = {}): Disk => ({
+ mountpoint: '/data',
+ type: 'ext4',
+ total: capacity,
+ used: capacity / 2,
+ usage: 50,
+ ...overrides,
+});
+
+const unavailable: [string, Partial][] = [
+ ['missing used bytes', { used: undefined }],
+ ['negative used bytes', { used: -1 }],
+ ['NaN used bytes', { used: NaN }],
+ ['infinite used bytes', { used: Infinity }],
+ ['negative infinite used bytes', { used: -Infinity }],
+ ['missing capacity', { total: undefined }],
+ ['zero capacity', { total: 0 }],
+ ['negative capacity', { total: -1 }],
+ ['NaN capacity', { total: NaN }],
+ ['infinite capacity', { total: Infinity }],
+ ['negative infinite capacity', { total: -Infinity }],
+ ['unknown usage sentinel', { usage: -1, used: 0 }],
+ ['NaN usage', { usage: NaN }],
+ ['infinite usage', { usage: Infinity }],
+ ['negative infinite usage', { usage: -Infinity }],
+ ['overflowing derived percentage', { total: 1, used: Number.MAX_VALUE }],
+];
+
+afterEach(cleanup);
+
+describe('filesystem readings need measured bytes, not zero defaults', () => {
+ it.each(unavailable)('withdraws a numeric reading for %s', (_name, values) => {
+ const input = disk(values);
+ const presentation = buildWorkloadsDiskPresentation(input, 0);
+ expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
+ expect(presentation.progressValue).toBeNull();
+ expect(presentation.progressWidth).toBe('0%');
+ expect(presentation.progressClass).toBe('bg-surface-hover');
+ expect(presentation.usagePercentLabel).toBe('—');
+ expect(presentation.usageText).not.toMatch(/NaN|Infinity|^0 B\//);
+ expect(presentation.label).toBe('/data');
+ expect(presentation.typeLabel).toBe('EXT4');
+ });
+
+ it('retains known capacity without inventing used bytes from a percentage or free bytes', () => {
+ const input = disk({ used: undefined, usage: 50, free: capacity / 2 });
+ expect(buildWorkloadsDiskPresentation(input, 0).usageText).toBe('?/10.0 GB');
+ expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
+ });
+
+ it('keeps an explicit measured zero, including when the redundant usage field is omitted', () => {
+ const presentation = buildWorkloadsDiskPresentation(disk({ used: 0, usage: undefined }), 0);
+ expect(presentation.progressValue).toBe(0);
+ expect(presentation.usagePercentLabel).toBe('0%');
+ expect(presentation.usageText).toBe('0 B/10.0 GB');
+ expect(presentation.progressClass).toContain('metric-normal');
+ });
+
+ it('derives ordinary and over-capacity percentages from the measured bytes', () => {
+ const input = disk({ used: capacity * 1.25, usage: 0 });
+ const presentation = buildWorkloadsDiskPresentation(input, 0);
+ expect(getWorkloadsDiskUsagePercent(disk({ usage: undefined }))).toBe(50);
+ expect(presentation.progressValue).toBe(125);
+ expect(presentation.usagePercentLabel).toBe('125%');
+ expect(presentation.progressWidth).toBe('100%');
+ expect(presentation.progressClass).toContain('metric-critical');
+ });
+});
+
+function renderPanels(initial: Disk) {
+ const [observation, setObservation] = createSignal(initial);
+ const guest = (): VM => ({
+ id: 'fixture:pve-a:101',
+ vmid: 101,
+ node: 'pve-a',
+ instance: 'fixture',
+ name: 'filesystem-evidence',
+ status: 'running',
+ type: 'qemu',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
+ disk: observation(),
+ disks: [observation()],
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ tags: [],
+ lock: '',
+ lastSeen: '2026-10-04T04:00:00Z',
+ });
+ render(() => (
+ <>
+
+
+ >
+ ));
+ return setObservation;
+}
+
+describe('both filesystem panels preserve unavailable versus measured zero', () => {
+ it.each([
+ ['missing used bytes', { used: undefined }],
+ ['invalid used bytes', { used: NaN }],
+ ['invalid capacity', { total: Infinity }],
+ ['unknown usage sentinel', { usage: -1, used: 0 }],
+ ] satisfies [string, Partial][])(
+ 'does not show an empty healthy disk for %s',
+ (_name, values) => {
+ renderPanels(disk(values));
+ for (const id of ['disk-list-panel', 'guest-technical-details']) {
+ const panel = screen.getByTestId(id);
+ expect(panel.textContent).toContain('—');
+ expect(panel.textContent).not.toMatch(/0%|NaN|Infinity/);
+ expect(within(panel).queryByRole('progressbar')).not.toBeInTheDocument();
+ }
+ },
+ );
+
+ it('withdraws a prior measurement and restores zero only after explicit same-filesystem evidence', () => {
+ const update = renderPanels(disk());
+ const details = screen.getByTestId('guest-technical-details');
+ expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
+ update(disk({ used: undefined }));
+ expect(within(details).queryByRole('progressbar')).not.toBeInTheDocument();
+ expect(details).toHaveTextContent('— · ?/10.0 GB · EXT4');
+ update(disk({ used: 0, usage: undefined }));
+ expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
+ expect(details).toHaveTextContent('0% · 0 B/10.0 GB · EXT4');
+ });
+});
diff --git a/frontend-modern/src/components/Workloads/diskListModel.ts b/frontend-modern/src/components/Workloads/diskListModel.ts
index 15da466a7d..61ba4c3d28 100644
--- a/frontend-modern/src/components/Workloads/diskListModel.ts
+++ b/frontend-modern/src/components/Workloads/diskListModel.ts
@@ -22,21 +22,32 @@ export interface WorkloadsDiskPresentation {
usagePercentLabel: string;
}
-export const hasWorkloadsDiskCapacity = (disk: Disk): boolean =>
- typeof disk.total === 'number' && disk.total > 0;
+export const hasWorkloadsDiskCapacity = (disk: Disk): disk is Disk & { total: number } =>
+ typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0;
// The poller reports usage -1 for mounts it can only see in the container
// config (capacity may be known, live usage is not).
export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean =>
- typeof disk.usage === 'number' && disk.usage < 0;
+ typeof disk.used !== 'number' ||
+ !Number.isFinite(disk.used) ||
+ disk.used < 0 ||
+ (typeof disk.usage === 'number' && (!Number.isFinite(disk.usage) || disk.usage < 0));
-export const getWorkloadsDiskUsagePercent = (disk: Disk): number => {
- const total = disk.total ?? 0;
- if (total <= 0) {
- return 0;
+// Capacity alone is not a measurement. In particular, an omitted `used`
+// field must not become a healthy empty filesystem, even if usage/free exist.
+export const getWorkloadsDiskUsagePercent = (disk: Disk): number | null => {
+ const { total, used } = disk;
+ if (
+ !hasWorkloadsDiskCapacity(disk) ||
+ isWorkloadsDiskUsageUnknown(disk) ||
+ typeof total !== 'number' ||
+ typeof used !== 'number'
+ ) {
+ return null;
}
- return ((disk.used ?? 0) / total) * 100;
+ const percent = (used / total) * 100;
+ return Number.isFinite(percent) ? percent : null;
};
export const getWorkloadsDiskLabel = (disk: Disk): string =>
@@ -46,35 +57,34 @@ export const getWorkloadsDiskLabelTitle = (label: string): string | undefined =>
label !== 'Unknown' ? label : undefined;
export const getWorkloadsDiskUsageText = (disk: Disk): string => {
- if (isWorkloadsDiskUsageUnknown(disk)) {
- return hasWorkloadsDiskCapacity(disk)
- ? `?/${formatBytes(disk.total ?? 0)}`
- : 'Usage unavailable';
- }
- return hasWorkloadsDiskCapacity(disk)
- ? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}`
- : 'Usage unavailable';
+ if (!hasWorkloadsDiskCapacity(disk)) return 'Usage unavailable';
+ const used =
+ getWorkloadsDiskUsagePercent(disk) === null || typeof disk.used !== 'number'
+ ? '?'
+ : formatBytes(disk.used);
+ return `${used}/${formatBytes(disk.total)}`;
};
-export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string =>
- hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
- ? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%`
- : '—';
+export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? '—' : `${percent.toFixed(0)}%`;
+};
export const getWorkloadsDiskProgressClass = (
disk: Disk,
thresholds?: MetricDisplayThresholds | null,
-): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
+): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? 'bg-surface-hover' : getMetricColorClass(percent, 'disk', thresholds);
+};
export const getWorkloadsDiskProgressValue = (disk: Disk): number | null =>
- hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
- ? getWorkloadsDiskUsagePercent(disk)
- : null;
+ getWorkloadsDiskUsagePercent(disk);
-export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
- isWorkloadsDiskUsageUnknown(disk)
- ? '0%'
- : `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
+export const getWorkloadsDiskProgressWidth = (disk: Disk): string => {
+ const percent = getWorkloadsDiskUsagePercent(disk);
+ return percent === null ? '0%' : `${Math.min(percent, 100)}%`;
+};
export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';