mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Integrate reviewed dated drawer History window
Change-source: pulse-maintainer
This commit is contained in:
commit
8a0937a0f3
8 changed files with 939 additions and 70 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
393
frontend-modern/browser-tests/history-window.cjs
Normal file
393
frontend-modern/browser-tests/history-window.cjs
Normal 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;
|
||||
});
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue