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
This commit is contained in:
pulse-triage[bot] 2026-10-02 13:57:17 +01:00
parent 811961243e
commit 60a6c933e0
8 changed files with 340 additions and 30 deletions

View file

@ -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

View file

@ -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;
});

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Storage capacity absence verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/pool-capacity.tsx"></script>
</body>
</html>

View file

@ -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<string, CapacitySnapshot> = {
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 (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1 class="text-lg font-semibold">Storage capacity absence verification</h1>
<p>Synthetic partial PBS snapshots in the production pool drawer.</p>
<div class="flex flex-wrap gap-2">
{Object.keys(samples).map((name) => (
<button class="min-h-11 rounded border border-border px-3" onClick={() => setPhase(name)}>
{name}
</button>
))}
</div>
<table class="w-full table-fixed">
<tbody>
<StoragePoolDetail
record={record()}
physicalDisks={[]}
summarySeriesId="synthetic-pbs-pool"
/>
</tbody>
</table>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -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"
]
}

View file

@ -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> = {}): StorageRecord => ({
});
describe('StoragePoolDetail', () => {
it('updates capacity rows without presenting missing readings as an empty pool', () => {
const [record, setRecord] = createSignal(makeRecord());
render(() => (
<table>
<tbody>
<StoragePoolDetail record={record()} physicalDisks={[]} summarySeriesId="pool:tank" />
</tbody>
</table>
));
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();

View file

@ -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');
});

View file

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