From b112524cd237602f9a9cab3bdeb3026bc6e3c91e Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 19:38:38 +0100
Subject: [PATCH] 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
---
.../subsystems/frontend-primitives.md | 16 ++
.../browser-tests/history-sparse.cjs | 154 ++++++++++++++++++
.../browser-tests/history-sparse.html | 1 +
.../browser-tests/history-sparse.tsx | 28 ++++
frontend-modern/browser-verification.json | 51 +++---
.../__tests__/HistoryChart.sparse.test.tsx | 137 ++++++++++++++++
.../shared/__tests__/HistoryChart.test.tsx | 40 +++++
.../historyChartModel.branchcov2.test.ts | 34 ++++
.../components/shared/historyChartModel.ts | 9 +-
.../components/shared/useHistoryChartState.ts | 88 ++++++----
10 files changed, 507 insertions(+), 51 deletions(-)
create mode 100644 frontend-modern/browser-tests/history-sparse.cjs
create mode 100644 frontend-modern/browser-tests/history-sparse.html
create mode 100644 frontend-modern/browser-tests/history-sparse.tsx
create mode 100644 frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 5e2e9760b..d945c8054 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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.
diff --git a/frontend-modern/browser-tests/history-sparse.cjs b/frontend-modern/browser-tests/history-sparse.cjs
new file mode 100644
index 000000000..2cde2cc36
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.cjs
@@ -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; });
diff --git a/frontend-modern/browser-tests/history-sparse.html b/frontend-modern/browser-tests/history-sparse.html
new file mode 100644
index 000000000..19d1b6947
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.html
@@ -0,0 +1 @@
+
Sparse History verification
diff --git a/frontend-modern/browser-tests/history-sparse.tsx b/frontend-modern/browser-tests/history-sparse.tsx
new file mode 100644
index 000000000..b876116d6
--- /dev/null
+++ b/frontend-modern/browser-tests/history-sparse.tsx
@@ -0,0 +1,28 @@
+import { render } from 'solid-js/web';
+import { HistoryChart } from '../src/components/shared/HistoryChart';
+import '../src/index.css';
+
+render(
+ () => (
+
+ Storage History: sparse observations
+ Synthetic stored samples; production chart, transport and styles.
+
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 3daf270df..d836db5f3 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -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."
}
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx
new file mode 100644
index 000000000..ce0261a8c
--- /dev/null
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx
@@ -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(() => (
+
+ ));
+ 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();
+ });
+});
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
index 7bfe9b99c..d2eb0a84b 100644
--- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
+++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx
@@ -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(() => (
+
+ ));
+ 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,
diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
index 76f4c1c49..fb517d9ae 100644
--- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts
@@ -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,
diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts
index 1e9cab000..bd7436171 100644
--- a/frontend-modern/src/components/shared/historyChartModel.ts
+++ b/frontend-modern/src/components/shared/historyChartModel.ts
@@ -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,
};
}
diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts
index a63b01d35..93d36881d 100644
--- a/frontend-modern/src/components/shared/useHistoryChartState.ts
+++ b/frontend-modern/src/components/shared/useHistoryChartState.ts
@@ -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 = () => {