Show sparse drawer History without inventing a trend

Render lone stored observations at their actual time instead of blank collecting panels. Label live legend fallbacks as current and describe empty windows without claiming collection is active. Preserve source/range isolation and failed-refresh recovery.

Add sparse-observation regressions and scoped browser evidence, and align both canonical contracts with the shared renderer's verification boundary.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 06:22:45 +01:00
parent 36418af66b
commit ec37284554
12 changed files with 532 additions and 77 deletions

View file

@ -336,10 +336,23 @@ These proofs establish presentation/recovery, not installed collection or delive
Shared workload, node, Docker-host, and resource-drawer history presentation
keeps current readings separate from stored samples. A current metric may
populate the legend while history is still being collected, but it must never
be expanded into synthetic timestamps or chart geometry. An empty stored
series renders the shared collecting-history state; zero remains a valid
populate the legend only with the explicit `current` label, never as a stored
reading, timestamp or chart geometry. A successful empty read says `No stored
history in this range`; it does not imply that collection is running or that
changing provider configuration will produce a sample. Zero remains a valid
reported reading, while an absent metric remains unavailable.
A lone stored series renders one point at its actual timestamp and value,
including measured zero, never an invented line. A group with one observation
time exposes that date visibly with `Single observation. No trend yet.` and
retains the existing accessible observation description. Multiple series with
separate single observations remain separate points, inspectable at their
actual times through the existing native control. Failed same-source refreshes
retain these points; source/range replacement clears them along with paths and
captions. `GuestDrawerHistory.sparse.test.tsx` checks geometry, units, provenance,
invalid data, refresh and replacement. `browser-tests/history-window.cjs` also
checks sparse/empty/current states through the production PBS drawer and direct
shared renderer. This is presentation proof, not provider collection or #1723
installed acceptance.
Shared history-chart gridlines must carry numeric labels derived from the
plotted scale rather than semantic `Avg` / `Max` placeholders. Byte and
byte-rate axes include their human-readable unit at each gridline, and the
@ -708,7 +721,7 @@ the synthetic estate continues updating, plus the alert timeline and note form.
The Backups surface passes its complete deduplicated route inventory to the PBS table. Only PBS servers render as rows; other resources supply correlation inputs. Preserve PBS drawer identity and use the correlated canonical history target. Missing disk utilisation does not gate CPU/memory history.
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no host series and renders the collecting-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
One agent can be surfaced twice for a single PBS host: folded into its PVE guest and as a standalone `source=pbs` host row. Those two rows are one machine, not an ambiguous pair. Correlation must collapse candidates that share an agent identity and prefer the guest representation, whose canonical metrics target carries the persisted host history; the PBS service target has no stored host series and renders the no-history state. Two candidates with distinct agent identities remain ambiguous, and a candidate with no agent identity must not be treated as proof of sameness.
A live snapshot can briefly omit the correlated host row while the PBS server row remains, for example while a realtime refresh replaces the merged estate. The correlation must retain the last resolved host per PBS server across that omission instead of falling back to the PBS service target, so the drawer's Discovery and Metrics Target rows and its History series do not flicker. Reuse the remembered host only while it is still fresh relative to the server, and drop it once stale so a removed or replaced host is not advertised indefinitely; a host row that is present but ambiguous still declines.

View file

@ -876,11 +876,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
the migration boundary.
11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx`
Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`.
A current metric may remain visible in a chart legend while history is
accumulating, but it must never be expanded into synthetic timestamps or a
flat line. Fewer than two stored points remains `Collecting history` and
only metrics-store samples may contribute chart geometry, bounds, or hover
inspection. Current-value legends must preserve the same availability
A current metric may remain visible in a chart legend only when labelled
`current`; it must never be expanded into synthetic timestamps or a flat
line. One stored sample is a visible point at its actual time, with a dated
single-observation caption rather than a collecting claim or invented trend.
Successful empty reads say `No stored history in this range`. Only
metrics-store samples may contribute chart geometry, bounds, or hover
inspection, including valid measured zero. This presentation adds no fetch,
point expansion, polling or retention budget. Verify sparse and mixed-series
geometry and failed-refresh/source-change behaviour in
`GuestDrawerHistory.sparse.test.tsx`, with rendered phone/desktop coverage in
`browser-tests/history-window.cjs`. Current-value legends preserve the same availability
semantics as workload rows: the poller's negative unknown-disk sentinel is
unavailable and renders `-`, never a fabricated negative percentage.
History cards must let the plot area stretch to the card height instead of

View file

@ -5223,6 +5223,7 @@
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"frontend-modern/src/components/Toast/Toast.tsx",
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
@ -5929,6 +5930,24 @@
"frontend-modern/src/features/docs/__tests__/docMarkdown.test.ts",
"frontend-modern/src/pages/__tests__/Docs.test.ts"
]
},
{
"id": "drawer-metric-history",
"label": "shared drawer stored/current history proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.inspection.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.pointer.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.window.test.tsx"
]
}
],
"match_files": null

View file

@ -138,7 +138,15 @@ const { chromium, firefox, webkit } = require('playwright');
}
if (state === 'failure')
return route.fulfill({ status: 503, json: { error: 'Private fixture detail' } });
return route.fulfill({ json: response(type, id, range, state === 'empty') });
const result = response(type, id, range, state === 'empty');
if (state === 'sparse')
result.metrics = {
cpu: [point(5, 0)],
netin: [point(5, 0)],
diskwrite: [point(5, 0)],
temperature: [point(5, 50)],
};
return route.fulfill({ json: result });
}
if (url.pathname === '/api/license/runtime-capabilities')
return route.fulfill({
@ -336,14 +344,54 @@ const { chromium, firefox, webkit } = require('playwright');
);
await detail.screenshot({ path: path.join(artifacts, `one-hour-${theme}.png`) });
progress('replacement-window-verified', { theme });
state = 'empty';
state = 'sparse';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await page.waitForFunction(
() => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 0,
);
await detail.screenshot({ path: path.join(artifacts, `sparse-${theme}.png`) });
const sparseObservation = {
theme,
pointCount: await detail.locator('[data-history-observation]').count(),
collectingClaims: await detail.getByText('Collecting history', { exact: true }).count(),
descriptions: await detail
.getByTestId('guest-history-plot')
.evaluateAll((plots) =>
plots.map(
(plot) => document.getElementById(plot.getAttribute('aria-describedby'))?.textContent,
),
),
};
progress('sparse-observation', sparseObservation);
assert.equal(sparseObservation.pointCount, 4, 'each lone series must be visible');
assert.equal(sparseObservation.collectingClaims, 0);
assert.equal(await detail.getByRole('slider').count(), 0);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
for (const dot of await detail.locator('[data-history-observation]').all()) {
assert.ok(Math.abs(Number(await dot.getAttribute('cx')) - expectedX(5, '1h')) < 0.01);
assert.ok(Number.isFinite(Number(await dot.getAttribute('cy'))));
}
await verifyWindow(detail, '1h');
state = 'failure';
await detail.getByRole('button', { name: 'Refresh history' }).click();
await detail
.getByText('History refresh failed. Showing previously loaded history.')
.waitFor();
assert.equal(await detail.locator('[data-history-observation]').count(), 4);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 4);
state = 'empty';
await detail.getByRole('button', { name: 'Retry history' }).click();
await page.waitForFunction(
() => document.querySelectorAll('[data-history-observation]').length === 0,
);
await verifyWindow(detail, '1h');
assert.equal(await detail.getByRole('slider').count(), 0);
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 4);
assert.equal(
await detail.getByText('No stored history in this range', { exact: true }).count(),
4,
);
assert.equal(await detail.getByText('Single observation. No trend yet.').count(), 0);
await detail.screenshot({ path: path.join(artifacts, `empty-${theme}.png`) });
state = 'success';
await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d');
await utilization.getByTestId('guest-history-plot').locator('path').first().waitFor();
@ -354,6 +402,23 @@ const { chromium, firefox, webkit } = require('playwright');
assert.equal(requests.length, readsBeforeLock);
assert.equal(await detail.getByTestId('guest-history-time-window').count(), 0);
assert.equal(errors.length, 0, errors.join('\n'));
// The direct production renderer deliberately supplies current readings.
// A live fallback must be labelled, never plotted or attributed to a stored time.
state = 'empty';
await page.goto('http://127.0.0.1:5225/browser-tests/pbs-history-refresh.html');
await page.evaluate(
(dark) => document.documentElement.classList.toggle('dark', dark),
theme === 'dark',
);
detail = page.getByTestId('history-refresh-fixture');
await detail.getByText('No stored history in this range', { exact: true }).first().waitFor();
const liveCPU = detail.locator(
'[data-history-group="utilization"] [data-history-current="cpu"]',
);
assert.match((await liveCPU.innerText()).replace(/\s+/g, ' '), /CPU\s*42\.0%\s*current/);
assert.equal(await detail.locator('[data-history-observation]').count(), 0);
assert.equal(await detail.getByTestId('guest-history-plot').locator('path').count(), 0);
await detail.screenshot({ path: path.join(artifacts, `current-empty-${theme}.png`) });
observations.push({ theme, requests, errors, utilizationX, networkX });
progress('completed-theme', { theme });
await page.close();

View file

@ -209,7 +209,10 @@ const { chromium } = require('playwright');
await page.getByRole('button', { name: 'Switch to service target', exact: true }).click();
await status.getByText('Failed to load history data', { exact: true }).waitFor();
assert.equal(await plots.count(), 0);
assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 0);
assert.equal(
await detail.getByText('No stored history in this range', { exact: true }).count(),
0,
);
assert.equal(await detail.getByText(/previously loaded/).count(), 0);
await checkLayout();
await screenshot('replacement-failed');

View file

@ -1,17 +1,17 @@
{
"version": 1,
"base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73",
"verified_at": "2026-10-01T03:23:21Z",
"base_sha": "36418af66beac94204276f7dc73de2c41e297fb1",
"verified_at": "2026-10-01T05:22:37Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/Table.tsx"
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1"
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "65538187de57957691eb00af08a9f86b916a6300f2d12065a1985fb2170ef7bf"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)",
"/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)"
"/browser-tests/pbs-identity-boundary.html (production PBS table and full ResourceDetailDrawer/History; synthetic bounded API/Agent/PVE resources)",
"/browser-tests/pbs-history-refresh.html (production shared History renderer with deliberately distinct current readings)"
],
"viewports": [
{
@ -24,43 +24,131 @@
}
],
"states": [
"WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next",
"History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs",
"delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row",
"shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works",
"PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow"
"All three synthetic PBS drawers retain vm-one, vm-two and agent-three history identity and one dated window",
"A lone stored CPU, network, disk-write and thermal series each displays one point at its actual time, with valid measured zeros and no manufactured path",
"Single-time panels visibly date their observation and explain that it is not a trend; empty successful reads report no stored history in the selected range",
"Live CPU/memory legend fallbacks carry an explicit current label and create no stored geometry",
"Failed same-source refresh retains lone points and the warning; uncached range replacement clears old geometry and captions while loading; locked ranges make no reads",
"Light/dark phone and desktop rendering: no page errors or document horizontal overflow"
],
"interactions": [
"phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation",
"tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space",
"tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button"
"Native disclosure and History tabs for each of three mapped drawers; keyboard/touch stored-time inspection remains dated and makes no extra reads",
"Refresh, failed refresh, retry, sparse response, successful empty response, and 24h/1h/7d/locked-14d range changes",
"Current-only direct renderer read confirms explicitly labelled live readings and empty plots"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.",
"command": "Four executions from assigned workspace root: pulse-worker-browser frontend-modern/browser-tests/history-window.cjs --engine=webkit --phone --theme=light; --engine=webkit --phone --theme=dark; --engine=firefox --theme=dark; --engine=chromium --phone --theme=light. Playwright 1.56.1 matches integration lock; Vite 6.4.3.",
"browser_versions": {
"chromium": "141.0.7390.37",
"webkit": "26.0",
"firefox": "142.0.1",
"webkit": "26.0"
"chromium": "141.0.7390.37"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/sparse-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/current-empty-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/empty-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-light/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/current-empty-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/sparse-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/empty-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-webkit-390-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/two-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/three-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/current-empty-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/one-hour-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/one-24h-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/sparse-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/retained-window-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/empty-dark.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-firefox-1365-dark/progress.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/sparse-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/current-empty-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/one-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/two-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/one-hour-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/empty-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/three-24h-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/retained-window-light.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-_7g3zqr_/browser-qualified/history-window-chromium-390-light/progress.json"
],
"notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline.",
"notes": "All 32 final screenshots inspected after the copy-style correction to Single observation. No trend yet. All four fresh passes match the runtime SHA256 above. Baseline WebKit phone returned exit 1: four valid lone series had zero points and four Collecting history claims. Earlier HMR/source-hash diagnostics and a batch wrapper exit 143 are retained outside this final proof; the final single Chromium execution exits 0. Offline renderer fixtures do not establish installed PBS/VirtualBox/provider acceptance, physical devices, screen-reader speech or Tailscale Serve update proof. No API, polling, cache or license boundary changes.",
"prior_verifications": [
{
"version": 1,
"base_sha": "920aa2f27c6cb717cca7eb8e2cd3f1379d0fdf73",
"verified_at": "2026-10-01T03:23:21Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/Table.tsx"
],
"content_sha256": {
"frontend-modern/src/components/shared/Table.tsx": "4009a751a3fa435cb4067a98989d25cac4e9e6d08acd7b91701fc98fbfd5aaf1"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table, full ResourceDetailDrawer and History with synthetic bounded API/Agent/PVE data)",
"/browser-tests/table-row-touch.html (production Table, SummaryRowActionButton and PulseDataGrid with synthetic rows and delegated embedded actions)"
],
"viewports": [
{
"width": 390,
"height": 844
},
{
"width": 1365,
"height": 900
}
],
"states": [
"WebKit phone light/dark, Chromium phone light and Firefox desktop dark: all three synthetic PBS drawers open with the first ordinary cell activation and close with the next",
"History tabs stay open and read only vm-one, vm-two and agent-three with bounded GETs",
"delegated embedded buttons, native fragment links and checkbox clicks retain their own action without firing the row",
"shared disclosure touch and Enter/Space activate once; disabled/static rows do not act; restoring the row handler works",
"PulseDataGrid row activates once and its embedded button does not activate the row; no page errors or document horizontal overflow"
],
"interactions": [
"phone-emulated locator.tap on ordinary PBS cells without focus, force, synthetic click dispatch or added event instrumentation",
"tap History, inspect real plotted paths, close each drawer from its same name cell; focus native disclosure and use Enter/Space",
"tap embedded button, fragment link and checkbox; tap and keyboard-activate native disclosure; withdraw and restore row action; activate data-grid row and its child button"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/table-row-touch.cjs --engine=webkit; --engine=webkit --dark; --engine=chromium; --engine=firefox --dark --desktop. Four separate bounded executions from the assigned workspace root. Playwright 1.56.1 / integration-lock parity checked; Vite 6.4.3.",
"browser_versions": {
"chromium": "141.0.7390.37",
"firefox": "142.0.1",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/chromium-390-light/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/firefox-1365-dark/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-dark/shared-controls.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-first-activation.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/pbs-third-history.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-0i3s168w/browser-final/webkit-390-light/shared-controls.png"
],
"notes": "All 12 final screenshots inspected. This repairs reproduced WebKit-emulated row touch, not physical iPhone/Safari acceptance, installed PBS telemetry, every #1723 metric outcome, screen-reader speech or a Tailscale Serve update. Initial harness working-directory/API-path/locator/output-wrapping failures and the instrumented diagnostic are retained in the output receipts; none is a release failure or passing uninstrumented baseline."
},
{
"version": 1,
"base_sha": "00f82e2afc3cd75035c997e9e245c6e0eeffffa9",

View file

@ -161,7 +161,7 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
describe('ResourceDetailDrawer change history section', () => {
it('keeps current readings separate from stored history samples', () => {
expect(guestDrawerHistorySource).toContain('currentMetrics');
expect(guestDrawerHistorySource).toContain('Collecting history');
expect(guestDrawerHistorySource).toContain('No stored history in this range');
expect(guestDrawerHistorySource).not.toContain('buildFallbackHistoryPoints');
});

View file

@ -556,8 +556,8 @@ describe('GuestDrawer', () => {
await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalled());
const utilizationChart = screen.getAllByTestId('guest-history-group-chart')[0];
expect(utilizationChart).toHaveTextContent('CPU25.0%');
expect(utilizationChart).toHaveTextContent('Collecting history');
expect(utilizationChart).toHaveTextContent('CPU25.0%current');
expect(utilizationChart).toHaveTextContent('No stored history in this range');
expect(utilizationChart.querySelector('path')).toBeNull();
});

View file

@ -274,9 +274,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
return `${count} stored observation ${count === 1 ? 'time' : 'times'}. ${inspectionValueText()}`;
});
const drawableSeries = createMemo(() => series().filter((item) => item.points.length >= 2));
const singlePointSeries = createMemo(() => series().filter((item) => item.points.length === 1));
const singleObservation = createMemo(() =>
observationTimes().length === 1 ? { timestamp: observationTimes()[0] } : null,
);
const scale = createMemo(() => getGuestDrawerHistoryScale(series(), props.group.unit));
const bounds = () => props.timeBounds;
const hasDrawableData = createMemo(() => drawableSeries().length > 0 && bounds() !== null);
const hasStoredData = createMemo(() => observationTimes().length > 0 && bounds() !== null);
const hoveredSeries = createMemo(() => {
const timestamp = activeTimestamp();
const rangeBounds = bounds();
@ -309,6 +313,11 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
const currentValue = props.currentMetrics?.[item.metric];
return {
...item,
isCurrent:
activeTimestamp() === null &&
item.points.length === 0 &&
typeof currentValue === 'number' &&
Number.isFinite(currentValue),
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: activeTimestamp() !== null
@ -369,12 +378,18 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<div class="flex flex-wrap justify-end gap-x-3 gap-y-1 text-[11px] text-muted">
<For each={displaySeries()}>
{(item) => (
<span class="inline-flex items-center gap-1">
<span
class="inline-flex items-center gap-1"
data-history-current={item.isCurrent ? item.metric : undefined}
>
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" fill={item.color} />
</svg>
<span class="font-medium text-base-content">{item.label}</span>
<span>{item.valueLabel}</span>
<Show when={item.isCurrent}>
<span>current</span>
</Show>
</span>
)}
</For>
@ -421,26 +436,48 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
<Show when={bounds()}>
{(rangeBounds) => (
<For each={drawableSeries()}>
{(item) => (
<path
d={buildGuestDrawerHistoryPath(
item.points,
scale(),
rangeBounds().startTime,
rangeBounds().endTime,
GUEST_DRAWER_HISTORY_CHART_WIDTH,
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
)}
fill="none"
stroke={item.color}
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
)}
</For>
<>
<For each={drawableSeries()}>
{(item) => (
<path
d={buildGuestDrawerHistoryPath(
item.points,
scale(),
rangeBounds().startTime,
rangeBounds().endTime,
GUEST_DRAWER_HISTORY_CHART_WIDTH,
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
)}
fill="none"
stroke={item.color}
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
)}
</For>
<For each={singlePointSeries()}>
{(item) => (
<circle
aria-hidden="true"
data-history-observation={item.metric}
cx={getGuestDrawerHistoryX(
item.points[0].timestamp,
rangeBounds().startTime,
rangeBounds().endTime,
)}
cy={getGuestDrawerHistoryY(item.points[0].value, scale())}
r="3.5"
fill={item.color}
stroke="currentColor"
stroke-width="1"
class="text-surface"
vector-effect="non-scaling-stroke"
/>
)}
</For>
</>
)}
</Show>
<Show when={hoverX() !== null && hoveredSeries().length > 0}>
@ -471,9 +508,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
</Show>
</svg>
<Show when={!hasDrawableData() && !props.loading}>
<Show when={!hasStoredData() && !props.loading}>
<div class="absolute inset-x-8 inset-y-2 flex items-center justify-center rounded-sm bg-surface/80 text-xs text-muted">
Collecting history
No stored history in this range
</div>
</Show>
<Show when={props.loading}>
@ -515,6 +552,19 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<p id={`${inspectionId}-description`} class="sr-only">
{chartDescription()}
</p>
<Show when={!props.loading && singleObservation()}>
{(observation) => (
<p class="mt-2 text-[10px] text-muted">
<span>Single observation. No trend yet.</span>
<time
class="block tabular-nums"
dateTime={new Date(observation().timestamp).toISOString()}
>
{new Date(observation().timestamp).toLocaleString()}
</time>
</p>
)}
</Show>
<Show when={observationTimes().length > 1}>
<div class="mt-2">
<label for={inspectionId} class="flex justify-between gap-2 text-[10px] text-muted">

View file

@ -57,7 +57,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
await screen.findByText('Failed to load history data');
const retry = screen.getByRole('button', { name: 'Retry history' });
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
expect(container).not.toHaveTextContent('private diagnostic detail');
expect(paths(container)).toHaveLength(0);
@ -204,7 +204,7 @@ describe('GuestDrawerHistory refresh recovery', () => {
await vi.advanceTimersByTimeAsync(30_000);
expect(screen.getByText('Failed to load history data')).toBeInTheDocument();
expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(screen.queryByText('No stored history in this range')).not.toBeInTheDocument();
expect(paths(container)).toHaveLength(0);
},
);

View file

@ -0,0 +1,211 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 7,
isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
const start = Date.UTC(2026, 9, 1, 11);
const time = start + 30 * 60_000;
const end = start + 60 * 60_000;
const target = { resourceType: 'agent' as const, resourceId: 'pbs-host' };
const point = (timestamp = time, value = 0) => ({ timestamp, value, min: value, max: value });
const response = (
metrics: AllMetricsHistoryResponse['metrics'] = {},
): AllMetricsHistoryResponse => ({
...target,
range: '1h',
start,
end,
source: 'store',
metrics,
});
const mount = (currentMetrics: Record<string, number | undefined> = {}) =>
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
groups={HOST_METRICS_HISTORY_GROUPS}
currentMetrics={currentMetrics}
/>
));
const group = (id = 'utilization') =>
screen
.getAllByTestId('guest-history-group-chart')
.find((chart) => chart.dataset.historyGroup === id)!;
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
it.each([
['cpu', 'utilization', 'CPU 0.0%'],
['netin', 'network', 'In 0 B/s'],
['temperature', 'thermals', 'CPU 0°C'],
])(
'plots a lone measured zero for %s, without a synthetic trend or collecting claim',
async (metric, id, label) => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValue(response({ [metric]: [point()] }));
mount({ [metric]: 99 });
await waitFor(() => expect(dots()).toHaveLength(1));
const chart = group(id);
const dot = chart.querySelector(`[data-history-observation="${metric}"]`)!;
expect(Number(dot.getAttribute('cx'))).toBeCloseTo(193);
expect(Number(dot.getAttribute('cy'))).toBeCloseTo(74);
expect(chart.querySelector('path')).toBeNull();
expect(chart.querySelector('[data-history-current]')).toBeNull();
expect(chart).not.toHaveTextContent('No stored history in this range');
expect(chart).toHaveTextContent('Single observation. No trend yet.');
expect(chart.querySelector('time.block')).toHaveAttribute(
'datetime',
new Date(time).toISOString(),
);
expect(within(chart).getByRole('img')).toHaveAccessibleDescription(
new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
expect(screen.queryByText('Collecting history')).not.toBeInTheDocument();
expect(fetch).toHaveBeenCalledTimes(1);
},
);
it('draws a lone series alongside a trend and labels only the live fallback', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(start, 10), point(end, 20)],
memory: [point(time, 0)],
}),
);
mount({ cpu: 99, memory: 98, disk: 88 });
await waitFor(() => expect(dots()).toHaveLength(1));
expect(group().querySelectorAll('path')).toHaveLength(1);
expect(group().querySelector('[data-history-observation]')).toHaveAttribute(
'data-history-observation',
'memory',
);
expect(group().querySelectorAll('[data-history-current]')).toHaveLength(1);
expect(group().querySelector('[data-history-current="disk"]')).toHaveTextContent(
'Disk88.0%current',
);
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
const slider = within(group()).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '1' } });
expect(group()).toHaveTextContent('CPU-');
expect(group()).toHaveTextContent('Memory0.0%');
expect(group()).toHaveTextContent('Disk-');
expect(group().querySelector('[data-history-current]')).toBeNull();
expect(dots()).toHaveLength(1);
});
it('does not connect single observations from different series or borrow their times', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({
cpu: [point(start, 10)],
memory: [point(end, 20)],
}),
);
mount();
await waitFor(() => expect(dots()).toHaveLength(2));
expect(group().querySelector('path')).toBeNull();
expect(group().querySelector('[data-history-observation="cpu"]')).toHaveAttribute('cx', '34');
expect(group().querySelector('[data-history-observation="memory"]')).toHaveAttribute(
'cx',
'352',
);
const slider = within(group()).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(slider).toHaveAttribute(
'aria-valuetext',
`${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk no observation.`,
);
});
it.each(['empty', 'invalid'] as const)(
'reports a successful %s read honestly and never plots current readings',
async (kind) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response(
kind === 'empty'
? {}
: {
cpu: [point(NaN, 12)],
memory: [point(time, Infinity)],
},
),
);
mount({ cpu: 0, memory: Infinity, disk: undefined });
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(4),
);
expect(group().querySelector('[data-history-current="cpu"]')).toHaveTextContent(
'CPU0.0%current',
);
expect(document.querySelectorAll('[data-history-current]')).toHaveLength(1);
expect(dots()).toHaveLength(0);
expect(document.querySelectorAll('path')).toHaveLength(0);
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
},
);
it('retains a lone observation after failed refresh and removes its marker and caption when a trend arrives', async () => {
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
.mockRejectedValueOnce(new Error('private diagnostic'))
.mockResolvedValueOnce(response({ cpu: [point(time, 0), point(end, 5)] }));
mount();
await waitFor(() => expect(dots()).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await screen.findByText('History refresh failed. Showing previously loaded history.');
expect(dots()).toHaveLength(1);
expect(group()).toHaveTextContent('Single observation. No trend yet.');
expect(document.body).not.toHaveTextContent('private diagnostic');
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
await screen.findByRole('slider');
expect(group().querySelectorAll('path')).toHaveLength(1);
expect(dots()).toHaveLength(0);
expect(group()).not.toHaveTextContent('Single observation. No trend yet.');
expect(fetch).toHaveBeenCalledTimes(3);
});
it.each(['target', 'range'] as const)(
'clears former observations and captions during an uncached %s replacement',
async (replacement) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response({ cpu: [point(time, 0)] }))
.mockImplementationOnce(() => new Promise(() => {}));
const [current, setTarget] = createSignal(target);
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
render(() => (
<GuestDrawerHistory
target={current()}
range={range()}
groups={HOST_METRICS_HISTORY_GROUPS}
/>
));
await waitFor(() => expect(dots()).toHaveLength(1));
if (replacement === 'target') setTarget({ ...target, resourceId: 'other-host' });
else setRange('6h');
await waitFor(() => expect(screen.getAllByText('Loading history')).toHaveLength(4));
expect(dots()).toHaveLength(0);
expect(screen.queryByText('Single observation. No trend yet.')).not.toBeInTheDocument();
expect(document.querySelectorAll('time')).toHaveLength(0);
},
);
});

View file

@ -177,7 +177,7 @@ describe('GuestDrawerHistory shared time window', () => {
it('keeps an empty valid query window without inventing observations or a trend', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ metrics: {} }));
mount();
await waitFor(() => expect(screen.getAllByText('Collecting history')).toHaveLength(4));
await waitFor(() => expect(screen.getAllByText('No stored history in this range')).toHaveLength(4));
expect(screen.queryByRole('slider')).toBeNull();
expect(document.querySelectorAll('path')).toHaveLength(0);
expect(screen.getAllByTestId('guest-history-time-window')).toHaveLength(4);