mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed truthful guest filesystem displays
Preserve exact reviewed History and incomplete-reading commits with their own-parent browser receipts. Retained final compiler, 407 regressions and production-build proof cover runtime content; preserve current data-retaining removal guidance. Change-source: pulse-maintainer
This commit is contained in:
@@ -20,6 +20,24 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Filesystem capacity is not measured usage
|
||||
|
||||
The existing filesystem list and guest Overview require finite positive capacity
|
||||
and finite non-negative measured used bytes before displaying a percentage or
|
||||
utilization bar. Missing used bytes, invalid numeric fields, the negative
|
||||
unknown-usage sentinel and an overflowing ratio remain unavailable, not a healthy
|
||||
zero. Known capacity is still useful as `?/capacity`; filesystem identity and
|
||||
type remain visible. An explicitly measured zero stays `0%`, and omitted redundant
|
||||
percentage metadata does not discard valid bytes. Valid over-capacity readings
|
||||
retain their percentage and severity while the visual fill stays capped at 100%.
|
||||
No percentage or free-byte field is used to invent an absent used-byte reading.
|
||||
|
||||
`filesystemUsage.evidence.test.tsx` covers the model and both production consumers,
|
||||
including same-filesystem known → unavailable → measured-zero replacement.
|
||||
`browser-tests/filesystem-usage-evidence.cjs` covers these rendered boundaries on
|
||||
desktop Chromium and touch Chromium/WebKit. These synthetic observations are
|
||||
not proof of appliance collection, backup thaw, installed recovery or delivery.
|
||||
|
||||
### Guest disk deferral and retained observations
|
||||
|
||||
The existing workload row, disk breakdown tooltip and guest Overview use
|
||||
@@ -69,6 +87,27 @@ The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
|
||||
shipped caller. Its empty-state title is not evidence of readable touch guidance;
|
||||
the shipped guest Overview supplies the readable pre-sample explanation.
|
||||
|
||||
Guest History applies the same provenance to its live legend fallback. A VM
|
||||
filesystem reason withdraws the `current` disk fallback; only a `prev-` reason
|
||||
with a valid retained value supplies a separately labelled `last known` value.
|
||||
Explicit telemetry unavailability and unknown/non-finite values stay unavailable,
|
||||
not zero. The readable fixed-reason notice remains beside the utilization group
|
||||
even when stored disk observations exist. Its wording identifies a live read,
|
||||
not the date or provenance of those stored points. Legend assistive descriptions
|
||||
reference that notice. CPU, memory and I/O evidence remain independent.
|
||||
|
||||
Deferred live evidence never supplies a chart point, timestamp or dated
|
||||
inspection value. Keyboard/pointer inspection continues to show only the
|
||||
selected stored observations, and denied History withdraws the whole chart
|
||||
including its deferred evidence. Fresh same-VM evidence clears the notice;
|
||||
lock clearance without a new read does not. `GuestDrawer.test.tsx`,
|
||||
`GuestDrawerHistory.disk-provenance.test.tsx` and `GuestDrawerHistory.sparse.test.tsx`
|
||||
cover the model, actual drawer plumbing, shared fallback precedence, retained
|
||||
zero, pre-sample status, dated inspection, access denial and resumption.
|
||||
`browser-tests/guest-history-provenance.cjs` uses the production full drawer
|
||||
and History API client at desktop and touch widths with synthetic responses.
|
||||
These checks do not establish native guest thaw, collection or release availability.
|
||||
|
||||
### Completed guest backup evidence versus current activity
|
||||
|
||||
The guest Overview keeps the last completed backup age and its existing
|
||||
|
||||
@@ -15,6 +15,29 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Unavailable filesystem values share the existing disk model
|
||||
|
||||
The filesystem list and guest Overview use the same nullable measured-byte ratio
|
||||
for text, severity and progress. Per-disk validation is constant work within the
|
||||
existing linear presentation pass; it adds no reads, queries, timers, remounts or
|
||||
resource-identity changes. Unavailable values stay neutral rather than passing a
|
||||
fabricated zero into threshold classification. Mounted filesystem evidence tests
|
||||
and the production browser fixture verify replacement and rendering, not a new
|
||||
latency, fleet-performance or native-collection claim.
|
||||
|
||||
### Guest History live filesystem provenance
|
||||
|
||||
The guest drawer derives deferred filesystem context from its current snapshot,
|
||||
separately from current CPU, memory and I/O. A retained `prev-` value may populate
|
||||
only the labelled last-known legend, never the current fallback or chart geometry.
|
||||
The added presentation input does not create a polling timer, trigger a guest
|
||||
read, or change History target/range ownership. Stable-target context updates
|
||||
reuse the mounted chart and existing query; the drawer's pre-existing target
|
||||
recomputation on guest-snapshot changes is unchanged. Mounted regressions in
|
||||
`GuestDrawer.test.tsx` and `GuestDrawerHistory.disk-provenance.test.tsx` verify
|
||||
the actual fallback/resumption path and request counts. This is presentation
|
||||
proof, not a native CPU improvement, QGA/thaw result or delivery acceptance.
|
||||
|
||||
### Guest filesystem provenance preserves dense row geometry
|
||||
|
||||
Visible retained/unavailable filesystem cues must fit the existing dense row
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/filesystem-usage-evidence';
|
||||
const runtime = [
|
||||
'frontend-modern/src/components/Workloads/diskListModel.ts',
|
||||
'frontend-modern/src/components/Workloads/DiskList.tsx',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
|
||||
];
|
||||
const unknown = [
|
||||
'missing-used',
|
||||
'negative-used',
|
||||
'NaN-used',
|
||||
'infinite-used',
|
||||
'missing-total',
|
||||
'zero-total',
|
||||
'negative-total',
|
||||
'NaN-total',
|
||||
'infinite-total',
|
||||
'unknown-sentinel',
|
||||
'NaN-usage',
|
||||
'infinite-usage',
|
||||
'overflow',
|
||||
'retained-missing',
|
||||
];
|
||||
const digest = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const playwrightVersion = require('playwright/package.json').version;
|
||||
assert.equal(
|
||||
playwrightVersion,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const hashes = Object.fromEntries(runtime.map((p) => [p, digest(path.join('/workspace', p))]));
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
server: { host: '127.0.0.1', port: 5302, strictPort: true },
|
||||
});
|
||||
const results = [];
|
||||
const screenshots = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height, dark] of [
|
||||
['chromium-desktop', chromium, 1365, 900, false],
|
||||
['chromium-phone', chromium, 390, 844, false],
|
||||
['webkit-phone', webkit, 360, 844, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const browserVersion = browser.version();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height },
|
||||
isMobile: width <= 768,
|
||||
hasTouch: width <= 768,
|
||||
locale: 'en-GB',
|
||||
});
|
||||
page.setDefaultTimeout(60000);
|
||||
const pageErrors = [];
|
||||
const apiRequests = [];
|
||||
const offOrigin = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5302') {
|
||||
offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (url.pathname.startsWith('/api/') || url.pathname === '/ws') {
|
||||
apiRequests.push(url.pathname);
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5302/browser-tests/filesystem-usage-evidence.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
const select = page.getByLabel('Synthetic observation');
|
||||
await select.waitFor({ state: 'visible' });
|
||||
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const list = page.getByRole('region', { name: 'Filesystem list' });
|
||||
const overview = page.getByRole('region', { name: 'Guest Overview' });
|
||||
// Overview owns a full-width guest drawer. A narrow fixture cap at an
|
||||
// xl viewport would activate four columns inside an artificial 672px
|
||||
// host and split the existing backup copy into single characters.
|
||||
const backupBounds = await overview.getByText('No completed backup found').boundingBox();
|
||||
assert(backupBounds && backupBounds.width >= 100 && backupBounds.height <= 40);
|
||||
await page.evaluate(() => {
|
||||
window.__listIdentity = document.querySelector('[aria-label="Filesystem list"]');
|
||||
window.__overviewIdentity = document.querySelector(
|
||||
'[data-testid="guest-technical-details"]',
|
||||
);
|
||||
});
|
||||
const checks = [];
|
||||
const selectState = async (state) => {
|
||||
await select.selectOption(state);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__listIdentity === document.querySelector('[aria-label="Filesystem list"]') &&
|
||||
window.__overviewIdentity ===
|
||||
document.querySelector('[data-testid="guest-technical-details"]'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
};
|
||||
const capture = async (state) => {
|
||||
const p = path.join(output, `${name}-${state}.png`);
|
||||
await page.screenshot({ path: p, fullPage: true });
|
||||
screenshots.push({ path: path.relative('/workspace', p), sha256: digest(p) });
|
||||
};
|
||||
for (const state of unknown) {
|
||||
await selectState(state);
|
||||
await overview.locator('[data-testid="guest-technical-details"]').waitFor();
|
||||
for (const panel of [list, overview]) {
|
||||
const text = await panel.innerText();
|
||||
assert(text.includes('—'), `${state}: missing unavailable marker`);
|
||||
assert(!/0%|NaN|Infinity/.test(text), `${state}: fabricated/invalid value: ${text}`);
|
||||
assert(text.includes('/data') && text.includes('EXT4'));
|
||||
assert.equal(await panel.getByRole('progressbar').count(), 0);
|
||||
}
|
||||
const listFill = list.locator('[style*="width:"]');
|
||||
assert.equal(await listFill.evaluate((element) => element.style.width), '0%');
|
||||
assert(!(await listFill.getAttribute('class')).includes('metric-normal'));
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
);
|
||||
checks.push({ state, unknown: true, numericProgress: false, noOverflow: true });
|
||||
if (['missing-used', 'infinite-total', 'retained-missing'].includes(state)) {
|
||||
if (state === 'retained-missing') {
|
||||
assert((await list.innerText()).includes('earlier guest request is still in progress'));
|
||||
}
|
||||
await capture(state);
|
||||
}
|
||||
}
|
||||
for (const [state, percentage] of [
|
||||
['zero', 0],
|
||||
['live', 50],
|
||||
['usage-omitted', 50],
|
||||
['over-capacity', 125],
|
||||
['retained-live', 50],
|
||||
]) {
|
||||
await selectState(state);
|
||||
assert((await list.innerText()).includes(`${percentage}%`));
|
||||
assert.equal(
|
||||
await overview.getByRole('progressbar').getAttribute('aria-valuenow'),
|
||||
String(Math.min(percentage, 100)),
|
||||
);
|
||||
checks.push({ state, percentage, measured: true });
|
||||
if (state === 'zero') await capture(state);
|
||||
}
|
||||
// Real keyboard input on the fixture replaces the same observation;
|
||||
// there is no new product control or collector action.
|
||||
await selectState('missing-used');
|
||||
await select.focus();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await select.inputValue(), 'live');
|
||||
assert.equal(await overview.getByRole('progressbar').getAttribute('aria-valuenow'), '50');
|
||||
assert.equal(pageErrors.length, 0);
|
||||
assert.equal(apiRequests.length, 0);
|
||||
assert.equal(offOrigin.length, 0);
|
||||
results.push({
|
||||
name,
|
||||
browserVersion,
|
||||
viewport: { width, height },
|
||||
dark,
|
||||
checks,
|
||||
pageErrors,
|
||||
apiRequests,
|
||||
offOrigin,
|
||||
});
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
result: 'passed',
|
||||
verified_at: new Date().toISOString(),
|
||||
playwrightVersion,
|
||||
hashes,
|
||||
results,
|
||||
screenshots,
|
||||
scope:
|
||||
'Production DiskList and GuestDrawerOverview under synthetic same-filesystem observations. No native collection, installed/release acceptance or full source qualification.',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
path.join(output, 'failure.json'),
|
||||
JSON.stringify({ error: error.message, results }, null, 2),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
process.stderr.write(`${error.stack}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Filesystem usage evidence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/filesystem-usage-evidence.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
// Production list and Overview, driven only by synthetic local observations.
|
||||
// This fixture cannot collect guest data or establish native backup safety.
|
||||
import { createMemo, createSignal, For } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { DiskList } from '../src/components/Workloads/DiskList';
|
||||
import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview';
|
||||
import type { Disk, VM } from '../src/types/api';
|
||||
import '../src/index.css';
|
||||
|
||||
const capacity = 10 * 1024 ** 3;
|
||||
const cases: Record<string, { values: Partial<Disk>; reason?: string }> = {
|
||||
live: { values: {} },
|
||||
'missing-used': { values: { used: undefined } },
|
||||
'negative-used': { values: { used: -1 } },
|
||||
'NaN-used': { values: { used: NaN } },
|
||||
'infinite-used': { values: { used: Infinity } },
|
||||
'missing-total': { values: { total: undefined } },
|
||||
'zero-total': { values: { total: 0 } },
|
||||
'negative-total': { values: { total: -1 } },
|
||||
'NaN-total': { values: { total: NaN } },
|
||||
'infinite-total': { values: { total: Infinity } },
|
||||
'unknown-sentinel': { values: { usage: -1, used: 0 } },
|
||||
'NaN-usage': { values: { usage: NaN } },
|
||||
'infinite-usage': { values: { usage: Infinity } },
|
||||
overflow: { values: { total: 1, used: Number.MAX_VALUE } },
|
||||
zero: { values: { used: 0, usage: undefined } },
|
||||
'usage-omitted': { values: { usage: undefined } },
|
||||
'over-capacity': { values: { used: capacity * 1.25, usage: 0 } },
|
||||
'retained-live': { values: {}, reason: 'prev-vm-locked' },
|
||||
'retained-missing': { values: { used: undefined }, reason: 'prev-agent-busy' },
|
||||
};
|
||||
|
||||
function Fixture() {
|
||||
const [selected, setSelected] = createSignal('live');
|
||||
const observation = createMemo(() => ({
|
||||
mountpoint: '/data',
|
||||
type: 'ext4',
|
||||
total: capacity,
|
||||
used: capacity / 2,
|
||||
usage: 50,
|
||||
...cases[selected()].values,
|
||||
}));
|
||||
const guest = createMemo<VM>(() => ({
|
||||
id: 'fixture:pve-a:101',
|
||||
vmid: 101,
|
||||
name: 'filesystem-evidence',
|
||||
node: 'pve-a',
|
||||
instance: 'fixture',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
|
||||
disk: observation(),
|
||||
disks: [observation()],
|
||||
diskStatusReason: cases[selected()].reason,
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: '2026-10-04T04:00:00Z',
|
||||
}));
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Filesystem usage evidence</h1>
|
||||
<label for="fixture-observation" class="flex flex-wrap items-center gap-2 text-sm">
|
||||
Synthetic observation
|
||||
<select
|
||||
id="fixture-observation"
|
||||
class="rounded-sm border border-border bg-surface p-2"
|
||||
value={selected()}
|
||||
onChange={(event) => setSelected(event.currentTarget.value)}
|
||||
>
|
||||
<For each={Object.keys(cases)}>{(name) => <option value={name}>{name}</option>}</For>
|
||||
</select>
|
||||
</label>
|
||||
<section aria-label="Filesystem list" class="max-w-xl">
|
||||
<DiskList disks={[observation()]} diskStatusReason={cases[selected()].reason} />
|
||||
</section>
|
||||
<section aria-label="Guest Overview">
|
||||
<GuestDrawerOverview
|
||||
guest={guest()}
|
||||
guestOsSummary=""
|
||||
agentHeading="Guest agent"
|
||||
agentLabel=""
|
||||
agentTitle=""
|
||||
hasAgentInfo={false}
|
||||
hasFilesystemDetails={true}
|
||||
hasNetworkInterfaces={false}
|
||||
hasOsInfo={false}
|
||||
hasWorkloadActionAgent={false}
|
||||
showInGuestAgentInstallCue={false}
|
||||
ipAddresses={[]}
|
||||
networkInterfaces={[]}
|
||||
normalizedTags={[]}
|
||||
backupPresentation={null}
|
||||
workloadActionAgentTitle=""
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
@@ -0,0 +1,340 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
const root = '/workspace/frontend-modern';
|
||||
const output = '/workspace/tmp/guest-history-provenance';
|
||||
const origin = 'http://127.0.0.1:5316';
|
||||
const runtime = [
|
||||
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
|
||||
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
|
||||
];
|
||||
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const settle = (page) =>
|
||||
page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const result = {
|
||||
result: 'incomplete',
|
||||
playwright: require('playwright/package.json').version,
|
||||
runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])),
|
||||
cases: [],
|
||||
screenshots: [],
|
||||
limits:
|
||||
'Production full GuestDrawer/History, API client and CSS with synthetic local responses. Not native QGA/thaw, provider collection, physical-phone, installed recovery or release acceptance.',
|
||||
};
|
||||
assert.equal(
|
||||
result.playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: output + '/cache',
|
||||
server: { host: '127.0.0.1', port: 5316, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['chromium-desktop', chromium, 1365, false],
|
||||
['chromium-phone', chromium, 390, false],
|
||||
['webkit-phone', webkit, 360, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width < 768 ? 844 : 900 },
|
||||
isMobile: width < 768,
|
||||
hasTouch: width < 768,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(15000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
let mode = 'empty';
|
||||
const record = {
|
||||
name,
|
||||
width,
|
||||
dark,
|
||||
browser: browser.version(),
|
||||
errors: [],
|
||||
requests: [],
|
||||
states: [],
|
||||
checks: [],
|
||||
};
|
||||
result.cases.push(record);
|
||||
page.on('pageerror', (error) => record.errors.push(error.message));
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin) {
|
||||
record.requests.push({ origin: url.origin, blocked: true });
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
record.requests.push({ path: url.pathname, method: route.request().method(), mode });
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
if (mode === '403' || mode === '503')
|
||||
return route.fulfill({
|
||||
status: Number(mode),
|
||||
json: { error: 'fixture-private-error-body' },
|
||||
});
|
||||
const start = Date.UTC(2026, 9, 4, 2),
|
||||
end = start + 60 * 60_000;
|
||||
const point = (timestamp, value) => ({ timestamp, value, min: value, max: value });
|
||||
const metrics =
|
||||
mode === 'stored'
|
||||
? {
|
||||
cpu: [point(start, 10), point(end, 15)],
|
||||
disk: [point(start, 20), point(end, 30)],
|
||||
}
|
||||
: mode === 'cpu-only'
|
||||
? { cpu: [point(start, 10), point(end, 15)] }
|
||||
: {};
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: url.searchParams.get('resourceType'),
|
||||
resourceId: url.searchParams.get('resourceId'),
|
||||
range: url.searchParams.get('range'),
|
||||
start,
|
||||
end,
|
||||
metrics,
|
||||
source: 'store',
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.pathname === '/api/license/runtime-capabilities'
|
||||
? {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
}
|
||||
: { success: true, data: [], alerts: [], anomalies: [], config: null },
|
||||
});
|
||||
});
|
||||
await page.addInitScript(
|
||||
(isDark) =>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (isDark) document.documentElement.classList.add('dark');
|
||||
}),
|
||||
dark,
|
||||
);
|
||||
await page.goto(origin + '/browser-tests/guest-history-provenance.html');
|
||||
await page.waitForFunction(() => window.__guestHistoryProvenance, undefined, {
|
||||
timeout: 60000,
|
||||
});
|
||||
const activate = (locator) => (width < 768 ? locator.tap() : locator.click());
|
||||
await activate(page.getByRole('tab', { name: 'History', exact: true }));
|
||||
const chart = page.locator('[data-history-group="utilization"]');
|
||||
const notice = chart.locator('[data-history-deferred="disk"]');
|
||||
const waitRead = async () => {
|
||||
await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]'));
|
||||
await settle(page);
|
||||
};
|
||||
const observe = async (next) => {
|
||||
await page.evaluate((value) => window.__guestHistoryProvenance.observe(value), next);
|
||||
await waitRead();
|
||||
};
|
||||
const screenshot = async (state) => {
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({
|
||||
path: output + '/' + file,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
result.screenshots.push({ file, sha256: hash(output + '/' + file), state, name });
|
||||
};
|
||||
const checkGeometry = async () => {
|
||||
const geometry = await notice.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect(),
|
||||
group = el.closest('section').getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
groupRight: group.right,
|
||||
textRight: Math.max(...Array.from(range.getClientRects(), (r) => r.right)),
|
||||
plotTop: el.nextElementSibling.getBoundingClientRect().top,
|
||||
overflow: document.documentElement.scrollWidth > innerWidth,
|
||||
};
|
||||
});
|
||||
assert.equal(geometry.overflow, false);
|
||||
assert.ok(
|
||||
geometry.left >= 0 &&
|
||||
geometry.right <= width &&
|
||||
geometry.textRight <= geometry.groupRight + 1,
|
||||
);
|
||||
assert.ok(geometry.bottom <= geometry.plotTop + 1);
|
||||
return geometry;
|
||||
};
|
||||
await waitRead();
|
||||
const reasons = await page.evaluate(() => window.__guestHistoryProvenance.deferrals);
|
||||
for (const [reason, message] of reasons) {
|
||||
for (const retained of [true, false]) {
|
||||
await observe({
|
||||
reason: (retained ? 'prev-' : '') + reason,
|
||||
usage: 50,
|
||||
lock: reason === 'vm-locked' ? 'backup' : '',
|
||||
});
|
||||
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
|
||||
assert.equal(
|
||||
await chart.locator('[data-history-last-known="disk"]').count(),
|
||||
retained ? 1 : 0,
|
||||
);
|
||||
assert.equal((await notice.innerText()).includes(message), true);
|
||||
if (retained) {
|
||||
const legend = chart.locator('[data-history-last-known="disk"]');
|
||||
assert.match(await legend.innerText(), /50\.0%[\s\S]*last known/);
|
||||
assert.equal(
|
||||
await legend.evaluate((el) =>
|
||||
document
|
||||
.getElementById(el.getAttribute('aria-describedby'))
|
||||
.textContent.includes('Using last known'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(await chart.locator('path').count(), 0);
|
||||
assert.equal(await chart.locator('[data-history-observation]').count(), 0);
|
||||
assert.equal(await chart.locator('[data-history-current="cpu"]').count(), 1);
|
||||
record.states.push({ reason, retained, geometry: await checkGeometry() });
|
||||
}
|
||||
}
|
||||
await observe({ reason: 'prev-vm-locked', usage: 0 });
|
||||
assert.match(
|
||||
await chart.locator('[data-history-last-known="disk"]').innerText(),
|
||||
/0\.0%[\s\S]*last known/,
|
||||
);
|
||||
record.checks.push('Retained zero is not lost or plotted.');
|
||||
await observe({ reason: 'prev-vm-locked', usage: 50, lock: 'backup' });
|
||||
await screenshot('last-known');
|
||||
await observe({ reason: 'prev-agent-busy', lock: '' });
|
||||
assert.match(await notice.innerText(), /earlier guest request is still in progress/);
|
||||
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
|
||||
record.checks.push('Lock clearance alone does not restore current evidence.');
|
||||
await observe({ reason: 'vm-locked', usage: -1 });
|
||||
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
|
||||
await screenshot('unavailable');
|
||||
await observe({ reason: 'prev-agent-timeout', usage: 50 });
|
||||
mode = 'stored';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-history-group="utilization"] path').length === 2,
|
||||
);
|
||||
await settle(page);
|
||||
const paths = await chart
|
||||
.locator('path')
|
||||
.evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d')));
|
||||
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
|
||||
assert.match(
|
||||
await notice.innerText(),
|
||||
/Completion is uncertain\. Do not restart the guest agent during a backup/,
|
||||
);
|
||||
const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
if (width < 768) await slider.tap({ position: { x: 3, y: 20 } });
|
||||
else {
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
}
|
||||
await settle(page);
|
||||
assert.match(await slider.getAttribute('aria-valuetext'), /Disk 20\.0%/);
|
||||
assert.equal(await chart.locator('[data-history-current]').count(), 0);
|
||||
assert.deepEqual(
|
||||
await chart.locator('path').evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d'))),
|
||||
paths,
|
||||
);
|
||||
await screenshot('stored-inspection');
|
||||
await slider.blur();
|
||||
record.checks.push(
|
||||
'Keyboard/touch inspection uses actual stored 20% at its own date, not retained 50%; paths remain unchanged.',
|
||||
);
|
||||
mode = 'cpu-only';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-history-group="utilization"] path').length === 1,
|
||||
);
|
||||
await settle(page);
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
assert.match(await slider.getAttribute('aria-valuetext'), /Disk no observation/);
|
||||
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
|
||||
await slider.blur();
|
||||
assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1);
|
||||
record.checks.push('A dated CPU observation cannot borrow the retained filesystem value.');
|
||||
mode = '503';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
await page.getByRole('button', { name: 'Retry history', exact: true }).waitFor();
|
||||
assert.equal(await chart.locator('path').count(), 1);
|
||||
assert.equal(await notice.count(), 1);
|
||||
assert.equal(
|
||||
(await page.locator('main').innerText()).includes('fixture-private-error-body'),
|
||||
false,
|
||||
);
|
||||
mode = '403';
|
||||
await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
|
||||
await page.waitForFunction(() => !document.querySelector('[data-history-group]'));
|
||||
assert.equal(await notice.count(), 0);
|
||||
assert.equal(await page.locator('[data-history-last-known]').count(), 0);
|
||||
record.checks.push(
|
||||
'Transient failure keeps prior stored data with warning; final access denial withdraws charts, fallback and notices.',
|
||||
);
|
||||
mode = 'empty';
|
||||
await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
|
||||
await notice.waitFor();
|
||||
await waitRead();
|
||||
await observe({ reason: '', usage: 75, lock: '' });
|
||||
assert.equal(await notice.count(), 0);
|
||||
assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-current="disk"]').innerText(),
|
||||
/75\.0%[\s\S]*current/,
|
||||
);
|
||||
assert.equal(await chart.locator('path').count(), 0);
|
||||
await screenshot('fresh-resumption');
|
||||
record.checks.push(
|
||||
'Successful read and fresh same-VM data restore current 75%, clear provenance, and create no history points.',
|
||||
);
|
||||
await observe({ reason: 'prev-vm-locked', kind: 'lxc', usage: 50 });
|
||||
assert.equal(await notice.count(), 0);
|
||||
assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1);
|
||||
assert.equal(
|
||||
record.requests.every((request) => request.method === 'GET' && !request.blocked),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(record.errors, []);
|
||||
await page.close();
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
result.result = 'passed';
|
||||
} catch (error) {
|
||||
result.error = { name: error.name, message: error.message };
|
||||
throw error;
|
||||
} finally {
|
||||
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
process.stderr.write(error.stack + '\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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>Guest History filesystem provenance verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./guest-history-provenance.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
// Full production drawer/CSS and client; observations and API responses are
|
||||
// synthetic. No backup, guest-agent or native recovery operation is performed.
|
||||
import { createMemo, createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { WorkloadGuest } from '../src/types/workloads';
|
||||
import { guestDiskDeferrals } from '../src/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
import '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
|
||||
function Fixture() {
|
||||
const [observation, setObservation] = createSignal({
|
||||
reason: 'prev-vm-locked',
|
||||
usage: 50,
|
||||
lock: 'backup',
|
||||
kind: 'qemu',
|
||||
});
|
||||
const guest = createMemo<WorkloadGuest>(() => ({
|
||||
id: 'fixture-pve:pve-a:101',
|
||||
vmid: 101,
|
||||
name: 'backup-guest',
|
||||
node: 'pve-a',
|
||||
instance: 'fixture-pve',
|
||||
status: 'running',
|
||||
type: observation().kind,
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
|
||||
disk: {
|
||||
total: 10 * 1024 ** 3,
|
||||
used: (observation().usage / 100) * 10 * 1024 ** 3,
|
||||
usage: observation().usage,
|
||||
},
|
||||
diskStatusReason: observation().reason,
|
||||
lock: observation().lock,
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
backupInProgress: Boolean(observation().lock),
|
||||
tags: [],
|
||||
lastSeen: '2026-10-04T03:00:00Z',
|
||||
}));
|
||||
(window as unknown as Record<string, unknown>).__guestHistoryProvenance = {
|
||||
observe: (next: Partial<ReturnType<typeof observation>>) =>
|
||||
setObservation((prev) => ({ ...prev, ...next })),
|
||||
deferrals: guestDiskDeferrals,
|
||||
};
|
||||
return (
|
||||
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Guest History filesystem provenance</h1>
|
||||
<p class="text-xs text-muted">
|
||||
Synthetic observations. No backup or guest-agent command is sent.
|
||||
</p>
|
||||
<section class="mx-auto max-w-5xl" aria-label="Guest details">
|
||||
<GuestDrawer guest={guest()} onClose={() => {}} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={() => (
|
||||
<DarkModeContext.Provider
|
||||
value={() => document.documentElement.classList.contains('dark')}
|
||||
>
|
||||
<Fixture />
|
||||
</DarkModeContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "d77f56bee4e2e79c9c41f1244f9a1b3e65b60dbb",
|
||||
"verified_at": "2026-10-04T03:28:54Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "8713b06fd7a8ec67d111b1b97ff84c9421e9bd738b769b28c09d5217dd41f50a",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "7422c574ca1dc1ce8ffc9d28b176656a9f9be951f319f1fa8ef38e8a9b4aec8c",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "eb057472aa06df1aa2f687a6d99f6409ecf849817fc70fad1e4c23e623bb1d9f"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/guest-history-provenance.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Production full GuestDrawer, History renderer, model, API client and CSS with synthetic transport/observations; desktop/phone Chromium and dark phone WebKit. Playwright 1.56.1 matches integration lock. All twelve final screenshots opened.",
|
||||
"48 fixed-deferral states: eight reasons x retained/unavailable x three contexts, visible safe reason with fitting geometry, independently current CPU/memory and never a current disk during deferral. Retained zero and pre-sample unavailable controls pass.",
|
||||
"Stored disk 20/30% remains dated chart evidence, independent from retained live 50%; dated CPU-only inspection reports no disk observation. Fallbacks never become plotted points.",
|
||||
"No physical phone, native QGA/backup/thaw/collection, installed recovery, release publication or customer acceptance established. Earlier failed fixture assertions/startup/tab attempts retained separately; only corrected fixture is accepted."
|
||||
],
|
||||
"interactions": [
|
||||
"Real mouse/phone touch opens the existing full drawer History tab and native refresh; desktop keyboard Home and phone touch range inspection use actual stored 20% at its own date without altering paths.",
|
||||
"Lock clearance with an in-flight read reason keeps the live note and last-known context. Successful fresh same-VM 75% withdraws provenance, updates current fallback and creates no history. Non-VM control is unaffected.",
|
||||
"Real same-origin synthetic 503 retains stored observations with warning; final 403 withdraws chart, fallback and deferral note until successful recovery. Private fixture error body stays hidden. All API requests are GET and no page errors occurred."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ba8b9fcd32f4f4d486e33d952ab09ecdc3d3b8a7",
|
||||
"verified_at": "2026-10-04T04:10:01.032Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/diskListModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/diskListModel.ts": "1afb2fa866a8c4772e56ce38861610705d43a619537c6d57e0a79b95d288394b"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/filesystem-usage-evidence.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"width": 360,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Production DiskList and GuestDrawerOverview with actual CSS; 57 synthetic states across desktop Chromium, phone Chromium and dark phone WebKit. Playwright 1.56.1 matches the integration lock.",
|
||||
"Missing, negative, non-finite and sentinel usage/capacity plus overflow never render a healthy zero, invalid percentage or numeric progress. Known capacity, mount identity and type remain visible.",
|
||||
"Explicit measured zero, ordinary bytes, omitted redundant usage, over-capacity and valid retained values preserve their labels and capped visual fill. Twelve final bitmap contents were inspected; eight phone bitmaps match already opened captures exactly.",
|
||||
"No native collection, backup thaw, installed recovery, physical phone, publication or release acceptance; full types/build qualification remains unavailable."
|
||||
],
|
||||
"interactions": [
|
||||
"Replaced each same-filesystem synthetic observation through the fixture select without remounting either production panel; list/Overview agree on unavailable versus measured values.",
|
||||
"Real fixture keyboard Home/Enter restores a live observation after unavailable. There is no new product action, route, tab, network call or collector change.",
|
||||
"No page errors, API requests or off-origin requests; unknown-state layout fits the document, and full-width drawer copy remains readable."
|
||||
]
|
||||
}
|
||||
@@ -220,6 +220,59 @@ afterEach(() => {
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GuestDrawer', () => {
|
||||
it('keeps deferred filesystem fallback last-known through History and fresh resumption', async () => {
|
||||
chartsApiMocks.getMetricsHistory.mockResolvedValue({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 3,
|
||||
metrics: {},
|
||||
source: 'store',
|
||||
});
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({
|
||||
disk: { total: 100, used: 50, usage: 50 },
|
||||
diskStatusReason: 'prev-vm-locked',
|
||||
lock: 'backup',
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
const history = screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'Disk50.0%last known',
|
||||
);
|
||||
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'Guest reads paused while Proxmox reports a VM operation lock',
|
||||
);
|
||||
setGuest({ ...guest(), lock: '', diskStatusReason: 'prev-agent-busy' });
|
||||
expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'earlier guest request is still in progress',
|
||||
);
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
setGuest({ ...guest(), diskStatusReason: '', disk: { total: 100, used: 75, usage: 75 } });
|
||||
expect(history.querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-deferred]')).toBeNull();
|
||||
expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'Disk75.0%current',
|
||||
);
|
||||
expect(history.querySelector('path')).toBeNull();
|
||||
// Existing drawer target recomputation follows each guest snapshot.
|
||||
// This presentation repair does not change that request ownership.
|
||||
for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) {
|
||||
expect(request).toMatchObject({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => {
|
||||
expect(guestDrawerSource).toContain('DiscoveryLoadingFallback');
|
||||
expect(guestDrawerSource).not.toContain(
|
||||
|
||||
@@ -6,7 +6,11 @@ import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessB
|
||||
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
|
||||
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
|
||||
import { getSimpleStatusIndicator } from '@/utils/status';
|
||||
import { getGuestDrawerCurrentMetrics, type GuestDrawerProps } from './guestDrawerModel';
|
||||
import {
|
||||
getGuestDrawerCurrentMetrics,
|
||||
getGuestDrawerDeferredMetrics,
|
||||
type GuestDrawerProps,
|
||||
} from './guestDrawerModel';
|
||||
import { useGuestDrawerState } from './useGuestDrawerState';
|
||||
import { GuestDrawerHistory, GuestDrawerHistoryRangeSelect } from './GuestDrawerHistory';
|
||||
import { GuestDrawerOverview } from './GuestDrawerOverview';
|
||||
@@ -49,6 +53,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
} = useGuestDrawerState(props);
|
||||
const headingId = () => `guest-drawer-heading-${guestId()}`;
|
||||
const historyCurrentMetrics = createMemo(() => getGuestDrawerCurrentMetrics(props.guest));
|
||||
const historyDeferredMetrics = createMemo(() => getGuestDrawerDeferredMetrics(props.guest));
|
||||
|
||||
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.guest.status));
|
||||
|
||||
@@ -132,6 +137,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||
target={historyTarget()}
|
||||
range={historyRange()}
|
||||
currentMetrics={historyCurrentMetrics()}
|
||||
deferredMetrics={historyDeferredMetrics()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,12 +41,14 @@ import {
|
||||
getGuestDrawerHistoryValueLabel,
|
||||
normalizeGuestDrawerHistoryPoints,
|
||||
type GuestDrawerHistoryGroupConfig,
|
||||
type GuestDrawerHistoryDeferredMetric,
|
||||
type GuestDrawerHistoryTarget,
|
||||
type GuestDrawerHistoryTimeBounds,
|
||||
} from './guestDrawerModel';
|
||||
|
||||
interface GuestDrawerHistoryProps {
|
||||
currentMetrics?: Record<string, number | null | undefined>;
|
||||
deferredMetrics?: Record<string, GuestDrawerHistoryDeferredMetric | undefined>;
|
||||
groups?: GuestDrawerHistoryGroupConfig[];
|
||||
range: HistoryTimeRange;
|
||||
target: GuestDrawerHistoryTarget | null;
|
||||
@@ -65,6 +67,7 @@ interface GuestDrawerHistoryQueryKey {
|
||||
|
||||
interface GuestDrawerHistoryGroupChartProps {
|
||||
currentMetrics?: Record<string, number | null | undefined>;
|
||||
deferredMetrics?: Record<string, GuestDrawerHistoryDeferredMetric | undefined>;
|
||||
group: GuestDrawerHistoryGroupConfig;
|
||||
loading: boolean;
|
||||
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
|
||||
@@ -309,24 +312,31 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
return byMetric;
|
||||
});
|
||||
const displaySeries = createMemo(() =>
|
||||
series().map((item) => {
|
||||
series().map((item, index) => {
|
||||
const hovered = hoveredByMetric().get(item.metric);
|
||||
const currentValue = props.currentMetrics?.[item.metric];
|
||||
const deferred = props.deferredMetrics?.[item.metric];
|
||||
const fallbackValue = deferred
|
||||
? deferred.lastKnownValue
|
||||
: props.currentMetrics?.[item.metric];
|
||||
const isFallback =
|
||||
activeTimestamp() === null &&
|
||||
item.points.length === 0 &&
|
||||
typeof fallbackValue === 'number' &&
|
||||
Number.isFinite(fallbackValue);
|
||||
return {
|
||||
...item,
|
||||
isCurrent:
|
||||
activeTimestamp() === null &&
|
||||
item.points.length === 0 &&
|
||||
typeof currentValue === 'number' &&
|
||||
Number.isFinite(currentValue),
|
||||
deferred,
|
||||
descriptionId: `${inspectionId}-deferred-${index}`,
|
||||
isCurrent: isFallback && !deferred,
|
||||
isLastKnown: isFallback && Boolean(deferred),
|
||||
valueLabel: hovered
|
||||
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
|
||||
: activeTimestamp() !== null
|
||||
? '-'
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: typeof fallbackValue === 'number' && Number.isFinite(fallbackValue)
|
||||
? formatHistoryChartTooltipValue(fallbackValue, item.unit)
|
||||
: '-',
|
||||
};
|
||||
}),
|
||||
@@ -382,6 +392,8 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
<span
|
||||
class="inline-flex items-center gap-1"
|
||||
data-history-current={item.isCurrent ? item.metric : undefined}
|
||||
data-history-last-known={item.isLastKnown ? item.metric : undefined}
|
||||
aria-describedby={item.deferred ? item.descriptionId : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
|
||||
<circle cx="5" cy="5" r="4" fill={item.color} />
|
||||
@@ -391,12 +403,27 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
<Show when={item.isCurrent}>
|
||||
<span>current</span>
|
||||
</Show>
|
||||
<Show when={item.isLastKnown}>
|
||||
<span>last known</span>
|
||||
</Show>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<For each={displaySeries().filter((item) => item.deferred)}>
|
||||
{(item) => (
|
||||
<p
|
||||
id={item.descriptionId}
|
||||
class="mb-2 text-xs leading-relaxed text-muted"
|
||||
data-history-deferred={item.metric}
|
||||
>
|
||||
{item.label} live reading: {item.deferred?.message}
|
||||
</p>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="relative min-h-24 flex-1">
|
||||
<For each={[0, 0.5, 1]}>
|
||||
{(tick) => (
|
||||
@@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
|
||||
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
|
||||
metrics={metrics()}
|
||||
currentMetrics={props.currentMetrics}
|
||||
deferredMetrics={props.deferredMetrics}
|
||||
range={props.range}
|
||||
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
|
||||
timeBounds={timeBounds()}
|
||||
|
||||
@@ -168,6 +168,27 @@ describe('DiskList', () => {
|
||||
});
|
||||
|
||||
describe('usage calculation', () => {
|
||||
it('withdraws one incomplete filesystem reading without losing another mount or inventing zero', () => {
|
||||
const stable = makeDisk({ mountpoint: '/stable', used: 26843545600 });
|
||||
const changing = makeDisk({ mountpoint: '/changing', device: '/dev/sda2' });
|
||||
const [disks, update] = createSignal([stable, changing]);
|
||||
render(() => <DiskList disks={disks()} />);
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
|
||||
update([stable, { ...changing, used: undefined }]);
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
expect(screen.getByText('/changing').parentElement).toHaveTextContent('?/100 GB');
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
expect(screen.queryByText('0%')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('50%')).not.toBeInTheDocument();
|
||||
|
||||
update([stable, { ...changing, used: 0, usage: undefined }]);
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(screen.queryByText('—')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('derives usage percent through the canonical disk-list model', () => {
|
||||
const disk = makeDisk({ used: 26843545600, total: 107374182400 });
|
||||
expect(getWorkloadsDiskUsagePercent(disk)).toBe(25);
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import { getGuestDrawerCurrentMetrics, getGuestDrawerDeferredMetrics } from '../guestDrawerModel';
|
||||
import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(async () => undefined),
|
||||
maxHistoryDays: () => 90,
|
||||
isRangeLocked: () => false,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const guest = (reason: string, usage = 50): WorkloadGuest => ({
|
||||
id: 'fixture:pve-a:101',
|
||||
vmid: 101,
|
||||
name: 'backup-guest',
|
||||
node: 'pve-a',
|
||||
instance: 'fixture',
|
||||
type: 'qemu',
|
||||
status: 'running',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: 100, used: 25, free: 75, usage: 25 },
|
||||
disk: { total: 100, used: usage, usage },
|
||||
networkIn: 100,
|
||||
networkOut: 200,
|
||||
diskRead: 300,
|
||||
diskWrite: 400,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
diskStatusReason: reason,
|
||||
lastSeen: '2026-10-04T03:00:00Z',
|
||||
});
|
||||
const start = Date.UTC(2026, 9, 4, 2);
|
||||
const end = start + 60 * 60_000;
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'fixture:pve-a:101',
|
||||
range: '1h',
|
||||
start,
|
||||
end,
|
||||
metrics,
|
||||
source: 'store',
|
||||
});
|
||||
const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value });
|
||||
const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve-a:101' };
|
||||
const mount = (value: () => WorkloadGuest) =>
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="1h"
|
||||
currentMetrics={getGuestDrawerCurrentMetrics(value())}
|
||||
deferredMetrics={getGuestDrawerDeferredMetrics(value())}
|
||||
/>
|
||||
));
|
||||
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
|
||||
// Actual backend provenance remains authoritative. Removing a VM lock alone
|
||||
// cannot turn retained readings into current data or prove native thaw.
|
||||
describe('GuestDrawerHistory filesystem provenance', () => {
|
||||
it.each(guestDiskDeferrals)(
|
||||
'labels retained %s as last known without creating a stored observation',
|
||||
async (reason, message) => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
mount(() => guest(`prev-${reason}`));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
const chart = utilization();
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
const retained = chart.querySelector('[data-history-last-known="disk"]')!;
|
||||
expect(retained).toHaveTextContent('Disk50.0%last known');
|
||||
expect(retained).toHaveAccessibleDescription(
|
||||
`Disk live reading: Using last known disk stats. ${message}`,
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
|
||||
expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
|
||||
'CPU10.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
|
||||
'Memory25.0%current',
|
||||
);
|
||||
expect(chart.querySelector('path')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-observation]')).toBeNull();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(guestDiskDeferrals)(
|
||||
'shows unavailable %s without promoting an unretained numeric summary',
|
||||
async (reason, message) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
mount(() => guest(reason));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
const chart = utilization();
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(chart).toHaveTextContent('Disk-');
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(message);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([-1, NaN, Infinity, undefined])(
|
||||
'does not display unusable retained disk usage %s',
|
||||
(usage) => {
|
||||
const value = guest('prev-vm-locked');
|
||||
value.disk.usage = usage as number;
|
||||
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
|
||||
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
|
||||
expect(getGuestDrawerDeferredMetrics(value).disk.message).toContain('Guest reads paused');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps retained zero valid and honours explicit disk telemetry unavailability', () => {
|
||||
const value = guest('prev-agent-busy', 0);
|
||||
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBe(0);
|
||||
value.telemetryAvailability = {
|
||||
cpu: true,
|
||||
memory: true,
|
||||
disk: false,
|
||||
networkIO: true,
|
||||
diskIO: true,
|
||||
uptime: true,
|
||||
};
|
||||
expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
|
||||
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
|
||||
expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({
|
||||
cpu: 10,
|
||||
memory: 25,
|
||||
diskread: 300,
|
||||
diskwrite: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-VM data independent and maps unknown reason text without exposing it', () => {
|
||||
for (const type of ['lxc', 'app-container'] as const) {
|
||||
const value = { ...guest('prev-vm-locked'), type };
|
||||
expect(getGuestDrawerDeferredMetrics(value)).toEqual({});
|
||||
expect(getGuestDrawerCurrentMetrics(value).disk).toBe(50);
|
||||
}
|
||||
const value = guest('private transport error');
|
||||
expect(getGuestDrawerDeferredMetrics(value).disk.message).toBe(
|
||||
'Disk stats unavailable. Guest agent may not be installed.',
|
||||
);
|
||||
expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps real stored points and dated keyboard inspection separate from live deferral', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
disk: [point(start, 20), point(end, 30)],
|
||||
cpu: [point(start, 10), point(end, 15)],
|
||||
}),
|
||||
);
|
||||
mount(() => guest('prev-agent-timeout'));
|
||||
await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(2));
|
||||
const chart = utilization();
|
||||
expect(chart).toHaveTextContent('Disk30.0%');
|
||||
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'Completion is uncertain. Do not restart the guest agent during a backup.',
|
||||
);
|
||||
const slider = within(chart).getByRole('slider');
|
||||
slider.focus();
|
||||
fireEvent.input(slider, { target: { value: '0' } });
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk 20.0%.`,
|
||||
);
|
||||
expect(chart).toHaveTextContent('Disk20.0%');
|
||||
expect(chart.querySelector('[data-history-current]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not borrow retained live disk evidence during another metric's dated inspection", async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({ cpu: [point(start, 10), point(end, 15)] }),
|
||||
);
|
||||
mount(() => guest('prev-vm-locked'));
|
||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
||||
const slider = screen.getByRole('slider');
|
||||
slider.focus();
|
||||
fireEvent.input(slider, { target: { value: '0' } });
|
||||
expect(utilization()).toHaveTextContent('Disk-');
|
||||
expect(utilization().querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
expect.stringContaining('Disk no observation.'),
|
||||
);
|
||||
fireEvent.blur(slider);
|
||||
expect(utilization().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'50.0%last known',
|
||||
);
|
||||
});
|
||||
|
||||
it('withdraws retained context only with fresh same-source data, without refetch or remount', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
const [value, setValue] = createSignal(guest('prev-vm-locked'));
|
||||
mount(value);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
const chart = utilization();
|
||||
setValue({ ...value(), lock: '', diskStatusReason: 'prev-agent-busy' });
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'still in progress',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
setValue(guest('', 75));
|
||||
expect(utilization()).toBe(chart);
|
||||
expect(chart.querySelector('[data-history-deferred]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent('75.0%current');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reveal retained evidence through denied History', async () => {
|
||||
const fetch = vi
|
||||
.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockRejectedValueOnce(Object.assign(new Error('private refusal'), { status: 403 }))
|
||||
.mockResolvedValueOnce(response());
|
||||
mount(() => guest('prev-vm-locked'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'false',
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-history-deferred]')).toBeNull();
|
||||
expect(screen.queryByText('private refusal')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
|
||||
await waitFor(() =>
|
||||
expect(utilization().querySelector('[data-history-last-known]')).not.toBeNull(),
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,30 @@ const group = (id = 'utilization') =>
|
||||
const dots = () => document.querySelectorAll('[data-history-observation]');
|
||||
|
||||
describe('GuestDrawerHistory sparse observations and current provenance', () => {
|
||||
it('gives explicit deferred evidence precedence over a numeric current fallback', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="1h"
|
||||
currentMetrics={{ disk: 99 }}
|
||||
deferredMetrics={{ disk: { lastKnownValue: 50, message: 'Guest reads paused.' } }}
|
||||
/>
|
||||
));
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
|
||||
);
|
||||
expect(group().querySelector('[data-history-current]')).toBeNull();
|
||||
expect(group().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'Disk50.0%last known',
|
||||
);
|
||||
expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription(
|
||||
'Disk live reading: Guest reads paused.',
|
||||
);
|
||||
expect(group().querySelector('path')).toBeNull();
|
||||
expect(dots()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cpu', 'utilization', 'CPU 0.0%'],
|
||||
['netin', 'network', 'In 0 B/s'],
|
||||
|
||||
+5
-5
@@ -60,8 +60,8 @@ describe('diskListModel (branch coverage 0713)', () => {
|
||||
});
|
||||
|
||||
describe('getWorkloadsDiskUsagePercent', () => {
|
||||
it('returns 0 when the disk has no capacity (false arm of the guard)', () => {
|
||||
expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBe(0);
|
||||
it('returns null when the disk has no capacity rather than a measured zero', () => {
|
||||
expect(getWorkloadsDiskUsagePercent(makeDisk({ total: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns (used / total) * 100 for a normal ratio (true arm of the guard)', () => {
|
||||
@@ -142,8 +142,8 @@ describe('diskListModel (branch coverage 0713)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a no-capacity disk (percent 0) to the normal class', () => {
|
||||
expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe(NORMAL_CLASS);
|
||||
it('keeps a no-capacity disk neutral rather than classifying an unmeasured zero', () => {
|
||||
expect(getWorkloadsDiskProgressClass(makeDisk({ total: 0 }))).toBe('bg-surface-hover');
|
||||
});
|
||||
|
||||
it('falls back to the default disk thresholds when thresholds is null (nullish arm)', () => {
|
||||
@@ -280,7 +280,7 @@ describe('diskListModel (branch coverage 0713)', () => {
|
||||
key: '/foo:/dev/sdb:5',
|
||||
label: '/foo',
|
||||
labelTitle: '/foo',
|
||||
progressClass: NORMAL_CLASS,
|
||||
progressClass: 'bg-surface-hover',
|
||||
progressValue: null,
|
||||
progressWidth: '0%',
|
||||
typeLabel: 'EXT4',
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import type { Disk, VM } from '@/types/api';
|
||||
import { DiskList } from '../DiskList';
|
||||
import { GuestDrawerOverview } from '../GuestDrawerOverview';
|
||||
import { buildWorkloadsDiskPresentation, getWorkloadsDiskUsagePercent } from '../diskListModel';
|
||||
|
||||
const capacity = 10 * 1024 ** 3;
|
||||
const disk = (overrides: Partial<Disk> = {}): Disk => ({
|
||||
mountpoint: '/data',
|
||||
type: 'ext4',
|
||||
total: capacity,
|
||||
used: capacity / 2,
|
||||
usage: 50,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const unavailable: [string, Partial<Disk>][] = [
|
||||
['missing used bytes', { used: undefined }],
|
||||
['negative used bytes', { used: -1 }],
|
||||
['NaN used bytes', { used: NaN }],
|
||||
['infinite used bytes', { used: Infinity }],
|
||||
['negative infinite used bytes', { used: -Infinity }],
|
||||
['missing capacity', { total: undefined }],
|
||||
['zero capacity', { total: 0 }],
|
||||
['negative capacity', { total: -1 }],
|
||||
['NaN capacity', { total: NaN }],
|
||||
['infinite capacity', { total: Infinity }],
|
||||
['negative infinite capacity', { total: -Infinity }],
|
||||
['unknown usage sentinel', { usage: -1, used: 0 }],
|
||||
['NaN usage', { usage: NaN }],
|
||||
['infinite usage', { usage: Infinity }],
|
||||
['negative infinite usage', { usage: -Infinity }],
|
||||
['overflowing derived percentage', { total: 1, used: Number.MAX_VALUE }],
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('filesystem readings need measured bytes, not zero defaults', () => {
|
||||
it.each(unavailable)('withdraws a numeric reading for %s', (_name, values) => {
|
||||
const input = disk(values);
|
||||
const presentation = buildWorkloadsDiskPresentation(input, 0);
|
||||
expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
|
||||
expect(presentation.progressValue).toBeNull();
|
||||
expect(presentation.progressWidth).toBe('0%');
|
||||
expect(presentation.progressClass).toBe('bg-surface-hover');
|
||||
expect(presentation.usagePercentLabel).toBe('—');
|
||||
expect(presentation.usageText).not.toMatch(/NaN|Infinity|^0 B\//);
|
||||
expect(presentation.label).toBe('/data');
|
||||
expect(presentation.typeLabel).toBe('EXT4');
|
||||
});
|
||||
|
||||
it('retains known capacity without inventing used bytes from a percentage or free bytes', () => {
|
||||
const input = disk({ used: undefined, usage: 50, free: capacity / 2 });
|
||||
expect(buildWorkloadsDiskPresentation(input, 0).usageText).toBe('?/10.0 GB');
|
||||
expect(getWorkloadsDiskUsagePercent(input)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an explicit measured zero, including when the redundant usage field is omitted', () => {
|
||||
const presentation = buildWorkloadsDiskPresentation(disk({ used: 0, usage: undefined }), 0);
|
||||
expect(presentation.progressValue).toBe(0);
|
||||
expect(presentation.usagePercentLabel).toBe('0%');
|
||||
expect(presentation.usageText).toBe('0 B/10.0 GB');
|
||||
expect(presentation.progressClass).toContain('metric-normal');
|
||||
});
|
||||
|
||||
it('derives ordinary and over-capacity percentages from the measured bytes', () => {
|
||||
const input = disk({ used: capacity * 1.25, usage: 0 });
|
||||
const presentation = buildWorkloadsDiskPresentation(input, 0);
|
||||
expect(getWorkloadsDiskUsagePercent(disk({ usage: undefined }))).toBe(50);
|
||||
expect(presentation.progressValue).toBe(125);
|
||||
expect(presentation.usagePercentLabel).toBe('125%');
|
||||
expect(presentation.progressWidth).toBe('100%');
|
||||
expect(presentation.progressClass).toContain('metric-critical');
|
||||
});
|
||||
});
|
||||
|
||||
function renderPanels(initial: Disk) {
|
||||
const [observation, setObservation] = createSignal(initial);
|
||||
const guest = (): VM => ({
|
||||
id: 'fixture:pve-a:101',
|
||||
vmid: 101,
|
||||
node: 'pve-a',
|
||||
instance: 'fixture',
|
||||
name: 'filesystem-evidence',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: { total: capacity, used: capacity / 2, free: capacity / 2, usage: 50 },
|
||||
disk: observation(),
|
||||
disks: [observation()],
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: '2026-10-04T04:00:00Z',
|
||||
});
|
||||
render(() => (
|
||||
<>
|
||||
<section data-testid="disk-list-panel">
|
||||
<DiskList disks={[observation()]} />
|
||||
</section>
|
||||
<GuestDrawerOverview
|
||||
guest={guest()}
|
||||
guestOsSummary=""
|
||||
agentHeading="Guest agent"
|
||||
agentLabel=""
|
||||
agentTitle=""
|
||||
hasAgentInfo={false}
|
||||
hasFilesystemDetails={true}
|
||||
hasNetworkInterfaces={false}
|
||||
hasOsInfo={false}
|
||||
hasWorkloadActionAgent={false}
|
||||
showInGuestAgentInstallCue={false}
|
||||
ipAddresses={[]}
|
||||
networkInterfaces={[]}
|
||||
normalizedTags={[]}
|
||||
backupPresentation={null}
|
||||
workloadActionAgentTitle=""
|
||||
/>
|
||||
</>
|
||||
));
|
||||
return setObservation;
|
||||
}
|
||||
|
||||
describe('both filesystem panels preserve unavailable versus measured zero', () => {
|
||||
it.each([
|
||||
['missing used bytes', { used: undefined }],
|
||||
['invalid used bytes', { used: NaN }],
|
||||
['invalid capacity', { total: Infinity }],
|
||||
['unknown usage sentinel', { usage: -1, used: 0 }],
|
||||
] satisfies [string, Partial<Disk>][])(
|
||||
'does not show an empty healthy disk for %s',
|
||||
(_name, values) => {
|
||||
renderPanels(disk(values));
|
||||
for (const id of ['disk-list-panel', 'guest-technical-details']) {
|
||||
const panel = screen.getByTestId(id);
|
||||
expect(panel.textContent).toContain('—');
|
||||
expect(panel.textContent).not.toMatch(/0%|NaN|Infinity/);
|
||||
expect(within(panel).queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('withdraws a prior measurement and restores zero only after explicit same-filesystem evidence', () => {
|
||||
const update = renderPanels(disk());
|
||||
const details = screen.getByTestId('guest-technical-details');
|
||||
expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '50');
|
||||
update(disk({ used: undefined }));
|
||||
expect(within(details).queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
expect(details).toHaveTextContent('— · ?/10.0 GB · EXT4');
|
||||
update(disk({ used: 0, usage: undefined }));
|
||||
expect(within(details).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0');
|
||||
expect(details).toHaveTextContent('0% · 0 B/10.0 GB · EXT4');
|
||||
});
|
||||
});
|
||||
@@ -22,21 +22,32 @@ export interface WorkloadsDiskPresentation {
|
||||
usagePercentLabel: string;
|
||||
}
|
||||
|
||||
export const hasWorkloadsDiskCapacity = (disk: Disk): boolean =>
|
||||
typeof disk.total === 'number' && disk.total > 0;
|
||||
export const hasWorkloadsDiskCapacity = (disk: Disk): disk is Disk & { total: number } =>
|
||||
typeof disk.total === 'number' && Number.isFinite(disk.total) && disk.total > 0;
|
||||
|
||||
// The poller reports usage -1 for mounts it can only see in the container
|
||||
// config (capacity may be known, live usage is not).
|
||||
export const isWorkloadsDiskUsageUnknown = (disk: Disk): boolean =>
|
||||
typeof disk.usage === 'number' && disk.usage < 0;
|
||||
typeof disk.used !== 'number' ||
|
||||
!Number.isFinite(disk.used) ||
|
||||
disk.used < 0 ||
|
||||
(typeof disk.usage === 'number' && (!Number.isFinite(disk.usage) || disk.usage < 0));
|
||||
|
||||
export const getWorkloadsDiskUsagePercent = (disk: Disk): number => {
|
||||
const total = disk.total ?? 0;
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
// Capacity alone is not a measurement. In particular, an omitted `used`
|
||||
// field must not become a healthy empty filesystem, even if usage/free exist.
|
||||
export const getWorkloadsDiskUsagePercent = (disk: Disk): number | null => {
|
||||
const { total, used } = disk;
|
||||
if (
|
||||
!hasWorkloadsDiskCapacity(disk) ||
|
||||
isWorkloadsDiskUsageUnknown(disk) ||
|
||||
typeof total !== 'number' ||
|
||||
typeof used !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((disk.used ?? 0) / total) * 100;
|
||||
const percent = (used / total) * 100;
|
||||
return Number.isFinite(percent) ? percent : null;
|
||||
};
|
||||
|
||||
export const getWorkloadsDiskLabel = (disk: Disk): string =>
|
||||
@@ -46,35 +57,34 @@ export const getWorkloadsDiskLabelTitle = (label: string): string | undefined =>
|
||||
label !== 'Unknown' ? label : undefined;
|
||||
|
||||
export const getWorkloadsDiskUsageText = (disk: Disk): string => {
|
||||
if (isWorkloadsDiskUsageUnknown(disk)) {
|
||||
return hasWorkloadsDiskCapacity(disk)
|
||||
? `?/${formatBytes(disk.total ?? 0)}`
|
||||
: 'Usage unavailable';
|
||||
}
|
||||
return hasWorkloadsDiskCapacity(disk)
|
||||
? `${formatBytes(disk.used ?? 0)}/${formatBytes(disk.total ?? 0)}`
|
||||
: 'Usage unavailable';
|
||||
if (!hasWorkloadsDiskCapacity(disk)) return 'Usage unavailable';
|
||||
const used =
|
||||
getWorkloadsDiskUsagePercent(disk) === null || typeof disk.used !== 'number'
|
||||
? '?'
|
||||
: formatBytes(disk.used);
|
||||
return `${used}/${formatBytes(disk.total)}`;
|
||||
};
|
||||
|
||||
export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string =>
|
||||
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
|
||||
? `${getWorkloadsDiskUsagePercent(disk).toFixed(0)}%`
|
||||
: '—';
|
||||
export const getWorkloadsDiskUsagePercentLabel = (disk: Disk): string => {
|
||||
const percent = getWorkloadsDiskUsagePercent(disk);
|
||||
return percent === null ? '—' : `${percent.toFixed(0)}%`;
|
||||
};
|
||||
|
||||
export const getWorkloadsDiskProgressClass = (
|
||||
disk: Disk,
|
||||
thresholds?: MetricDisplayThresholds | null,
|
||||
): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
|
||||
): string => {
|
||||
const percent = getWorkloadsDiskUsagePercent(disk);
|
||||
return percent === null ? 'bg-surface-hover' : getMetricColorClass(percent, 'disk', thresholds);
|
||||
};
|
||||
|
||||
export const getWorkloadsDiskProgressValue = (disk: Disk): number | null =>
|
||||
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
|
||||
? getWorkloadsDiskUsagePercent(disk)
|
||||
: null;
|
||||
getWorkloadsDiskUsagePercent(disk);
|
||||
|
||||
export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
|
||||
isWorkloadsDiskUsageUnknown(disk)
|
||||
? '0%'
|
||||
: `${Math.min(getWorkloadsDiskUsagePercent(disk), 100)}%`;
|
||||
export const getWorkloadsDiskProgressWidth = (disk: Disk): string => {
|
||||
const percent = getWorkloadsDiskUsagePercent(disk);
|
||||
return percent === null ? '0%' : `${Math.min(percent, 100)}%`;
|
||||
};
|
||||
|
||||
export const getWorkloadsDiskTypeLabel = (disk: Disk): string => disk.type?.toUpperCase() ?? '';
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import type {
|
||||
|
||||
import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel';
|
||||
import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format';
|
||||
import { getWorkloadsGuestBackupStatusPresentation } from '@/utils/workloadGuestPresentation';
|
||||
import {
|
||||
getWorkloadGuestDiskStatusMessage,
|
||||
getWorkloadsGuestBackupStatusPresentation,
|
||||
} from '@/utils/workloadGuestPresentation';
|
||||
import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads';
|
||||
import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
@@ -60,6 +63,11 @@ export interface GuestDrawerHistoryTimeBounds {
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface GuestDrawerHistoryDeferredMetric {
|
||||
lastKnownValue?: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GuestDrawerBackupPresentation {
|
||||
ageClass: string;
|
||||
ageLabel: string;
|
||||
@@ -93,6 +101,25 @@ export const getGuestDrawerAlertMessage = (
|
||||
return `${alert.message} (${comparison})`;
|
||||
};
|
||||
|
||||
const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
|
||||
const usage = guest.telemetryAvailability?.disk === false ? undefined : guest.disk?.usage;
|
||||
return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
|
||||
};
|
||||
|
||||
export const getGuestDrawerDeferredMetrics = (
|
||||
guest: Guest,
|
||||
): Record<string, GuestDrawerHistoryDeferredMetric> => {
|
||||
if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {};
|
||||
return {
|
||||
disk: {
|
||||
lastKnownValue: guest.diskStatusReason.startsWith('prev-')
|
||||
? getGuestDrawerDiskUsage(guest)
|
||||
: undefined,
|
||||
message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Current-value metrics displayed beside history legends while the metrics
|
||||
// store is still accumulating samples. These values never become chart points:
|
||||
// a current reading is not evidence of a historical trend.
|
||||
@@ -103,13 +130,10 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record<string, numbe
|
||||
const cpuPercent = available('cpu') ? getWorkloadCPUPercent(guest.cpu) : undefined;
|
||||
const memUsage =
|
||||
available('memory') && !guest.memory?.usageUnavailable ? guest.memory?.usage : undefined;
|
||||
const reportedDiskUsage = available('disk') ? guest.disk?.usage : undefined;
|
||||
// A paused filesystem read is not current, even if the snapshot still
|
||||
// carries a numeric summary. Retained evidence has its own labelled path.
|
||||
const diskUsage =
|
||||
typeof reportedDiskUsage === 'number' &&
|
||||
Number.isFinite(reportedDiskUsage) &&
|
||||
reportedDiskUsage >= 0
|
||||
? reportedDiskUsage
|
||||
: undefined;
|
||||
isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
|
||||
const finite = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user