Integrate reviewed dated drawer History window

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 03:39:50 +01:00
commit 8a0937a0f3
8 changed files with 939 additions and 70 deletions

View file

@ -7597,3 +7597,29 @@ series, zero rates, single observations, ties, focus precedence and replacement.
in Chromium/Firefox desktop and Chromium/WebKit phone emulation in both themes,
including failed refreshes and source replacement. Scripted APIs establish
presentation accuracy, not real PBS collection or installed #1723 acceptance.
### Drawer History panels share a dated time window
`GuestDrawerHistory` uses one common time interval for every configured metric
group, including utilization, network, disk I/O and thermals. The fulfilled
API response's valid `start`/`end` interval remains in view even if it contains
only a few minutes of observations. A sample at a given timestamp occupies the
same horizontal position in every panel; a group's sparse coverage must not
stretch independently to fill the selected range. Visible date/time endpoints
and full local timestamps in their accessible labels distinguish overnight and
multi-day windows. Native inspection still selects actual observations.
Returned edge observations widen the common envelope instead of being clipped
or discarded, including aggregated bucket timestamps. An invalid API interval
falls back to the valid observed envelope across configured groups, not a
fabricated range. Non-date timestamps and unconfigured metrics cannot poison
that geometry. A valid empty window has labelled endpoints but no trend or
inspection control. Failed matching refreshes retain the window with its data;
target/range replacement clears both until matching data arrives.
`GuestDrawerHistory.window.test.tsx` pins geometry, labels, refresh/replacement,
empty/invalid windows and edge observations. `browser-tests/history-window.cjs`
uses the production PBS table, resource drawers, History query and CSS with
synthetic APIs, checking three separately mapped drawers and range/refresh
behaviour in desktop and phone-emulated engines. This is presentation proof,
not installed PBS/VirtualBox collection or a complete #1723 acceptance result.

View file

@ -3360,3 +3360,20 @@ reproduced the CI magnitude or establishes an improvement. Keep the signal
open as an environment-bound observation under the existing
`performance-post-rc-headroom` follow-up; no chart-path code change is
justified by this comparison alone.
### Shared drawer History window calculation
The drawer model computes bounds from the requested API interval and supplied
valid observation timestamps. The renderer derives one envelope per fulfilled
query from its configured metric groups and shares it across all panels; it
does not synthesize, resample or fetch extra points to fill an empty window.
Invalid dates cannot enter the labelled geometry, and returned edge buckets
remain inside the common envelope. The existing 240-point request cap per
metric, 30-second poll and retained-query cache limits are unchanged. No
backend, metrics-store, performance threshold or latency claim changes here.
`GuestDrawer.test.tsx` verifies requested, expanded, invalid and absent bounds;
`GuestDrawerHistory.window.test.tsx` verifies the mounted query and geometry,
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.

View file

@ -0,0 +1,393 @@
// Production PBS table/drawers/History with synthetic APIs, not installed collection.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createHash } = require('node:crypto');
const { chromium, firefox, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const engine =
process.argv.find((arg) => arg.startsWith('--engine='))?.split('=')[1] || 'chromium';
assert.ok(['chromium', 'firefox', 'webkit'].includes(engine));
const width = process.argv.includes('--phone') ? 390 : 1365;
const baseline = process.argv.includes('--baseline');
const requestedTheme = process.argv.find((arg) => arg.startsWith('--theme='))?.split('=')[1];
assert.ok(!requestedTheme || ['light', 'dark'].includes(requestedTheme));
const artifacts = path.join(
root,
'node_modules',
`history-window-${engine}-${width}${baseline ? '-base' : ''}${requestedTheme ? `-${requestedTheme}` : ''}`,
);
fs.mkdirSync(artifacts, { recursive: true });
fs.chmodSync(artifacts, 0o755);
const progress = (stage, detail = {}) => {
const record = { stage, ...detail, at: new Date().toISOString() };
fs.writeFileSync(path.join(artifacts, 'progress.json'), JSON.stringify(record) + '\n', {
mode: 0o644,
});
console.log(JSON.stringify(record));
};
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: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')),
server: { host: '127.0.0.1', port: 5225, strictPort: true },
});
const sourceHashes = Object.fromEntries(
[
'src/components/Workloads/GuestDrawerHistory.tsx',
'src/components/Workloads/guestDrawerModel.ts',
].map((file) => [
file,
createHash('sha256')
.update(fs.readFileSync(path.join(root, file)))
.digest('hex'),
]),
);
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
let browser;
const observations = [];
try {
await server.listen();
browser = await { chromium, firefox, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const theme of requestedTheme ? [requestedTheme] : ['light', 'dark']) {
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
locale: 'en-GB',
timezoneId: 'UTC',
...(width === 390 ? { isMobile: true, hasTouch: true } : {}),
});
page.setDefaultTimeout(25_000);
const requests = [],
errors = [];
page.on('pageerror', (error) => errors.push(error.message));
let state = 'success',
advance = 0,
held;
const hour = 3_600_000;
const end = Date.UTC(2026, 9, 1, 12);
const durations = { '1h': hour, '6h': 6 * hour, '24h': 24 * hour, '7d': 7 * 24 * hour };
const point = (minutes, value) => ({
timestamp: end - minutes * 60_000,
value,
min: value,
max: value,
});
const response = (type, id, range, empty = false) => ({
resourceType: type,
resourceId: id,
range,
start: end + advance - durations[range],
end: end + advance,
source: 'store',
metrics: empty
? {}
: Object.fromEntries(
Object.entries({
cpu: [
point(10, id.endsWith('one') ? 21 : id.endsWith('two') ? 31 : 41),
point(5, 45),
],
memory: [point(5, 55)],
disk: [point(8, 20), point(4, 30)],
netin: [point(60, 0), point(5, 2048)],
netout: [point(5, 4096)],
diskread: [point(2, 1024), point(0, 2048)],
diskwrite: [point(5, 0), point(0, 0)],
temperature: [point(5, 50)],
}).map(([metric, points]) => [
metric,
points.filter(
(point) =>
point.timestamp >= end + advance - durations[range] &&
point.timestamp <= end + advance,
),
]),
),
});
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5225') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname === '/api/metrics-store/history') {
const type = url.searchParams.get('resourceType'),
id = url.searchParams.get('resourceId'),
range = url.searchParams.get('range');
assert.ok(
['vm-one', 'vm-two', 'agent-three'].includes(id),
`uncorroborated target ${id}`,
);
assert.ok(durations[range], `unexpected or locked read ${range}`);
requests.push({ type, id, range, state, advance, method: route.request().method() });
if (state === 'hold') {
held = { route, type, id, range };
return;
}
if (state === 'failure')
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
return route.fulfill({ json: response(type, id, range, state === 'empty') });
}
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
json: {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community', label: 'Pulse Community runtime' },
blocked_capabilities: [],
},
});
return route.fulfill({ json: { data: [], enabled: false } });
});
progress('loading-production-table', { theme });
await page.goto('http://127.0.0.1:5225/browser-tests/pbs-identity-boundary.html', {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
theme === 'dark',
);
await page.getByRole('button', { name: 'Corroborate links', exact: true }).click();
progress('linked-table-ready', { theme });
const xs = async (plot) =>
[
...(await plot.locator('path').first().getAttribute('d')).matchAll(/[ML](-?[\d.]+),/g),
].map((match) => Number(match[1]));
const expectedX = (minutes, range) =>
34 + ((durations[range] - advance - minutes * 60_000) / durations[range]) * 318;
const verifyWindow = async (detail, range) => {
const axes = detail.getByTestId('guest-history-time-window');
const groups = await detail.getByTestId('guest-history-group-chart').count();
await axes.first().waitFor();
assert.equal(await axes.count(), groups);
for (const axis of await axes.all()) {
const times = axis.locator('time');
assert.equal(
await times.nth(0).getAttribute('datetime'),
new Date(end + advance - durations[range]).toISOString(),
);
assert.equal(
await times.nth(1).getAttribute('datetime'),
new Date(end + advance).toISOString(),
);
assert.match(await times.nth(0).getAttribute('aria-label'), /^Window start: /);
assert.match(await times.nth(1).getAttribute('aria-label'), /^Window end: /);
assert.notEqual(await times.nth(0).innerText(), await times.nth(1).innerText());
}
};
let detail;
for (const suffix of baseline ? ['three'] : ['one', 'two', 'three']) {
const toggle = page.getByRole('button', {
name: `Expand details for backup-connection-${suffix}`,
exact: true,
});
// WebKit phone uses the native keyboard disclosure; its full-row touch
// opening was not established. Touch inspection is exercised below.
if (width === 1365 || engine === 'webkit') {
await toggle.focus();
await page.keyboard.press('Enter');
} else await page.locator(`td[title="backup-connection-${suffix} · tank"]`).tap();
assert.equal(
await page
.getByRole('button', {
name: `Collapse details for backup-connection-${suffix}`,
exact: true,
})
.getAttribute('aria-expanded'),
'true',
);
detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`);
await detail.getByRole('tab', { name: 'History', exact: true }).click();
const utilization = detail.locator('[data-history-group="utilization"]');
const plot = utilization.getByTestId('guest-history-plot');
await plot.locator('path').first().waitFor();
const observedXs = await xs(plot);
const observed = {
suffix,
theme,
observedXs,
expectedXs: [expectedX(10, '24h'), expectedX(5, '24h')],
axes: await detail.getByTestId('guest-history-time-window').count(),
};
console.log(JSON.stringify({ timeWindowObservation: observed, sourceHashes }));
await detail.screenshot({ path: path.join(artifacts, `${suffix}-24h-${theme}.png`) });
assert.ok(Math.abs(observedXs[0] - expectedX(10, '24h')) < 0.01, JSON.stringify(observed));
assert.ok(Math.abs(observedXs[1] - expectedX(5, '24h')) < 0.01, JSON.stringify(observed));
await verifyWindow(detail, '24h');
assert.ok(
requests.some(
(request) => request.id === (suffix === 'three' ? 'agent-three' : `vm-${suffix}`),
),
);
const dims = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
width: innerWidth,
}));
assert.ok(dims.scroll <= dims.width + 1, JSON.stringify(dims));
observations.push({ ...observed, dims });
progress('drawer-verified', { suffix, theme });
}
const utilization = detail.locator('[data-history-group="utilization"]');
const slider = utilization.getByRole('slider', { name: 'Inspect Utilization history' });
const network = detail.locator('[data-history-group="network"]');
const networkSlider = network.getByRole('slider', { name: 'Inspect Network I/O history' });
const readsBefore = requests.length;
await slider.focus();
await slider.press('Home');
await slider.press('ArrowRight');
await slider.press('ArrowRight');
assert.match(
await slider.getAttribute('aria-valuetext'),
/01\/10\/2026, 11:55:00\. CPU 45\.0%\. Memory 55\.0%\. Disk no observation/,
);
const utilizationX = await utilization
.getByTestId('guest-history-plot')
.locator('circle[r="3"]')
.first()
.getAttribute('cx');
await networkSlider.focus();
await networkSlider.press('End');
const networkX = await network
.getByTestId('guest-history-plot')
.locator('circle[r="3"]')
.first()
.getAttribute('cx');
assert.equal(networkX, utilizationX);
assert.match(
(await network.innerText()).replace(/\s+/g, ' '),
/In\s*2\.00 KB\/s Out\s*4\.00 KB\/s/,
);
if (width === 390) {
await networkSlider.scrollIntoViewIfNeeded();
const box = await networkSlider.boundingBox();
assert.ok(box.height >= 44);
await page.touchscreen.tap(box.x + 3, box.y + box.height / 2);
assert.match(
await networkSlider.getAttribute('aria-valuetext'),
/In 0 B\/s\. Out no observation/,
);
}
assert.equal(requests.length, readsBefore, 'inspection must not request metrics');
progress('inspection-verified', { theme });
await networkSlider.press('Tab');
state = 'failure';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await detail
.getByText('History refresh failed. Showing previously loaded history.')
.waitFor();
await verifyWindow(detail, '24h');
assert.ok(
Math.abs(
(await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'),
) < 0.01,
);
assert.ok(!(await detail.innerText()).includes('Private fixture detail'));
await detail.screenshot({ path: path.join(artifacts, `retained-window-${theme}.png`) });
progress('retained-window-verified', { theme });
advance = 10 * 60_000;
state = 'success';
await detail.getByRole('button', { name: 'Retry history' }).click();
await detail.getByRole('button', { name: 'Refresh history' }).waitFor();
await page.waitForFunction(
(expected) =>
document
.querySelector('[data-testid="guest-history-time-window"] time:last-child')
?.getAttribute('datetime') === expected,
new Date(end + advance).toISOString(),
);
await verifyWindow(detail, '24h');
assert.ok(
Math.abs(
(await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '24h'),
) < 0.01,
);
// Source/range replacement must clear the old window as well as its samples.
state = 'hold';
progress('refreshed-window-verified', { theme });
await detail.getByRole('combobox', { name: 'History range' }).selectOption('1h');
await detail.getByText('Loading history', { exact: true }).first().waitFor();
assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0);
assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0);
assert.equal(await detail.getByRole('slider').count(), 0);
assert.ok(held);
state = 'success';
await held.route.fulfill({ json: response(held.type, held.id, held.range) });
await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor();
await verifyWindow(detail, '1h');
assert.ok(
Math.abs(
(await xs(utilization.getByTestId('guest-history-plot')))[0] - expectedX(10, '1h'),
) < 0.01,
);
await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) });
progress('replacement-window-verified', { theme });
state = 'empty';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await page.waitForFunction(
() => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0,
);
await verifyWindow(detail, '1h');
assert.equal(await detail.getByRole('slider').count(), 0);
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4);
state = 'success';
await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d');
await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor();
await verifyWindow(detail, '7d');
const readsBeforeLock = requests.length;
await detail.getByRole('combobox', { name: 'History range' }).selectOption('14d');
await detail.getByText(/14 days history requires a higher license plan/).waitFor();
assert.equal(requests.length, readsBeforeLock);
assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0);
assert.equal(errors.length, 0, errors.join('\n'));
observations.push({ theme, requests, errors, utilizationX, networkX });
progress('completed-theme', { theme });
await page.close();
}
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify(
{
engine,
width,
browserVersion: browser.version(),
playwrightVersion,
sourceHashes,
observations,
},
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
result: 'passed',
engine,
width,
browserVersion: browser.version(),
sourceHashes,
cases: observations.length,
}),
);
} finally {
await browser?.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -1,16 +1,18 @@
{
"version": 1,
"base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881",
"verified_at": "2026-10-01T00:37:51Z",
"base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9",
"verified_at": "2026-10-01T01:42:43Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a"
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a49f3c6496e5515284a94c52b818f78f72d2e3530ae398794ea7432f251f48ec",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "5ea99d3d995ddb2fa777273ed3f224b7fbb9e7b10a015bed52d61a8e10dc0b99"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)"
"/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer, shared History/query and CSS with synthetic VM/Agent APIs; not installed collection or complete #1723 acceptance)"
],
"viewports": [
{
@ -23,65 +25,174 @@
}
],
"states": [
"light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp",
"a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data",
"zero-valued lone observations can be inspected without a synthetic trend",
"genuinely aligned series share cursor geometry and their own observed values",
"network directions preserve independently missing and zero rates",
"native keyboard/touch selection takes precedence over pointer movement",
"failed same-source refresh retains correctly attributed observations and a visible warning",
"a successful refresh updates the hovered timestamp values without choosing other latest metrics",
"held service-target replacement has no former-host observations, cursor or dated pointer description"
"light/dark short sample coverage stays at its true position in the fulfilled 24-hour window rather than stretching across each plot",
"all configured utilization/network/disk/thermal panels share dated endpoints, including lone and empty panels",
"three separately mapped synthetic PBS drawers read vm-one, vm-two and agent-three, not unrelated host identities",
"same-time observations occupy the same horizontal coordinate across groups with different sample coverage",
"failed same-source refresh retains samples and window with a sanitised warning; successful refresh advances the window",
"held 1-hour range replacement clears former endpoints, paths and inspection controls; matching data then uses the new window",
"empty window has labelled bounds without a synthetic trend; seven-day bounds remain dated; locked fourteen-day range makes no read"
],
"interactions": [
"move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry",
"leave the plot to restore latest/current legends; verify pointer inspection makes no API reads",
"focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection",
"Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area",
"fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response"
"desktop native keyboard disclosure and History tab; Chromium phone row touch; WebKit phone native keyboard disclosure (whole-row touch opening not established)",
"focus native observation input, use Home/ArrowRight/End and compare dated values and same-time markers between utilization and network",
"Chromium/WebKit phone-emulated touch taps on the at-least-44px native History input; inspection performs no extra API reads",
"fail a real query refresh; retry with advancing bounds; hold and fulfil a 1-hour replacement read; refresh empty, choose seven days and a licence-locked range"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
"command": "pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=chromium --theme=light; --engine=chromium --theme=dark; both themes with --engine=chromium --phone; both themes with --engine=firefox; both themes with --engine=webkit --phone. Each is a separate bounded command from the assigned workspace root. Vite 6.4.3 / Playwright 1.56.1; integration-lock parity checked.",
"browser_versions": {
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log"
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-1365-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-chromium-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-firefox-1365-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/browser-final/history-window-webkit-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-desktop-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-chromium-phone-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-dark-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-firefox-desktop-light-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-dark-native.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-6zj90pef/receipts/browser-webkit-phone-light-native.log"
],
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability.",
"notes": "All 40 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow and exact final runtime hashes. The ten-case exact-base source-fbz6bjig input fails all ten assertions; an inspected full PBS drawer baseline measures five minutes occupying 265/318 SVG plot units under 24 hours, versus about 1.10 units after repair. Initial navigation timeout, unknown browser-execution capability-unavailable exit, synthetic edge-point fixture mismatch and unsuccessful WebKit row/hidden-button touch attempts remain in internal receipts. WebKit final opening uses native keyboard disclosure, not a claimed touch repair; History touch inspection passes. Six Chromium/Firefox cases ran before the runner added the disclosure-state assertion and WebKit keyboard-opening branch; their runtime source hashes are identical to the final two WebKit cases. This establishes presentation, not real PBS/VirtualBox collection, all reported #1723 drawer/metric outcomes, screen-reader speech, installed Tailscale Serve/SSE update or signed upgrade/rollback, protected integration or release availability.",
"prior_verifications": [
{
"version": 1,
"base_sha": "0dc858cb366a394b159d2c911c5d85b3020e7881",
"verified_at": "2026-10-01T00:37:51Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "e1658b5843ab22e0ea83d1bf27e8c1565a16d9f528eb3832524a67318d281d6a"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory/query/CSS with synthetic host/service metrics API responses; not a complete PBS drawer or installed acceptance)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"light/dark sparse CPU, memory and lone disk observations with one common inspected timestamp",
"a missing metric at the inspected time stays unavailable instead of borrowing nearby, latest or live data",
"zero-valued lone observations can be inspected without a synthetic trend",
"genuinely aligned series share cursor geometry and their own observed values",
"network directions preserve independently missing and zero rates",
"native keyboard/touch selection takes precedence over pointer movement",
"failed same-source refresh retains correctly attributed observations and a visible warning",
"a successful refresh updates the hovered timestamp values without choosing other latest metrics",
"held service-target replacement has no former-host observations, cursor or dated pointer description"
],
"interactions": [
"move a real pointer to sparse, aligned and single-sample timestamps; inspect visible legends, exact-time SVG descriptions and marker geometry",
"leave the plot to restore latest/current legends; verify pointer inspection makes no API reads",
"focus native range input and use Home/ArrowRight while moving the pointer elsewhere; blur to restore pointer inspection",
"Chromium/WebKit phone-emulated touch taps at each end of the native observation input, retaining its 44px minimum hit area",
"fail a real query refresh, inspect retained samples; successfully refresh the same source while hovering; switch resource with its GET held, then fulfil the service response"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-pointer.cjs --engine=chromium; the same with --phone; --engine=firefox; --engine=webkit --phone. All from the assigned workspace root, Vite 6.4.3 / Playwright 1.56.1 (integration-lock parity verified).",
"browser_versions": {
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/chromium-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/firefox-1365/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/retained-sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/browser-final/webkit-390/sparse-pointer-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-desktop-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-chromium-phone.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-firefox-desktop.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-gc5fjc3_/receipts/browser-webkit-phone-isolated.log"
],
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass with no page errors or horizontal overflow. Exact-base source-5g7tw1c0 has eight failing assertions and two passing replacement controls; a separate Chromium base screenshot reproduces the misattribution. The first phone run captured correct hover text before an element screenshot scrolled the tall fixture and ended its hover; the final runner captures the viewport without changing pointer state. The first WebKit navigation timed out during overlapping browser runs; its cause is not established, and a subsequent isolated run completes both themes. Adverse attempts remain in internal output receipts. These synthetic API proofs establish shared-renderer presentation, not installed PBS collection, all three #1723 drawers, VirtualBox/disk/network/I/O collection, update or upgrade/rollback acceptance, screen-reader speech, containing-line delivery or release availability."
},
{
"version": 1,
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
"verified_at": "2026-09-30T23:28:26Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
},
@ -221,9 +332,7 @@
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
"verified_at": "2026-09-30T20:12:09Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
},
@ -278,9 +387,7 @@
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
"verified_at": "2026-09-30T01:40:13Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
],
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36"
},
@ -329,9 +436,7 @@
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
"verified_at": "2026-09-29T23:04:22Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
],
"changed_paths": ["frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
},
@ -427,9 +532,7 @@
"base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
"verified_at": "2026-09-29T19:50:45Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/pages/Actions.tsx"
],
"changed_paths": ["frontend-modern/src/pages/Actions.tsx"],
"content_sha256": {
"frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
},
@ -470,9 +573,7 @@
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
"verified_at": "2026-09-29T19:09:52Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
],
"changed_paths": ["frontend-modern/src/features/actions/ActionReviewDialog.tsx"],
"content_sha256": {
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
},

View file

@ -16,9 +16,30 @@ import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeSt
import guestDrawerSource from './GuestDrawer.tsx?raw';
import guestDrawerManageSource from './GuestDrawerManage.tsx?raw';
import guestDrawerOverviewSource from './GuestDrawerOverview.tsx?raw';
import { getGuestDrawerHistoryRangeBounds } from './guestDrawerModel';
// ── Mocks ──────────────────────────────────────────────────────────────
describe('shared drawer History window geometry', () => {
it('preserves the requested interval and edge observations without manufacturing timestamps', () => {
const sample = (timestamp: number) => ({ timestamp, value: 0, min: 0, max: 0 });
const series = [{ points: [sample(800), sample(900)] }, { points: [sample(300)] }];
expect(getGuestDrawerHistoryRangeBounds(series, { start: 0, end: 1000 })).toEqual({
startTime: 0,
endTime: 1000,
});
expect(getGuestDrawerHistoryRangeBounds(series, { start: 500, end: 1000 })).toEqual({
startTime: 300,
endTime: 1000,
});
expect(getGuestDrawerHistoryRangeBounds(series, { start: 1000, end: 0 })).toEqual({
startTime: 300,
endTime: 900,
});
expect(getGuestDrawerHistoryRangeBounds([], { start: 0, end: 0 })).toBeNull();
});
});
const chartsApiMocks = vi.hoisted(() => ({
getMetricsHistory: vi.fn(),
}));

View file

@ -41,6 +41,7 @@ import {
normalizeGuestDrawerHistoryPoints,
type GuestDrawerHistoryGroupConfig,
type GuestDrawerHistoryTarget,
type GuestDrawerHistoryTimeBounds,
} from './guestDrawerModel';
interface GuestDrawerHistoryProps {
@ -68,6 +69,7 @@ interface GuestDrawerHistoryGroupChartProps {
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
range: HistoryTimeRange;
sourceKey: string;
timeBounds: GuestDrawerHistoryTimeBounds | null;
}
const GUEST_DRAWER_HISTORY_MAX_POINTS = 240;
@ -273,7 +275,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
});
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series()));
const bounds = () => props.timeBounds;
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
const hoveredSeries = createMemo(() => {
const timestamp = activeTimestamp();
@ -480,6 +482,36 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</div>
</Show>
</div>
<Show when={bounds()}>
{(window) => (
<div
class="ml-[34px] mr-2 mt-1 flex justify-between gap-2 text-[10px] tabular-nums text-muted"
data-testid="guest-history-time-window"
>
<For
each={[
{ label: 'Window start', timestamp: window().startTime },
{ label: 'Window end', timestamp: window().endTime },
]}
>
{(endpoint) => (
<time
dateTime={new Date(endpoint.timestamp).toISOString()}
aria-label={`${endpoint.label}: ${new Date(endpoint.timestamp).toLocaleString()}`}
title={new Date(endpoint.timestamp).toLocaleString()}
>
{new Date(endpoint.timestamp).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</time>
)}
</For>
</div>
)}
</Show>
<p id={`${inspectionId}-description`} class="sr-only">
{chartDescription()}
</p>
@ -560,6 +592,16 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
const metrics = createMemo(() => historyQuery.value().metrics ?? {});
const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS);
const timeBounds = createMemo(() =>
getGuestDrawerHistoryRangeBounds(
groups().flatMap((group) =>
group.series.map((series) => ({
points: normalizeGuestDrawerHistoryPoints(metrics()[series.metric], series.unit),
})),
),
historyQuery.value(),
),
);
const hasHistoryPoints = createMemo(() =>
groups().some((group) =>
group.series.some(
@ -628,6 +670,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
currentMetrics={props.currentMetrics}
range={props.range}
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
timeBounds={timeBounds()}
/>
)}
</For>

View file

@ -0,0 +1,243 @@
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, type HistoryTimeRange } from '@/api/charts';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
import {
getGuestDrawerHistoryRangeBounds,
normalizeGuestDrawerHistoryPoints,
} from '../guestDrawerModel';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 7,
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
vi.useRealTimers();
});
const start = Date.UTC(2026, 8, 30, 12);
const hour = 3_600_000;
const end = start + 24 * hour;
const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' };
const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value });
const response = (
overrides: Partial<AllMetricsHistoryResponse> = {},
): AllMetricsHistoryResponse => ({
...target,
range: '24h',
start,
end,
source: 'store',
metrics: {
cpu: [point(end - 10 * 60_000, 12), point(end - 5 * 60_000, 25)],
memory: [point(end - 5 * 60_000, 40)],
netin: [point(end - hour, 0), point(end - 5 * 60_000, 2048)],
diskwrite: [point(end - 5 * 60_000, 0), point(end, 0)],
temperature: [point(end - 5 * 60_000, 50)],
},
...overrides,
});
const mount = () =>
render(() => (
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
));
const group = (id: string) =>
screen
.getAllByTestId('guest-history-group-chart')
.find((chart) => chart.dataset.historyGroup === id)!;
const pathXs = (chart: HTMLElement) =>
[
...chart
.querySelector('path')!
.getAttribute('d')!
.matchAll(/[ML](-?[\d.]+),/g),
].map((match) => Number(match[1]));
const expectedX = (timestamp: number, first = start, last = end) =>
34 + ((timestamp - first) / Math.max(1, last - first)) * 318;
const axis = (chart: HTMLElement) => within(chart).getByTestId('guest-history-time-window');
const selectSharedTime = (chart: HTMLElement) => {
const slider = within(chart).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '1' } });
return chart.querySelector('circle[r="3"]')!;
};
describe('GuestDrawerHistory shared time window', () => {
it('does not stretch five minutes of stored CPU across a 24-hour query', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const xs = pathXs(group('utilization'));
expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2);
expect(xs[1]).toBeCloseTo(expectedX(end - 5 * 60_000), 2);
expect(xs[1] - xs[0]).toBeLessThan(2);
});
it('places the same observation time at the same x in panels with different sample coverage', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount();
await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
const utilizationX = selectSharedTime(group('utilization')).getAttribute('cx');
const networkX = selectSharedTime(group('network')).getAttribute('cx');
expect(networkX).toBe(utilizationX);
expect(Number(networkX)).toBeCloseTo(expectedX(end - 5 * 60_000));
});
it('labels every panel with the full dated interval, including empty and lone-observation panels', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
for (const chart of screen.getAllByTestId('guest-history-group-chart')) {
const times = axis(chart).querySelectorAll('time');
expect(times).toHaveLength(2);
expect(times[0]).toHaveAttribute('datetime', new Date(start).toISOString());
expect(times[1]).toHaveAttribute('datetime', new Date(end).toISOString());
expect(times[0]).toHaveAccessibleName(`Window start: ${new Date(start).toLocaleString()}`);
expect(times[1]).toHaveAccessibleName(`Window end: ${new Date(end).toLocaleString()}`);
expect(times[0].textContent).not.toBe(times[1].textContent);
}
});
it('retains the last fulfilled window with its data after a failed refresh', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockRejectedValueOnce(new Error('Private fixture detail'));
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const before = group('utilization').querySelector('path')!.getAttribute('d');
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await screen.findByText('History refresh failed. Showing previously loaded history.');
expect(group('utilization').querySelector('path')).toHaveAttribute('d', before);
expect(axis(group('utilization')).querySelector('time')).toHaveAttribute(
'datetime',
new Date(start).toISOString(),
);
expect(document.body).not.toHaveTextContent('Private fixture detail');
});
it('moves the window on a successful poll without moving a selected observation to a different time', async () => {
vi.useFakeTimers();
const shift = 10 * 60_000;
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockResolvedValueOnce(response({ start: start + shift, end: end + shift }));
mount();
await vi.advanceTimersByTimeAsync(0);
selectSharedTime(group('utilization'));
const valueText = within(group('utilization'))
.getByRole('slider')
.getAttribute('aria-valuetext');
await vi.advanceTimersByTimeAsync(30_000);
expect(within(group('utilization')).getByRole('slider')).toHaveAttribute(
'aria-valuetext',
valueText,
);
expect(
Number(group('utilization').querySelector('circle[r="3"]')!.getAttribute('cx')),
).toBeCloseTo(expectedX(end - 5 * 60_000, start + shift, end + shift));
expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute(
'datetime',
new Date(end + shift).toISOString(),
);
});
it('does not keep former-range endpoints while a replacement read is pending', async () => {
let finish!: (value: AllMetricsHistoryResponse) => void;
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
render(() => (
<GuestDrawerHistory target={target} range={range()} groups={HOST_METRICS_HISTORY_GROUPS} />
));
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
setRange('1h');
await waitFor(() =>
expect(screen.queryAllByTestId('guest-history-time-window')).toHaveLength(0),
);
expect(group('utilization').querySelectorAll('path')).toHaveLength(0);
finish(response({ range: '1h', start: end - hour }));
await waitFor(() => expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4));
expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour));
});
it('keeps an empty valid query window without inventing observations or a trend', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} }));
mount();
await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4));
expect(screen.queryByRole('slider')).toBeNull();
expect(document.querySelectorAll('path')).toHaveLength(0);
expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4);
});
it('falls back to one shared observed interval when the API window is invalid', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({ start: NaN, end: Infinity }),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const xs = pathXs(group('utilization'));
expect(xs[0]).toBeCloseTo(expectedX(end - 10 * 60_000, end - hour));
expect(axis(group('utilization')).querySelector('time')).toHaveAttribute(
'datetime',
new Date(end - hour).toISOString(),
);
expect(selectSharedTime(group('utilization')).getAttribute('cx')).toBe(
selectSharedTime(group('network')).getAttribute('cx'),
);
});
it('encloses returned edge observations rather than clipping or discarding an aggregated bucket', async () => {
const edge = start - 5 * 60_000;
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
metrics: { ...response().metrics, cpu: [point(edge, 10), point(end - 5 * 60_000, 25)] },
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(axis(group('network')).querySelector('time')).toHaveAttribute(
'datetime',
new Date(edge).toISOString(),
);
expect(pathXs(group('network'))[0]).toBeCloseTo(expectedX(end - hour, edge));
expect(within(group('utilization')).getByRole('slider')).toHaveAttribute('max', '1');
});
it('rejects non-dates before geometry and labels can be poisoned', () => {
expect(normalizeGuestDrawerHistoryPoints([point(9e15, 12), point(start, 0)], '%')).toEqual([
point(start, 0),
]);
expect(
getGuestDrawerHistoryRangeBounds([{ points: [point(9e15, 12), point(start, 0)] }]),
).toEqual({ startTime: start, endTime: start });
});
it('does not let an unconfigured metric change the visible panels time window', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
metrics: { ...response().metrics, unrelated: [point(end + 90 * 24 * hour, 10)] },
}),
);
mount();
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(axis(group('utilization')).querySelectorAll('time')[1]).toHaveAttribute(
'datetime',
new Date(end).toISOString(),
);
expect(pathXs(group('utilization'))[0]).toBeCloseTo(expectedX(end - 10 * 60_000), 2);
});
});

View file

@ -55,6 +55,11 @@ export interface GuestDrawerHistoryScale {
maxValue: number;
}
export interface GuestDrawerHistoryTimeBounds {
startTime: number;
endTime: number;
}
export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
@ -157,12 +162,15 @@ const clampHistoryPointValue = (value: number, unit: string): number => {
return unit === '%' ? Math.min(100, nonNegative) : nonNegative;
};
const isHistoryTimestamp = (timestamp: number): boolean =>
Number.isFinite(timestamp) && Number.isFinite(new Date(timestamp).getTime());
export const normalizeGuestDrawerHistoryPoints = (
points: AggregatedMetricPoint[] | undefined,
unit: string,
): AggregatedMetricPoint[] =>
(points ?? [])
.filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value))
.filter((point) => isHistoryTimestamp(point.timestamp) && Number.isFinite(point.value))
.map((point) => {
const value = clampHistoryPointValue(point.value, unit);
return {
@ -271,12 +279,29 @@ export const getGuestDrawerHistoryValueLabel = (
export const getGuestDrawerHistoryRangeBounds = (
groupedSeries: readonly { points: readonly AggregatedMetricPoint[] }[],
): { startTime: number; endTime: number } | null => {
const timestamps = groupedSeries.flatMap((item) => item.points.map((point) => point.timestamp));
if (timestamps.length === 0) return null;
window?: { start: number; end: number },
): GuestDrawerHistoryTimeBounds | null => {
const timestamps = groupedSeries
.flatMap((item) => item.points.map((point) => point.timestamp))
.filter(isHistoryTimestamp);
const windowBounds =
window &&
isHistoryTimestamp(window.start) &&
isHistoryTimestamp(window.end) &&
window.end > window.start
? { startTime: window.start, endTime: window.end }
: null;
if (timestamps.length === 0) return windowBounds;
// The API's requested window is shared across every panel. Do not stretch
// a few recent readings across the whole selected range, or scale each
// metric group to different times. Preserve returned edge observations
// (including aggregate bucket timestamps) by widening the common envelope.
const first = Math.min(...timestamps);
const last = Math.max(...timestamps);
return {
startTime: Math.min(...timestamps),
endTime: Math.max(...timestamps),
startTime: Math.min(windowBounds?.startTime ?? first, first),
endTime: Math.max(windowBounds?.endTime ?? last, last),
};
};