Host filesystem readings
+Synthetic resource snapshots; no host operations.
+From c8e4276ca470b0cf7cdff8b10f8aa0b08fe54b55 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:57:11 +0100 Subject: [PATCH] Keep unknown filesystem usage out of host health readings Share byte-reading admission across host summaries and filesystem drawers. Keep unavailable rows neutral without counting missing capacity or unknown usage as healthy space, while preserving genuine omitted-zero bytes and independent aggregate readings. Exercise canonical resource refreshes through desktop and phone host tables and register the contract regression proofs. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 32 ++ .../subsystems/performance-and-scalability.md | 19 + .../v6/internal/subsystems/registry.json | 6 + .../internal/subsystems/unified-resources.md | 31 ++ .../browser-tests/build-host-disk-usage.mjs | 99 +++++ .../browser-tests/host-disk-usage.cjs | 403 ++++++++++++++++++ .../browser-tests/host-disk-usage.html | 12 + .../browser-tests/host-disk-usage.tsx | 65 +++ .../5e815e670123aac4.json | 72 ++++ .../Workloads/DrawerDiskListCard.tsx | 61 ++- .../Workloads/NodeDrawerOverview.tsx | 15 +- .../DrawerDiskListCard.readings.test.tsx | 77 ++++ .../HostDrawer.filesystem-readings.test.tsx | 83 ++++ .../__tests__/StackedDiskBar.test.tsx | 5 +- ...stackedDiskBarModel.branchcov0712b.test.ts | 78 +++- .../Workloads/stackedDiskBarModel.ts | 46 +- .../docker/DockerHostDrawerOverview.tsx | 15 +- .../src/utils/filesystemByteUsage.ts | 21 + 18 files changed, 1046 insertions(+), 94 deletions(-) create mode 100644 frontend-modern/browser-tests/build-host-disk-usage.mjs create mode 100644 frontend-modern/browser-tests/host-disk-usage.cjs create mode 100644 frontend-modern/browser-tests/host-disk-usage.html create mode 100644 frontend-modern/browser-tests/host-disk-usage.tsx create mode 100644 frontend-modern/browser-verification/5e815e670123aac4.json create mode 100644 frontend-modern/src/components/Workloads/__tests__/DrawerDiskListCard.readings.test.tsx create mode 100644 frontend-modern/src/components/Workloads/__tests__/HostDrawer.filesystem-readings.test.tsx create mode 100644 frontend-modern/src/utils/filesystemByteUsage.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index cad393432f..6bf27918cf 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -9426,3 +9426,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 @@ + + +
+ + +Synthetic resource snapshots; no host operations.
+