mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-02 20:29:43 +00:00
Keep single History observations visible and inspectable
A one-point stored History response drew no visible series and pointer inspection mapped it into a fictional one-millisecond window. Show a centred marker and one real timestamp, including measured zero, and share timestamp geometry with pointer inspection. Preserve multi-point rendering, keyboard access, empty states and request ownership. Chromium and WebKit reproduce the predecessor defect. Twelve final desktop and phone states verify real canvas pixels, pointer and keyboard inspection, and sparse-to-empty refreshes. Synthetic presentation proof does not establish native collection or release availability. Change-source: pulse-maintainer
This commit is contained in:
parent
ad7f7a1063
commit
b112524cd2
10 changed files with 507 additions and 51 deletions
|
|
@ -7713,3 +7713,19 @@ remain separate errors. No polling frequency, request or access policy changes.
|
|||
and selection changes; `browser-tests/history-status.cjs` checks actual chart
|
||||
states in desktop Chromium and phone WebKit with synthetic transport failures.
|
||||
These checks do not establish native collection or released availability.
|
||||
|
||||
### Shared History single observations
|
||||
|
||||
A single stored observation renders as a visible centred marker with one real
|
||||
timestamp label, including measured zero. It must not imply a trend or invent
|
||||
a duration. Pointer and keyboard inspection resolve to that actual observation;
|
||||
ordinary multi-observation interpolation and empty-result states are unchanged.
|
||||
The geometry's inverse timestamp mapping is shared by pointer inspection.
|
||||
|
||||
`HistoryChart.sparse.test.tsx` and `historyChartModel.branchcov2.test.ts` cover
|
||||
marker geometry, one timestamp label, pointer bounds, keyboard inspection and
|
||||
single/zero/multiple/empty refreshes. `browser-tests/history-sparse.cjs` checks
|
||||
actual series pixels and tooltips in desktop Chromium and phone WebKit, using
|
||||
the production chart and API transport with synthetic stored samples. This
|
||||
does not establish native collection, whole-report resolution or availability
|
||||
in a release. Existing request, source, units and access boundaries are unchanged.
|
||||
|
|
|
|||
154
frontend-modern/browser-tests/history-sparse.cjs
Normal file
154
frontend-modern/browser-tests/history-sparse.cjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
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-sparse-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: 5226, strictPort: true },
|
||||
});
|
||||
const parent = process.argv.includes('--parent');
|
||||
const timestamp = 1790942400000;
|
||||
const point = (value, offset = 0) => ({ timestamp: timestamp + offset, value, min: value, max: value });
|
||||
const observations = [];
|
||||
let browser;
|
||||
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', error => errors.push(error.message));
|
||||
let samples = [point(42)], calls = 0;
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5226') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname.includes('/metrics-store/history')) {
|
||||
calls++;
|
||||
return route.fulfill({ json: { points: samples, source: 'store' } });
|
||||
}
|
||||
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.clock.install();
|
||||
await page.goto('http://127.0.0.1:5226/browser-tests/history-sparse.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();
|
||||
const description = page.locator('#' + await chart.getAttribute('aria-describedby'));
|
||||
const tooltip = page.locator('[data-history-chart-tooltip]');
|
||||
const live = page.locator('[aria-live="polite"]');
|
||||
async function state(phase, expected) {
|
||||
await page.waitForFunction(({ id, expected }) => document.getElementById(id).textContent.includes(expected),
|
||||
{ id: await chart.getAttribute('aria-describedby'), expected });
|
||||
await page.clock.runFor(100);
|
||||
// Count actual blue series pixels, not mocked drawing calls or tooltip DOM.
|
||||
const pixels = await chart.evaluate(canvas => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i] >= 40 && data[i] <= 80 && data[i + 1] >= 100 && data[i + 1] <= 150 && data[i + 2] >= 220 && data[i + 2] <= 255 && data[i + 3] >= 80) count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
const screenshot = `${parent ? 'parent-' : ''}${name}-${phase}.png`;
|
||||
await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true });
|
||||
observations.push({ name, version: browser.version(), phase, description: await description.textContent(), pixels, calls, dimensions, screenshot });
|
||||
return pixels;
|
||||
}
|
||||
const pixels = await state('single', '1 data point');
|
||||
const box = await chart.boundingBox();
|
||||
assert.ok(box);
|
||||
if (parent) {
|
||||
assert.equal(pixels, 0, 'predecessor singleton has no visible series pixels');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await tooltip.count(), 0, 'predecessor pointer rejects singleton');
|
||||
observations.at(-1).pointerTooltipCount = await tooltip.count();
|
||||
} else {
|
||||
assert.ok(pixels >= 30, 'singleton has a visible marker without hover');
|
||||
for (const fraction of [0.25, 0.5, 0.75]) {
|
||||
await page.mouse.move(box.x + box.width * fraction, box.y + box.height / 2);
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await tooltip.count(), 1);
|
||||
assert.ok((await tooltip.textContent()).includes('42.0%'));
|
||||
assert.ok((await tooltip.textContent()).includes(await page.evaluate(ts => new Date(ts).toLocaleString(), timestamp)));
|
||||
assert.equal(await live.textContent(), '', 'pointer does not announce mouse movements');
|
||||
}
|
||||
await state('pointer', '1 data point');
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByRole('button', { name: 'Before chart' }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await chart.evaluate(el => el === document.activeElement), true);
|
||||
for (const key of ['Home', 'ArrowLeft', 'ArrowRight', 'End']) {
|
||||
await page.keyboard.press(key);
|
||||
await page.clock.runFor(100);
|
||||
assert.ok((await live.textContent()).includes('42.0%'));
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await tooltip.count(), 0);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.getByRole('button', { name: 'After chart' }).evaluate(el => el === document.activeElement), true);
|
||||
|
||||
samples = [point(0)];
|
||||
await page.clock.runFor(10000);
|
||||
assert.ok(await state('zero', '0.0%') >= 30, 'measured zero keeps its marker');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.clock.runFor(100);
|
||||
assert.ok((await tooltip.textContent()).includes('0.0%'));
|
||||
|
||||
samples = [point(10), point(20, 60000), point(30, 120000)];
|
||||
await page.mouse.move(0, 0);
|
||||
await page.clock.runFor(10000);
|
||||
assert.ok(await state('multiple', '3 data points') > 100, 'normal series still draws');
|
||||
await chart.focus();
|
||||
await page.keyboard.press('Home');
|
||||
await page.clock.runFor(100);
|
||||
assert.ok((await live.textContent()).includes('10.0%'));
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.clock.runFor(100);
|
||||
assert.ok((await live.textContent()).includes('20.0%'));
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
samples = [];
|
||||
await page.clock.runFor(10000);
|
||||
assert.equal(await state('empty', 'No 1-hour'), 0);
|
||||
assert.equal(await tooltip.count(), 0);
|
||||
assert.equal(await live.textContent(), '');
|
||||
assert.equal(await page.getByText('No history samples in this time range.', { exact: true }).count(), 1);
|
||||
|
||||
samples = [point(70, 180000)];
|
||||
await page.clock.runFor(10000);
|
||||
assert.ok(await state('recovered-single', '1 data point') >= 30);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.clock.runFor(100);
|
||||
assert.ok((await tooltip.textContent()).includes('70.0%'));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close(); browser = null;
|
||||
}
|
||||
fs.writeFileSync(path.join(artifacts, parent ? 'parent.json' : 'result.json'), JSON.stringify({
|
||||
playwright: require('playwright/package.json').version, parent, observations,
|
||||
}, null, 2));
|
||||
console.log(JSON.stringify({ result: 'passed', parent, 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-sparse.html
Normal file
1
frontend-modern/browser-tests/history-sparse.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>Sparse History verification</title></head><body><div id="root"></div><script type="module" src="./history-sparse.tsx"></script></body></html>
|
||||
28
frontend-modern/browser-tests/history-sparse.tsx
Normal file
28
frontend-modern/browser-tests/history-sparse.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { render } from 'solid-js/web';
|
||||
import { HistoryChart } from '../src/components/shared/HistoryChart';
|
||||
import '../src/index.css';
|
||||
|
||||
render(
|
||||
() => (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Storage History: sparse observations</h1>
|
||||
<p>Synthetic stored samples; production chart, transport and styles.</p>
|
||||
<button class="min-h-11 border px-3">Before chart</button>
|
||||
<section class="max-w-xl">
|
||||
<HistoryChart
|
||||
resourceType="storage"
|
||||
resourceId="fixture-pool"
|
||||
metric="usage"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
compact
|
||||
range="1h"
|
||||
height={140}
|
||||
/>
|
||||
</section>
|
||||
<button class="min-h-11 border px-3">After chart</button>
|
||||
</main>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "df18123e7da3b682eed811c0e01c94a44dc2c29c",
|
||||
"verified_at": "2026-10-02T16:36:02.327983Z",
|
||||
"base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac",
|
||||
"verified_at": "2026-10-02T18:37:50.309899Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts"
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4"
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/disk-temperature.html"
|
||||
"/browser-tests/history-sparse.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -23,28 +25,37 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"Temperature without extended SMART; critical temperature; Fahrenheit; absent temperature; full SMART snapshot",
|
||||
"Chromium desktop light and WebKit phone dark: 10 final states, no page errors or horizontal overflow; synthetic inputs, not native collection"
|
||||
"Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton",
|
||||
"Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"Same-identity snapshot replacement and reactive temperature-unit changes",
|
||||
"Parent Chromium and WebKit reproduce hidden 42 C; representative desktop and all five phone final screenshots inspected"
|
||||
"Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements",
|
||||
"Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results",
|
||||
"Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected."
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/disk-temperature.cjs",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent-chromium-temperature-hidden.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/chromium-temperature-only.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-temperature-only.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-hot.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-fahrenheit.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-missing.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-extended.png"
|
||||
]
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png"
|
||||
],
|
||||
"limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import type { AggregatedMetricPoint } from '@/api/charts';
|
||||
import { HistoryChart } from '../HistoryChart';
|
||||
import { formatHistoryChartTimeLabel } from '../historyChartModel';
|
||||
|
||||
vi.mock('@/stores/license', () => ({
|
||||
isRangeLocked: () => false,
|
||||
loadRuntimeCapabilities: vi.fn(),
|
||||
maxHistoryDays: () => 7,
|
||||
}));
|
||||
vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } }));
|
||||
|
||||
const ctx = {
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 20 })),
|
||||
};
|
||||
const point = (value: number, timestamp = 1000): AggregatedMetricPoint => ({
|
||||
timestamp,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
});
|
||||
|
||||
function mount(initial: AggregatedMetricPoint[]) {
|
||||
const [data, setData] = createSignal(initial);
|
||||
const view = render(() => (
|
||||
<HistoryChart
|
||||
resourceType="storage"
|
||||
resourceId="pool"
|
||||
metric="usage"
|
||||
unit="%"
|
||||
range="1h"
|
||||
data={data()}
|
||||
/>
|
||||
));
|
||||
const canvas = screen.getByRole('img', { name: 'History chart' });
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 200,
|
||||
width: 300,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
return { ...view, canvas, setData };
|
||||
}
|
||||
|
||||
describe('Sparse History observations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
ctx as unknown as CanvasRenderingContext2D,
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([0, 42])(
|
||||
'draws a %s singleton marker and exactly one timestamp before hover',
|
||||
(value) => {
|
||||
mount([point(value)]);
|
||||
const lastClear = ctx.clearRect.mock.invocationCallOrder.at(-1)!;
|
||||
const arcs = ctx.arc.mock.calls.filter(
|
||||
(_, i) => ctx.arc.mock.invocationCallOrder[i] > lastClear,
|
||||
);
|
||||
expect(arcs).toHaveLength(1);
|
||||
expect(arcs[0][0]).toBe(164);
|
||||
expect(arcs[0][1]).toBeCloseTo(180 - (value / 100) * 160);
|
||||
expect(arcs[0][2]).toBe(4);
|
||||
const times = ctx.fillText.mock.calls.filter(
|
||||
(args, i) =>
|
||||
ctx.fillText.mock.invocationCallOrder[i] > lastClear &&
|
||||
args[0] === formatHistoryChartTimeLabel(1000, '1h'),
|
||||
);
|
||||
expect(times).toHaveLength(1);
|
||||
expect(times[0][1]).toBe(164);
|
||||
},
|
||||
);
|
||||
|
||||
it('inspects the actual singleton from any pointer position and clears outside the plot', () => {
|
||||
const { canvas, container } = mount([point(42)]);
|
||||
for (const clientX of [100, 175, 250]) {
|
||||
fireEvent.mouseMove(canvas, { clientX });
|
||||
const tooltip = container.querySelector('[data-history-chart-tooltip]')!;
|
||||
expect(tooltip).toHaveTextContent('42.0%');
|
||||
expect(tooltip).toHaveTextContent(new Date(1000).toLocaleString());
|
||||
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('');
|
||||
}
|
||||
fireEvent.mouseMove(canvas, { clientX: 5 });
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps zero, multiple and empty refreshes truthful during inspection', () => {
|
||||
const { canvas, container, setData } = mount([point(42)]);
|
||||
fireEvent.focus(canvas);
|
||||
for (const key of ['Home', 'End', 'ArrowLeft', 'ArrowRight']) {
|
||||
fireEvent.keyDown(canvas, { key });
|
||||
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('42.0%');
|
||||
}
|
||||
setData([point(0)]);
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('0.0%');
|
||||
setData([point(10), point(20, 2000), point(30, 3000)]);
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' });
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('20.0%');
|
||||
setData([]);
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
|
||||
expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('');
|
||||
expect(screen.getByText('No history samples in this time range.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -231,6 +231,46 @@ describe('HistoryChart', () => {
|
|||
expect(screen.queryByText('Min')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes a stored singleton to pointer inspection without treating a later empty response as zero', async () => {
|
||||
const request = vi.mocked(ChartsAPI.getMetricsHistory);
|
||||
request.mockResolvedValueOnce({
|
||||
points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }],
|
||||
source: 'store',
|
||||
} as never);
|
||||
const [target, setTarget] = createSignal('pool-a');
|
||||
const { container } = render(() => (
|
||||
<HistoryChart
|
||||
resourceType="storage"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
unit="%"
|
||||
range="1h"
|
||||
/>
|
||||
));
|
||||
const chart = screen.getByRole('img', { name: 'History chart' });
|
||||
const description = document.getElementById(chart.getAttribute('aria-describedby')!)!;
|
||||
const rectSpy = vi.spyOn(chart, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 200,
|
||||
width: 300,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
await waitFor(() => expect(description).toHaveTextContent('1 data point'));
|
||||
fireEvent.mouseMove(chart, { clientX: 175 });
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42.0%');
|
||||
request.mockResolvedValueOnce({ points: [], source: 'store' } as never);
|
||||
setTarget('pool-b');
|
||||
await waitFor(() => expect(description).toHaveTextContent('No 1-hour history'));
|
||||
expect(description).not.toHaveTextContent('0.0%');
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('synchronizes the hovered timestamp across charts in the same group', () => {
|
||||
const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
|
|
|
|||
|
|
@ -406,6 +406,40 @@ describe('createHistoryChartGeometry', () => {
|
|||
expect(geo.getX(100)).toBe(40);
|
||||
});
|
||||
|
||||
it('centres a single timestamp and maps every plot position back to that observation', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 300,
|
||||
height: 120,
|
||||
startTime: 1000,
|
||||
endTime: 1000,
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
leftInset: 60,
|
||||
rightInset: 20,
|
||||
});
|
||||
expect(geo.getX(1000)).toBe(170);
|
||||
for (const x of [60, 100, 170, 280]) {
|
||||
expect(geo.getTimestamp(x)).toBe(1000);
|
||||
}
|
||||
expect(geo.getY(0)).toBe(100);
|
||||
});
|
||||
|
||||
it('inverts timestamp geometry without changing multi-observation interpolation', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 300,
|
||||
height: 120,
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
leftInset: 60,
|
||||
rightInset: 20,
|
||||
});
|
||||
for (const timestamp of [1000, 1500, 2000, 3000]) {
|
||||
expect(geo.getTimestamp(geo.getX(timestamp))).toBeCloseTo(timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
it('left-pads the first timestamp to the chart origin', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 200,
|
||||
|
|
|
|||
|
|
@ -241,14 +241,21 @@ export function createHistoryChartGeometry({
|
|||
rightInset?: number;
|
||||
}) {
|
||||
const timeSpan = Math.max(1, endTime - startTime);
|
||||
const plotWidth = width - leftInset - rightInset;
|
||||
const singleTimestamp = startTime === endTime;
|
||||
const getX = (timestamp: number) =>
|
||||
leftInset + ((timestamp - startTime) / timeSpan) * (width - leftInset - rightInset);
|
||||
singleTimestamp
|
||||
? leftInset + plotWidth / 2
|
||||
: leftInset + ((timestamp - startTime) / timeSpan) * plotWidth;
|
||||
const getTimestamp = (x: number) =>
|
||||
singleTimestamp ? startTime : startTime + ((x - leftInset) / plotWidth) * timeSpan;
|
||||
const getY = (value: number) =>
|
||||
height - 20 - ((value - minValue) / (maxValue - minValue)) * (height - 40);
|
||||
|
||||
return {
|
||||
timeSpan,
|
||||
getX,
|
||||
getTimestamp,
|
||||
getY,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,15 +197,22 @@ export function useHistoryChartState(
|
|||
const yAxisTicks = getHistoryChartYAxisLabels(scale, props.unit);
|
||||
const labelCount = 4;
|
||||
const timeAxisTicks =
|
||||
points.length > 0
|
||||
? Array.from({ length: labelCount }, (_, index) => {
|
||||
const timestamp =
|
||||
points[0].timestamp +
|
||||
((points[points.length - 1].timestamp - points[0].timestamp) * index) /
|
||||
(labelCount - 1);
|
||||
return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) };
|
||||
})
|
||||
: [];
|
||||
points.length === 1
|
||||
? [
|
||||
{
|
||||
timestamp: points[0].timestamp,
|
||||
label: formatHistoryChartTimeLabel(points[0].timestamp, range()),
|
||||
},
|
||||
]
|
||||
: points.length > 0
|
||||
? Array.from({ length: labelCount }, (_, index) => {
|
||||
const timestamp =
|
||||
points[0].timestamp +
|
||||
((points[points.length - 1].timestamp - points[0].timestamp) * index) /
|
||||
(labelCount - 1);
|
||||
return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) };
|
||||
})
|
||||
: [];
|
||||
|
||||
ctx.font = '10px sans-serif';
|
||||
chartLeftInset = getHistoryChartLeftInset(
|
||||
|
|
@ -248,26 +255,39 @@ export function useHistoryChartState(
|
|||
rightInset: chartRightInset,
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20);
|
||||
ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
if (points.length > 0) {
|
||||
if (points.length === 1) {
|
||||
// A stored observation is visible before inspection, including measured
|
||||
// zero. A line or filled area would invent a duration for this sample.
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
geometry.getX(points[0].timestamp),
|
||||
geometry.getY(points[0].value),
|
||||
4,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fillStyle = mainColor;
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20);
|
||||
ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
ctx.lineTo(geometry.getX(points[points.length - 1].timestamp), height - 20);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = `${mainColor}66`;
|
||||
ctx.fill();
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = `${mainColor}66`;
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = mainColor;
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = mainColor;
|
||||
ctx.lineWidth = 2;
|
||||
points.forEach((point, index) => {
|
||||
if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value));
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = axisTextColor;
|
||||
ctx.font = '10px sans-serif';
|
||||
|
|
@ -404,9 +424,17 @@ export function useHistoryChartState(
|
|||
return;
|
||||
}
|
||||
|
||||
const ratio = (x - chartLeftInset) / (width - chartLeftInset - chartRightInset);
|
||||
const timeSpan = Math.max(1, points[points.length - 1].timestamp - points[0].timestamp);
|
||||
setHoveredTimestamp(points[0].timestamp + ratio * timeSpan);
|
||||
const geometry = createHistoryChartGeometry({
|
||||
width,
|
||||
height: chartHeight(),
|
||||
startTime: points[0].timestamp,
|
||||
endTime: points[points.length - 1].timestamp,
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
leftInset: chartLeftInset,
|
||||
rightInset: chartRightInset,
|
||||
});
|
||||
setHoveredTimestamp(geometry.getTimestamp(x));
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue