From 744160fa0ebf599ebc36e82c9f12178947a533e6 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Wed, 30 Sep 2026 23:02:05 +0100
Subject: [PATCH] Keep drawer History usable through failed refreshes
Retain only matching stored observations with an explicit refresh-failure warning and a keyboard-safe scoped retry. Settle latest-read loading when polling overtakes a foreground refresh. Preserve target isolation, licence gates and diagnostic containment with regressions and exact-content browser receipts.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 14 +
.../browser-tests/pbs-history-refresh.cjs | 268 ++++++++++++++++++
.../browser-tests/pbs-history-refresh.html | 12 +
.../browser-tests/pbs-history-refresh.tsx | 35 +++
frontend-modern/browser-verification.json | 126 ++++++--
.../Workloads/GuestDrawerHistory.tsx | 54 +++-
.../GuestDrawerHistory.refresh.test.tsx | 222 +++++++++++++++
.../createNonSuspendingQuery.test.tsx | 48 +++-
.../src/hooks/createNonSuspendingQuery.ts | 6 +-
9 files changed, 741 insertions(+), 44 deletions(-)
create mode 100644 frontend-modern/browser-tests/pbs-history-refresh.cjs
create mode 100644 frontend-modern/browser-tests/pbs-history-refresh.html
create mode 100644 frontend-modern/browser-tests/pbs-history-refresh.tsx
create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.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 04a4e3fdf1..a646bca4a2 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -320,6 +320,20 @@ renderer, cache readback and cancellation, not a mocked chart. The mock-backed
PBS browser runner verifies delayed range and withdrawn-target reads at desktop
and phone widths; neither proof establishes installed collection or #1723 relief.
+Failed same-source history refreshes keep valid previously loaded observations
+visible with an explicit warning, rather than hiding the entire chart. Initial
+or uncached replacement failures show unavailable history, never borrowed points
+or a collecting claim. The existing target/range owns every manual refresh;
+locked or absent targets expose no refresh control. The control remains mounted
+and focusable through retry and recovery, rejects activation while busy, and
+updates a pre-mounted polite status region without exposing transport diagnostics.
+The latest query read settles loading even when background polling supersedes
+a foreground refresh; late superseded results remain inert. Verification:
+`GuestDrawerHistory.refresh.test.tsx`, `createNonSuspendingQuery.test.tsx`, and
+`browser-tests/pbs-history-refresh.cjs` (direct production History renderer, scripted failures,
+keyboard retry, overlap, target withdrawal, phone/desktop and light/dark themes).
+These proofs establish presentation/recovery, not installed collection or delivery.
+
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
diff --git a/frontend-modern/browser-tests/pbs-history-refresh.cjs b/frontend-modern/browser-tests/pbs-history-refresh.cjs
new file mode 100644
index 0000000000..2eb3133e97
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-history-refresh.cjs
@@ -0,0 +1,268 @@
+// Direct production History renderer and query proof, not a complete PBS drawer or installed acceptance.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createHash } = require('node:crypto');
+const { chromium } = require('playwright');
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ process.chdir(root);
+ const artifacts = path.join(root, 'node_modules/history-refresh-renderer-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-cache-')),
+ server: { host: '127.0.0.1', port: 5219, strictPort: true },
+ });
+ let browser;
+ const observations = [];
+ try {
+ await server.listen();
+ browser = await chromium.launch({
+ headless: true,
+ channel: 'chromium',
+ args: ['--no-sandbox'],
+ });
+ for (const width of process.argv.includes('--phone') ? [390] : [1365]) {
+ for (const theme of ['light', 'dark']) {
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ });
+ page.setDefaultTimeout(40_000);
+ console.log(JSON.stringify({ width, theme, stage: 'opened context' }));
+ const requests = [],
+ errors = [],
+ failures = [];
+ let nextAction = 'success',
+ held,
+ historyValue = 42;
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('requestfailed', (request) => {
+ if (request.url().includes('/api/metrics-store/history'))
+ failures.push({ url: request.url(), error: request.failure() });
+ });
+ const response = (id, range, value = historyValue) => ({
+ resourceType: 'agent',
+ resourceId: id,
+ range,
+ start: 1_700_000_000_000,
+ end: 1_700_000_060_000,
+ source: 'store',
+ metrics: Object.fromEntries(
+ (id === 'pbs-three'
+ ? ['cpu', 'memory']
+ : ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite']
+ ).map((metric, i) => [
+ metric,
+ [0, 1].map((step) => ({
+ timestamp: 1_700_000_000_000 + step * 60_000,
+ value: value + i + step,
+ min: value,
+ max: value + i + step,
+ })),
+ ]),
+ ),
+ });
+ await page.route('**/*', async (route) => {
+ const request = route.request(),
+ url = new URL(request.url());
+ if (url.origin !== 'http://127.0.0.1:5219') return route.abort();
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (url.pathname === '/api/metrics-store/history') {
+ const id = url.searchParams.get('resourceId'),
+ range = url.searchParams.get('range');
+ assert.ok(['agent-three', 'pbs-three'].includes(id), `unexpected target ${id}`);
+ const action = nextAction;
+ nextAction = 'success';
+ requests.push({ id, range, action, method: request.method() });
+ if (action === 'hold') {
+ held = route;
+ return;
+ }
+ if (action === 'failure')
+ return route.fulfill({ status: 503, json: { error: 'Fixture unavailable' } });
+ return route.fulfill({ json: response(id, range) });
+ }
+ if (url.pathname === '/api/license/runtime-capabilities')
+ return route.fulfill({
+ json: {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community', label: 'Pulse Community runtime' },
+ blocked_capabilities: [],
+ },
+ });
+ return route.fulfill({ json: { data: [], enabled: false } });
+ });
+ await page.goto('http://127.0.0.1:5219/browser-tests/pbs-history-refresh.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 30_000,
+ });
+ await page.evaluate(
+ (dark) => document.documentElement.classList.toggle('dark', dark),
+ theme === 'dark',
+ );
+ console.log(JSON.stringify({ width, theme, stage: 'page loaded' }));
+ const detail = page.getByTestId('history-refresh-fixture');
+ const plots = detail.locator('[data-testid="guest-history-plot"] path');
+ console.log(JSON.stringify({ width, theme, stage: 'renderer available' }));
+ const waitForPaths = async (count) =>
+ page.waitForFunction(
+ (expected) =>
+ document.querySelectorAll('[data-testid="guest-history-plot"] path').length ===
+ expected,
+ count,
+ );
+ await waitForPaths(7);
+ console.log(JSON.stringify({ width, theme, stage: 'history loaded' }));
+ const status = detail.getByRole('status', { name: 'History refresh status' });
+ assert.equal(await status.count(), 1);
+ assert.equal(await status.innerText(), '');
+ const paths = () =>
+ plots.evaluateAll((items) => items.map((item) => item.getAttribute('d')));
+ const original = await paths();
+ const checkLayout = async () => {
+ const dimensions = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ inner: innerWidth,
+ }));
+ assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
+ const button = detail.getByRole('button', { name: /^(Retry|Refresh) history$/ });
+ const bounds = await button.boundingBox();
+ assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width + 1);
+ if (width === 390) assert.ok(bounds.height >= 44, JSON.stringify(bounds));
+ return dimensions;
+ };
+ const screenshot = (state) =>
+ detail.screenshot({ path: path.join(artifacts, `${state}-${theme}-${width}.png`) });
+ const waitForHeld = async () => {
+ for (let i = 0; !held && i < 100; i++) await page.waitForTimeout(10);
+ assert.ok(held, 'manual refresh was not intercepted');
+ };
+
+ // A failed background read must keep this source's observations, labelled honestly.
+ nextAction = 'failure';
+ await status
+ .getByText('History refresh failed. Showing previously loaded history.', { exact: true })
+ .waitFor();
+ assert.deepEqual(await paths(), original);
+ assert.equal(await status.getAttribute('aria-live'), 'polite');
+ assert.equal(await status.getAttribute('aria-atomic'), 'true');
+ await checkLayout();
+ await screenshot('refresh-failed');
+ console.log(JSON.stringify({ width, theme, stage: 'failure retained' }));
+
+ // Enter starts one retry; focus and the old points survive until its successful response.
+ const retry = detail.getByRole('button', { name: 'Retry history', exact: true });
+ nextAction = 'hold';
+ held = undefined;
+ await retry.focus();
+ await page.keyboard.press('Enter');
+ await waitForHeld();
+ assert.equal(await retry.getAttribute('aria-busy'), 'true');
+ assert.equal(await retry.getAttribute('aria-disabled'), 'true');
+ await page.keyboard.press('Enter');
+ assert.equal(requests.filter((r) => r.action === 'hold').length, 1);
+ assert.deepEqual(await paths(), original);
+ historyValue = 12;
+ await held.fulfill({ json: response('agent-three', '24h') });
+ await status.getByText(/History refresh failed/).waitFor({ state: 'hidden' });
+ const refresh = detail.getByRole('button', { name: 'Refresh history', exact: true });
+ assert.equal(await refresh.evaluate((button) => document.activeElement === button), true);
+ assert.notDeepEqual(await paths(), original);
+ assert.equal(await refresh.getAttribute('aria-busy'), 'false');
+ await screenshot('retry-recovered');
+ console.log(JSON.stringify({ width, theme, stage: 'retry recovered' }));
+
+ // A slow manual refresh replaced by polling must release its loading ownership.
+ nextAction = 'hold';
+ held = undefined;
+ await refresh.click();
+ await waitForHeld();
+ const obsoleteRoute = held;
+ historyValue = 25;
+ await page.waitForFunction(
+ () => document.querySelector('button[aria-busy="false"]') !== null,
+ );
+ for (let i = 0; (await refresh.getAttribute('aria-busy')) !== 'false' && i < 100; i++)
+ await page.waitForTimeout(10);
+ assert.equal(await refresh.getAttribute('aria-busy'), 'false');
+ assert.equal(await refresh.getAttribute('aria-disabled'), 'false');
+ const latest = await paths();
+ let lateResponse;
+ try {
+ await obsoleteRoute.fulfill({ json: response('agent-three', '24h', 99) });
+ lateResponse = 'fulfilled';
+ } catch (error) {
+ lateResponse = String(error);
+ }
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+ assert.deepEqual(await paths(), latest);
+
+ // A different target has no right to the old host's history, including after a failure.
+ nextAction = 'failure';
+ 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(/previously loaded/).count(), 0);
+ await checkLayout();
+ await screenshot('replacement-failed');
+ await detail.getByRole('button', { name: 'Retry history', exact: true }).click();
+ await waitForPaths(2);
+ assert.equal(requests.at(-1).id, 'pbs-three');
+ assert.equal(requests.at(-1).range, '24h');
+ assert.equal(await detail.locator('[data-history-group="network"] path').count(), 0);
+ assert.equal(await detail.locator('[data-history-group="disk-io"] path').count(), 0);
+ const dimensions = await checkLayout();
+ const beforeLocked = requests.length;
+ await detail.getByTestId('guest-history-range-control').selectOption('14d');
+ await detail.getByText(/14 days history requires a higher license plan/).waitFor();
+ assert.equal(
+ await detail.getByRole('button', { name: /^(Retry|Refresh) history$/ }).count(),
+ 0,
+ );
+ assert.equal(requests.length, beforeLocked);
+ assert.ok(requests.every((r) => r.method === 'GET'));
+ assert.deepEqual(errors, []);
+ observations.push({ width, theme, requests, failures, dimensions, lateResponse, errors });
+ await page.close();
+ }
+ }
+ const files = [
+ 'src/components/Workloads/GuestDrawerHistory.tsx',
+ 'src/hooks/createNonSuspendingQuery.ts',
+ ];
+ const result = {
+ result: 'passed',
+ browser: browser.version(),
+ playwright: require('playwright/package.json').version,
+ content_sha256: Object.fromEntries(
+ files.map((file) => [
+ `frontend-modern/${file}`,
+ createHash('sha256')
+ .update(fs.readFileSync(path.join(root, file)))
+ .digest('hex'),
+ ]),
+ ),
+ observations,
+ };
+ fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify(result));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ for (const name of fs.readdirSync(artifacts)) {
+ const file = path.join(artifacts, name);
+ if (fs.statSync(file).isFile()) fs.chmodSync(file, 0o644);
+ }
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/pbs-history-refresh.html b/frontend-modern/browser-tests/pbs-history-refresh.html
new file mode 100644
index 0000000000..f23cbfcdb3
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-history-refresh.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ History refresh recovery verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/pbs-history-refresh.tsx b/frontend-modern/browser-tests/pbs-history-refresh.tsx
new file mode 100644
index 0000000000..77f2886739
--- /dev/null
+++ b/frontend-modern/browser-tests/pbs-history-refresh.tsx
@@ -0,0 +1,35 @@
+// Direct production renderer proof; this is not the complete PBS table/drawer.
+import { createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import type { HistoryTimeRange } from '../src/api/charts';
+import {
+ GuestDrawerHistory,
+ GuestDrawerHistoryRangeSelect,
+} from '../src/components/Workloads/GuestDrawerHistory';
+import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel';
+import '../src/index.css';
+
+const Fixture = () => {
+ const [resourceId, setResourceId] = createSignal('agent-three');
+ const [range, setRange] = createSignal('24h');
+ return (
+
+ History refresh recovery verification
+
+
+
+ );
+};
+render(() => , document.getElementById('root') as HTMLElement);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 46f3f9af25..b95b05717e 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,18 @@
{
"version": 1,
- "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
- "verified_at": "2026-09-30T20:12:09Z",
+ "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3",
+ "verified_at": "2026-09-30T22:01:47Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
+ "frontend-modern/src/hooks/createNonSuspendingQuery.ts"
],
"content_sha256": {
- "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903",
+ "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79"
},
"routes": [
- "/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
+ "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)"
],
"viewports": [
{
@@ -23,38 +25,100 @@
}
],
"states": [
- "corroborated host with seven observed metric paths",
- "uncached range replacement pending with no old paths",
- "new range loaded before the old range response",
- "withdrawn host link with replacement target pending",
- "replacement target returns 503 with no former-host history",
- "service-only response retains CPU/memory, not host disk/network/I/O",
- "locked 14-day range issues no history read"
+ "light/dark current host history",
+ "real 30-second poll fails while matching points remain explicitly labelled",
+ "keyboard retry held with busy/activation guard and retained points",
+ "successful retry keeps keyboard focus and updates the same control",
+ "real background polling supersedes a slow foreground read and settles loading",
+ "late aborted response cannot replace current paths",
+ "uncached service-target failure has neither former-host points nor a collecting claim",
+ "service-target retry renders CPU/memory only",
+ "locked range exposes no read or refresh action"
],
"interactions": [
- "desktop keyboard Enter / phone row click to open PBS details",
- "select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
- "withdraw third host link while service GET is held, then return 503",
- "select 6h after failure and inspect the service-only response",
- "select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
+ "Enter retry twice while its GET is held; exactly one retry read starts",
+ "fulfil retry; inspect warning removal, changed paths and retained focus",
+ "hold manual refresh across the real polling interval; inspect loading settlement and aborted read",
+ "switch to service target, fail its GET, retry the same key, select locked 14d"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-refresh.cjs --phone; then the same command without --phone, from assigned workspace root. Native polling timers, Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1 (integration-lock parity verified).",
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-phone.log",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/renderer-browser-desktop.log",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-phone-result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/receipts/browser-desktop-result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-dark-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/refresh-failed-light-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-dark-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/replacement-failed-light-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-dark-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-jybdl1pm/browser-final/retry-recovered-light-390.png"
],
- "notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately.",
+ "notes": "All 12 final screenshots inspected. These are direct production-renderer and real query proofs, not containing-build #1723 acceptance, screen-reader announcement quality, or a complete PBS drawer/browser update journey. Earlier broader fixture/copy/cache/timeout failures are retained in the outcome receipts; no broad pass is claimed. A diagnostic stage label was clarified after desktop launch; no tested runtime content or assertions changed.",
"prior_verifications": [
+ {
+ "version": 1,
+ "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
+ "verified_at": "2026-09-30T20:12:09Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
+ },
+ "routes": [
+ "/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "corroborated host with seven observed metric paths",
+ "uncached range replacement pending with no old paths",
+ "new range loaded before the old range response",
+ "withdrawn host link with replacement target pending",
+ "replacement target returns 503 with no former-host history",
+ "service-only response retains CPU/memory, not host disk/network/I/O",
+ "locked 14-day range issues no history read"
+ ],
+ "interactions": [
+ "desktop keyboard Enter / phone row click to open PBS details",
+ "select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
+ "withdraw third host link while service GET is held, then return 503",
+ "select 6h after failure and inspect the service-only response",
+ "select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
+ ],
+ "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
+ "artifacts": [
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
+ ],
+ "notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately."
+ },
{
"version": 1,
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
index 369646de81..42a7d1435d 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
@@ -10,6 +10,9 @@ import {
} from '@/api/charts';
import { FormSelect } from '@/components/shared/FormSelect';
import { filterSelectClass } from '@/components/shared/FilterToolbar';
+import { Button } from '@/components/shared/Button';
+import { InlineNotice } from '@/components/shared/InlineNotice';
+import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import {
HISTORY_CHART_RANGES,
formatHistoryChartTooltipValue,
@@ -447,7 +450,14 @@ export const GuestDrawerHistory: Component = (props) =>
const metrics = createMemo(() => historyQuery.value().metrics ?? {});
const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS);
- const errorText = createMemo(() => (historyQuery.error() ? 'Failed to load history data' : ''));
+ const hasHistoryPoints = createMemo(() =>
+ groups().some((group) =>
+ group.series.some(
+ (series) =>
+ normalizeGuestDrawerHistoryPoints(metrics()[series.metric], series.unit).length > 0,
+ ),
+ ),
+ );
return (
= (props) =>
}
>
-
- {errorText()}
-
- }
- >
+
+
+
+
+ {hasHistoryPoints()
+ ? 'History refresh failed. Showing previously loaded history.'
+ : 'Failed to load history data'}
+
+
+
+
+
+
3 ? 'xl:grid-cols-4' : 'xl:grid-cols-3'}`}>
{(group) => (
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx
new file mode 100644
index 0000000000..f6097b4c7a
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx
@@ -0,0 +1,222 @@
+import { cleanup, fireEvent, render, screen, waitFor } 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 { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
+import { GuestDrawerHistory } from '../GuestDrawerHistory';
+import type { GuestDrawerHistoryTarget } from '../guestDrawerModel';
+
+vi.mock('@/stores/license', () => ({
+ loadRuntimeCapabilities: vi.fn(async () => undefined),
+ maxHistoryDays: () => 7,
+ isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range),
+}));
+
+afterEach(() => {
+ cleanup();
+ resetCreateNonSuspendingQueryCacheForTest();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' };
+const response = (value = 42, resourceId = target.resourceId): AllMetricsHistoryResponse => ({
+ resourceType: 'agent',
+ resourceId,
+ range: '24h',
+ start: 1_700_000_000_000,
+ end: 1_700_000_060_000,
+ source: 'store',
+ metrics: {
+ cpu: [
+ { timestamp: 1_700_000_000_000, value: value - 1, min: value - 1, max: value - 1 },
+ { timestamp: 1_700_000_060_000, value, min: value, max: value },
+ ],
+ },
+});
+const paths = (container: HTMLElement) =>
+ container.querySelectorAll('[data-testid="guest-history-plot"] path');
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+
+describe('GuestDrawerHistory refresh recovery', () => {
+ it('offers one scoped retry after initial failure without claiming collection or exposing errors', async () => {
+ const pending = deferred();
+ const fetch = vi
+ .spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockRejectedValueOnce(new Error('private diagnostic detail'))
+ .mockReturnValueOnce(pending.promise);
+ const { container } = render(() => );
+ 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(/previously loaded/)).not.toBeInTheDocument();
+ expect(container).not.toHaveTextContent('private diagnostic detail');
+ expect(paths(container)).toHaveLength(0);
+ fireEvent.click(retry);
+ fireEvent.click(retry);
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
+ expect(retry).toHaveAttribute('aria-disabled', 'true');
+ expect(retry).toHaveAttribute('aria-busy', 'true');
+ expect(fetch.mock.calls[1][0]).toMatchObject({ ...target, range: '24h' });
+ pending.resolve(response());
+ await waitFor(() => expect(paths(container)).toHaveLength(1));
+ expect(retry).toHaveAttribute('aria-disabled', 'false');
+ expect(retry).toHaveAccessibleName('Refresh history');
+ });
+
+ it('retains only the current history on a failed poll and updates its already-mounted status region', async () => {
+ vi.useFakeTimers();
+ vi.spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockRejectedValueOnce(new Error('Unavailable'));
+ const { container } = render(() => );
+ const status = screen.getByRole('status', { name: 'History refresh status' });
+ expect(status).toBeEmptyDOMElement();
+ await vi.advanceTimersByTimeAsync(0);
+ const original = paths(container)[0].getAttribute('d');
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(paths(container)).toHaveLength(1);
+ expect(paths(container)[0]).toHaveAttribute('d', original);
+ expect(status).toHaveTextContent('History refresh failed. Showing previously loaded history.');
+ expect(status).toHaveAttribute('aria-live', 'polite');
+ expect(status).toHaveAttribute('aria-atomic', 'true');
+ expect(screen.queryByText('Loading history')).not.toBeInTheDocument();
+ });
+
+ it('keeps retry keyboard focus through successful recovery without losing retained points', async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ vi.spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockRejectedValueOnce(new Error('Unavailable'))
+ .mockReturnValueOnce(pending.promise);
+ const { container } = render(() => );
+ await vi.advanceTimersByTimeAsync(30_000);
+ const original = paths(container)[0].getAttribute('d');
+ const retry = screen.getByRole('button', { name: 'Retry history' });
+ retry.focus();
+ fireEvent.click(retry);
+ expect(paths(container)[0]).toHaveAttribute('d', original);
+ expect(retry).toHaveFocus();
+ pending.resolve(response(12));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(paths(container)[0]).not.toHaveAttribute('d', original);
+ expect(screen.getByRole('button', { name: 'Refresh history' })).toBe(retry);
+ expect(retry).toHaveFocus();
+ expect(screen.getByRole('status', { name: 'History refresh status' })).toBeEmptyDOMElement();
+ });
+
+ it('settles retry loading when a background poll supersedes a slow manual refresh', async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ const fetch = vi
+ .spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockReturnValueOnce(pending.promise)
+ .mockResolvedValueOnce(response(12));
+ const { container } = render(() => );
+ await vi.advanceTimersByTimeAsync(0);
+ const refresh = screen.getByRole('button', { name: 'Refresh history' });
+ fireEvent.click(refresh);
+ expect(refresh).toHaveAttribute('aria-disabled', 'true');
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(fetch).toHaveBeenCalledTimes(3);
+ expect(fetch.mock.calls[1][0].signal!.aborted).toBe(true);
+ expect(refresh).toHaveAttribute('aria-disabled', 'false');
+ expect(refresh).toHaveAttribute('aria-busy', 'false');
+ const latest = paths(container)[0].getAttribute('d');
+ pending.resolve(response(99));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(paths(container)[0]).toHaveAttribute('d', latest);
+ });
+
+ it('retains the failure after a failed retry and allows another attempt', async () => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockRejectedValueOnce(new Error('Unavailable'))
+ .mockRejectedValueOnce(new Error('Still unavailable'))
+ .mockResolvedValueOnce(response());
+ const { container } = render(() => );
+ await screen.findByText('Failed to load history data');
+ const retry = screen.getByRole('button', { name: 'Retry history' });
+ fireEvent.click(retry);
+ await waitFor(() => expect(retry).toHaveAttribute('aria-disabled', 'false'));
+ expect(screen.getByText('Failed to load history data')).toBeInTheDocument();
+ expect(paths(container)).toHaveLength(0);
+ fireEvent.click(retry);
+ await waitFor(() => expect(paths(container)).toHaveLength(1));
+ });
+
+ it.each(['resource', 'range'] as const)(
+ 'does not label a failed replacement %s as retained history',
+ async (change) => {
+ vi.useFakeTimers();
+ const fetch = vi
+ .spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockRejectedValueOnce(new Error('Poll failed'))
+ .mockRejectedValueOnce(new Error('Replacement failed'))
+ .mockResolvedValueOnce(response(12, 'replacement'));
+ const [currentTarget, setTarget] = createSignal(target);
+ const [range, setRange] = createSignal('24h');
+ const { container } = render(() => (
+
+ ));
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(paths(container)).toHaveLength(1);
+ if (change === 'resource') setTarget({ ...target, resourceId: 'replacement' });
+ else setRange('6h');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(paths(container)).toHaveLength(0);
+ expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument();
+ expect(screen.getByText('Failed to load history data')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(fetch.mock.calls[3][0]).toMatchObject({ ...currentTarget(), range: range() });
+ expect(paths(container)).toHaveLength(1);
+ },
+ );
+
+ it.each(['empty', 'invalid'] as const)(
+ 'does not present %s history or current readings as previously loaded observations',
+ async (observation) => {
+ vi.useFakeTimers();
+ vi.spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce({
+ ...response(),
+ metrics:
+ observation === 'empty'
+ ? {}
+ : { cpu: [{ timestamp: NaN, value: NaN, min: NaN, max: NaN }] },
+ })
+ .mockRejectedValueOnce(new Error('Unavailable'));
+ const { container } = render(() => (
+
+ ));
+ 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(paths(container)).toHaveLength(0);
+ },
+ );
+
+ it('offers no refresh when the target is absent or the range is locked', async () => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory');
+ const [currentTarget, setTarget] = createSignal(null);
+ render(() => );
+ expect(screen.queryByRole('button', { name: /history/i })).not.toBeInTheDocument();
+ setTarget(target);
+ await screen.findByText(/14 days history requires a higher license plan/);
+ expect(screen.queryByRole('button', { name: /history/i })).not.toBeInTheDocument();
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx
index d8442b46de..488876a2c2 100644
--- a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx
+++ b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
+import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import {
createNonSuspendingQuery,
@@ -265,3 +265,49 @@ describe('createNonSuspendingQuery', () => {
});
});
});
+
+it.each(['success', 'failure'] as const)(
+ 'settles a foreground loading state when its latest background replacement ends in %s',
+ async (outcome) => {
+ vi.useFakeTimers();
+ let finishManual!: (value: string) => void;
+ const manual = new Promise((resolve) => {
+ finishManual = resolve;
+ });
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce('first')
+ .mockReturnValueOnce(manual)
+ .mockImplementationOnce(() =>
+ outcome === 'success'
+ ? Promise.resolve('latest')
+ : Promise.reject(new Error('Unavailable')),
+ );
+ const Probe = () => {
+ const query = createNonSuspendingQuery({
+ source: () => 'pbs-host',
+ fetcher,
+ initialValue: '',
+ pollMs: 30_000,
+ });
+ return (
+ <>
+
+
+ >
+ );
+ };
+ render(() => );
+ await vi.advanceTimersByTimeAsync(0);
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
+ expect(screen.getByTestId('query')).toHaveTextContent('first|loading:true');
+ await vi.advanceTimersByTimeAsync(30_000);
+ expect(fetcher).toHaveBeenCalledTimes(3);
+ expect(fetcher.mock.calls[1][1].aborted).toBe(true);
+ const expected = outcome === 'success' ? 'latest|loading:false' : 'first|loading:false';
+ expect(screen.getByTestId('query')).toHaveTextContent(expected);
+ finishManual('obsolete');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(screen.getByTestId('query')).toHaveTextContent(expected);
+ },
+);
diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts
index f1835606bd..39c88f8a79 100644
--- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts
+++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts
@@ -243,9 +243,9 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery
value: value(),
});
}
- if (!runOptions.background) {
- setLoading(false);
- }
+ // A background poll can replace a pending foreground refresh. The
+ // latest read owns settlement, regardless of how that read started.
+ setLoading(false);
}
}
};