diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 66d10fc274..07375fbcc7 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index d8d9ab98da..9c263489f7 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 0c60aa5364..bfdeb82285 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 22ac317998..a5633c541f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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. diff --git a/frontend-modern/browser-tests/build-host-disk-usage.mjs b/frontend-modern/browser-tests/build-host-disk-usage.mjs new file mode 100644 index 0000000000..4252b31747 --- /dev/null +++ b/frontend-modern/browser-tests/build-host-disk-usage.mjs @@ -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(); +} diff --git a/frontend-modern/browser-tests/host-disk-usage.cjs b/frontend-modern/browser-tests/host-disk-usage.cjs new file mode 100644 index 0000000000..7e0712b7bd --- /dev/null +++ b/frontend-modern/browser-tests/host-disk-usage.cjs @@ -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, + }), + ); + }); diff --git a/frontend-modern/browser-tests/host-disk-usage.html b/frontend-modern/browser-tests/host-disk-usage.html new file mode 100644 index 0000000000..d00fdcd8b9 --- /dev/null +++ b/frontend-modern/browser-tests/host-disk-usage.html @@ -0,0 +1,12 @@ + + + + + + Host filesystem usage verification + + +
+ + + diff --git a/frontend-modern/browser-tests/host-disk-usage.tsx b/frontend-modern/browser-tests/host-disk-usage.tsx new file mode 100644 index 0000000000..6cdf62a72e --- /dev/null +++ b/frontend-modern/browser-tests/host-disk-usage.tsx @@ -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 ( +
+

Host filesystem readings

+

Synthetic resource snapshots; no host operations.

+ value.type === 'docker-host')} + emptyIcon={} + emptyTitle="No Docker hosts" + emptyDescription="Waiting for synthetic observations" + showToolbar={false} + /> + } + > + value.type === 'agent')} + guests={[]} + emptyIcon={} + emptyTitle="No nodes" + emptyDescription="Waiting for synthetic observations" + /> + +
+ ); +} +render( + () => ( + false, + } as unknown as WebSocketStore + } + > + document.documentElement.classList.contains('dark')}> + + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/5e815e670123aac4.json b/frontend-modern/browser-verification/5e815e670123aac4.json new file mode 100644 index 0000000000..b1b78e41fb --- /dev/null +++ b/frontend-modern/browser-verification/5e815e670123aac4.json @@ -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." +} diff --git a/frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx b/frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx index 4de0e0150d..ba13953655 100644 --- a/frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx +++ b/frontend-modern/src/components/Workloads/DrawerDiskListCard.tsx @@ -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} - - {`${Math.round(disk.percent)}%`} - - ({formatBytes(disk.used)} - 0}> / {formatBytes(disk.total)}) - - - -
-
+ {disk.usageText}
+ +
+
+
+
)} diff --git a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx index c7072ae28e..8971dce852 100644 --- a/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx @@ -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()), diff --git a/frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx b/frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx new file mode 100644 index 0000000000..582ae96c61 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx @@ -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(() => ( + + )); + expect(screen.getByText('Usage unavailable · 100 GB total')).toHaveClass('text-muted'); + expect(container.querySelectorAll('[style]')).toHaveLength(1); + expect(container.querySelector('[style]')).toHaveStyle({ width: '0%' }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx b/frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx new file mode 100644 index 0000000000..558c786057 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx @@ -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(() => ( + + {kind === 'Proxmox node' ? ( + + ) : ( + + )} + + )); + 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%'); + }); + }); +} diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index 0958819512..46929d2fc3 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -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(() => ); - 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', () => { diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index cf5cb97cae..158dc85cc9 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -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); + }); +}); diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 2632fec79a..85ace33d5f 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -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'; diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index eb2e71f918..9753cc7196 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -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()), diff --git a/frontend-modern/src/utils/filesystemByteUsage.ts b/frontend-modern/src/utils/filesystemByteUsage.ts new file mode 100644 index 0000000000..5f3c1ca12e --- /dev/null +++ b/frontend-modern/src/utils/filesystemByteUsage.ts @@ -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; +};