Integrate reviewed host filesystem reading admission

Preserve exact Web candidate c8e4276ca4 with published alert navigation. Retain its complete source and per-content browser proof; native guest safety and restart recovery remain separate owning work.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-13 04:25:45 +01:00
18 changed files with 1046 additions and 94 deletions
@@ -9435,3 +9435,35 @@ VM/LXC rows, policy producer, hover, keyboard/touch dialogs and the guest drawer
in non-UTC desktop and phone contexts. This is unambiguous presentation, not
PBS verification ingestion, independent thaw, filesystem liveness, native
guest restart recovery or containing software availability.
### Host drawers distinguish unknown filesystem usage from empty filesystems
The existing Proxmox node and Docker host per-filesystem rows use the shared
host disk presentation. An explicit negative usage sentinel, negative or
non-finite used bytes, or absent/non-positive/non-finite capacity produces
**Usage unavailable**, with any finite positive capacity retained separately.
Unavailable usage has neutral text and no utilization bar; a retained capacity
alone is not a healthy zero. Finite byte ratios preserve their existing
threshold presentation. Real zero-byte values, including zeros omitted by the
canonical DiskInfo JSON representation, remain valid empty filesystems when
capacity is known and no unknown sentinel is present.
The shared usage text wraps in both existing drawers, rather than relying on
a tooltip or rendering an old percentage after a new unavailable read. The
existing mount filtering, row disclosure and single-filesystem summary stay
unchanged. The connected `HostDrawer.filesystem-readings.test.tsx` and shared
`DrawerDiskListCard.readings.test.tsx` controls cover unknown/capacity/zero,
invalid bytes and same-mounted-drawer replacement. The desktop Chromium and
narrow touch WebKit `browser-tests/host-disk-usage.cjs` fixture consumes synthetic
Resources responses through the actual platform tables and drawers. No new
route, monitoring policy, provider operation or native recovery claim is added.
The shared `filesystemByteUsage.ts` admission also applies to filesystem inputs
of `stackedDiskBarModel.ts`: unusable byte rows remain neutral in tooltips and
do not contribute used bytes, capacity, bars, combined percentages or highest
usage. All-unmeasured filesystem lists with no independent accepted aggregate
render N/A, not a fabricated zero. Independently supplied aggregate metrics and
retained/unavailable source classifications preserve their existing semantics.
The connected stacked-model controls retain explicit aggregate fallback tests;
the browser fixture checks mixed and all-unknown lists in the actual summaries
as well as both drawers, including live replacement at narrow widths.
@@ -569,6 +569,8 @@ in-place click and chevron activation over a selection.
## Canonical Files
- `frontend-modern/src/utils/filesystemByteUsage.ts`: shared finite byte/capacity admission for filesystem utilization.
1. `pkg/metrics/store.go`
2. `pkg/metrics/store_query_plan_test.go`
3. `pkg/metrics/store_slo_test.go`
@@ -4159,3 +4161,20 @@ offset/numeric/Date inputs and unusable timestamp safety. Browser proof checks
that disclosures and passive ticks add no API requests while production
table/drawer times agree in opposing non-UTC contexts. This is not whole-fleet
CPU/RSS relief or native guest/backup recovery.
### Filesystem summaries admit only usable byte readings
The shared stacked-disk model uses the same finite byte/capacity admission as
the host drawer rows. Unknown sentinels, invalid used bytes and unmeasured
capacity stay in tooltip identity/capacity evidence, without entering byte
sums, utilization segments or severity. Mixed summaries count only accepted
filesystem byte rows; all-unknown lists render N/A unless an independent
accepted aggregate is available. True zero and source-owned last-known states
remain distinct. The existing aggregate-only percentage fallback is unchanged.
`stackedDiskBarModel.branchcov0712b.test.ts` covers mixed partial byte lists,
invalid values, all-unknown admission and explicit independent aggregate
controls. `browser-tests/host-disk-usage.cjs` exercises the actual resource
HTTP/table/summary/drawer flow at desktop and touch widths. Disclosure adds no
measurement or storage operation. This is truthful presentation, not native
collector recovery or an installed performance result.
@@ -5948,6 +5948,8 @@
"frontend-modern/src/components/shared/InlineNotice.test.tsx",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts",
"frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/NodeDrawerOverview.updateEvidence.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx",
"frontend-modern/src/utils/__tests__/proxmoxUpdateEvidence.test.ts"
@@ -7161,6 +7163,7 @@
"frontend-modern/src/stores/websocket.ts",
"frontend-modern/src/types/workloads.ts",
"frontend-modern/src/useAppRuntimeState.ts",
"frontend-modern/src/utils/filesystemByteUsage.ts",
"frontend-modern/src/utils/resourceStateAdapters.ts",
"frontend-modern/src/utils/searchQuery.ts",
"frontend-modern/src/utils/thresholdSliderPresentation.ts",
@@ -7316,6 +7319,7 @@
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/workloads.ts",
"frontend-modern/src/utils/filesystemByteUsage.ts",
"frontend-modern/src/utils/searchQuery.ts",
"frontend-modern/src/utils/thresholdSliderPresentation.ts",
"frontend-modern/src/utils/workloads.ts"
@@ -8336,6 +8340,8 @@
"frontend-modern/src/components/Infrastructure/__tests__/ResourceFacetSummary.test.tsx",
"frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx",
"frontend-modern/src/features/docker/__tests__/DockerAlertsTable.test.tsx",
"frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts",
"frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts",
@@ -8284,3 +8284,34 @@ classification use the existing shared clock for freshness and ages. No clock
tick initiates a probe, grants health, changes thresholds or widens token scope.
The vSphere VM table has one h2 title, including kiosk and filtered-empty state;
its count follows filtered rows only when the page-owned toolbar state is ready.
### Host filesystem consumers retain uncertainty
The existing Proxmox node and Docker host drawers preserve a negative
filesystem usage sentinel through normalization into **Usage unavailable**,
not 0% or healthy empty storage. Missing/invalid capacity and negative or
non-finite used bytes also withhold utilization; a finite positive capacity
can still be shown independently. Canonical DiskInfo omits genuine zero bytes,
so those omissions retain their existing zero semantics when positive capacity
is present and there is no unknown sentinel. Valid measured byte ratios,
mount filtering and resource identity remain unchanged.
Both drawers consume the same host disk presentation text and neutral unknown
state. A same-ID accepted resource refresh replaces the old reading, in both
directions, without adding a read, scan or disk operation to disclosure.
`HostDrawer.filesystem-readings.test.tsx` checks both production consumers;
`DrawerDiskListCard.readings.test.tsx` checks normalized unknown/invalid and
zero controls. The source-bound `browser-tests/host-disk-usage.cjs` fixture
connects synthetic Resources HTTP, the real resource hook, platform tables and
keyboard/touch drawer controls. This is presentation admission, not installed
collector, guest safety, restart or release acceptance.
The shared `filesystemByteUsage.ts` admission also applies to filesystem inputs
of `stackedDiskBarModel.ts`: unusable byte rows remain neutral in tooltips and
do not contribute used bytes, capacity, bars, combined percentages or highest
usage. All-unmeasured filesystem lists with no independent accepted aggregate
render N/A, not a fabricated zero. Independently supplied aggregate metrics and
retained/unavailable source classifications preserve their existing semantics.
The connected stacked-model controls retain explicit aggregate fallback tests;
the browser fixture checks mixed and all-unknown lists in the actual summaries
as well as both drawers, including live replacement at narrow widths.
@@ -0,0 +1,99 @@
// Compile the real host-disk-usage fixture before bounded browser execution.
// Only a bounded static preview runs in the browser tree; no live compiler or guest command.
import { build } from 'vite';
import solid from 'vite-plugin-solid';
import tailwindcss from '@tailwindcss/vite';
import { stop } from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const output = path.resolve(root, '../tmp/host-disk-usage-static');
const hash = (data) => crypto.createHash('sha256').update(data).digest('hex');
try {
for (const variant of ['final']) {
const sources = {};
const artifacts = {};
const outDir = path.join(output, variant);
await build({
root,
// Root-relative assets keep the real shipped Docs viewer functional in a new tab.
base: '/',
configFile: false,
publicDir: false,
plugins: [
{
name: 'exact-host-disk-usage-fixture-binding',
enforce: 'pre',
generateBundle(_options, bundle) {
for (const id of this.getModuleIds()) {
if (!id.startsWith(root + '/') || id.includes('/node_modules/')) continue;
const file = id.split('?')[0];
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
sources[path.relative(root, file)] = hash(fs.readFileSync(file));
}
for (const [file, asset] of Object.entries(bundle)) {
artifacts[file] = hash(asset.type === 'chunk' ? asset.code : asset.source);
}
},
},
tailwindcss(),
solid(),
],
resolve: {
alias: { '@': path.join(root, 'src') },
conditions: ['import', 'browser', 'default'],
},
build: {
// Match the application's declared target, not Vite's older default.
target: 'esnext',
outDir,
emptyOutDir: true,
rollupOptions: { input: path.join(root, 'browser-tests/host-disk-usage.html') },
},
});
sources['browser-tests/build-host-disk-usage.mjs'] = hash(
fs.readFileSync(fileURLToPath(import.meta.url)),
);
const importedSources = { ...sources };
const scanInputs = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) scanInputs(file);
else if (/\.(tsx?|css|html)$/.test(file))
sources[path.relative(root, file)] = hash(fs.readFileSync(file));
}
};
scanInputs(path.join(root, 'src'));
scanInputs(path.join(root, 'browser-tests'));
// Hash the bytes actually written, including HTML post-processing.
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) walk(file);
else artifacts[path.relative(outDir, file)] = hash(fs.readFileSync(file));
}
};
walk(outDir);
fs.writeFileSync(
path.join(outDir, 'binding.json'),
JSON.stringify(
{
variant,
graph: hash(fs.readFileSync(path.join(root, 'package-lock.json'))),
builder: hash(fs.readFileSync(fileURLToPath(import.meta.url))),
sources,
importedSources,
artifacts,
},
null,
2,
) + '\n',
);
}
} finally {
stop();
}
@@ -0,0 +1,403 @@
// Synthetic Resources snapshots consumed through the production HTTP client,
// tables, disclosure controls, normalization, host drawers and shared CSS.
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
const root = '/workspace';
const frontend = path.join(root, 'frontend-modern');
const compiled = path.join(root, 'tmp/host-disk-usage-static/final');
const output = path.join(root, 'tmp/host-disk-usage/browser');
const origin = 'http://127.0.0.1:5299';
const hash = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const binding = JSON.parse(fs.readFileSync(path.join(compiled, 'binding.json')));
const GB = 1024 ** 3;
const result = {
base: '33267809f325631a0863aced8cb56b81c2bc1070',
scope:
'Canonical resource HTTP client -> ProxmoxNodesTable/DockerHostsTable -> production host drawers with synthetic disk observations. No provider, disk, guest, backup or recovery operation.',
playwright: require('playwright/package.json').version,
buildBinding: binding,
harnessSha256: hash(fs.readFileSync(__filename)),
checks: [],
requests: [],
captures: [],
errors: [],
cleanup: {},
served: {},
};
const disks = (known, allUnknown = false) =>
[
{
mountpoint: '/var/lib/containers/storage/volumes/monitoring-and-backup-history',
total: 100 * GB,
usage: known ? 70 : -1,
...(known ? { used: 70 * GB } : {}),
},
{ mountpoint: '/capacity-unavailable', used: 30 * GB },
{ mountpoint: '/empty', total: 100 * GB, free: 100 * GB },
{ mountpoint: '/measured-critical', total: 100 * GB, used: 95 * GB, usage: 95 },
{ mountpoint: '/boot/efi', total: 100 * GB, used: 20 * GB },
].map((disk) => (allUnknown ? { ...disk, used: undefined, usage: -1 } : disk));
const resources = (known, allUnknown) => [
{
id: 'fixture-node',
type: 'agent',
name: 'pve-fixture',
displayName: 'pve-fixture',
platformType: 'proxmox-pve',
platformId: 'fixture-node',
status: 'online',
sources: ['proxmox', 'agent'],
platformScopes: ['proxmox-pve', 'agent'],
lastSeen: '2026-10-13T02:00:00Z',
metrics: {
cpu: { percent: 10 },
memory: { percent: 25, used: GB, total: 4 * GB },
disk: { percent: 25, used: 25 * GB, total: 100 * GB },
},
proxmox: {
nodeName: 'pve-fixture',
instance: 'lab',
pveVersion: '9.2.21',
connectionHealth: 'healthy',
},
agent: { agentId: 'node-agent', hostname: 'pve-fixture', disks: disks(known, allUnknown) },
},
{
id: 'fixture-docker',
type: 'docker-host',
name: 'docker-fixture',
displayName: 'docker-fixture',
platformType: 'docker',
platformId: 'fixture-docker',
status: 'online',
sources: ['docker', 'agent'],
platformScopes: ['docker', 'agent'],
lastSeen: '2026-10-13T02:00:00Z',
docker: {
hostSourceId: 'fixture-docker',
runtime: 'docker',
runtimeVersion: '28.0.0',
os: 'Debian 13',
},
agent: { agentId: 'docker-agent', hostname: 'docker-fixture', disks: disks(known, allUnknown) },
},
];
let server, browser;
(async () => {
fs.mkdirSync(output, { recursive: true });
assert.equal(
result.playwright,
JSON.parse(fs.readFileSync(path.join(root, 'tests/integration/package-lock.json'))).packages[
'node_modules/@playwright/test'
].version,
);
assert.equal(binding.graph, hash(fs.readFileSync(path.join(frontend, 'package-lock.json'))));
for (const [file, digest] of Object.entries(binding.sources))
assert.equal(
hash(fs.readFileSync(path.join(frontend, file))),
digest,
`changed source ${file}`,
);
const assets = new Map();
for (const [file, digest] of Object.entries(binding.artifacts)) {
const bytes = fs.readFileSync(path.join(compiled, file));
assert.equal(hash(bytes), digest, `changed asset ${file}`);
assets.set('/' + file, bytes);
}
server = http.createServer((request, response) => {
const file = new URL(request.url, origin).pathname;
const bytes = assets.get(file);
if (!bytes || !['GET', 'HEAD'].includes(request.method)) {
response.writeHead(404);
response.end();
return;
}
result.served[file] = hash(bytes);
response.writeHead(200, {
'Content-Type':
{ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[
path.extname(file)
] || 'application/octet-stream',
});
response.end(request.method === 'HEAD' ? undefined : bytes);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(5299, '127.0.0.1', resolve);
});
for (const spec of [
{ name: 'desktop', engine: chromium, width: 1280, height: 900, colorScheme: 'light' },
{ name: 'phone', engine: webkit, width: 375, height: 812, colorScheme: 'dark' },
]) {
browser = await spec.engine.launch(
spec.engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const kind of ['node', 'docker']) {
const page = await browser.newPage({
viewport: { width: spec.width, height: spec.height },
colorScheme: spec.colorScheme,
hasTouch: spec.name === 'phone',
isMobile: spec.name === 'phone',
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
await page.clock.setFixedTime(new Date('2026-10-13T02:00:00Z'));
if (spec.name === 'phone')
await page.addInitScript(() =>
document.addEventListener('DOMContentLoaded', () =>
document.documentElement.classList.add('dark'),
),
);
let known = false;
let allUnknown = false;
const requests = [];
page.on('pageerror', (error) =>
result.errors.push({ view: spec.name, kind, message: error.message }),
);
page.on('console', (event) => {
if (event.type() === 'error')
result.errors.push({ view: spec.name, kind, message: event.text() });
});
// The real global client may connect even for a REST-only resource view.
// A synthetic idle socket supplies no resource frames or monitoring work.
await page.routeWebSocket('**/ws?*', (socket) => {
result.syntheticIdleSockets = (result.syntheticIdleSockets || 0) + 1;
socket.onMessage(() => {});
});
await page.route('**/*', async (route) => {
const request = route.request(),
url = new URL(request.url());
assert.equal(url.origin, origin, 'off-origin request');
if (!url.pathname.startsWith('/api/')) return route.continue();
assert.equal(request.method(), 'GET', 'unexpected mutation');
let body;
if (url.pathname === '/api/resources')
body = { data: resources(known, allUnknown), meta: { totalPages: 1 } };
else if (url.pathname === '/api/alerts/config')
body = { enabled: false, hostDefaults: {}, guestDefaults: {} };
else if (url.pathname === '/api/alerts/activation') body = { enabled: false };
else if (url.pathname === '/api/alerts/active') body = [];
else if (url.pathname === '/api/metrics-store/history')
body = {
resourceType: url.searchParams.get('resourceType') || 'agent',
resourceId: url.searchParams.get('resourceId') || 'fixture',
range: '1h',
start: 0,
end: Date.now(),
metrics: {},
source: 'store',
};
else if (url.pathname === '/api/license/runtime-capabilities')
body = { plan: 'free', capabilities: {}, features: [] };
else if (url.pathname === '/api/ai/intelligence/anomalies')
body = { anomalies: [], count: 0 };
else if (url.pathname === '/api/ai/intelligence/correlations')
body = { correlations: [], count: 0 };
else {
result.errors.push({
view: spec.name,
kind,
message: `Unmatched API read: ${url.pathname}`,
});
await route.abort();
return;
}
requests.push(url.pathname);
result.requests.push({
view: spec.name,
kind,
path: url.pathname,
method: request.method(),
bodySha256: hash(JSON.stringify(body)),
...(url.pathname === '/api/resources' ? { body } : {}),
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
await page.goto(`${origin}/browser-tests/host-disk-usage.html?kind=${kind}`);
const label = kind === 'node' ? 'pve-fixture' : 'docker-fixture';
const drawer = page.getByTestId(kind === 'node' ? 'node-drawer' : 'docker-host-drawer');
const open = page.getByRole('button', { name: `Expand details for ${label}` });
if (spec.name === 'desktop') {
let reached = false;
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (await open.evaluate((element) => element === document.activeElement)) {
reached = true;
break;
}
}
assert(reached, 'real keyboard traversal reaches disclosure');
await page.keyboard.press('Enter');
} else {
if (kind === 'node') await open.tap();
else await page.getByText(label, { exact: true }).first().tap();
}
await drawer.waitFor();
const row = (mountpoint) =>
drawer.getByText(mountpoint, { exact: true }).locator('xpath=ancestor::tr[1]');
const unknown = row('/var/lib/containers/storage/volumes/monitoring-and-backup-history');
await unknown.getByText('Usage unavailable · 100 GB total', { exact: true }).waitFor();
assert.match(await unknown.innerText(), /Usage unavailable/);
assert(!/%|0 B/.test(await unknown.innerText()), 'unknown is not zero/healthy');
assert(
await unknown
.locator('td')
.last()
.evaluate((element) => element.classList.contains('text-muted')),
'unknown has neutral tone',
);
assert.equal(
(await row('/capacity-unavailable').innerText()).includes('Usage unavailable'),
true,
);
assert.match(await row('/empty').innerText(), /0% · 0 B \/ 100 GB/);
assert.match(await row('/measured-critical').innerText(), /95% · 95.0 GB \/ 100 GB/);
assert.equal(await drawer.getByText('/boot/efi', { exact: true }).count(), 0);
assert(
await unknown
.locator('td')
.last()
.evaluate(
(element) =>
element.textContent.includes('Usage unavailable') &&
!element.querySelector('.truncate'),
),
'message wraps rather than hiding in a tooltip',
);
const fit = await page.evaluate(() => ({
width: innerWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
assert(fit.scrollWidth <= fit.width + 1, 'no page horizontal overflow');
result.checks.push({
view: spec.name,
kind,
name: 'real disclosure and honest unknown/capacity/zero/measured/filter evidence',
fit,
});
const capture = async (state) => {
await drawer.scrollIntoViewIfNeeded();
const file = `${spec.name}-${kind}-${state}.png`;
await page.screenshot({ path: path.join(output, file) });
result.captures.push({
file,
sha256: hash(fs.readFileSync(path.join(output, file))),
view: spec.name,
kind,
state,
});
};
const summary = page
.getByText(label, { exact: true })
.first()
.locator('xpath=ancestor::tr[1]');
if (kind === 'node' && spec.name === 'phone')
assert.match(
await summary.innerText(),
/48%/,
'combined percent excludes bytes with missing capacity',
);
result.checks.push({
view: spec.name,
kind,
name: 'summary excludes unmeasured byte rows',
summary: await summary.innerText(),
});
await capture('unavailable');
known = true;
await page.evaluate(() => window.__hostDiskUsage.refetch());
await unknown.getByText('70% · 70.0 GB / 100 GB', { exact: true }).waitFor();
assert.match(await row('/capacity-unavailable').innerText(), /Usage unavailable/);
assert.match(await row('/empty').innerText(), /0% · 0 B/);
result.checks.push({
view: spec.name,
kind,
name: 'accepted refresh replaces unknown with actual usage; peers unchanged',
});
if (kind === 'node' && spec.name === 'phone')
await summary
.locator('[data-stacked-disk-trigger]')
.getByText('55%', { exact: true })
.waitFor();
await capture('measured');
known = false;
await page.evaluate(() => window.__hostDiskUsage.refetch());
await unknown.getByText('Usage unavailable · 100 GB total', { exact: true }).waitFor();
result.checks.push({
view: spec.name,
kind,
name: 'later unknown read removes old utilization from same open drawer',
});
allUnknown = true;
await page.evaluate(() => window.__hostDiskUsage.refetch());
await row('/measured-critical')
.getByText('Usage unavailable · 100 GB total', { exact: true })
.waitFor();
await summary
.locator('[data-stacked-disk-trigger]')
.getByText(kind === 'node' ? '25%' : 'N/A', { exact: true })
.waitFor();
result.checks.push({
view: spec.name,
kind,
name: 'all unknown volumes cannot manufacture healthy zero; independent known node aggregate survives',
summary: await summary.innerText(),
});
await capture('all-unknown');
const readsBeforeClose = requests.filter((value) => value === '/api/resources').length;
const close = drawer.getByRole('button', { name: `Collapse ${label} details` });
if (spec.name === 'phone') await close.tap();
else await close.click();
await drawer.waitFor({ state: 'detached' });
assert.equal(
requests.filter((value) => value === '/api/resources').length,
readsBeforeClose,
'closing does not initiate resource read',
);
result.checks.push({
view: spec.name,
kind,
name: 'ordinary collapse releases drawer without additional resource read',
});
await page.close();
}
result.browserVersions = { ...result.browserVersions, [spec.name]: browser.version() };
await browser.close();
browser = undefined;
}
assert.equal(result.errors.length, 0, 'no browser errors');
result.result = 'passed';
})()
.catch((error) => {
result.result = 'failed';
result.failure = error.stack;
process.exitCode = 1;
})
.finally(async () => {
if (browser) await browser.close();
result.cleanup.browserClosed = true;
if (server) await new Promise((resolve) => server.close(resolve));
result.cleanup.serverClosed = !server || !server.listening;
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
console.log(
JSON.stringify({
result: result.result,
failure: result.failure,
checks: result.checks.length,
captures: result.captures,
errors: result.errors,
cleanup: result.cleanup,
}),
);
});
@@ -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>Host filesystem usage verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./host-disk-usage.tsx"></script>
</body>
</html>
@@ -0,0 +1,65 @@
// Canonical Resources HTTP -> real platform tables -> real host drawers/CSS.
// Synthetic source observations only; no disk, guest or recovery operation.
import { Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Route, Router } from '@solidjs/router';
import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable';
import { DockerHostsTable } from '../src/features/docker/DockerHostsTable';
import { DarkModeContext, WebSocketContext, type WebSocketStore } from '../src/contexts/appRuntime';
import '../src/index.css';
function Fixture() {
const state = useUnifiedResources({
query: 'type=agent,docker-host',
realtimeEnabled: () => false,
});
const kind = new URLSearchParams(window.location.search).get('kind');
(window as any).__hostDiskUsage = { refetch: state.refetch, resources: state.resources };
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content pulse-wide-data-surface">
<h1 class="text-base font-semibold">Host filesystem readings</h1>
<p class="text-xs text-muted">Synthetic resource snapshots; no host operations.</p>
<Show
when={kind === 'node'}
fallback={
<DockerHostsTable
resources={state.resources().filter((value) => value.type === 'docker-host')}
emptyIcon={<span />}
emptyTitle="No Docker hosts"
emptyDescription="Waiting for synthetic observations"
showToolbar={false}
/>
}
>
<ProxmoxNodesTable
nodes={state.resources().filter((value) => value.type === 'agent')}
guests={[]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="Waiting for synthetic observations"
/>
</Show>
</main>
);
}
render(
() => (
<WebSocketContext.Provider
value={
{
activeAlerts: {},
state: { pmg: [] },
connected: () => false,
} as unknown as WebSocketStore
}
>
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<Router>
<Route path="*" component={Fixture} />
</Router>
</DarkModeContext.Provider>
</WebSocketContext.Provider>
),
document.getElementById('root')!,
);
@@ -0,0 +1,72 @@
{
"version": 1,
"result": "passed",
"base_sha": "33267809f325631a0863aced8cb56b81c2bc1070",
"changed_paths": [
"frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx",
"frontend-modern/src/utils/filesystemByteUsage.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx": "339639c52459e85a322c9fa8da80cd22b982c2c32af5f805f02196d37f845a9d",
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "9c8ba41601e63934215a64e77eadf0f386acf1c901483a9805f2aa24c719fdad",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "a023f8653c8636e205db59f92ee6582cb8d18f7aff57d893de5ed9749e87b27f",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "cb4e3aab9b5da6afabdb609d2eddd70ccb0d3d90a8c408928f2f773dbc125d54",
"frontend-modern/src/utils/filesystemByteUsage.ts": "2c7b16951cfed3794d97cb18a10556f12b437fc22ac760661b92287bc6720d24"
},
"routes": [
"/browser-tests/host-disk-usage.html?kind=node (synthetic canonical Resources API -> useUnifiedResources -> production ProxmoxNodesTable and host drawer)",
"/browser-tests/host-disk-usage.html?kind=docker (synthetic canonical Resources API -> useUnifiedResources -> production DockerHostsTable and host drawer)"
],
"states": [
"Explicit unknown usage with positive capacity; used bytes without capacity",
"Genuine empty filesystem with omitted used=0 and independent 95% critical peer",
"Same resource identity unknown -> measured 70% -> unknown -> all unknown",
"Measured-only node combined summary, independent node aggregate and Docker N/A fallback",
"Existing EFI mount filtering and independent peer readings retained"
],
"interactions": [
"Desktop sequential Tab/Enter opens production disclosure; phone node disclosure tap and Docker row/name tap",
"Explicit same-ID Resources API refetch updates the same open drawer and summary without renewing old utilization",
"Ordinary collapse removes drawer without another resource read; only synthetic local GET requests",
"Inspect desktop/narrow pixels and no horizontal page overflow"
],
"viewports": [
{
"width": 1280,
"height": 900,
"browser": "Chromium",
"browser_version": "141.0.7390.37",
"color_scheme": "light"
},
{
"width": 375,
"height": 812,
"browser": "WebKit",
"browser_version": "26.0",
"color_scheme": "dark",
"has_touch": true
}
],
"verified_at": "2026-10-13T02:56:37.497486Z",
"playwright_version": "1.56.1",
"checks": 24,
"pixel_review": "All twelve retained captures opened with native view_image. Desktop and 375px phone layouts fit; unavailable values are neutral and capacity text wraps, genuine omitted zero is 0% / 0 B, and the measured 95% peer stays red. Same-ID refresh shows 70% / 70 GB then withdraws it on unknown; all-unknown Docker is N/A while the separately reported node aggregate stays 25%. Phone node combined summary is 48% of measured bytes, becoming 55% after the valid 70% read. Existing mount-label truncation remains, with full labels in titles; this candidate does not claim to repair it.",
"capture_sha256": {
"desktop-node-unavailable.png": "ae9b2b1f60632248da32e8182a4d4f35d8757ee37569fe787250943537a06e23",
"desktop-node-measured.png": "eef96ebf5fcb90d9051e8d0700f0829a9a45794f87e240150d8ba490bf217f30",
"desktop-node-all-unknown.png": "8e9d93c9e36d193d9e65d976c5c3443fcfee1f84b43e0acde3b3d9340d55a1cf",
"desktop-docker-unavailable.png": "d9d54ffcb936925c3c1ef6238cbb8dbb6809ceb34bd276d0a60574a998b14955",
"desktop-docker-measured.png": "ba03c84cfdac1b48bda331674147de98fb3db9307aa39d73a6b121013dd5d2d0",
"desktop-docker-all-unknown.png": "c85300b4f4bd277d6a823c5d74995976ce1c3c47cd7636703272b3f3c202cb93",
"phone-node-unavailable.png": "2bfde1d525d694f431f263d346cfb13530471254c84d7eca6ba0116d276007cd",
"phone-node-measured.png": "9c15a52dde6eb7fa61be48998c9762900073fbe80c85dec68b1adb985c3b2339",
"phone-node-all-unknown.png": "9a874976a2e5e825f165bff3429e98c7d8618404fa90450f81911a7a89cffeb2",
"phone-docker-unavailable.png": "16fd68d8191ced1b29290a6f636af18010384394354b5770177678ce978975f2",
"phone-docker-measured.png": "b73544524267c34e8e47101a17af620f443d89cd4de204b2ca52720897d7535e",
"phone-docker-all-unknown.png": "0a62f92fd968e41e947b1380b1256ac6d439fbe2ff5bdad1198660edcb81077a"
},
"scope_limit": "Canonical resource HTTP client -> ProxmoxNodesTable/DockerHostsTable -> production host drawers with synthetic disk observations. No provider, disk, guest, backup or recovery operation. This is synthetic presentation, not native guest/host recovery, released availability or independent thaw/write safety."
}
@@ -3,47 +3,38 @@ import { For, Show } from 'solid-js';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import type { Disk } from '@/types/api';
import { formatBytes } from '@/utils/format';
import { getFilesystemByteUsagePercent } from '@/utils/filesystemByteUsage';
import { getMetricColorRgba, getMetricTextColorClass } from '@/utils/metricThresholds';
export interface DrawerDiskListItem {
key: string;
label: string;
device: string;
percent: number;
used: number;
total: number;
percent: number | null;
usageText: string;
color: string;
textClass: string;
}
const getDiskUsagePercent = (disk: Disk): number => {
const total = disk.total ?? 0;
const used = disk.used ?? 0;
if (total > 0 && Number.isFinite(used)) {
return (used / total) * 100;
}
const usage = disk.usage;
if (typeof usage === 'number' && Number.isFinite(usage)) {
return usage <= 1 ? usage * 100 : usage;
}
return 0;
};
const getDiskLabel = (disk: Disk, index: number): string =>
disk.mountpoint || disk.device || `Disk ${index + 1}`;
export const buildDrawerDiskListItems = (disks: Disk[]): DrawerDiskListItem[] =>
disks.map((disk, index) => {
const percent = getDiskUsagePercent(disk);
const percent = getFilesystemByteUsagePercent(disk);
return {
key: `${disk.device ?? ''}|${disk.mountpoint ?? ''}|${index}`,
label: getDiskLabel(disk, index),
device: disk.device ?? '',
percent,
used: disk.used ?? 0,
total: disk.total ?? 0,
color: getMetricColorRgba(percent, 'disk'),
textClass: getMetricTextColorClass(percent, 'disk'),
usageText:
percent !== null
? `${Math.round(percent)}% · ${formatBytes(disk.used ?? 0)} / ${formatBytes(disk.total!)}`
: typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0
? `Usage unavailable · ${formatBytes(disk.total)} total`
: 'Usage unavailable',
color: percent === null ? '' : getMetricColorRgba(percent, 'disk'),
textClass: percent === null ? 'text-muted' : getMetricTextColorClass(percent, 'disk'),
};
});
@@ -70,23 +61,19 @@ export function DrawerDiskListCard(props: DrawerDiskListCardProps) {
>
{disk.label}
</span>
<span class={`shrink-0 tabular-nums ${disk.textClass}`}>
{`${Math.round(disk.percent)}%`}
<span class="ml-1 text-muted">
({formatBytes(disk.used)}
<Show when={disk.total > 0}> / {formatBytes(disk.total)}</Show>)
</span>
</span>
</div>
<div class="relative h-1.5 w-full overflow-hidden rounded-sm bg-surface-hover">
<div
class="absolute inset-y-0 left-0 rounded-sm"
style={{
width: `${Math.max(0, Math.min(100, disk.percent))}%`,
background: disk.color,
}}
/>
<span class={`text-right tabular-nums ${disk.textClass}`}>{disk.usageText}</span>
</div>
<Show when={disk.percent !== null}>
<div class="relative h-1.5 w-full overflow-hidden rounded-sm bg-surface-hover">
<div
class="absolute inset-y-0 left-0 rounded-sm"
style={{
width: `${Math.max(0, Math.min(100, disk.percent!))}%`,
background: disk.color,
}}
/>
</div>
</Show>
</div>
)}
</For>
@@ -313,16 +313,11 @@ export function NodeDrawerOverview(props: NodeDrawerOverviewProps) {
perDiskItems().length > 0
? compactDetailRows(
perDiskItems().map((disk) =>
makeDetailRow(
disk.label,
`${Math.round(disk.percent)}% · ${formatBytes(disk.used)} / ${formatBytes(
disk.total,
)}`,
{
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: getDetailTone(disk.textClass),
},
),
makeDetailRow(disk.label, disk.usageText, {
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: disk.percent === null ? 'muted' : getDetailTone(disk.textClass),
wrap: true,
}),
),
)
: toDetailRows(storageRows()),
@@ -0,0 +1,77 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { Disk } from '@/types/api';
import { normalizeDiskArray } from '@/utils/format';
import { getMetricTextColorClass } from '@/utils/metricThresholds';
import { buildDrawerDiskListItems, DrawerDiskListCard } from '../DrawerDiskListCard';
const GB = 1024 ** 3;
afterEach(cleanup);
describe('host drawer filesystem measurement admission', () => {
it.each([
{ total: 100 * GB, usage: -1 },
{ total: 100 * GB, used: 70 * GB, usage: -1 },
{ total: 100 * GB, used: -1 },
{ total: 100 * GB, used: NaN },
{ total: 100 * GB, used: Infinity },
{ used: 30 * GB },
{ total: 0, used: 0 },
{ total: -1, used: 0 },
{ total: Infinity, used: 0 },
{ total: NaN, used: 0 },
{ mountpoint: '/capacity-unavailable', usage: 0.5 },
] satisfies Disk[])('does not turn unknown/invalid bytes into healthy usage: %j', (disk) => {
// These are the same defaulting/filtering helpers used by both callers.
const [item] = buildDrawerDiskListItems(normalizeDiskArray([disk])!);
expect(item.percent).toBeNull();
expect(item.usageText).toMatch(/^Usage unavailable/);
expect(item.usageText).not.toMatch(/%|0 B/);
expect(item.color).toBe('');
expect(item.textClass).toBe('text-muted');
});
it('retains valid capacity without using it as evidence of usage', () => {
const [item] = buildDrawerDiskListItems([
{ total: 100 * GB, usage: -1, device: '/dev/fixture' },
]);
expect(item.usageText).toBe('Usage unavailable · 100 GB total');
expect(item.device).toBe('/dev/fixture');
});
it.each([{ used: 0 }, {}])(
'preserves true empty filesystems including DiskInfo omitted zero: %j',
(bytes) => {
const [item] = buildDrawerDiskListItems(
normalizeDiskArray([{ total: 100 * GB, free: 100 * GB, ...bytes }])!,
);
expect(item.percent).toBe(0);
expect(item.usageText).toBe('0% · 0 B / 100 GB');
expect(item.textClass).toBe(getMetricTextColorClass(0, 'disk'));
},
);
it.each([0.5, 70, 95, 110])(
'uses the byte ratio, not a guessed fraction, at %s percent',
(percent) => {
const disk = { total: 100 * GB, used: percent * GB, usage: percent };
const [item] = buildDrawerDiskListItems([disk]);
expect(item.percent).toBeCloseTo(percent);
expect(item.usageText).toMatch(new RegExp(`^${Math.round(percent)}%`));
},
);
it('does not draw a utilization bar for an unavailable filesystem', () => {
const { container } = render(() => (
<DrawerDiskListCard
disks={buildDrawerDiskListItems([
{ mountpoint: '/unknown', total: 100 * GB, usage: -1 },
{ mountpoint: '/empty', total: 100 * GB, used: 0 },
])}
/>
));
expect(screen.getByText('Usage unavailable · 100 GB total')).toHaveClass('text-muted');
expect(container.querySelectorAll('[style]')).toHaveLength(1);
expect(container.querySelector('[style]')).toHaveStyle({ width: '0%' });
});
});
@@ -0,0 +1,83 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { WebSocketContext, type WebSocketStore } from '@/contexts/appRuntime';
import { afterEach, describe, expect, it } from 'vitest';
import type { Disk, Node } from '@/types/api';
import type { Resource } from '@/types/resource';
import { NodeDrawerOverview } from '../NodeDrawerOverview';
import { DockerHostDrawerOverview } from '@/features/docker/DockerHostDrawerOverview';
const GB = 1024 ** 3;
const disks: Disk[] = [
{ mountpoint: '/unknown', total: 100 * GB, usage: -1 },
{ mountpoint: '/no-capacity', used: 30 * GB },
{ mountpoint: '/empty', total: 100 * GB, free: 100 * GB },
{ mountpoint: '/critical', total: 100 * GB, used: 95 * GB },
{ mountpoint: '/boot/efi', total: 100 * GB, used: 20 * GB },
];
const node = {
id: 'fixture-node',
name: 'fixture-node',
status: 'online',
instance: 'fixture',
type: 'node',
} as Node;
afterEach(cleanup);
for (const kind of ['Proxmox node', 'Docker host'] as const) {
describe(`${kind} per-filesystem presentation`, () => {
const mount = () => {
const [value, setValue] = createSignal(disks);
render(() => (
<WebSocketContext.Provider value={{ activeAlerts: {} } as unknown as WebSocketStore}>
{kind === 'Proxmox node' ? (
<NodeDrawerOverview node={node} disks={value()} />
) : (
<DockerHostDrawerOverview
host={
{
id: 'fixture-docker',
type: 'docker-host',
name: 'fixture-docker',
agent: { disks: value() },
} as Resource
}
/>
)}
</WebSocketContext.Provider>
));
return setValue;
};
it('shows missing usage neutrally, preserves capacity, and keeps measured zero/critical readings', () => {
mount();
const unknown = screen.getByText('/unknown').closest('tr')!;
expect(unknown).toHaveTextContent('Usage unavailable · 100 GB total');
expect(
within(unknown).getByText('Usage unavailable · 100 GB total').closest('td'),
).toHaveClass('text-muted');
expect(screen.getByText('/no-capacity').closest('tr')).toHaveTextContent('Usage unavailable');
expect(screen.getByText('/empty').closest('tr')).toHaveTextContent('0% · 0 B / 100 GB');
expect(screen.getByText('/critical').closest('tr')).toHaveTextContent(
'95% · 95.0 GB / 100 GB',
);
expect(screen.queryByText('/boot/efi')).not.toBeInTheDocument();
});
it('replaces unavailable/current evidence on the mounted drawer without affecting peers', () => {
const setValue = mount();
setValue(
disks.map((disk) =>
disk.mountpoint === '/unknown' ? { ...disk, usage: 70, used: 70 * GB } : disk,
),
);
expect(screen.getByText('/unknown').closest('tr')).toHaveTextContent(
'70% · 70.0 GB / 100 GB',
);
expect(screen.getByText('/no-capacity').closest('tr')).toHaveTextContent('Usage unavailable');
setValue(disks);
expect(screen.getByText('/unknown').closest('tr')).toHaveTextContent('Usage unavailable');
expect(screen.getByText('/critical').closest('tr')).toHaveTextContent('95%');
});
});
}
@@ -515,10 +515,11 @@ describe('StackedDiskBar', () => {
// ── Zero capacity edge case ─────────────────────────────────────────────
describe('zero capacity', () => {
it('handles zero total capacity gracefully', () => {
it('shows unavailable rather than empty utilization for zero total capacity', () => {
const disk = makeDisk({ used: 0, total: 0, free: 0 });
render(() => <StackedDiskBar disks={[disk]} />);
expect(screen.getByText('0%')).toBeInTheDocument();
expect(screen.getByText('N/A')).toBeInTheDocument();
expect(screen.queryByText('0%')).not.toBeInTheDocument();
});
it('handles zero total in stacked mode without NaN segments', () => {
@@ -59,31 +59,34 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
expect(p.miniDisks[0].percent).toBe(25);
});
it('scales a fractional usage (<=1) by 100 when total <= 0', () => {
// Branch: total <= 0, Number.isFinite(usage), usage <= 1 -> usage * 100.
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ total: 0, used: 0, free: 0, usage: 0.5 })] },
400,
);
expect(p.miniDisks[0].percent).toBe(50);
});
it.each([0.5, 75, Number.NaN])(
'withholds per-filesystem utilization without byte capacity (%s)',
(usage) => {
// Normalized filesystem rows do not carry independently observed
// aggregate percentages. No capacity means no usable byte reading.
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ total: 0, used: 0, free: 0, usage })] },
400,
);
expect(p.miniDisks).toEqual([]);
expect(p.unavailable).toBe(true);
expect(p.displayLabel).toBe('N/A');
expect(p.tooltipContent[0]).toMatchObject({ percent: '—', total: '—', used: '?' });
},
);
it('returns a usage value > 1 unchanged when total <= 0', () => {
// Branch: total <= 0, usage finite, usage > 1 -> usage.
it.each([
{ usage: 0.5, expected: 50 },
{ usage: 75, expected: 75 },
{ usage: 0, expected: 0 },
])('preserves independently supplied aggregate-only percentages: %j', ({ usage, expected }) => {
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ total: 0, used: 0, free: 0, usage: 75 })] },
{ disks: [makeDisk({ total: 0 })], aggregateDisk: { usage } },
400,
);
expect(p.miniDisks[0].percent).toBe(75);
});
it('returns 0 when total <= 0 and usage is not finite', () => {
// Branch: total <= 0, !Number.isFinite(usage) -> 0.
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ total: 0, used: 0, free: 0, usage: Number.NaN })] },
400,
);
expect(p.miniDisks[0].percent).toBe(0);
expect(p.unavailable).toBe(false);
expect(p.displayPercentValue).toBe(expected);
expect(p.miniDisks).toEqual([]);
});
});
@@ -720,3 +723,36 @@ describe('disk observation decorations', () => {
expect(zero.unavailable).toBe(false);
});
});
describe('partial filesystem byte admission shared with host drawers', () => {
it('excludes unavailable capacity and invalid used bytes from combined utilization and bars', () => {
const known = makeDisk({ mountpoint: '/known', total: 100, used: 50, usage: 50 });
const partials = [
makeDisk({ mountpoint: '/no-capacity', total: 0, used: 30, usage: 0 }),
makeDisk({ mountpoint: '/negative', total: 200, used: -1, usage: 0 }),
makeDisk({ mountpoint: '/non-finite', total: Infinity, used: 20, usage: 0 }),
makeDisk({ mountpoint: '/sentinel', total: 200, used: 70, usage: -1 }),
];
const presentation = buildStackedDiskBarPresentation(
{ disks: [known, ...partials], mode: 'aggregate' },
200,
);
expect(presentation.displayPercentValue).toBe(50);
expect(presentation.tooltipContent.map((item) => item.percent)).toEqual([
'50%',
'—',
'—',
'—',
'—',
]);
expect(presentation.tooltipContent[1].total).toBe('—');
expect(presentation.tooltipContent[2].total).toBe('200 B');
expect(presentation.tooltipContent[3].total).toBe('—');
const vertical = buildStackedDiskBarPresentation(
{ disks: [known, ...partials], mode: 'vertical-bars' },
200,
);
expect(vertical.verticalBars).toHaveLength(1);
expect(vertical.verticalBars[0].fillPercent).toBe(50);
});
});
@@ -1,4 +1,5 @@
import type { Disk } from '@/types/api';
import { getFilesystemByteUsagePercent } from '@/utils/filesystemByteUsage';
import type { WorkloadGuestDiskRead } from '@/utils/workloadGuestPresentation';
import type { AnomalyReport } from '@/types/aiIntelligence';
import {
@@ -121,11 +122,11 @@ function getDiskUsagePercent(disk: Disk): number {
return 0;
}
// The poller reports usage -1 for mounts it only knows from the guest config
// (capacity may be known, live usage is not). Those cannot be drawn as usage
// bars; they surface in the tooltip instead.
// Unknown sentinels and invalid/incomplete byte readings cannot contribute
// to utilization, totals or severity. Keep their identity/capacity in the
// tooltip, with the same admission as the per-filesystem drawer rows.
export function isStackedDiskUsageUnknown(disk: Disk): boolean {
return typeof disk.usage === 'number' && disk.usage < 0;
return getFilesystemByteUsagePercent(disk) === null;
}
const UNKNOWN_DISK_COLOR = 'rgba(148, 163, 184, 0.5)';
@@ -219,7 +220,10 @@ function buildTooltipContent(
color: UNKNOWN_DISK_COLOR,
label: getDiskLabel(disk, index),
percent: '—',
total: (disk.total ?? 0) > 0 ? formatBytes(disk.total ?? 0) : '—',
total:
typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0
? formatBytes(disk.total)
: '—',
used: '?',
};
}
@@ -280,17 +284,31 @@ export function buildStackedDiskBarPresentation(
props: StackedDiskBarProps,
containerWidth: number,
): StackedDiskBarPresentation {
// Retained numbers explain the last report, not live pressure. Unannotated
// host/platform callers preserve their existing behaviour.
const unavailable = props.reading?.state === 'unavailable';
const current = !props.reading || props.reading.state === 'current';
const allDisks = props.disks ?? [];
const measuredDisks = allDisks.filter((disk) => !isStackedDiskUsageUnknown(disk));
// Independently supplied aggregate percentages keep their legacy fallback;
// they do not make an unmeasured per-filesystem byte row usable.
const aggregate = props.aggregateDisk;
const hasAggregateUsage = Boolean(
aggregate &&
(getFilesystemByteUsagePercent(aggregate) !== null ||
((aggregate.total ?? 0) <= 0 &&
typeof aggregate.usage === 'number' &&
Number.isFinite(aggregate.usage) &&
aggregate.usage >= 0)),
);
// Retained numbers explain the last report, not live pressure. If every
// supplied filesystem is unmeasured, do not fall through to a fabricated 0%.
const unavailable =
props.reading?.state === 'unavailable' ||
((allDisks.length > 0 || Boolean(aggregate)) &&
measuredDisks.length === 0 &&
!hasAggregateUsage);
const current = !unavailable && (!props.reading || props.reading.state === 'current');
const valuePrefix = props.reading?.state === 'last-known' ? 'Last known ' : '';
const anomaly = current ? props.anomaly : null;
const aggregateDisk = unavailable ? undefined : props.aggregateDisk;
const allDisks = props.disks ?? [];
// Usage visualisations only draw disks with measured usage; config-only
// mounts (usage -1) stay listed in the tooltip.
const disks = unavailable ? [] : allDisks.filter((disk) => !isStackedDiskUsageUnknown(disk));
const aggregateDisk = unavailable ? undefined : aggregate;
const disks = unavailable ? [] : measuredDisks;
const hasDisks = disks.length > 0;
const hasMultipleDisks = disks.length > 1;
const aggregateMode = props.mode === 'aggregate';
@@ -316,16 +316,11 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) {
perDiskItems().length > 0
? compactDetailRows(
perDiskItems().map((disk) =>
makeDetailRow(
disk.label,
`${Math.round(disk.percent)}% · ${formatBytes(disk.used)} / ${formatBytes(
disk.total,
)}`,
{
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: getDetailTone(disk.textClass),
},
),
makeDetailRow(disk.label, disk.usageText, {
title: disk.device ? `${disk.label} · ${disk.device}` : disk.label,
tone: disk.percent === null ? 'muted' : getDetailTone(disk.textClass),
wrap: true,
}),
),
)
: toDetailRows(storageRows()),
@@ -0,0 +1,21 @@
import type { Disk } from '@/types/api';
// A capacity or explicit unknown sentinel is not a healthy empty filesystem.
// DiskInfo omits genuine zero used bytes; retain that wire contract while
// requiring finite non-negative used bytes and a finite positive capacity.
export const getFilesystemByteUsagePercent = (disk: Disk): number | null => {
if (typeof disk.usage === 'number' && disk.usage < 0) return null;
const total = disk.total;
const used = disk.used ?? 0;
if (
typeof total !== 'number' ||
!Number.isFinite(total) ||
total <= 0 ||
!Number.isFinite(used) ||
used < 0
) {
return null;
}
const percent = (used / total) * 100;
return Number.isFinite(percent) ? percent : null;
};