mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Keep missing drawer History readings out of continuous trends
Split series at actual in-panel stored times without that metric, retaining every isolated and measured-zero observation. Explain gaps visibly and accessibly without guessing cadence, outage cause or duration. Live recovery cannot fill stored gaps. Production drawer browser proof and connected controls preserve source/access isolation, exact-time inspection, independent readings and the request budget. Change-source: pulse-maintainer
This commit is contained in:
@@ -8304,3 +8304,34 @@ chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS,
|
||||
403/503 controls and pending recovery. Unit controls separately cover current
|
||||
request ownership and disposal. No mutation, route, navigation entry, queue
|
||||
policy, permission or recipient-delivery contract is added by these states.
|
||||
|
||||
### Drawer History does not interpolate across missing observations
|
||||
|
||||
The shared `GuestDrawerHistory` renderer connects a series only across
|
||||
consecutive stored times in that configured panel. If another series has a
|
||||
stored observation at an intervening time and this series does not, its line
|
||||
stops. Each isolated observation, including a measured zero, remains visible
|
||||
as a point; separate continuous segments retain all their original values,
|
||||
timestamps and common-window coordinates. Independently sampled series must
|
||||
not imply simultaneous observations or a continuous trend through absent data.
|
||||
|
||||
A visible, SVG-described explanation names the affected configured series and
|
||||
states the limit: missing readings do not identify an outage's cause or duration.
|
||||
There is no elapsed-time/cadence heuristic, synthetic zero, invented observation,
|
||||
new API field or collector change. Times outside the configured panel cannot
|
||||
split its series. Entirely absent series retain the existing missing/fallback
|
||||
policy. Live or retained snapshot carriers cannot repair a stored gap; only a
|
||||
matching stored response can. Source/range/access isolation, transient retained
|
||||
reads and exact-time keyboard/pointer/touch inspection remain in force.
|
||||
|
||||
`GuestDrawerHistory.gaps.test.tsx` and `guestHistorySegments.test.ts` cover
|
||||
split lines, singleton recovery, zero rates, interleaved sampling, multiple
|
||||
segments, configured-group isolation, invalid points, elapsed-time controls,
|
||||
live updates, stored refresh and denied/transient reads. `GuestDrawer.test.tsx`
|
||||
checks the full drawer's live-state recovery and unchanged request budget.
|
||||
Updated pointer/sparse
|
||||
regressions preserve their original missing-value and fallback obligations.
|
||||
`browser-tests/history-observation-gaps.cjs` exercises the production full guest
|
||||
drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light
|
||||
and dark views. This is presentation acceptance, not native backup/QGA safety,
|
||||
provider outage attribution, installed recovery or release availability.
|
||||
|
||||
@@ -3661,3 +3661,17 @@ backend, metrics-store, performance threshold or latency claim changes here.
|
||||
including selection retention while a same-source window advances. Browser
|
||||
receipts exercise the actual PBS table/drawer renderer with synthetic APIs,
|
||||
not installed collection or a performance benchmark.
|
||||
|
||||
### Stored drawer History segmentation stays bounded
|
||||
|
||||
Missing-observation rendering uses the existing normalized, configured-group
|
||||
timestamps and the unchanged maximum of 240 requested points per metric. The
|
||||
segment builder indexes those timestamps once per series and walks its stored
|
||||
points without nested nearest-point searches. It adds no request, polling timer,
|
||||
collector or synthetic reading. Separate singletons/segments retain the source
|
||||
data and shared time window; live updates cannot fill absent stored evidence.
|
||||
The mounted `GuestDrawerHistory.gaps.test.tsx` controls pin one initial read and
|
||||
only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the
|
||||
unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins
|
||||
complete point preservation at the existing request bound. Neither claims a
|
||||
timing benchmark, fleet CPU attribution or native incident relief.
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
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/history-observation-gaps/browser';
|
||||
const origin = 'http://127.0.0.1:5317';
|
||||
const runtime = [
|
||||
'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, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone 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: 5317, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['chromium-desktop-light', chromium, 1365, false],
|
||||
['chromium-desktop-dark', chromium, 1365, true],
|
||||
['webkit-phone-dark', webkit, 390, true],
|
||||
['webkit-narrow-light', webkit, 320, false],
|
||||
]) {
|
||||
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(12000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const record = {
|
||||
name,
|
||||
width,
|
||||
dark,
|
||||
browser: browser.version(),
|
||||
errors: [],
|
||||
requests: [],
|
||||
checks: [],
|
||||
};
|
||||
result.cases.push(record);
|
||||
let mode = 'gaps';
|
||||
const start = Date.UTC(2026, 9, 4, 12);
|
||||
const point = (minute, value) => ({
|
||||
timestamp: start + minute * 60_000,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const times = [0, 15, 30, 45, 60];
|
||||
const metrics = () => ({
|
||||
cpu: times.map((minute, index) => point(minute, 10 + index * 5)),
|
||||
memory: (mode === 'complete' ? times : [0, 15, 60]).map((minute) =>
|
||||
point(minute, 20 + minute / 6),
|
||||
),
|
||||
disk: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 50)),
|
||||
netin: times.map((minute) => point(minute, 100)),
|
||||
netout: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)),
|
||||
diskread: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)),
|
||||
diskwrite: times.map((minute) => point(minute, 200)),
|
||||
});
|
||||
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') {
|
||||
assert.equal(route.request().method(), 'GET');
|
||||
assert.equal(url.searchParams.get('resourceId'), 'fixture:pve:101');
|
||||
assert.equal(url.searchParams.get('maxPoints'), '240');
|
||||
if (mode === '503' || mode === '403')
|
||||
return route.fulfill({
|
||||
status: Number(mode),
|
||||
json: { error: 'private-fixture-detail' },
|
||||
});
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: url.searchParams.get('resourceType'),
|
||||
resourceId: url.searchParams.get('resourceId'),
|
||||
range: url.searchParams.get('range'),
|
||||
start,
|
||||
end: start + 60 * 60_000,
|
||||
metrics: metrics(),
|
||||
source: 'store',
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.pathname === '/api/license/runtime-capabilities'
|
||||
? {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 90,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
}
|
||||
: { success: true, data: [], alerts: [], anomalies: [], config: null },
|
||||
});
|
||||
});
|
||||
await page.addInitScript((isDark) => {
|
||||
document.addEventListener('DOMContentLoaded', () =>
|
||||
document.documentElement.classList.toggle('dark', isDark),
|
||||
);
|
||||
window.__trustedInputs = [];
|
||||
for (const type of ['click', 'input', 'keydown'])
|
||||
document.addEventListener(
|
||||
type,
|
||||
(event) => window.__trustedInputs.push({ type, trusted: event.isTrusted }),
|
||||
true,
|
||||
);
|
||||
}, dark);
|
||||
await page.goto(origin + '/browser-tests/history-observation-gaps.html');
|
||||
const activate = async (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 slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
await settle(page);
|
||||
await page.evaluate(() => {
|
||||
window.__originalHistoryChart = document.querySelector(
|
||||
'[data-history-group="utilization"]',
|
||||
);
|
||||
});
|
||||
const memoryPath = chart.locator('path[stroke="#f59e0b"]');
|
||||
assert.equal(await memoryPath.count(), 1);
|
||||
assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 1);
|
||||
assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1);
|
||||
assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0);
|
||||
assert.equal(await chart.locator('[data-history-observation="disk"]').count(), 2);
|
||||
assert.equal(
|
||||
(await chart.locator('path[stroke="#8b5cf6"]').getAttribute('d')).match(/L/g).length,
|
||||
4,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-gaps]').innerText(),
|
||||
/Missing observations: Memory, Disk\./,
|
||||
);
|
||||
assert.equal(await page.locator('[data-history-gaps]').count(), 3);
|
||||
const description = await chart
|
||||
.getByRole('img', { name: 'Utilization history' })
|
||||
.evaluate((el) =>
|
||||
el
|
||||
.getAttribute('aria-describedby')
|
||||
.split(' ')
|
||||
.map((id) => document.getElementById(id).textContent)
|
||||
.join(' '),
|
||||
);
|
||||
assert.match(description, /Missing observations: Memory, Disk\./);
|
||||
assert.match(description, /does not identify the cause or duration/);
|
||||
const noteGeometry = await chart.locator('[data-history-gaps]').evaluate((el) => ({
|
||||
width: el.clientWidth,
|
||||
scrollWidth: el.scrollWidth,
|
||||
colour: getComputedStyle(el).color,
|
||||
}));
|
||||
assert.ok(noteGeometry.scrollWidth <= noteGeometry.width + 1);
|
||||
record.noteGeometry = noteGeometry;
|
||||
record.checks.push(
|
||||
'Actual stored gap splits only affected metrics; recovered singleton/zero samples stay visible; visible/accessibly linked explanation fits.',
|
||||
);
|
||||
const screenshot = async (suffix) => {
|
||||
await settle(page);
|
||||
const file = `${name}-${suffix}.png`;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true });
|
||||
result.screenshots.push({
|
||||
file,
|
||||
sha256: hash(path.join(output, file)),
|
||||
name,
|
||||
state: suffix,
|
||||
});
|
||||
};
|
||||
await screenshot('gaps');
|
||||
|
||||
await slider.focus();
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.equal(await slider.inputValue(), '2');
|
||||
assert.match(
|
||||
await slider.getAttribute('aria-valuetext'),
|
||||
/Memory no observation\. Disk no observation\./,
|
||||
);
|
||||
assert.match(await chart.innerText(), /Memory\s*-/);
|
||||
assert.equal(await chart.locator('circle[r="3"]').count(), 1);
|
||||
record.checks.push(
|
||||
'Trusted Home/Arrow keys inspect the missing stored time without borrowing nearby/live memory or disk.',
|
||||
);
|
||||
if (width < 768) {
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
const box = await slider.boundingBox();
|
||||
await page.touchscreen.tap(box.x + box.width * 0.75, box.y + box.height / 2);
|
||||
assert.notEqual(await slider.inputValue(), '2');
|
||||
record.checks.push('Trusted touch operates the native inspector.');
|
||||
} else {
|
||||
await page.getByRole('heading', { name: 'Drawer History missing observations' }).click();
|
||||
const box = await chart.getByRole('img', { name: 'Utilization history' }).boundingBox();
|
||||
await page.mouse.move(box.x + box.width * 0.537, box.y + box.height / 2);
|
||||
assert.match(
|
||||
await chart.locator('[data-testid="guest-history-hover-time"]').innerText(),
|
||||
/12:30/,
|
||||
);
|
||||
assert.equal(await chart.locator('circle[r="3"]').count(), 1);
|
||||
record.checks.push(
|
||||
'Trusted pointer matches the exact stored time, not a synthetic line value.',
|
||||
);
|
||||
}
|
||||
const reads = () =>
|
||||
record.requests.filter((r) => r.path === '/api/metrics-store/history').length;
|
||||
const before = reads();
|
||||
await activate(page.getByRole('button', { name: 'Simulate live recovery' }));
|
||||
assert.equal(reads(), before);
|
||||
assert.equal(await chart.locator('[data-history-gaps]').count(), 1);
|
||||
assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__originalHistoryChart ===
|
||||
document.querySelector('[data-history-group="utilization"]'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
record.checks.push(
|
||||
'Live status recovery cannot fill missing stored evidence, refetch or remount the chart.',
|
||||
);
|
||||
|
||||
mode = '503';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history' }));
|
||||
await page.getByRole('button', { name: 'Retry history' }).waitFor();
|
||||
assert.equal(await chart.locator('[data-history-gaps]').count(), 1);
|
||||
assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0);
|
||||
assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/);
|
||||
record.checks.push('Transient read failure retains the gap and does not leak the raw error.');
|
||||
mode = 'complete';
|
||||
await activate(page.getByRole('button', { name: 'Retry history' }));
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-history-gaps]').length === 0,
|
||||
);
|
||||
assert.equal(await chart.locator('[data-history-observation]').count(), 0);
|
||||
assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 4);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__originalHistoryChart ===
|
||||
document.querySelector('[data-history-group="utilization"]'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
record.checks.push(
|
||||
'Matching complete stored refresh joins actual consecutive observations; chart identity stays stable.',
|
||||
);
|
||||
await screenshot('complete');
|
||||
mode = '403';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history' }));
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-testid="guest-history-group-chart"]').length === 0,
|
||||
);
|
||||
assert.equal(await page.locator('[data-history-gaps]').count(), 0);
|
||||
assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/);
|
||||
record.checks.push('Access denial withdraws all stored chart/gap/inspection evidence.');
|
||||
assert.equal(reads(), before + 3);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
record.requests.some((r) => r.method && r.method !== 'GET'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
record.requests.some((r) => r.blocked),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(record.errors, []);
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__trustedInputs.every((entry) => entry.trusted)),
|
||||
true,
|
||||
);
|
||||
record.checks.push(
|
||||
'No page error, horizontal overflow, external request or mutation; unchanged user-request read budget.',
|
||||
);
|
||||
await page.close();
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
result.result = 'passed';
|
||||
} catch (error) {
|
||||
result.failure = error.stack || String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
})().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>Drawer History missing-observation verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./history-observation-gaps.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
// Real production drawer/CSS. The browser supplies synthetic stored readings;
|
||||
// no collector, guest-agent, backup, thaw or installation operation is run.
|
||||
import { 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 '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
|
||||
function Fixture() {
|
||||
const [recovered, setRecovered] = createSignal(false);
|
||||
const guest = (): WorkloadGuest => ({
|
||||
id: 'fixture:pve:101',
|
||||
vmid: 101,
|
||||
name: 'backup-guest',
|
||||
node: 'pve',
|
||||
instance: 'fixture',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: {
|
||||
total: 1024 ** 3,
|
||||
used: 256 * 1024 ** 2,
|
||||
free: 768 * 1024 ** 2,
|
||||
usage: 25,
|
||||
observation: {
|
||||
state: recovered() ? 'current' : 'last-known',
|
||||
source: 'status-mem',
|
||||
observedAt: '2026-10-04T12:01:00Z',
|
||||
},
|
||||
},
|
||||
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
|
||||
diskStatusReason: recovered() ? '' : 'prev-vm-locked',
|
||||
lock: recovered() ? '' : 'backup',
|
||||
guestAgentStatus: recovered() ? 'available' : 'deferred',
|
||||
backupInProgress: !recovered(),
|
||||
networkIn: 0,
|
||||
networkOut: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lastSeen: '2026-10-04T12:04:00Z',
|
||||
});
|
||||
return (
|
||||
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Drawer History missing observations</h1>
|
||||
<p class="text-xs text-muted">
|
||||
Synthetic readings. No backup or guest-agent command is sent.
|
||||
</p>
|
||||
<button
|
||||
class="rounded-sm border border-border p-2 text-xs"
|
||||
onClick={() => setRecovered(true)}
|
||||
>
|
||||
Simulate live recovery
|
||||
</button>
|
||||
<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,59 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "0ad41e4c9a309ce459dd717d16d343b46efcac8a",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "79e176dd5640190f6bf11d3a6bbddc63fcd18c37b19e4e7098163a95d542b0a5"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/history-observation-gaps.html \u2014 production full GuestDrawer/History/client/CSS, not the full App route"
|
||||
],
|
||||
"states": [
|
||||
"Stored CPU is continuous while memory and disk miss intervening group times; preserve split segments and recovery singleton/zero readings.",
|
||||
"Live recovery alone preserves stored gaps and chart identity without a read.",
|
||||
"Transient 503 retains data/gaps; matching complete stored refresh restores actual contiguous lines; denied 403 withdraws all history."
|
||||
],
|
||||
"interactions": [
|
||||
"Trusted desktop click, Home/Arrow keyboard inspection and pointer at exact missing stored time.",
|
||||
"Trusted WebKit 390/320 px native inspector touch, keyboard arrows and refresh/retry.",
|
||||
"Trusted live-recovery action does not alter stored points, query budget or chart identity.",
|
||||
"All eight captures visually inspected for gaps, isolated points, contiguous recovery, light/dark readability and 390/320 px wrapping."
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900,
|
||||
"browser": "chromium-desktop-light",
|
||||
"theme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900,
|
||||
"browser": "chromium-desktop-dark",
|
||||
"theme": "dark"
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"browser": "webkit-phone-dark",
|
||||
"theme": "dark"
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 844,
|
||||
"browser": "webkit-narrow-light",
|
||||
"theme": "light"
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-10-04T22:39:45.443984Z",
|
||||
"playwright_version": "1.56.1",
|
||||
"checks": 32,
|
||||
"screenshots_inspected": 8,
|
||||
"script": "frontend-modern/browser-tests/history-observation-gaps.cjs",
|
||||
"limits": "Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance."
|
||||
}
|
||||
@@ -220,6 +220,61 @@ afterEach(() => {
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GuestDrawer', () => {
|
||||
it('keeps stored History gaps through live guest recovery without another read', async () => {
|
||||
const points = makeHistoryPoints(10);
|
||||
const payload = {
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 3,
|
||||
source: 'store',
|
||||
metrics: { cpu: points, memory: [points[0], points[2]] },
|
||||
};
|
||||
chartsApiMocks.getMetricsHistory.mockResolvedValue(payload);
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({ lock: 'backup', diskStatusReason: 'prev-vm-locked' }),
|
||||
);
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
|
||||
const chart = screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
expect(chart.querySelectorAll('path')).toHaveLength(1);
|
||||
expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2);
|
||||
expect(chart.querySelector('[data-history-gaps]')).toHaveTextContent(
|
||||
'Missing observations: Memory.',
|
||||
);
|
||||
setGuest({
|
||||
...guest(),
|
||||
lock: '',
|
||||
diskStatusReason: '',
|
||||
memory: {
|
||||
...guest().memory,
|
||||
usage: 99,
|
||||
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T12:00:00Z' },
|
||||
},
|
||||
});
|
||||
expect(screen.getAllByTestId('guest-history-group-chart')[0]).toBe(chart);
|
||||
expect(chart.querySelectorAll('[data-history-observation="memory"]')).toHaveLength(2);
|
||||
expect(chart).not.toHaveTextContent('99.0%');
|
||||
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1);
|
||||
expect(chartsApiMocks.getMetricsHistory.mock.calls[0][0]).toMatchObject({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
maxPoints: 240,
|
||||
});
|
||||
chartsApiMocks.getMetricsHistory.mockResolvedValue({
|
||||
...payload,
|
||||
metrics: { cpu: points, memory: points },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await waitFor(() => expect(chart.querySelector('[data-history-gaps]')).toBeNull());
|
||||
expect(chart.querySelectorAll('path')).toHaveLength(2);
|
||||
expect(chart.querySelector('[data-history-observation="memory"]')).toBeNull();
|
||||
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the backup precaution visible across drawer tabs without starting a guest check', async () => {
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
buildGuestDrawerHistoryPath,
|
||||
getGuestDrawerHistoryRangeBounds,
|
||||
getGuestDrawerHistoryScale,
|
||||
getGuestDrawerHistorySegments,
|
||||
getGuestDrawerHistoryValueLabel,
|
||||
normalizeGuestDrawerHistoryPoints,
|
||||
type GuestDrawerHistoryGroupConfig,
|
||||
@@ -277,8 +278,30 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
if (count === 0) return inspectionValueText();
|
||||
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
|
||||
});
|
||||
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
|
||||
const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1));
|
||||
const segmentedSeries = createMemo(() =>
|
||||
series().map((item) => ({
|
||||
...item,
|
||||
segments: getGuestDrawerHistorySegments(item.points, observationTimes()),
|
||||
})),
|
||||
);
|
||||
const gapMessage = createMemo(() => {
|
||||
const labels = segmentedSeries()
|
||||
.filter((item) => item.segments.length > 1)
|
||||
.map((item) => item.label);
|
||||
return labels.length > 0
|
||||
? `Missing observations: ${labels.join(', ')}. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.`
|
||||
: null;
|
||||
});
|
||||
const drawableSeries = createMemo(() =>
|
||||
segmentedSeries().flatMap((item) =>
|
||||
item.segments.filter((points) => points.length >= 2).map((points) => ({ ...item, points })),
|
||||
),
|
||||
);
|
||||
const singlePointSeries = createMemo(() =>
|
||||
segmentedSeries().flatMap((item) =>
|
||||
item.segments.filter((points) => points.length === 1).map((points) => ({ ...item, points })),
|
||||
),
|
||||
);
|
||||
const singleObservation = createMemo(() =>
|
||||
observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null,
|
||||
);
|
||||
@@ -443,7 +466,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
</For>
|
||||
<svg
|
||||
aria-label={`${props.group.label} history`}
|
||||
aria-describedby={`${inspectionId}-description`}
|
||||
aria-describedby={`${inspectionId}-description${gapMessage() ? ` ${inspectionId}-gaps` : ''}`}
|
||||
class="absolute inset-0 h-full w-full cursor-crosshair"
|
||||
data-testid="guest-history-plot"
|
||||
onMouseMove={handleHoverMove}
|
||||
@@ -585,6 +608,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
<Show when={gapMessage()}>
|
||||
{(message) => (
|
||||
<p id={`${inspectionId}-gaps`} class="mt-2 text-xs text-muted" data-history-gaps>
|
||||
{message()}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!props.loading && singleObservation()}>
|
||||
{(observation) => (
|
||||
<p class="mt-2 text-[10px] text-muted">
|
||||
|
||||
+3
-2
@@ -84,7 +84,7 @@ describe('GuestDrawerHistory common-time pointer inspection', () => {
|
||||
expect(
|
||||
within(utilization()).getByRole('img', { name: 'Utilization history' }),
|
||||
).toHaveAccessibleDescription(
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation. Missing observations: CPU, Memory. Lines stop where a series has no reading at another stored time in this panel. This does not identify the cause or duration of a monitoring outage.`,
|
||||
);
|
||||
fireEvent.pointerLeave(plot);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
@@ -101,7 +101,8 @@ describe('GuestDrawerHistory common-time pointer inspection', () => {
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).toHaveTextContent('Disk0.0%');
|
||||
expect(utilization().querySelectorAll('circle[r="3"]')).toHaveLength(1);
|
||||
expect(utilization().querySelectorAll('path')).toHaveLength(2);
|
||||
expect(utilization().querySelectorAll('path')).toHaveLength(1);
|
||||
expect(utilization().querySelectorAll('[data-history-observation]')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders all metrics that really share a timestamp at the same cursor position', async () => {
|
||||
|
||||
+6
-8
@@ -165,7 +165,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
|
||||
},
|
||||
);
|
||||
|
||||
it('draws a lone series alongside a trend and labels only the live fallback', async () => {
|
||||
it('preserves interleaved lone observations and labels only the live fallback', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
cpu: [point(start, 10), point(end, 20)],
|
||||
@@ -173,12 +173,10 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
|
||||
}),
|
||||
);
|
||||
mount({ cpu: 99, memory: 98, disk: 88 });
|
||||
await waitFor(() => expect(dots()).toHaveLength(1));
|
||||
expect(group().querySelectorAll('path')).toHaveLength(1);
|
||||
expect(group().querySelector('[data-history-observation]')).toHaveAttribute(
|
||||
'data-history-observation',
|
||||
'memory',
|
||||
);
|
||||
await waitFor(() => expect(dots()).toHaveLength(3));
|
||||
expect(group().querySelectorAll('path')).toHaveLength(0);
|
||||
expect(group().querySelectorAll('[data-history-observation="memory"]')).toHaveLength(1);
|
||||
expect(group().querySelectorAll('[data-history-observation="cpu"]')).toHaveLength(2);
|
||||
expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1);
|
||||
expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'Disk88.0%current',
|
||||
@@ -191,7 +189,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
|
||||
expect(group()).toHaveTextContent('Memory0.0%');
|
||||
expect(group()).toHaveTextContent('Disk-');
|
||||
expect(group().querySelector('[data-history-current]')).toBeNull();
|
||||
expect(dots()).toHaveLength(1);
|
||||
expect(dots()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('does not connect single observations from different series or borrow their times', async () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getGuestDrawerHistorySegments } from '../guestDrawerModel';
|
||||
|
||||
const point = (timestamp: number, value = 0) => ({ timestamp, value, min: value, max: value });
|
||||
|
||||
describe('stored History segments', () => {
|
||||
it('retains real points and both sides of each missing in-panel time', () => {
|
||||
const points = Object.freeze([point(1), point(2), point(4), point(6), point(7)]);
|
||||
const times = Object.freeze([1, 2, 3, 4, 5, 6, 7]);
|
||||
const segments = getGuestDrawerHistorySegments(points, times);
|
||||
expect(segments).toEqual([[points[0], points[1]], [points[2]], [points[3], points[4]]]);
|
||||
expect(segments.flat()).toEqual(points);
|
||||
expect(segments[1][0]).toBe(points[2]);
|
||||
});
|
||||
|
||||
it('keeps duplicate-time readings in the same segment, without creating a new time', () => {
|
||||
const points = [point(1, 0), point(1, 10), point(2, 20), point(4, 30)];
|
||||
expect(getGuestDrawerHistorySegments(points, [1, 2, 3, 4])).toEqual([
|
||||
points.slice(0, 3),
|
||||
[points[3]],
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot guess gaps from elapsed time when all stored times match', () => {
|
||||
const points = [point(1), point(1_000_000)];
|
||||
expect(getGuestDrawerHistorySegments(points, [1, 1_000_000])).toEqual([points]);
|
||||
});
|
||||
|
||||
it('keeps an empty series empty and never accepts a point outside the group times', () => {
|
||||
expect(getGuestDrawerHistorySegments([], [1, 2, 3])).toEqual([]);
|
||||
expect(getGuestDrawerHistorySegments([point(1)], [])).toEqual([]);
|
||||
expect(getGuestDrawerHistorySegments([point(1), point(2), point(3)], [1, 3])).toEqual([
|
||||
[point(1), point(3)],
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves every point at the existing request bound', () => {
|
||||
const times = Array.from({ length: 240 }, (_, index) => index);
|
||||
const points = times.filter((time) => time % 2 === 0).map((time) => point(time));
|
||||
const segments = getGuestDrawerHistorySegments(points, times);
|
||||
expect(segments).toHaveLength(120);
|
||||
expect(segments.every((segment) => segment.length === 1)).toBe(true);
|
||||
expect(segments.flat()).toEqual(points);
|
||||
});
|
||||
});
|
||||
@@ -355,6 +355,29 @@ export const getGuestDrawerHistoryScale = (
|
||||
return { minValue: 0, maxValue: Math.max(1, maxValue * 1.15) };
|
||||
};
|
||||
|
||||
// Both inputs are normalized stored observations, ordered by timestamp. A
|
||||
// series cannot span a time where another series in its panel has a reading
|
||||
// and this one does not. Do not infer failed polls or cadence from elapsed
|
||||
// time: the API supplies neither. Keep singleton segments as real evidence.
|
||||
export const getGuestDrawerHistorySegments = (
|
||||
points: readonly AggregatedMetricPoint[],
|
||||
observationTimes: readonly number[],
|
||||
): AggregatedMetricPoint[][] => {
|
||||
const timeIndices = new Map(observationTimes.map((timestamp, index) => [timestamp, index]));
|
||||
const segments: AggregatedMetricPoint[][] = [];
|
||||
let previousIndex: number | undefined;
|
||||
for (const point of points) {
|
||||
const index = timeIndices.get(point.timestamp);
|
||||
if (index === undefined) continue;
|
||||
if (previousIndex === undefined || (index !== previousIndex && index !== previousIndex + 1)) {
|
||||
segments.push([]);
|
||||
}
|
||||
segments[segments.length - 1].push(point);
|
||||
previousIndex = index;
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const buildGuestDrawerHistoryPath = (
|
||||
points: readonly AggregatedMetricPoint[],
|
||||
scale: GuestDrawerHistoryScale,
|
||||
|
||||
Reference in New Issue
Block a user