From 60a6c933e0057435dd49cb440995f275f6a567d6 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:57:17 +0100 Subject: [PATCH] Preserve missing capacity readings in storage pool details Do not turn absent used capacity into an empty pool or invented free space. Preserve independently observed bytes and explicit percentages, with guarded derivation only from known inputs. Cover live drawer transitions and desktop/phone browser states. Contract-Neutral: Correct presentation of existing nullable capacity fields; no shared primitive API, layout, collector schema or recovery authority changes. Change-source: pulse-maintainer --- .../internal/subsystems/storage-recovery.md | 18 +++ .../browser-tests/pool-capacity.cjs | 113 ++++++++++++++++++ .../browser-tests/pool-capacity.html | 12 ++ .../browser-tests/pool-capacity.tsx | 56 +++++++++ frontend-modern/browser-verification.json | 39 +++--- .../__tests__/StoragePoolDetail.test.tsx | 41 +++++++ .../storagePoolDetailPresentation.test.ts | 65 ++++++++++ .../storagePoolDetailPresentation.ts | 26 ++-- 8 files changed, 340 insertions(+), 30 deletions(-) create mode 100644 frontend-modern/browser-tests/pool-capacity.cjs create mode 100644 frontend-modern/browser-tests/pool-capacity.html create mode 100644 frontend-modern/browser-tests/pool-capacity.tsx 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) {