mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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()),
|
||||
|
||||
+77
@@ -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%' });
|
||||
});
|
||||
});
|
||||
+83
@@ -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', () => {
|
||||
|
||||
+57
-21
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user