mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-02 20:29:43 +00:00
Integrate reviewed History selection ownership repair
Change-source: pulse-maintainer
This commit is contained in:
commit
cf5d6cdf06
8 changed files with 536 additions and 100 deletions
|
|
@ -7667,3 +7667,22 @@ uses the production PBS table, resource drawers, History query and CSS with
|
||||||
synthetic APIs, checking three separately mapped drawers and range/refresh
|
synthetic APIs, checking three separately mapped drawers and range/refresh
|
||||||
behaviour in desktop and phone-emulated engines. This is presentation proof,
|
behaviour in desktop and phone-emulated engines. This is presentation proof,
|
||||||
not installed PBS/VirtualBox collection or a complete #1723 acceptance result.
|
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.
|
||||||
|
|
|
||||||
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,16 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"base_sha": "60a6c933e0057435dd49cb440995f275f6a567d6",
|
"base_sha": "93531c9cd3bfc9278633476ba26a1c77e66bc802",
|
||||||
"verified_at": "2026-10-02T12:57:15.855691Z",
|
"verified_at": "2026-10-02T14:23:47.145834Z",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"changed_paths": [
|
"changed_paths": [
|
||||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts"
|
"frontend-modern/src/components/shared/useHistoryChartState.ts"
|
||||||
],
|
],
|
||||||
"content_sha256": {
|
"content_sha256": {
|
||||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "59ca48c06cb3c63f30ccb46cb30536cbc6d86fda1b50e6ea49ecefb9eb377fae"
|
"frontend-modern/src/components/shared/useHistoryChartState.ts": "f10b5cfc7cb296c60ad7e87bf7c3001c4308d29af702b8c7a7e77e7c4c4c3fb6"
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": [
|
||||||
"/browser-tests/pool-capacity.html"
|
"/browser-tests/history-selection.html"
|
||||||
],
|
],
|
||||||
"viewports": [
|
"viewports": [
|
||||||
{
|
{
|
||||||
|
|
@ -23,26 +23,23 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"Production pool drawer with synthetic partial PBS snapshots, not native collector or installed acceptance",
|
"Synthetic delayed transport using the production shared chart, with hidden selector matching storage drawers; not native PBS acceptance",
|
||||||
"Missing used/free/usage remain n/a; measured empty and full pools preserve 0 B and 0%",
|
"Selected B remains 80% after old A completes at 10%; new C clears readings while loading and displays its own failure",
|
||||||
"Independent byte observations remain visible without total; absent capacity stays n/a",
|
"Desktop Chromium light and phone WebKit dark: eight states, no page errors or overflow in storage layout"
|
||||||
"Live transitions across five states on desktop Chromium light and phone WebKit dark; no page errors or horizontal overflow"
|
|
||||||
],
|
],
|
||||||
"interactions": [
|
"interactions": [
|
||||||
"Switch missing, empty, full, partial and absent snapshots without reload",
|
"Select B, complete B then delayed A",
|
||||||
"Read Used, Free, Total and Usage values in the actual Configuration rows"
|
"Select C then fail its request; verify accessible description and plotted extrema"
|
||||||
],
|
],
|
||||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pool-capacity.cjs",
|
"command": "pulse-worker-browser frontend-modern/browser-tests/history-selection.cjs",
|
||||||
"browser_versions": {
|
"browser_versions": {
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
"chromium": "141.0.7390.37",
|
"chromium": "141.0.7390.37",
|
||||||
"webkit": "26.0"
|
"webkit": "26.0"
|
||||||
},
|
},
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/result.json",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/result.json",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/chromium-partial.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/webkit-old-a-finished.png",
|
||||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-5pdbcfmm/browser/webkit-missing.png",
|
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/chromium-c-loading.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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
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 historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw';
|
||||||
import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw';
|
import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw';
|
||||||
import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.tsx?raw';
|
import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.tsx?raw';
|
||||||
|
|
@ -160,6 +162,24 @@ describe('HistoryChart', () => {
|
||||||
expect(description).toHaveTextContent('Minimum 8.0%. Maximum 35.0%.');
|
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', () => {
|
it('synchronizes the hovered timestamp across charts in the same group', () => {
|
||||||
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
x: 0,
|
x: 0,
|
||||||
|
|
@ -213,6 +233,32 @@ describe('HistoryChart', () => {
|
||||||
rectSpy.mockRestore();
|
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', () => {
|
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']);
|
expect(HISTORY_CHART_RANGES).toEqual(['1h', '6h', '12h', '24h', '7d', '14d', '30d', '90d']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,8 +40,6 @@ export function useHistoryChartState(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [maxPoints, setMaxPoints] = createSignal<number | 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 [localHoveredTimestamp, setLocalHoveredTimestamp] = createSignal<number | null>(null);
|
||||||
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
|
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
|
||||||
const [chartWidth, setChartWidth] = createSignal(300);
|
const [chartWidth, setChartWidth] = createSignal(300);
|
||||||
|
|
@ -63,14 +61,6 @@ export function useHistoryChartState(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.data) {
|
|
||||||
setData(props.data);
|
|
||||||
if (!hasLoadedOnce()) setHasLoadedOnce(true);
|
|
||||||
setSource('live');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateRange = (nextRange: HistoryTimeRange) => {
|
const updateRange = (nextRange: HistoryTimeRange) => {
|
||||||
setRange(nextRange);
|
setRange(nextRange);
|
||||||
props.onRangeChange?.(nextRange);
|
props.onRangeChange?.(nextRange);
|
||||||
|
|
@ -100,82 +90,85 @@ export function useHistoryChartState(
|
||||||
const dataMin = createMemo(() => getHistoryChartDataMin(data()));
|
const dataMin = createMemo(() => getHistoryChartDataMin(data()));
|
||||||
const dataMax = createMemo(() => getHistoryChartDataMax(data()));
|
const dataMax = createMemo(() => getHistoryChartDataMax(data()));
|
||||||
|
|
||||||
const loadData = async (
|
let previousSelection: string | undefined;
|
||||||
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;
|
|
||||||
|
|
||||||
|
// 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 chartRange = range();
|
||||||
const locked = isLocked();
|
|
||||||
const pointsCap = maxPoints();
|
const pointsCap = maxPoints();
|
||||||
|
const locked = isLocked();
|
||||||
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 interval = refreshIntervalMs();
|
const interval = refreshIntervalMs();
|
||||||
if (!interval || interval <= 0) return;
|
let active = true;
|
||||||
const timer = window.setInterval(() => {
|
let pending = false;
|
||||||
setRefreshTick((value) => value + 1);
|
let hasLoaded = false;
|
||||||
}, interval);
|
let controller: AbortController | undefined;
|
||||||
onCleanup(() => window.clearInterval(timer));
|
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 = () => {
|
const drawChart = () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue