Keep History readings on target and accessible by keyboard (#2393)

## Outcome
Keep Storage History readings tied to the selected target, and make
individual readings usable without a mouse.

Late responses and polling from a previous target could replace the
selected target's data. The reviewed repair invalidates superseded
requests and clears stale readings while retaining pointer inspection
during matching live refreshes. Keyboard focus, arrows, Home/End and
Escape now inspect actual samples; polite announcements and a taller
tooltip keep timestamp/value readings usable. This improves the existing
History surface, not a new feature.

## Included source
Base `1ecb0d6e49` → proposed
`65a36dd4e7`.
- Target ownership candidate `9dc6bd0ffe..1177e4d143`, integrated by
`cf5d6cdf06`.
- Keyboard/tooltip candidate `0be430813f..65a36dd4e7`, including
corrected geometry and empty-announcement regression assertions.
All reviewed commit identities and ancestry are retained.

## Validation
Recorded exact-source proof `source-haln3xw5`: 1,405 tests, typecheck,
lint, production/embedded builds and budgets passed with complete output
and guest stop. Recorded Chromium/WebKit parent/final browser controls
cover 14 keyboard navigation states plus linked/empty charts and focus
exit; target-selection regressions cover delayed responses and refresh
ownership. Initial browser setup and assertion failures remain recorded
with their corrections.

Publication checks: provenance, whitespace, aggregate completion guard
and committed-range browser receipt validation passed. Existing
sufficient proofs were not replayed. Required exact-head CI and
independent review still apply. Synthetic browser/DOM checks are not
screen-reader or native PBS acceptance, and this PR does not establish
release availability or whole-issue resolution.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot] 2026-10-02 16:21:44 +00:00 • committed by GitHub
commit 1a41c8f0e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 793 additions and 107 deletions

View file

@ -7667,3 +7667,35 @@ 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.
### Shared canvas History responses belong to their selection
`useHistoryChartState` cancels and invalidates old requests when the resource,
metric, range, sampling cap, supplied-data mode or access state changes. Late
successes and failures cannot replace the current selection's samples, loading
or error state, even if cancellation is ignored. Selection changes clear old
readings and hover state while loading; current initial failures remain visible.
Matching supplied-sample refreshes retain pointer inspection; changing the selection
clears it. Matching background refresh failures retain already loaded samples. Polls never
overlap and stop for supplied data (including empty arrays), unavailable targets,
locked ranges and unmount.
`useHistoryChartState.test.tsx` checks delayed success/failure, each selection
field, polling, supplied-data transitions, locked/empty targets and cleanup.
`browser-tests/history-selection.cjs` exercises the production canvas chart and
accessible description in desktop Chromium and phone WebKit, including a late
old-target response and current-target loading/failure. Synthetic response proof
is not native PBS collection or whole-report #1723 acceptance.
### Shared History keyboard inspection
The existing Storage pool and disk canvas charts accept keyboard focus with a
visible focus indicator. Focus selects the latest observed sample; Left/Right
step through actual observations, Home/End select the endpoints, and Escape or
blur clears inspection without trapping focus. Each keyboard-selected timestamp
and formatted value is announced politely. Pointer inspection remains available
without announcing every mouse move. Matching sample refreshes retain inspection;
selection changes and empty data clear stale readings. No readings are invented
for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
browser proof does not establish native collector or assistive-device acceptance.

View file

@ -0,0 +1,80 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/history-keyboard-proof');
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-')), server: { host: '127.0.0.1', port: 5223, strictPort: true } });
let browser;
const observations = [];
try {
await server.listen();
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', 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();
return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { data: [], enabled: false } });
});
await page.goto('http://127.0.0.1:5223/browser-tests/history-keyboard.html');
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
await chart.waitFor();
if (process.argv.includes('--parent')) {
assert.equal(await chart.getAttribute('tabindex'), null);
await page.getByRole('button', { name: 'Change target' }).focus();
await page.keyboard.press('Tab');
assert.equal(await page.evaluate(() => document.activeElement.textContent), 'After charts');
await page.screenshot({ path: path.join(artifacts, 'parent-skipped.png'), fullPage: true });
console.log('Parent reproduced: Tab skips both storage charts; no keyboard inspection');
break;
}
assert.equal(await chart.getAttribute('tabindex'), '0');
await page.getByRole('button', { name: 'Change target' }).focus();
await page.keyboard.press('Tab');
assert.equal(await chart.evaluate(el => el === document.activeElement), true);
const live = page.locator('[aria-live="polite"]').first();
for (const [phase, key, expected] of [['focus', null, '30.0%'], ['previous', 'ArrowLeft', '20.0%'], ['first', 'Home', '10.0%'], ['lower-bound', 'ArrowLeft', '10.0%'], ['last', 'End', '30.0%'], ['upper-bound', 'ArrowRight', '30.0%'], ['escape', 'Escape', '']]) {
if (key) await page.keyboard.press(key);
await page.waitForTimeout(60);
assert.ok((await live.textContent()).includes(expected));
if (!expected) assert.equal(await live.textContent(), '');
else assert.ok((await page.locator('[data-history-chart-tooltip]').first().textContent()).includes(expected));
if (phase === 'previous') {
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
for (const tooltip of await page.locator('[data-history-chart-tooltip]').all()) {
assert.ok(await tooltip.evaluate(el => el.scrollHeight <= el.clientHeight));
}
assert.equal(await page.locator('[aria-live="polite"]').nth(1).textContent(), '');
const outline = await chart.evaluate(el => getComputedStyle(el).outlineWidth);
assert.notEqual(outline, '0px');
await page.screenshot({ path: path.join(artifacts, `${name}-keyboard.png`), fullPage: true });
}
observations.push({ name, version: browser.version(), phase, announcement: await live.textContent() });
}
await page.keyboard.press('Tab');
assert.equal(await page.getByRole('img', { name: 'Read chart', exact: true }).evaluate(el => el === document.activeElement), true);
await page.keyboard.press('Tab');
assert.equal(await page.getByRole('button', { name: 'After charts' }).evaluate(el => el === document.activeElement), true);
await page.getByRole('button', { name: 'Toggle empty' }).click();
await chart.focus();
await page.keyboard.press('End');
assert.equal(await live.textContent(), '');
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 0);
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
assert.deepEqual(errors, []);
await browser.close(); browser = null;
}
fs.writeFileSync(path.join(artifacts, process.argv.includes('--parent') ? 'parent.json' : 'result.json'), JSON.stringify({ playwright: require('playwright/package.json').version, observations }, null, 2));
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
} finally { if (browser) await browser.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History keyboard verification</title></head><body><div id="root"></div><script type="module" src="./history-keyboard.tsx"></script></body></html>

View file

@ -0,0 +1,48 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
import '../src/index.css';
const Fixture = () => {
const [empty, setEmpty] = createSignal(false);
const [target, setTarget] = createSignal('a');
const points = () =>
empty()
? []
: [10, 20, 30].map((value, i) => ({
timestamp: 1790942400000 + i * 60000,
value,
min: value,
max: value,
}));
return (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History keyboard inspection</h1>
<button onClick={() => setEmpty(!empty())}>Toggle empty</button>
<button onClick={() => setTarget(target() === 'a' ? 'b' : 'a')}>Change target</button>
<HistoryChartHoverGroup>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
label="Usage"
unit="%"
hideSelector
range="1h"
data={points()}
/>
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="diskread"
label="Read"
unit="B/s"
hideSelector
range="1h"
data={points()}
/>
</HistoryChartHoverGroup>
<button>After charts</button>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -0,0 +1,110 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/history-selection-proof');
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-')),
server: { host: '127.0.0.1', port: 5222, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const scenario of [
{ engine: chromium, name: 'chromium', width: 1365, height: 900 },
{ engine: webkit, name: 'webkit', width: 390, height: 844 },
]) {
browser = await scenario.engine.launch(
scenario.name === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width: scenario.width, height: scenario.height },
isMobile: scenario.name === 'webkit',
hasTouch: scenario.name === 'webkit',
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.route('**/*', (route) => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5222') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { data: [], enabled: false },
});
});
await page.goto('http://127.0.0.1:5222/browser-tests/history-selection.html');
if (scenario.name === 'webkit')
await page.evaluate(() => document.documentElement.classList.add('dark'));
const chart = page.getByRole('img', { name: 'CPU usage chart' });
await chart.waitFor();
const description = page.locator('#' + (await chart.getAttribute('aria-describedby')));
await page.getByRole('button', { name: 'Select b', exact: true }).click();
await page.getByRole('button', { name: 'Finish b', exact: true }).click();
for (const [phase, button, expected] of [
['b-loaded', null, '80.0%'],
['old-a-finished', 'Finish old a', '80.0%'],
['c-loading', 'Select c', 'Loading'],
['c-failed', 'Fail c', 'could not be loaded'],
]) {
if (button) await page.getByRole('button', { name: button, exact: true }).click();
await page.waitForTimeout(100);
const actual = await description.textContent();
const screenshot = path.join(artifacts, `${scenario.name}-${phase}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
assert.ok(actual.includes(expected), `${scenario.name} ${phase}: ${actual}`);
if (phase.startsWith('c-')) assert.ok(!actual.includes('80.0%'));
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
width: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
observations.push({
browser: scenario.name,
version: browser.version(),
phase,
actual,
dimensions,
screenshot,
});
}
assert.deepEqual(errors, []);
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(artifacts, 'result.json'),
JSON.stringify(
{ playwright: require('playwright/package.json').version, observations },
null,
2,
),
);
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>History selection verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/history-selection.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,71 @@
// Synthetic delayed transport deliberately ignores AbortSignal to test response ownership.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { ChartsAPI, type SingleMetricHistoryResponse } from '../src/api/charts';
import { HistoryChart } from '../src/components/shared/HistoryChart';
import '../src/index.css';
const pending = new Map<
string,
Array<{
resolve: (response: SingleMetricHistoryResponse) => void;
reject: (error: Error) => void;
}>
>();
ChartsAPI.getMetricsHistory = (params) =>
new Promise((resolve, reject) => {
pending.set(params.resourceId, [
...(pending.get(params.resourceId) ?? []),
{ resolve, reject },
]);
});
const complete = (target: string, value: number, fail = false) => {
for (const request of pending.get(target) ?? []) {
if (fail) request.reject(new Error('Synthetic current-target failure'));
else
request.resolve({
points: [0, 1, 2].map((i) => ({
timestamp: 1790942400000 + i * 60000,
value,
min: value,
max: value,
})),
source: 'store',
} as SingleMetricHistoryResponse);
}
pending.delete(target);
};
const Fixture = () => {
const [target, setTarget] = createSignal('a');
return (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>History selection verification</h1>
<p>Synthetic delayed responses; selected target: {target()}</p>
<div class="flex flex-wrap gap-2">
{['b', 'c'].map((name) => (
<button class="min-h-11 border px-3" onClick={() => setTarget(name)}>
Select {name}
</button>
))}
<button class="min-h-11 border px-3" onClick={() => complete('b', 80)}>
Finish b
</button>
<button class="min-h-11 border px-3" onClick={() => complete('a', 10)}>
Finish old a
</button>
<button class="min-h-11 border px-3" onClick={() => complete('c', 0, true)}>
Fail c
</button>
</div>
<HistoryChart
resourceType="agent"
resourceId={target()}
metric="cpu"
label="CPU usage"
unit="%"
hideSelector
range="1h"
/>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);

View file

@ -1,16 +1,20 @@
{
"version": 1,
"base_sha": "60a6c933e0057435dd49cb440995f275f6a567d6",
"verified_at": "2026-10-02T12:57:15.855691Z",
"base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a",
"verified_at": "2026-10-02T15:02:34.899181Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts"
"frontend-modern/src/components/shared/HistoryChart.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts",
"frontend-modern/src/components/shared/historyChartModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "59ca48c06cb3c63f30ccb46cb30536cbc6d86fda1b50e6ea49ecefb9eb377fae"
"frontend-modern/src/components/shared/HistoryChart.tsx": "24cd863fb6288bc2c13ef2d26e02aad95d3e4f54278f3078b1703fabf8785f8c",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "b1ebd93d5fee328b78f8f88f1837514a30d12be069d852d08f5d7f2b6ce999ec",
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751"
},
"routes": [
"/browser-tests/pool-capacity.html"
"/browser-tests/history-keyboard.html"
],
"viewports": [
{
@ -23,26 +27,22 @@
}
],
"states": [
"Production pool drawer with synthetic partial PBS snapshots, not native collector or installed acceptance",
"Missing used/free/usage remain n/a; measured empty and full pools preserve 0 B and 0%",
"Independent byte observations remain visible without total; absent capacity stays n/a",
"Live transitions across five states on desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
"Populated, empty and linked charts in Chromium light and WebKit phone dark; synthetic data, not native PBS acceptance",
"Visible keyboard focus, wrapped timestamp and reading contained within tooltip; no horizontal overflow or page errors"
],
"interactions": [
"Switch missing, empty, full, partial and absent snapshots without reload",
"Read Used, Free, Total and Usage values in the actual Configuration rows"
"Tab into and out of both charts; arrows, Home/End, endpoint clamping, Escape; only focused chart announces keyboard readings",
"Parent Tab skips both charts; final empty chart invents no reading"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pool-capacity.cjs",
"command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-partial.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-missing.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-full.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-empty.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/webkit-keyboard.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/chromium-keyboard.png"
]
}

View file

@ -1,5 +1,6 @@
import { Component, createMemo, createUniqueId } from 'solid-js';
import {
formatHistoryChartTooltipValue,
getHistoryChartAccessibleDescription,
getHistoryChartAccessibleLabel,
type HistoryChartProps,
@ -56,7 +57,11 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
>
<canvas
ref={canvasRef}
class="block w-full h-full cursor-crosshair"
class="block w-full h-full cursor-crosshair rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"
tabIndex={0}
onFocus={chart.handleFocus}
onBlur={chart.handleBlur}
onKeyDown={chart.handleKeyDown}
role="img"
aria-label={getHistoryChartAccessibleLabel(props.label)}
aria-describedby={descriptionId}
@ -64,7 +69,13 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
onMouseLeave={chart.handleMouseLeave}
/>
<p id={descriptionId} class="sr-only">
{accessibleDescription()}
{accessibleDescription()} Use Left and Right arrow keys to inspect readings, Home and End
for the first and last reading, and Escape to clear inspection.
</p>
<p class="sr-only" aria-live="polite" aria-atomic="true">
{chart.keyboardInspecting() && chart.hoveredPoint()
? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}`
: ''}
</p>
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
<HistoryChartTooltip

View file

@ -1,5 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { ChartsAPI } from '@/api/charts';
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw';
import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw';
import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.tsx?raw';
@ -128,6 +130,56 @@ describe('HistoryChart', () => {
expect(historyChartTooltipSource).not.toContain('ChartsAPI.getMetricsHistory');
});
it('inspects actual readings with the keyboard and clears on escape, blur and selection changes', () => {
const [target, setTarget] = createSignal('a');
const [points, setPoints] = createSignal([
{ timestamp: 1_000, value: 10, min: 10, max: 10 },
{ timestamp: 2_000, value: 20, min: 20, max: 20 },
{ timestamp: 3_000, value: 30, min: 30, max: 30 },
]);
const { container } = render(() => (
<HistoryChart
resourceType="disk"
resourceId={target()}
metric="usage"
unit="%"
hideSelector
data={points()}
/>
));
const canvas = screen.getByRole('img', { name: 'History chart' });
const announcement = container.querySelector('[aria-live="polite"]')!;
expect(canvas).toHaveAttribute('tabindex', '0');
expect(announcement.textContent).toBe('');
fireEvent.focus(canvas);
expect(announcement).toHaveTextContent('30.0%');
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
expect(announcement).toHaveTextContent('20.0%');
fireEvent.keyDown(canvas, { key: 'Home' });
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
expect(announcement).toHaveTextContent('10.0%');
fireEvent.keyDown(canvas, { key: 'ArrowRight', ctrlKey: true });
expect(announcement).toHaveTextContent('10.0%');
fireEvent.keyDown(canvas, { key: 'End' });
fireEvent.keyDown(canvas, { key: 'ArrowRight' });
expect(announcement).toHaveTextContent('30.0%');
fireEvent.keyDown(canvas, { key: 'Escape' });
expect(announcement.textContent).toBe('');
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
fireEvent.keyDown(canvas, { key: 'Home' });
setPoints(points().map((point) => ({ ...point, value: point.value + 1 })));
expect(announcement).toHaveTextContent('11.0%');
setTarget('b');
expect(announcement.textContent).toBe('');
fireEvent.keyDown(canvas, { key: 'Home' });
fireEvent.blur(canvas);
expect(announcement.textContent).toBe('');
setPoints([]);
fireEvent.focus(canvas);
fireEvent.keyDown(canvas, { key: 'End' });
expect(announcement.textContent).toBe('');
});
it('renders the default history label', () => {
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
@ -160,6 +212,24 @@ describe('HistoryChart', () => {
expect(description).toHaveTextContent('Minimum 8.0%. Maximum 35.0%.');
});
it('removes previous-target values from the accessible chart while the next target loads', async () => {
const request = vi.mocked(ChartsAPI.getMetricsHistory);
request.mockResolvedValueOnce({
points: [{ timestamp: 1000, value: 10, min: 10, max: 10 }],
source: 'store',
} as never);
const [target, setTarget] = createSignal('a');
render(() => <HistoryChart resourceType="agent" resourceId={target()} metric="cpu" unit="%" />);
const chart = screen.getByRole('img', { name: 'History chart' });
const description = document.getElementById(chart.getAttribute('aria-describedby')!)!;
await waitFor(() => expect(description).toHaveTextContent('10.0%'));
request.mockImplementationOnce(() => new Promise(() => {}));
setTarget('b');
expect(description).toHaveTextContent('Loading');
expect(description).not.toHaveTextContent('10.0%');
expect(screen.queryByText('Min')).not.toBeInTheDocument();
});
it('synchronizes the hovered timestamp across charts in the same group', () => {
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
@ -213,6 +283,32 @@ describe('HistoryChart', () => {
rectSpy.mockRestore();
});
it('keeps pointer inspection when matching supplied samples refresh', () => {
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 120,
width: 400,
height: 120,
toJSON: () => ({}),
});
const samples = (value: number) =>
[1000, 2000, 3000].map((timestamp) => ({ timestamp, value, min: value, max: value }));
const [data, setData] = createSignal(samples(10));
const { container } = render(() => (
<HistoryChart resourceType="agent" resourceId="a" metric="cpu" data={data()} />
));
fireEvent.mouseMove(container.querySelector('canvas')!, { clientX: 220 });
expect(container.querySelector('[data-history-chart-tooltip="true"]')).not.toBeNull();
setData(samples(20));
expect(container.querySelector('[data-history-chart-tooltip="true"]')).not.toBeNull();
expect(container.querySelector('[data-history-chart-tooltip="true"]')).toHaveTextContent('20');
rectSpy.mockRestore();
});
it('exposes the sub-day and Relay history ranges as first-class chart options', () => {
expect(HISTORY_CHART_RANGES).toEqual(['1h', '6h', '12h', '24h', '7d', '14d', '30d', '90d']);
});
@ -226,7 +322,8 @@ describe('HistoryChart', () => {
expect(layout.x).toBe(162);
expect(layout.x).toBeGreaterThan(150);
expect(layout.y).toBe(47);
expect(layout.height).toBe(64);
expect(layout.y + layout.height / 2).toBe(70);
});
it('moves the tooltip to the left edge side near the right chart boundary', () => {

View file

@ -496,7 +496,7 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
expect(layout).toStrictEqual({ x: 162, y: 47, width: 156, height: 46 });
expect(layout).toStrictEqual({ x: 162, y: 38, width: 156, height: 64 });
});
it('places the tooltip to the left when only the left side has room', () => {
@ -537,7 +537,7 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
expect(layout).toStrictEqual({ x: 12, y: 12, width: 156, height: 46 });
expect(layout).toStrictEqual({ x: 12, y: 82, width: 156, height: 64 });
});
it('pushes an overlapping tooltip above the hovered point when there is headroom above', () => {
@ -558,6 +558,6 @@ describe('getHistoryChartTooltipLayout', () => {
chartHeight: 180,
});
expect(layout.y).toBe(126);
expect(layout.y + layout.height).toBe(172);
});
});

View file

@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { cleanup, render } from '@solidjs/testing-library';
import { ChartsAPI } from '@/api/charts';
import type { HistoryChartProps } from '../historyChartModel';
import { useHistoryChartState, type HistoryChartState } from '../useHistoryChartState';
vi.mock('@/stores/license', () => ({
isRangeLocked: (range: string) => range === '90d',
loadRuntimeCapabilities: vi.fn(),
maxHistoryDays: () => 7,
}));
vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } }));
const request = vi.mocked(ChartsAPI.getMetricsHistory);
const points = (value: number) => [{ timestamp: 1000, value, min: value, max: value }];
const settle = async () => {
await Promise.resolve();
await Promise.resolve();
};
function deferred() {
let resolve!: (result: Awaited<ReturnType<typeof ChartsAPI.getMetricsHistory>>) => void;
let reject!: (error: Error) => void;
const promise = new Promise<Awaited<ReturnType<typeof ChartsAPI.getMetricsHistory>>>(
(yes, no) => {
resolve = yes;
reject = no;
},
);
return { promise, resolve, reject };
}
function mount() {
const [props, setProps] = createSignal<HistoryChartProps>({
resourceId: 'a',
resourceType: 'agent',
metric: 'cpu',
range: '1h',
});
let state!: HistoryChartState;
const view = render(() => {
state = useHistoryChartState(
{
get resourceId() {
return props().resourceId;
},
get resourceType() {
return props().resourceType;
},
get metric() {
return props().metric;
},
get range() {
return props().range;
},
get data() {
return props().data;
},
},
{ getCanvas: () => undefined, getContainer: () => undefined },
);
return <div />;
});
return {
state,
unmount: view.unmount,
change: (next: Partial<HistoryChartProps>) => setProps((p) => ({ ...p, ...next })),
};
}
describe('History request ownership', () => {
beforeEach(() => {
vi.useFakeTimers();
request.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it.each(['success', 'failure'])(
'ignores a superseded %s even when cancellation is ignored',
async (completion) => {
const old = deferred(),
current = deferred();
request.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise);
const { state, change } = mount();
const signal = request.mock.calls[0][0].signal!;
change({ resourceId: 'b' });
expect(signal.aborted).toBe(true);
current.resolve({ points: points(80), source: 'store' } as never);
await settle();
if (completion === 'success') old.resolve({ points: points(10), source: 'store' } as never);
else old.reject(new Error('old error'));
await settle();
expect(state.data()).toEqual(points(80));
expect(state.error()).toBeNull();
expect(state.loading()).toBe(false);
},
);
it.each([{ resourceId: 'b' }, { resourceType: 'node' }, { metric: 'memory' }, { range: '6h' }])(
'clears old readings on selection change %j and exposes its failure',
async (next) => {
request.mockResolvedValueOnce({ points: points(10), source: 'store' } as never);
const { state, change } = mount();
await settle();
const current = deferred();
request.mockReturnValueOnce(current.promise);
change(next as Partial<HistoryChartProps>);
expect(state.data()).toEqual([]);
expect(state.loading()).toBe(true);
vi.spyOn(console, 'error').mockImplementation(() => {});
current.reject(new Error('current failed'));
await settle();
expect(state.error()).toBe('Failed to load history data');
expect(state.data()).toEqual([]);
},
);
it('does not overlap polling and preserves matching samples on refresh failure', async () => {
const initial = deferred();
request.mockReturnValueOnce(initial.promise);
const { state } = mount();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
initial.resolve({ points: points(10), source: 'store' } as never);
await settle();
const refresh = deferred();
request.mockReturnValueOnce(refresh.promise);
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(2);
vi.spyOn(console, 'error').mockImplementation(() => {});
refresh.reject(new Error('refresh failed'));
await settle();
expect(state.data()).toEqual(points(10));
expect(state.error()).toBeNull();
expect(state.loading()).toBe(false);
});
it('cancels fetched data when supplied data takes ownership, including empty samples', async () => {
const old = deferred();
request.mockReturnValueOnce(old.promise);
const { state, change } = mount();
change({ data: [] });
expect(request.mock.calls[0][0].signal?.aborted).toBe(true);
old.resolve({ points: points(10), source: 'store' } as never);
await settle();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
expect(state.data()).toEqual([]);
expect(state.source()).toBe('live');
change({ data: points(80) });
expect(state.data()).toEqual(points(80));
request.mockResolvedValueOnce({ points: points(30), source: 'store' } as never);
change({ data: undefined });
await settle();
expect(state.data()).toEqual(points(30));
});
it.each([{ range: '90d' }, { resourceId: '' }])(
'invalidates in-flight work for unavailable selection %j',
async (next) => {
const old = deferred();
request.mockReturnValueOnce(old.promise);
const { state, change } = mount();
change(next as Partial<HistoryChartProps>);
old.resolve({ points: points(10), source: 'store' } as never);
await settle();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
expect(state.data()).toEqual([]);
expect(state.loading()).toBe(false);
},
);
it('aborts and stops polling on unmount without consuming a late completion', async () => {
const old = deferred();
request.mockReturnValueOnce(old.promise);
const { state, unmount } = mount();
unmount();
expect(request.mock.calls[0][0].signal?.aborted).toBe(true);
old.resolve({ points: points(10), source: 'store' } as never);
await settle();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
expect(state.data()).toEqual([]);
});
});

View file

@ -279,7 +279,8 @@ export function getHistoryChartTooltipLayout({
chartHeight: number;
}): HistoryChartTooltipLayout {
const width = 156;
const height = 46;
// Allow a wrapped local timestamp plus the reading at normal text size.
const height = 64;
const margin = 8;
const pointGap = 12;
const minX = margin;

View file

@ -34,14 +34,13 @@ export function useHistoryChartState(
) {
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
const [data, setData] = createSignal(props.data ?? []);
const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
null,
);
const [maxPoints, setMaxPoints] = createSignal<number | null>(null);
const [refreshTick, setRefreshTick] = createSignal(0);
const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false);
const [localHoveredTimestamp, setLocalHoveredTimestamp] = createSignal<number | null>(null);
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
const [chartWidth, setChartWidth] = createSignal(300);
@ -63,14 +62,6 @@ export function useHistoryChartState(
}
});
createEffect(() => {
if (props.data) {
setData(props.data);
if (!hasLoadedOnce()) setHasLoadedOnce(true);
setSource('live');
}
});
const updateRange = (nextRange: HistoryTimeRange) => {
setRange(nextRange);
props.onRangeChange?.(nextRange);
@ -100,82 +91,85 @@ export function useHistoryChartState(
const dataMin = createMemo(() => getHistoryChartDataMin(data()));
const dataMax = createMemo(() => getHistoryChartDataMax(data()));
const loadData = async (
chartRange: HistoryTimeRange,
pointsCap: number | null,
isBackgroundRefresh: boolean,
) => {
if (!isBackgroundRefresh && !hasLoadedOnce()) {
setLoading(true);
}
setError(null);
if (!isBackgroundRefresh) {
setSource(null);
}
try {
const result = await ChartsAPI.getMetricsHistory({
resourceType: props.resourceType,
resourceId: props.resourceId,
metric: props.metric,
range: chartRange,
maxPoints: pointsCap ?? undefined,
});
if ('points' in result) {
setData(result.points || []);
setSource(result.source ?? 'store');
} else {
setData([]);
setSource(result.source ?? 'store');
}
if (!hasLoadedOnce()) {
setHasLoadedOnce(true);
}
} catch (err) {
console.error('Failed to fetch metrics history:', err);
if (!hasLoadedOnce()) {
setError('Failed to load history data');
}
setSource(null);
} finally {
setLoading(false);
}
};
createEffect(async () => {
if (props.data) return;
if (!props.resourceId || !props.resourceType) return;
let previousSelection: string | undefined;
// One effect owns a selection, its request and its polling timer. Cleanup
// invalidates completions even when a transport ignores cancellation.
createEffect(() => {
const suppliedData = props.data;
const resourceId = props.resourceId;
const resourceType = props.resourceType;
const metric = props.metric;
const chartRange = range();
const locked = isLocked();
const pointsCap = maxPoints();
if (locked) {
setLoading(false);
setError(null);
setSource(null);
return;
}
void loadData(chartRange, pointsCap, false);
});
createEffect(() => {
const tick = refreshTick();
if (tick === 0) return;
if (!props.resourceId || !props.resourceType || isLocked()) return;
void loadData(range(), maxPoints(), true);
});
createEffect(() => {
const locked = isLocked();
const interval = refreshIntervalMs();
if (!interval || interval <= 0) return;
const timer = window.setInterval(() => {
setRefreshTick((value) => value + 1);
}, interval);
onCleanup(() => window.clearInterval(timer));
let active = true;
let pending = false;
let hasLoaded = false;
let controller: AbortController | undefined;
let timer: number | undefined;
onCleanup(() => {
active = false;
controller?.abort();
if (timer !== undefined) window.clearInterval(timer);
});
setData(suppliedData ?? []);
setSource(suppliedData !== undefined ? 'live' : null);
setError(null);
setLoading(false);
const selection = JSON.stringify([
resourceType,
resourceId,
metric,
chartRange,
pointsCap,
locked,
suppliedData !== undefined,
]);
if (selection !== previousSelection) {
setHoveredPoint(null);
setHoveredTimestamp(null);
}
previousSelection = selection;
if (suppliedData !== undefined || locked || !resourceId || !resourceType) return;
const loadData = async () => {
if (!active || pending) return;
pending = true;
controller = new AbortController();
if (!hasLoaded) setLoading(true);
setError(null);
try {
const result = await ChartsAPI.getMetricsHistory({
resourceType,
resourceId,
metric,
range: chartRange,
maxPoints: pointsCap ?? undefined,
signal: controller.signal,
});
if (!active) return;
setData('points' in result ? (result.points ?? []) : []);
setSource(result.source ?? 'store');
hasLoaded = true;
} catch (err) {
if (!active) return;
console.error('Failed to fetch metrics history:', err);
if (!hasLoaded) setError('Failed to load history data');
setSource(null);
} finally {
if (active) {
pending = false;
setLoading(false);
}
}
};
void loadData();
if (interval > 0) timer = window.setInterval(() => void loadData(), interval);
});
const drawChart = () => {
@ -358,11 +352,47 @@ export function useHistoryChartState(
onCleanup(() => resizeObserver.disconnect());
});
const handleFocus = () => {
setKeyboardInspecting(true);
const points = data();
setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
};
const handleBlur = () => {
setKeyboardInspecting(false);
setHoveredTimestamp(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if (event.key === 'Escape') {
setHoveredTimestamp(null);
return;
}
const points = data();
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
setKeyboardInspecting(true);
const timestamp = hoveredTimestamp();
const index =
timestamp === null
? points.length - 1
: points.indexOf(findHistoryChartClosestPoint(points, timestamp));
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? points.length - 1
: Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
setHoveredTimestamp(points[next].timestamp);
};
const handleMouseMove = (event: MouseEvent) => {
const canvas = refs.getCanvas();
const points = data();
if (!canvas || points.length === 0) return;
setKeyboardInspecting(false);
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
@ -377,6 +407,7 @@ export function useHistoryChartState(
};
const handleMouseLeave = () => {
if (keyboardInspecting()) return;
setHoveredTimestamp(null);
};
@ -385,6 +416,10 @@ export function useHistoryChartState(
dataMax,
dataMin,
error,
handleFocus,
handleBlur,
handleKeyDown,
keyboardInspecting,
handleMouseLeave,
handleMouseMove,
chartHeight,