mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Make drawer History inspectable by keyboard and touch
Expose existing stored history through labelled native observation controls and dated accessible descriptions. Keep missing samples distinct from live or neighbouring readings, preserve the selected timestamp through refreshes and reset selection at resource/range boundaries. Cover input state and real native browser interaction without changing collection or licence gates. Change-source: pulse-maintainer
This commit is contained in:
parent
0d3e5a801a
commit
d38e2ec61a
5 changed files with 869 additions and 64 deletions
|
|
@ -7556,3 +7556,24 @@ catalog, the header metadata and every locale catalog; the product name is not
|
|||
translated. Its route id, feature gate and read capability are unchanged.
|
||||
Relay never provided remote access to the web UI, so no settings chrome may
|
||||
label it Remote Access.
|
||||
|
||||
### Drawer History is inspectable without pointer hover
|
||||
|
||||
`GuestDrawerHistory` exposes a labelled native range input for groups with
|
||||
multiple stored observation times. Native arrow keys, Home/End and touch input
|
||||
select real stored timestamps; the control's value text includes the full local
|
||||
date/time and separately formatted series values. A series without a sample at
|
||||
the selected time remains missing rather than borrowing a neighbouring or live
|
||||
reading. The SVG has a linked textual description, including lone observations
|
||||
and the absence of stored data; a lone observation does not fabricate a trend.
|
||||
|
||||
Selection follows its timestamp across same-source refreshes, not an ordinal
|
||||
index that shifts when samples arrive. An expired selection snaps to an actual
|
||||
remaining observation. Changing resource type, id or range clears pointer and
|
||||
keyboard selection even when matching cached data arrives immediately. Empty,
|
||||
failed initial, absent-target and licence-locked views expose no inspection
|
||||
control. Existing matching-point retention and scoped retry remain unchanged.
|
||||
Mounted inspection regressions cover these boundaries. The direct-renderer
|
||||
browser fixture verifies native keys, focus, pointer coexistence and touch at
|
||||
desktop/phone widths across Chromium, Firefox and WebKit; it is not installed
|
||||
PBS collection or a screen-reader announcement-quality claim.
|
||||
|
|
|
|||
345
frontend-modern/browser-tests/history-inspection.cjs
Normal file
345
frontend-modern/browser-tests/history-inspection.cjs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
// Direct production History/query + CSS with synthetic API data. Not installed PBS acceptance.
|
||||
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 artifacts = path.join(root, 'node_modules', `history-inspection-${engine}-${width}`);
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
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: 5223, strictPort: true },
|
||||
});
|
||||
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 ['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(20_000);
|
||||
const requests = [],
|
||||
errors = [];
|
||||
let action = 'original',
|
||||
held;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const time = 1_700_000_000_000;
|
||||
const point = (offset, value) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const metrics = (kind) => {
|
||||
if (kind === 'single') return { cpu: [point(0, 11)] };
|
||||
if (kind === 'empty') return {};
|
||||
if (kind === 'service')
|
||||
return {
|
||||
cpu: [point(0, 77), point(120_000, 88)],
|
||||
memory: [point(0, 33), point(120_000, 44)],
|
||||
};
|
||||
return {
|
||||
cpu:
|
||||
kind === 'updated'
|
||||
? [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
]
|
||||
: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
// Zero throughput is an observation, not missing history.
|
||||
netin: [point(0, 0), point(120_000, 2048)],
|
||||
netout: [point(0, 1024), point(120_000, 4096)],
|
||||
diskread: [point(0, 2048), point(120_000, 8192)],
|
||||
diskwrite: [point(0, 0), point(120_000, 0)],
|
||||
};
|
||||
};
|
||||
const response = (id, range, kind) => ({
|
||||
resourceType: 'agent',
|
||||
resourceId: id,
|
||||
range,
|
||||
start: time,
|
||||
end: time + 180_000,
|
||||
source: 'store',
|
||||
metrics: metrics(kind),
|
||||
});
|
||||
await page.route('**/*', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const id = url.searchParams.get('resourceId'),
|
||||
range = url.searchParams.get('range');
|
||||
assert.ok(['agent-three', 'pbs-three'].includes(id));
|
||||
const kind = action;
|
||||
action = id === 'pbs-three' ? 'service' : 'original';
|
||||
requests.push({ id, range, kind, method: route.request().method() });
|
||||
if (kind === 'hold') {
|
||||
held = route;
|
||||
return;
|
||||
}
|
||||
if (kind === 'failure')
|
||||
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
|
||||
return route.fulfill({ json: response(id, range, kind) });
|
||||
}
|
||||
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 } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5223/browser-tests/pbs-history-refresh.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
await page.evaluate(
|
||||
(dark) => document.documentElement.classList.toggle('dark', dark),
|
||||
theme === 'dark',
|
||||
);
|
||||
const fixture = page.getByTestId('history-refresh-fixture');
|
||||
const utilization = fixture.locator('[data-history-group="utilization"]');
|
||||
const slider = fixture.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
const countBeforeInspection = requests.length;
|
||||
const value = () => slider.getAttribute('aria-valuetext');
|
||||
const screenshot = (state) =>
|
||||
fixture.screenshot({ path: path.join(artifacts, `${state}-${theme}.png`) });
|
||||
const assertValue = async (pattern) => assert.match(await value(), pattern);
|
||||
const layout = async () => {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
const box = await slider.boundingBox();
|
||||
assert.ok(box && box.x >= 0 && box.x + box.width <= width + 1);
|
||||
if (width === 390) assert.ok(box.height >= 44, JSON.stringify(box));
|
||||
return dimensions;
|
||||
};
|
||||
// Tab reaches a native slider after the existing range/refresh controls.
|
||||
await fixture.getByTestId('guest-history-range-control').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await fixture
|
||||
.getByRole('button', { name: 'Refresh history' })
|
||||
.evaluate((el) => document.activeElement === el),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const focusStyle = await slider.evaluate((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return {
|
||||
visible: el.matches(':focus-visible'),
|
||||
colour: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
style: style.outlineStyle,
|
||||
background: getComputedStyle(el.closest('section')).backgroundColor,
|
||||
};
|
||||
});
|
||||
assert.equal(focusStyle.visible, true);
|
||||
assert.equal(focusStyle.colour, 'rgb(59, 130, 246)');
|
||||
assert.equal(focusStyle.style, 'solid');
|
||||
assert.ok(parseFloat(focusStyle.width) >= 2);
|
||||
const luminance = (colour) => {
|
||||
const channels = colour
|
||||
.match(/\d+/g)
|
||||
.slice(0, 3)
|
||||
.map((n) => {
|
||||
const s = Number(n) / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
||||
};
|
||||
const a = luminance(focusStyle.colour),
|
||||
b = luminance(focusStyle.background);
|
||||
focusStyle.contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
assert.ok(focusStyle.contrast >= 3, JSON.stringify(focusStyle));
|
||||
await assertValue(/CPU 43\.0%\. Memory 55\.0%\. Disk no observation\./);
|
||||
await slider.press('Home');
|
||||
assert.equal(await slider.inputValue(), '0');
|
||||
await assertValue(/CPU 11\.0%\. Memory no observation\. Disk no observation\./);
|
||||
await slider.press('ArrowRight');
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU no observation\. Memory 30\.0%\. Disk no observation\./);
|
||||
assert.match(await utilization.innerText(), /CPU\s*-/);
|
||||
assert.doesNotMatch(await utilization.innerText(), /53\.0%/);
|
||||
await layout();
|
||||
await screenshot('selected-sparse-time');
|
||||
await slider.press('End');
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
await slider.press('ArrowLeft');
|
||||
await assertValue(/CPU 22\.0%\. Memory no observation/);
|
||||
assert.equal(
|
||||
requests.length,
|
||||
countBeforeInspection,
|
||||
'inspection must not start another API read',
|
||||
);
|
||||
const selectedBeforePoll = await value();
|
||||
// Exercise an actual same-source refresh without moving focus, as a background poll would.
|
||||
action = 'updated';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).evaluate((el) => el.click());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector('input[aria-label="Inspect Utilization history"]')?.max === '5',
|
||||
);
|
||||
assert.equal(await slider.inputValue(), '3');
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.equal(await slider.evaluate((el) => document.activeElement === el), true);
|
||||
const plot = utilization.getByTestId('guest-history-plot');
|
||||
const box = await plot.boundingBox();
|
||||
await page.mouse.move(box.x + box.width - 4, box.y + box.height / 2);
|
||||
await page.mouse.move(1, 1);
|
||||
assert.equal(await value(), selectedBeforePoll);
|
||||
assert.match(await utilization.innerText(), /CPU\s*22\.0%/);
|
||||
// Native blur restores the latest legend; mouse inspection still works.
|
||||
await slider.press('Tab');
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
await page.mouse.move(box.x + (34 / 360) * box.width, box.y + box.height / 2);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.mouse.move(1, 1);
|
||||
|
||||
if (width === 390) {
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
const touchBox = await slider.boundingBox();
|
||||
await page.touchscreen.tap(touchBox.x + 3, touchBox.y + touchBox.height / 2);
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*8\.0%/);
|
||||
await page.touchscreen.tap(
|
||||
touchBox.x + touchBox.width - 3,
|
||||
touchBox.y + touchBox.height / 2,
|
||||
);
|
||||
await assertValue(/CPU 66\.0%\. Memory no observation/);
|
||||
assert.match(await utilization.innerText(), /CPU\s*66\.0%/);
|
||||
}
|
||||
action = 'failure';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await fixture
|
||||
.getByRole('status', { name: 'History refresh status' })
|
||||
.getByText('History refresh failed. Showing previously loaded history.', { exact: true })
|
||||
.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
await assertValue(/CPU 8\.0%\. Memory no observation/);
|
||||
assert.doesNotMatch(await fixture.innerText(), /Private fixture detail/);
|
||||
await layout();
|
||||
await screenshot('failed-refresh-inspection');
|
||||
const semantics = await slider.ariaSnapshot();
|
||||
action = 'single';
|
||||
await fixture.getByRole('button', { name: 'Retry history' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
const description = await utilization
|
||||
.getByRole('img')
|
||||
.evaluate((el) => document.getElementById(el.getAttribute('aria-describedby')).textContent);
|
||||
assert.match(description, /1 stored observation time\. .*CPU 11\.0%/);
|
||||
assert.equal(await utilization.locator('path').count(), 0);
|
||||
action = 'empty';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector('[data-history-group="utilization"] svg[role="img"]');
|
||||
return (
|
||||
document.getElementById(image.getAttribute('aria-describedby')).textContent ===
|
||||
'No stored history observations.'
|
||||
);
|
||||
});
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
// Changing resource cannot carry an inspected former-host point into the new target.
|
||||
action = 'original';
|
||||
await fixture.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await slider.waitFor();
|
||||
await slider.focus();
|
||||
await slider.press('Home');
|
||||
action = 'hold';
|
||||
await page.getByRole('button', { name: 'Switch to service target' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('input[type="range"]').length === 0,
|
||||
);
|
||||
assert.equal(await fixture.locator('svg path').count(), 0);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
assert.ok(held, 'new target read must be held');
|
||||
await held.fulfill({ json: response('pbs-three', '24h', 'service') });
|
||||
await slider.waitFor();
|
||||
assert.equal(await slider.inputValue(), '1');
|
||||
await assertValue(/CPU 88\.0%\. Memory 44\.0%\. Disk no observation/);
|
||||
assert.equal(await fixture.getByTestId('guest-history-hover-time').count(), 0);
|
||||
const readsBeforeLock = requests.length;
|
||||
await fixture.getByTestId('guest-history-range-control').selectOption('14d');
|
||||
await fixture.getByText(/14 days history requires a higher license plan/).waitFor();
|
||||
assert.equal(await fixture.getByRole('slider').count(), 0);
|
||||
assert.equal(requests.length, readsBeforeLock);
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({
|
||||
engine,
|
||||
version: browser.version(),
|
||||
width,
|
||||
theme,
|
||||
requests,
|
||||
errors,
|
||||
semantics,
|
||||
layout: { width },
|
||||
singleDescription: description,
|
||||
focusStyle,
|
||||
nativeKeys: ['Tab', 'Home', 'ArrowRight', 'End', 'ArrowLeft'],
|
||||
touch: width === 390,
|
||||
});
|
||||
console.log(JSON.stringify({ engine, width, theme, result: 'passed' }));
|
||||
await page.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
result: 'passed',
|
||||
engine,
|
||||
version: browser.version(),
|
||||
playwright: require('playwright/package.json').version,
|
||||
runtime_sha256: createHash('sha256')
|
||||
.update(
|
||||
fs.readFileSync(path.join(root, 'src/components/Workloads/GuestDrawerHistory.tsx')),
|
||||
)
|
||||
.digest('hex'),
|
||||
observations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -1,18 +1,14 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3",
|
||||
"verified_at": "2026-09-30T22:01:47Z",
|
||||
"base_sha": "0d3e5a801ab8fd1c1d224ef561cbf99e169c589f",
|
||||
"verified_at": "2026-09-30T23:28:26Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts"
|
||||
],
|
||||
"changed_paths": ["frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79"
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "327e75cabb99edffc712302c00645f06d92dc44dbebdd00aef08074e7fa53eb2"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)"
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory/query with production CSS and synthetic metrics API data; not a complete PBS drawer or installed acceptance)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -25,51 +21,127 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark current host history",
|
||||
"real 30-second poll fails while matching points remain explicitly labelled",
|
||||
"keyboard retry held with busy/activation guard and retained points",
|
||||
"successful retry keeps keyboard focus and updates the same control",
|
||||
"real background polling supersedes a slow foreground read and settles loading",
|
||||
"late aborted response cannot replace current paths",
|
||||
"uncached service-target failure has neither former-host points nor a collecting claim",
|
||||
"service-target retry renders CPU/memory only",
|
||||
"locked range exposes no read or refresh action"
|
||||
"light/dark ordered and sparse stored series",
|
||||
"native focus with a 2px blue outline, measured contrast 3.68:1 light and 3.98:1 dark against the chart surface",
|
||||
"missing series at a selected timestamp stay missing, not nearby or live values",
|
||||
"same-source refresh adds samples without changing the selected timestamp or keyboard focus",
|
||||
"failed refresh retains inspectable matching observations and honest warning",
|
||||
"single stored observation has a dated description but no fabricated trend or pointless slider",
|
||||
"empty store/current readings expose no inspection control",
|
||||
"held replacement target has no former-host points or selection; new target starts at its own latest observation",
|
||||
"licence-locked range has no reads or inspection action"
|
||||
],
|
||||
"interactions": [
|
||||
"Enter retry twice while its GET is held; exactly one retry read starts",
|
||||
"fulfil retry; inspect warning removal, changed paths and retained focus",
|
||||
"hold manual refresh across the real polling interval; inspect loading settlement and aborted read",
|
||||
"switch to service target, fail its GET, retry the same key, select locked 14d"
|
||||
"Tab from History range through Refresh to the native observation slider; Home, ArrowRight, End and ArrowLeft select actual stored times with no extra API reads",
|
||||
"refresh through the existing real query while the input stays focused; new sample indexes do not move the selected timestamp",
|
||||
"move and leave the plot while the slider is focused; then blur and confirm existing mouse hover still works",
|
||||
"Chromium/WebKit phone touch taps at both ends of the 44px-high native control update the selected stored values",
|
||||
"fail a refresh, inspect retained observations; replace with a single sample and then no history; switch resource while its GET is held; load the service response; select locked 14d"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-inspection.cjs --engine=chromium; the same command 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-jybdl1pm/receipts/renderer-browser-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/chromium-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/firefox-1365/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-light.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/selected-sparse-time-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/failed-refresh-inspection-dark.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/browser-final/webkit-390/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-chromium-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-firefox-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-frhttefs/receipts/browser-webkit-phone.log"
|
||||
],
|
||||
"notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed.",
|
||||
"notes": "All 16 final screenshots inspected. Eight engine/viewport/theme cases pass native keyboard, touch, state and layout assertions with no page errors. Semantic snapshots exercise native slider names/value text, not actual screen-reader speech quality. Initial dependency-copy/install failures, unsupported helper invocation, preliminary browser sequence exit 143 with unknown cause and the corrected dark-focus-colour defect remain in output receipts. No real PBS telemetry, full drawer, in-browser update, installed upgrade/rollback, containing-line or release availability claim. Copy-lint failure source-zhg72ioz at unshared 7c625696... is retained. Its semicolon-separated accessible value text is replaced by sentences; that unshared candidate was abandoned at the supplied base, not amended or integrated. All four browser commands and all 16 inspected screenshots now bind the corrected runtime. No failed earlier pass or shared history is rewritten.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3",
|
||||
"verified_at": "2026-09-30T22:01:47Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903",
|
||||
"frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"light/dark current host history",
|
||||
"real 30-second poll fails while matching points remain explicitly labelled",
|
||||
"keyboard retry held with busy/activation guard and retained points",
|
||||
"successful retry keeps keyboard focus and updates the same control",
|
||||
"real background polling supersedes a slow foreground read and settles loading",
|
||||
"late aborted response cannot replace current paths",
|
||||
"uncached service-target failure has neither former-host points nor a collecting claim",
|
||||
"service-target retry renders CPU/memory only",
|
||||
"locked range exposes no read or refresh action"
|
||||
],
|
||||
"interactions": [
|
||||
"Enter retry twice while its GET is held; exactly one retry read starts",
|
||||
"fulfil retry; inspect warning removal, changed paths and retained focus",
|
||||
"hold manual refresh across the real polling interval; inspect loading settlement and aborted read",
|
||||
"switch to service target, fail its GET, retry the same key, select locked 14d"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png"
|
||||
],
|
||||
"notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"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"
|
||||
},
|
||||
|
|
@ -124,9 +196,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"
|
||||
},
|
||||
|
|
@ -175,9 +245,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"
|
||||
},
|
||||
|
|
@ -273,9 +341,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"
|
||||
},
|
||||
|
|
@ -316,9 +382,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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { For, Show, createMemo, createSignal, onMount, type Component } from 'solid-js';
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
on,
|
||||
onMount,
|
||||
type Component,
|
||||
} from 'solid-js';
|
||||
|
||||
import {
|
||||
ChartsAPI,
|
||||
|
|
@ -57,6 +67,7 @@ interface GuestDrawerHistoryGroupChartProps {
|
|||
loading: boolean;
|
||||
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
|
||||
range: HistoryTimeRange;
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
const GUEST_DRAWER_HISTORY_MAX_POINTS = 240;
|
||||
|
|
@ -196,24 +207,73 @@ const findClosestGuestDrawerHistoryPoint = (
|
|||
|
||||
const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps> = (props) => {
|
||||
const [hoverTimestamp, setHoverTimestamp] = createSignal<number | null>(null);
|
||||
const [selectedTimestamp, setSelectedTimestamp] = createSignal<number | null>(null);
|
||||
const inspectionId = `history-inspection-${createUniqueId()}`;
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sourceKey,
|
||||
() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(null);
|
||||
},
|
||||
),
|
||||
);
|
||||
const series = createMemo(() =>
|
||||
props.group.series.map((config) => ({
|
||||
...config,
|
||||
points: normalizeGuestDrawerHistoryPoints(props.metrics[config.metric], config.unit),
|
||||
})),
|
||||
);
|
||||
// Step through actual stored times, not synthetic points between samples.
|
||||
// Keep the selected time across polls even if its ordinal index changes.
|
||||
const observationTimes = createMemo(() =>
|
||||
[...new Set(series().flatMap((item) => item.points.map((point) => point.timestamp)))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
);
|
||||
const selectedIndex = createMemo(() => {
|
||||
const times = observationTimes();
|
||||
const selected = selectedTimestamp();
|
||||
if (selected === null) return Math.max(0, times.length - 1);
|
||||
let index = 0;
|
||||
for (let i = 1; i < times.length; i++) {
|
||||
if (Math.abs(times[i] - selected) < Math.abs(times[index] - selected)) index = i;
|
||||
}
|
||||
return index;
|
||||
});
|
||||
const inspectionTimestamp = createMemo(() =>
|
||||
selectedTimestamp() === null ? null : (observationTimes()[selectedIndex()] ?? null),
|
||||
);
|
||||
const inspectionValueText = createMemo(() => {
|
||||
const timestamp = observationTimes()[selectedIndex()];
|
||||
if (timestamp === undefined) return 'No stored history observations.';
|
||||
const values = series().map((item) => {
|
||||
const point = item.points.find((point) => point.timestamp === timestamp);
|
||||
return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`;
|
||||
});
|
||||
return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`;
|
||||
});
|
||||
const chartDescription = createMemo(() => {
|
||||
if (props.loading) return 'Loading history.';
|
||||
const count = observationTimes().length;
|
||||
if (count === 0) return inspectionValueText();
|
||||
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
|
||||
});
|
||||
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
|
||||
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
|
||||
const bounds = createMemo(() => getGuestDrawerHistoryRangeBounds(series()));
|
||||
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
|
||||
const hoveredSeries = createMemo(() => {
|
||||
const timestamp = hoverTimestamp();
|
||||
const timestamp = inspectionTimestamp() ?? hoverTimestamp();
|
||||
const rangeBounds = bounds();
|
||||
if (timestamp === null || !rangeBounds) return [];
|
||||
|
||||
return drawableSeries()
|
||||
return (inspectionTimestamp() === null ? drawableSeries() : series())
|
||||
.map((item) => {
|
||||
const point = findClosestGuestDrawerHistoryPoint(item.points, timestamp);
|
||||
const point =
|
||||
inspectionTimestamp() === null
|
||||
? findClosestGuestDrawerHistoryPoint(item.points, timestamp)
|
||||
: item.points.find((point) => point.timestamp === timestamp);
|
||||
if (!point) return null;
|
||||
return {
|
||||
...item,
|
||||
|
|
@ -240,11 +300,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
...item,
|
||||
valueLabel: hovered
|
||||
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
: inspectionTimestamp() !== null
|
||||
? '-'
|
||||
: item.points.length > 0
|
||||
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
|
||||
: typeof currentValue === 'number' && Number.isFinite(currentValue)
|
||||
? formatHistoryChartTooltipValue(currentValue, item.unit)
|
||||
: '-',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
|
@ -254,6 +316,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
});
|
||||
|
||||
const handleHoverMove = (event: MouseEvent & { currentTarget: SVGSVGElement }) => {
|
||||
if (inspectionTimestamp() !== null) return;
|
||||
const rangeBounds = bounds();
|
||||
if (!rangeBounds) return;
|
||||
|
||||
|
|
@ -321,6 +384,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
</For>
|
||||
<svg
|
||||
aria-label={`${props.group.label} history`}
|
||||
aria-describedby={`${inspectionId}-description`}
|
||||
class="absolute inset-0 h-full w-full cursor-crosshair"
|
||||
data-testid="guest-history-plot"
|
||||
onMouseMove={handleHoverMove}
|
||||
|
|
@ -407,6 +471,43 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<p id={`${inspectionId}-description`} class="sr-only">
|
||||
{chartDescription()}
|
||||
</p>
|
||||
<Show when={observationTimes().length > 1}>
|
||||
<div class="mt-2">
|
||||
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">
|
||||
<span>Inspect history</span>
|
||||
<span aria-hidden="true">
|
||||
{selectedIndex() + 1} / {observationTimes().length}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={inspectionId}
|
||||
type="range"
|
||||
aria-label={`Inspect ${props.group.label} history`}
|
||||
aria-valuetext={inspectionValueText()}
|
||||
aria-describedby={`${inspectionId}-help`}
|
||||
min="0"
|
||||
max={observationTimes().length - 1}
|
||||
step="1"
|
||||
value={selectedIndex()}
|
||||
class="h-11 w-full cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 sm:h-6"
|
||||
onFocus={() => {
|
||||
setHoverTimestamp(null);
|
||||
setSelectedTimestamp(observationTimes()[selectedIndex()] ?? null);
|
||||
}}
|
||||
onBlur={() => setSelectedTimestamp(null)}
|
||||
onInput={(event) => {
|
||||
const index = event.currentTarget.valueAsNumber;
|
||||
if (Number.isFinite(index)) setSelectedTimestamp(observationTimes()[index] ?? null);
|
||||
}}
|
||||
/>
|
||||
<p id={`${inspectionId}-help`} class="sr-only">
|
||||
Use arrow keys, Home or End to inspect stored observations.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -517,6 +618,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
|
|||
metrics={metrics()}
|
||||
currentMetrics={props.currentMetrics}
|
||||
range={props.range}
|
||||
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
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 { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
|
||||
import type { GuestDrawerHistoryTarget } 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 time = 1_700_000_000_000;
|
||||
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' };
|
||||
const point = (offset: number, value: number) => ({
|
||||
timestamp: time + offset,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
const response = (
|
||||
metrics: AllMetricsHistoryResponse['metrics'] = {
|
||||
cpu: [point(120_000, 43), point(0, 11), point(60_000, 22)],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
},
|
||||
): AllMetricsHistoryResponse => ({
|
||||
...target,
|
||||
range: '24h',
|
||||
start: time,
|
||||
end: time + 120_000,
|
||||
source: 'store',
|
||||
metrics,
|
||||
});
|
||||
const inspect = () => screen.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
const choose = (slider: HTMLElement, index: number) =>
|
||||
fireEvent.input(slider, { target: { value: String(index) } });
|
||||
|
||||
// Native keyboard/touch stepping is exercised in the offline browser; jsdom
|
||||
// tests the renderer's input/focus contract rather than simulating browser defaults.
|
||||
describe('GuestDrawerHistory stored-observation inspection', () => {
|
||||
it('exposes an ordered native control, dated values and chart description without another read', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ disk: 88 }} />);
|
||||
await waitFor(() => expect(inspect()).toHaveAttribute('max', '3'));
|
||||
const slider = inspect();
|
||||
expect(slider).toHaveAttribute('type', 'range');
|
||||
expect(slider).toHaveAttribute('min', '0');
|
||||
expect(slider).toHaveAttribute('step', '1');
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 120_000).toLocaleString()}. CPU 43.0%. Memory 55.0%. Disk no observation.`,
|
||||
);
|
||||
expect(
|
||||
within(utilization()).getByRole('img', { name: 'Utilization history' }),
|
||||
).toHaveAccessibleDescription(/4 stored observation times/);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('inspects exact stored times and does not borrow nearby or current values for missing series', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => (
|
||||
<GuestDrawerHistory
|
||||
target={target}
|
||||
range="24h"
|
||||
currentMetrics={{ cpu: 99, memory: 98, disk: 88 }}
|
||||
/>
|
||||
));
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).toHaveTextContent('Memory-');
|
||||
expect(utilization()).not.toHaveTextContent('88.0%');
|
||||
choose(slider, 1);
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 30_000).toLocaleString()}. CPU no observation. Memory 30.0%. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU-');
|
||||
expect(utilization()).toHaveTextContent('Memory30.0%');
|
||||
expect(slider).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps focused selection independent of plot pointer movement, then restores normal hover on blur', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
const slider = inspect();
|
||||
const plot = within(utilization()).getByTestId('guest-history-plot');
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 360 } as DOMRect);
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
fireEvent.pointerMove(plot, { clientX: 352 });
|
||||
fireEvent.pointerLeave(plot);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
fireEvent.blur(slider);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
fireEvent.mouseMove(plot, { clientX: 34 });
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
});
|
||||
|
||||
it('preserves the selected stored timestamp, not its index, when a poll adds observations', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
cpu: [
|
||||
point(-60_000, 8),
|
||||
point(0, 11),
|
||||
point(60_000, 22),
|
||||
point(120_000, 43),
|
||||
point(180_000, 66),
|
||||
],
|
||||
memory: [point(30_000, 30), point(120_000, 55)],
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 2);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(inspect()).toBe(slider);
|
||||
expect(slider).toHaveFocus();
|
||||
expect(slider).toHaveValue('3');
|
||||
expect(slider).toHaveAttribute(
|
||||
'aria-valuetext',
|
||||
`${new Date(time + 60_000).toLocaleString()}. CPU 22.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU22.0%');
|
||||
});
|
||||
|
||||
it('snaps an expired inspected timestamp to an actual remaining observation on refresh', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(120_000, 43), point(180_000, 66)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const slider = inspect();
|
||||
slider.focus();
|
||||
choose(slider, 0);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(slider).toHaveValue('0');
|
||||
expect(slider.getAttribute('aria-valuetext')).toContain(
|
||||
new Date(time + 120_000).toLocaleString(),
|
||||
);
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
});
|
||||
|
||||
it.each(['target', 'resource type', 'range'] as const)(
|
||||
'clears selection on a cached %s replacement even when its timestamps coincide',
|
||||
async (change) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockResolvedValueOnce(response({ cpu: [point(0, 77), point(120_000, 88)] }))
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
const [current, setTarget] = createSignal(target);
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
|
||||
render(() => <GuestDrawerHistory target={current()} range={range()} />);
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
if (change === 'target') setTarget({ ...target, resourceId: 'other-pbs' });
|
||||
else if (change === 'resource type') setTarget({ ...target, resourceType: 'vm' });
|
||||
else setRange('6h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('1'));
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU77.0%');
|
||||
if (change !== 'range') setTarget(target);
|
||||
else setRange('24h');
|
||||
await waitFor(() => expect(inspect()).toHaveValue('3'));
|
||||
expect(utilization()).toHaveTextContent('CPU43.0%');
|
||||
expect(
|
||||
within(utilization()).queryByTestId('guest-history-hover-time'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('inspects zero throughput and temperature using the existing host metric units', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response({
|
||||
netin: [point(0, 0), point(60_000, 2048)],
|
||||
temperature: [point(0, 42), point(60_000, 57)],
|
||||
}),
|
||||
);
|
||||
render(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" groups={HOST_METRICS_HISTORY_GROUPS} />
|
||||
));
|
||||
const network = await screen.findByRole('slider', { name: 'Inspect Network I/O history' });
|
||||
network.focus();
|
||||
choose(network, 0);
|
||||
expect(network.getAttribute('aria-valuetext')).toContain('In 0 B/s. Out no observation.');
|
||||
const thermal = screen.getByRole('slider', { name: 'Inspect Thermals history' });
|
||||
thermal.focus();
|
||||
choose(thermal, 1);
|
||||
expect(thermal.getAttribute('aria-valuetext')).toContain('CPU 57°C.');
|
||||
});
|
||||
|
||||
it('keeps stored observations inspectable after a failed poll without exposing the error', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory')
|
||||
.mockResolvedValueOnce(response())
|
||||
.mockRejectedValueOnce(new Error('private backend detail'));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toHaveTextContent(
|
||||
'Showing previously loaded history.',
|
||||
);
|
||||
inspect().focus();
|
||||
choose(inspect(), 0);
|
||||
expect(utilization()).toHaveTextContent('CPU11.0%');
|
||||
expect(utilization()).not.toHaveTextContent('private backend detail');
|
||||
});
|
||||
|
||||
it('describes a lone stored observation without manufacturing a trend or pointless slider', async () => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ cpu: [point(0, 11)] }));
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await waitFor(() =>
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
/1 stored observation time/,
|
||||
),
|
||||
);
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
`1 stored observation time. ${new Date(time).toLocaleString()}. CPU 11.0%. Memory no observation. Disk no observation.`,
|
||||
);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(utilization().querySelector('path')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['empty', 'invalid'] as const)(
|
||||
'does not offer inspection for %s stored history or live readings',
|
||||
async (kind) => {
|
||||
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
|
||||
response(kind === 'empty' ? {} : { cpu: [point(0, NaN)] }),
|
||||
);
|
||||
render(() => <GuestDrawerHistory target={target} range="24h" currentMetrics={{ cpu: 42 }} />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading history')).not.toBeInTheDocument());
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(within(utilization()).getByRole('img')).toHaveAccessibleDescription(
|
||||
'No stored history observations.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('offers no inspection or reads for an absent target or locked range', async () => {
|
||||
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory');
|
||||
const [current, setTarget] = createSignal<GuestDrawerHistoryTarget | null>(null);
|
||||
render(() => <GuestDrawerHistory target={current()} range="14d" />);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
setTarget(target);
|
||||
await screen.findByText(/14 days history requires/);
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue