diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 6431645e1..bd8ef3ced 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -21,6 +21,24 @@
## Purpose
+### Pool detail capacity observations
+
+The existing pool drawer's Configuration rows preserve missing capacity as
+`n/a`, independently for Used, Free, Total and Usage. Finite non-negative
+observations, including measured zero, remain visible even when another field
+is absent. Free space may be derived only when both total and used bytes are
+observed; usage may be derived only when used is observed and total is positive.
+Explicit provider free-space and percentage observations take precedence over
+those derived values. Unknown capacity is not an empty pool or proof of free
+space. This presentation does not establish freshness, pool health or recovery.
+
+Regression verification: the capacity evidence matrix in
+`frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts`
+and the production-drawer fixture in
+`frontend-modern/browser-tests/pool-capacity.cjs` cover missing, empty, full and
+partial snapshots, including live transitions on desktop and phone viewports.
+These synthetic checks are not native collector acceptance.
+
### RAID required-member count evidence — issue #2369
Canonical host RAID metadata carries optional `requiredDevices`, excluding
diff --git a/frontend-modern/browser-tests/pool-capacity.cjs b/frontend-modern/browser-tests/pool-capacity.cjs
new file mode 100644
index 000000000..51e05cf6d
--- /dev/null
+++ b/frontend-modern/browser-tests/pool-capacity.cjs
@@ -0,0 +1,113 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium, webkit } = require('playwright');
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/pool-capacity-proof');
+ fs.mkdirSync(artifacts, { recursive: true });
+ 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: fs.mkdtempSync(path.join(artifacts, 'vite-')),
+ server: { host: '127.0.0.1', port: 5222, strictPort: true },
+ });
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const scenario of [
+ { engine: chromium, name: 'chromium', width: 1365, height: 900 },
+ { engine: webkit, name: 'webkit', width: 390, height: 844 },
+ ]) {
+ browser = await scenario.engine.launch(
+ scenario.name === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width: scenario.width, height: scenario.height },
+ isMobile: scenario.name === 'webkit',
+ hasTouch: scenario.name === 'webkit',
+ });
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.route('**/*', (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin !== 'http://127.0.0.1:5222') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ return route.fulfill({
+ json:
+ url.pathname === '/api/license/runtime-capabilities'
+ ? {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ }
+ : { data: [], enabled: false },
+ });
+ });
+ await page.goto('http://127.0.0.1:5222/browser-tests/pool-capacity.html');
+ if (scenario.name === 'webkit')
+ await page.evaluate(() => document.documentElement.classList.add('dark'));
+ for (const [phase, expected] of Object.entries({
+ missing: ['n/a', 'n/a', '1.00 KB', 'n/a'],
+ empty: ['0 B', '1.00 KB', '1.00 KB', '0%'],
+ full: ['1.00 KB', '0 B', '1.00 KB', '100%'],
+ partial: ['512 B', '0 B', 'n/a', 'n/a'],
+ absent: ['n/a', 'n/a', 'n/a', 'n/a'],
+ })) {
+ await page.getByRole('button', { name: phase, exact: true }).click();
+ const actual = [];
+ for (const label of ['Used', 'Free', 'Total', 'Usage'])
+ actual.push(
+ await page
+ .getByText(label, { exact: true })
+ .locator('..')
+ .locator('span')
+ .last()
+ .textContent(),
+ );
+ const screenshot = path.join(artifacts, `${scenario.name}-${phase}.png`);
+ await page.screenshot({ path: screenshot, fullPage: true });
+ assert.deepEqual(actual, expected, `${scenario.name} ${phase}`);
+ const dimensions = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ width: innerWidth,
+ }));
+ assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
+ observations.push({
+ browser: scenario.name,
+ version: browser.version(),
+ phase,
+ actual,
+ dimensions,
+ screenshot,
+ });
+ }
+ assert.deepEqual(errors, []);
+ await browser.close();
+ browser = null;
+ }
+ fs.writeFileSync(
+ path.join(artifacts, 'result.json'),
+ JSON.stringify(
+ { playwright: require('playwright/package.json').version, observations },
+ null,
+ 2,
+ ),
+ );
+ console.log(JSON.stringify({ result: 'passed', states: observations.length }));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/pool-capacity.html b/frontend-modern/browser-tests/pool-capacity.html
new file mode 100644
index 000000000..ff9b243a3
--- /dev/null
+++ b/frontend-modern/browser-tests/pool-capacity.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Storage capacity absence verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/pool-capacity.tsx b/frontend-modern/browser-tests/pool-capacity.tsx
new file mode 100644
index 000000000..4fb009e90
--- /dev/null
+++ b/frontend-modern/browser-tests/pool-capacity.tsx
@@ -0,0 +1,56 @@
+// Production pool drawer with synthetic partial collector snapshots; not appliance proof.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { StoragePoolDetail } from '../src/components/Storage/StoragePoolDetail';
+import type { CapacitySnapshot, StorageRecord } from '../src/features/storageBackups/models';
+import '../src/index.css';
+
+const samples: Record = {
+ missing: { totalBytes: 1024, usedBytes: null, freeBytes: null, usagePercent: null },
+ empty: { totalBytes: 1024, usedBytes: 0, freeBytes: null, usagePercent: null },
+ full: { totalBytes: 1024, usedBytes: 1024, freeBytes: 0, usagePercent: 100 },
+ partial: { totalBytes: null, usedBytes: 512, freeBytes: 0, usagePercent: null },
+ absent: { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null },
+};
+const Fixture = () => {
+ const [phase, setPhase] = createSignal('missing');
+ const record = (): StorageRecord => ({
+ id: 'synthetic-pbs-pool',
+ name: 'Archive',
+ category: 'datastore',
+ health: 'unknown',
+ location: { label: 'archive-host', scope: 'host' },
+ source: {
+ platform: 'proxmox-pbs',
+ family: 'onprem',
+ origin: 'resource',
+ adapterId: 'resource-storage',
+ },
+ capacity: samples[phase()],
+ capabilities: ['capacity'],
+ observedAt: Date.now(),
+ });
+ return (
+
+ Storage capacity absence verification
+ Synthetic partial PBS snapshots in the production pool drawer.
+
+ {Object.keys(samples).map((name) => (
+
+ ))}
+
+
+
+ );
+};
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index bc8f3ba8b..a8527e5b2 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,16 @@
{
"version": 1,
- "base_sha": "33d6d729c1dc7c0913a970fd4c463a9f9575b7b1",
- "verified_at": "2026-10-02T10:16:57.632722Z",
+ "base_sha": "811961243e6418c858c80fd073faf4251fe838d3",
+ "verified_at": "2026-10-02T12:57:15.855691+00:00",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/Storage/DiskList.tsx"
+ "frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Storage/DiskList.tsx": "df63db162779ff0d2064475a9db1fd67ac748703f398a88d108ecdd154dae9d2"
+ "frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "59ca48c06cb3c63f30ccb46cb30536cbc6d86fda1b50e6ea49ecefb9eb377fae"
},
"routes": [
- "/browser-tests/disk-live-refresh.html"
+ "/browser-tests/pool-capacity.html"
],
"viewports": [
{
@@ -23,31 +23,26 @@
}
],
"states": [
- "Production DiskList, DiskDetail, keyed renderer and styles with synthetic PBS collector snapshots, not installed appliance evidence",
- "Healthy to failed disk updates health, warning text, endurance, temperature, model and canonical target without remounting the row or its expanded detail",
- "Missing measurements remove obsolete values and critical styling; unavailable detail evidence remains visible; recovery restores current values",
- "Disclosure keyboard focus and aria-controls survive same-ID updates; changed metric targets update summary/detail linkage",
- "Attention filter includes the failed disk, removes the recovered disk and returns current healthy values in All",
- "Desktop Chromium light and phone-emulated WebKit dark have no outer-page overflow or page errors; six fault/missing/recovered screenshots inspected; existing dense phone truncation is unchanged"
+ "Production pool drawer with synthetic partial PBS snapshots, not native collector or installed acceptance",
+ "Missing used/free/usage remain n/a; measured empty and full pools preserve 0 B and 0%",
+ "Independent byte observations remain visible without total; absent capacity stays n/a",
+ "Live transitions across five states on desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
],
"interactions": [
- "Expand the real disk row and focus its disclosure, apply fault/missing/recovery snapshots without navigating or reloading",
- "Check current values, stable owner, focus, live disclosure target and selected detail after each snapshot",
- "Toggle Attention/All across fault and recovery"
+ "Switch missing, empty, full, partial and absent snapshots without reload",
+ "Read Used, Free, Total and Usage values in the actual Configuration rows"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/disk-live-refresh.cjs",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/pool-capacity.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-fault.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-missing.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/chromium-recovered.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-fault.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-missing.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/webkit-recovered.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-zuzev1gs/browser/result.json"
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-partial.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-missing.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-full.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-empty.png"
]
}
diff --git a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx
index 8912d5e4e..d0f472d70 100644
--- a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx
+++ b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx
@@ -1,3 +1,4 @@
+import { createSignal } from 'solid-js';
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { describe, expect, it, vi } from 'vitest';
import { StoragePoolDetail } from '@/components/Storage/StoragePoolDetail';
@@ -41,6 +42,46 @@ const makeRecord = (overrides: Partial = {}): StorageRecord => ({
});
describe('StoragePoolDetail', () => {
+ it('updates capacity rows without presenting missing readings as an empty pool', () => {
+ const [record, setRecord] = createSignal(makeRecord());
+ render(() => (
+
+ ));
+ const rowValue = (label: string) =>
+ screen.getByText(label, { exact: true }).parentElement?.lastElementChild?.textContent;
+ expect(rowValue('Usage')).toBe('40%');
+ setRecord(
+ makeRecord({
+ capacity: { totalBytes: 1000, usedBytes: null, freeBytes: null, usagePercent: null },
+ }),
+ );
+ expect(rowValue('Used')).toBe('n/a');
+ expect(rowValue('Free')).toBe('n/a');
+ expect(rowValue('Total')).toBe('1000 B');
+ expect(rowValue('Usage')).toBe('n/a');
+ setRecord(
+ makeRecord({
+ capacity: { totalBytes: 1000, usedBytes: 0, freeBytes: null, usagePercent: null },
+ }),
+ );
+ expect(rowValue('Used')).toBe('0 B');
+ expect(rowValue('Free')).toBe('1000 B');
+ expect(rowValue('Usage')).toBe('0%');
+ setRecord(
+ makeRecord({
+ capacity: { totalBytes: null, usedBytes: 500, freeBytes: 0, usagePercent: null },
+ }),
+ );
+ expect(rowValue('Used')).toBe('500 B');
+ expect(rowValue('Free')).toBe('0 B');
+ expect(rowValue('Total')).toBe('n/a');
+ expect(rowValue('Usage')).toBe('n/a');
+ });
+
it('uses canonical metrics target for capacity history charts', () => {
historyChartSpy.mockClear();
diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts
index b0a42f7fe..47f5645cf 100644
--- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts
+++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts
@@ -94,6 +94,71 @@ describe('storagePoolDetailPresentation', () => {
expect(getZfsErrorTextClass()).toBe('font-medium text-red-600 dark:text-red-400');
});
+ it.each([
+ [
+ 'missing used',
+ { totalBytes: 1024, usedBytes: null, freeBytes: null, usagePercent: null },
+ ['n/a', 'n/a', '1.00 KB', 'n/a'],
+ ],
+ [
+ 'all absent',
+ { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null },
+ ['n/a', 'n/a', 'n/a', 'n/a'],
+ ],
+ [
+ 'measured empty',
+ { totalBytes: 1024, usedBytes: 0, freeBytes: null, usagePercent: null },
+ ['0 B', '1.00 KB', '1.00 KB', '0%'],
+ ],
+ [
+ 'measured full',
+ { totalBytes: 1024, usedBytes: 1024, freeBytes: 0, usagePercent: 100 },
+ ['1.00 KB', '0 B', '1.00 KB', '100%'],
+ ],
+ [
+ 'independent observations',
+ { totalBytes: null, usedBytes: 512, freeBytes: 0, usagePercent: null },
+ ['512 B', '0 B', 'n/a', 'n/a'],
+ ],
+ [
+ 'explicit ratio',
+ { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: 25 },
+ ['n/a', 'n/a', 'n/a', '25%'],
+ ],
+ [
+ 'explicit zero ratio',
+ { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: 0 },
+ ['n/a', 'n/a', 'n/a', '0%'],
+ ],
+ [
+ 'zero total',
+ { totalBytes: 0, usedBytes: 0, freeBytes: 0, usagePercent: null },
+ ['0 B', '0 B', '0 B', 'n/a'],
+ ],
+ [
+ 'invalid observations',
+ { totalBytes: Infinity, usedBytes: NaN, freeBytes: -1, usagePercent: -1 },
+ ['n/a', 'n/a', 'n/a', 'n/a'],
+ ],
+ [
+ 'derived ratio',
+ { totalBytes: 1024, usedBytes: 512, freeBytes: null, usagePercent: null },
+ ['512 B', '512 B', '1.00 KB', '50%'],
+ ],
+ [
+ 'provider free differs from subtraction',
+ { totalBytes: 1024, usedBytes: 512, freeBytes: 256, usagePercent: 60 },
+ ['512 B', '256 B', '1.00 KB', '60%'],
+ ],
+ ] as const)('preserves capacity evidence: %s', (_name, capacity, expected) => {
+ const rows = buildStoragePoolDetailConfigRows(buildRecord({ capacity }));
+ expect(
+ ['Used', 'Free', 'Total', 'Usage'].map(
+ (label) => rows.find((row) => row.label === label)?.value,
+ ),
+ ).toEqual(expected);
+ });
+
it('formats zfs error summaries canonically', () => {
expect(getZfsErrorSummary(1, 2, 3)).toBe('Errors: R:1 W:2 C:3');
});
diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts
index 703f78991..1fc455ff9 100644
--- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts
+++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts
@@ -7,7 +7,6 @@ import {
getStorageRecordShared,
getStorageRecordStatus,
getStorageRecordType,
- getStorageRecordUsagePercent,
getStorageRecordZfsPool,
} from '@/features/storageBackups/recordPresentation';
import { resolveStorageRecordMetricResourceId } from '@/features/storageBackups/storageMetricsIdentity';
@@ -106,10 +105,21 @@ export function resolveStoragePoolDetailChartTarget(
export function buildStoragePoolDetailConfigRows(
record: StorageRecord,
): StoragePoolDetailConfigRow[] {
- const totalBytes = record.capacity.totalBytes || 0;
- const usedBytes = record.capacity.usedBytes || 0;
+ // Missing collector observations are not measurements of an empty pool.
+ const observed = (value: number | null): number | null =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
+ const totalBytes = observed(record.capacity.totalBytes);
+ const usedBytes = observed(record.capacity.usedBytes);
const freeBytes =
- record.capacity.freeBytes ?? (totalBytes > 0 ? Math.max(totalBytes - usedBytes, 0) : 0);
+ observed(record.capacity.freeBytes) ??
+ (totalBytes !== null && usedBytes !== null ? Math.max(totalBytes - usedBytes, 0) : null);
+ const usagePercent =
+ observed(record.capacity.usagePercent) ??
+ (totalBytes !== null && totalBytes > 0 && usedBytes !== null
+ ? (usedBytes / totalBytes) * 100
+ : null);
+ const bytesLabel = (value: number | null): string =>
+ value === null ? 'n/a' : formatBytes(value);
const content = getStorageRecordContent(record);
const rows: StoragePoolDetailConfigRow[] = [
{ label: 'Node', value: getStorageRecordNodeLabel(record) },
@@ -125,10 +135,10 @@ export function buildStoragePoolDetailConfigRows(
? 'Yes'
: 'No',
},
- { label: 'Used', value: totalBytes > 0 ? formatBytes(usedBytes) : 'n/a' },
- { label: 'Free', value: totalBytes > 0 ? formatBytes(freeBytes) : 'n/a' },
- { label: 'Total', value: totalBytes > 0 ? formatBytes(totalBytes) : 'n/a' },
- { label: 'Usage', value: formatPercent(getStorageRecordUsagePercent(record)) },
+ { label: 'Used', value: bytesLabel(usedBytes) },
+ { label: 'Free', value: bytesLabel(freeBytes) },
+ { label: 'Total', value: bytesLabel(totalBytes) },
+ { label: 'Usage', value: usagePercent === null ? 'n/a' : formatPercent(usagePercent) },
];
if (content) {