mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Keep paused guest filesystem readings truthful in History
Separate current and explicitly retained disk evidence in the existing guest drawer. Show the fixed safe read reason beside History, preserve dated observations and access withdrawal, and clear retained provenance only with fresh source evidence. Record desktop and touch browser verification; native backup thaw and release delivery remain separate. Change-source: pulse-maintainer
This commit is contained in:
@@ -69,6 +69,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,19 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### 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,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."
|
||||
]
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
+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'],
|
||||
|
||||
@@ -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