mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-02 20:29:43 +00:00
Show and inspect single stored History readings (#2405)
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Backend tests (api-1) (push) Blocked by required conditions
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Provider pair Docker acceptance (push) Blocked by required conditions
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-0) (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Build and Test / Backend tests (api-2) (push) Blocked by required conditions
Build and Test / Backend tests (api-3) (push) Blocked by required conditions
Build and Test / Backend tests (api-4) (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Backend tests (api-1) (push) Blocked by required conditions
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Provider pair Docker acceptance (push) Blocked by required conditions
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Backend tests (api-0) (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Offline Organization provisioning (push) Waiting to run
Build and Test / Backend tests (api-2) (push) Blocked by required conditions
Build and Test / Backend tests (api-3) (push) Blocked by required conditions
Build and Test / Backend tests (api-4) (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Canonical Private Governance / private-governance (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
## What changed and why A History chart with one stored reading now shows a visible marker and its actual timestamp, including a measured zero. Pointer and keyboard inspection use the same real timestamp instead of an invented one-millisecond window. Multiple readings, empty results, request ownership and existing keyboard access remain intact. Inspection of the existing production chart reproduced a blank series for a real one-point response. This repairs an existing History surface; it adds no collection, polling, access or new product surface, and does not establish resolution of every PBS or TrueNAS symptom. ## Included source - Reviewed Web candidate `20261002T182804Z-web-product`: `b112524cd2`, based on `ad7f7a1063`. - Complete unpublished range: `761f714af8f0409c7efff0e95da9f1adc7e3b104..34257d19f64d9bf44e666c1af1663293c0aa19f9`, including additive integration `34257d19f6`. - Reviewed commit identities are preserved. Final frontend source and dependency manifests exactly match the reviewed candidate. The only candidate-to-integration differences are the signing-preflight workflow and its README, already present on current published main. ## Validation and limits - Recorded reviewed proof `source-sph989yd`: 21,709 tests passed, three skipped; typecheck, lint, production/embedded builds and budgets passed. The recorded outcome establishes complete hash-matched output and guest shutdown. - Recorded Chromium and WebKit predecessor controls reproduce the missing series; final desktop/phone checks pass 12 states covering visible pixels, singleton/zero, pointer and keyboard inspection, multiple readings, empty response and restored singleton. Representative screenshots were inspected during review. - Publication provenance, range whitespace and the full-range browser-verification guard passed. Final runtime hashes match the committed browser receipt and reviewed candidate. Sufficient heavy/browser evidence was not replayed solely for publication. - Required exact-head CI remains a landing gate. Native panels, installed acceptance and containing release qualification remain separate. This PR does not change the frozen v6.4.6 packet or publish a release. [How Pulse handles triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
commit
e5d0843851
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