mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
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:
commit
1a41c8f0e0
14 changed files with 793 additions and 107 deletions
|
|
@ -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.
|
||||
|
|
|
|||
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal file
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal 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; });
|
||||
1
frontend-modern/browser-tests/history-keyboard.html
Normal file
1
frontend-modern/browser-tests/history-keyboard.html
Normal 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>
|
||||
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal file
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal 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')!);
|
||||
110
frontend-modern/browser-tests/history-selection.cjs
Normal file
110
frontend-modern/browser-tests/history-selection.cjs
Normal 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;
|
||||
});
|
||||
12
frontend-modern/browser-tests/history-selection.html
Normal file
12
frontend-modern/browser-tests/history-selection.html
Normal 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>
|
||||
71
frontend-modern/browser-tests/history-selection.tsx
Normal file
71
frontend-modern/browser-tests/history-selection.tsx
Normal 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')!);
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue