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) {