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:
pulse-triage[bot]
2026-10-04 23:40:10 +01:00
parent 0ad41e4c9a
commit 5e7e49650f
12 changed files with 707 additions and 13 deletions
@@ -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">
@@ -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 () => {
@@ -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,