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
This commit is contained in:
pulse-triage[bot]
2026-09-30 23:02:05 +01:00
parent 781cbfa98e
commit 744160fa0e
9 changed files with 741 additions and 44 deletions
@@ -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
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>History refresh recovery verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/pbs-history-refresh.tsx"></script>
</body>
</html>
@@ -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<HistoryTimeRange>('24h');
return (
<main class="min-h-screen bg-surface p-4 text-base-content">
<h1 class="mb-4 text-lg font-semibold">History refresh recovery verification</h1>
<button class="mb-4" onClick={() => setResourceId('pbs-three')}>
Switch to service target
</button>
<section data-testid="history-refresh-fixture" class="space-y-3">
<div class="flex justify-end">
<GuestDrawerHistoryRangeSelect range={range()} onRangeChange={setRange} />
</div>
<GuestDrawerHistory
target={{ resourceType: 'agent', resourceId: resourceId() }}
range={range()}
groups={HOST_METRICS_HISTORY_GROUPS}
currentMetrics={{ cpu: 42, memory: 53 }}
/>
</section>
</main>
);
};
render(() => <Fixture />, document.getElementById('root') as HTMLElement);
+95 -31
View File
@@ -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",
@@ -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<GuestDrawerHistoryProps> = (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 (
<Show
@@ -464,14 +474,40 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
</div>
}
>
<Show
when={!errorText()}
fallback={
<div class="rounded-sm border border-red-500/30 bg-red-500/10 p-5 text-sm text-red-300">
{errorText()}
</div>
}
>
<div class="flex flex-wrap items-start justify-end gap-3">
<div
class="min-w-0 flex-1"
role="status"
aria-label="History refresh status"
aria-live="polite"
aria-atomic="true"
>
<Show when={historyQuery.error()}>
<InlineNotice tone={hasHistoryPoints() ? 'warning' : 'danger'}>
{hasHistoryPoints()
? 'History refresh failed. Showing previously loaded history.'
: 'Failed to load history data'}
</InlineNotice>
</Show>
</div>
<Button
size="sm"
class="min-h-11 shrink-0 aria-disabled:opacity-50 sm:min-h-8"
aria-busy={historyQuery.loading()}
aria-disabled={historyQuery.loading()}
onClick={() => {
// Keep the control focusable through a retry, but reject repeat
// activation until the current read settles.
if (!historyQuery.loading()) void historyQuery.refetch();
}}
>
<Show when={historyQuery.loading()}>
<LoadingSpinner size="sm" tone="current" class="mr-2" />
</Show>
{historyQuery.error() ? 'Retry history' : 'Refresh history'}
</Button>
</div>
<Show when={!historyQuery.error() || hasHistoryPoints()}>
<div class={`grid gap-3 ${groups().length > 3 ? 'xl:grid-cols-4' : 'xl:grid-cols-3'}`}>
<For each={groups()}>
{(group) => (
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockRejectedValueOnce(new Error('private diagnostic detail'))
.mockReturnValueOnce(pending.promise);
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(/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(() => <GuestDrawerHistory target={target} range="24h" />);
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<AllMetricsHistoryResponse>();
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockRejectedValueOnce(new Error('Unavailable'))
.mockReturnValueOnce(pending.promise);
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
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<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce(response(12));
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
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(() => <GuestDrawerHistory target={target} range="24h" />);
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<GuestDrawerHistoryTarget>(target);
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
const { container } = render(() => (
<GuestDrawerHistory target={currentTarget()} range={range()} />
));
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(() => (
<GuestDrawerHistory target={target} range="24h" currentMetrics={{ cpu: 42 }} />
));
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<GuestDrawerHistoryTarget | null>(null);
render(() => <GuestDrawerHistory target={currentTarget()} range="14d" />);
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();
});
});
@@ -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<string>((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 (
<>
<button onClick={() => void query.refetch()}>Refresh</button>
<output data-testid="query">{`${query.value()}|loading:${query.loading()}`}</output>
</>
);
};
render(() => <Probe />);
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);
},
);
@@ -243,9 +243,9 @@ export function createNonSuspendingQuery<T, K>(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);
}
}
};