Distinguish empty History from failed background refreshes

Keep the last successful readings and source visible with an accessible warning until recovery or selection replacement. Stop promising future collection for empty history. Cover failure and recovery with request-state and two-engine browser controls.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-02 16:52:26 +01:00
parent 65a36dd4e7
commit c6edeee869
10 changed files with 229 additions and 41 deletions
@@ -7699,3 +7699,17 @@ selection changes and empty data clear stale readings. No readings are invented
for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
browser proof does not establish native collector or assistive-device acceptance.
### Shared History request-state honesty
An empty successful response states only that the selected time range has no
samples; it does not claim collection is running or promise future readings.
A failed matching background refresh keeps the last successful data and source,
with a visible polite status also included in the chart's accessible description.
The warning persists while retrying and clears only on success or selection
replacement. This applies to previously empty results too. Initial failures
remain separate errors. No polling frequency, request or access policy changes.
`useHistoryChartState.test.tsx` covers failure, pending recovery, empty results
and selection changes; `browser-tests/history-status.cjs` checks actual chart
states in desktop Chromium and phone WebKit with synthetic transport failures.
These checks do not establish native collection or released availability.
@@ -0,0 +1,93 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium, webkit } = require('playwright');
(async () => {
const root = '/workspace/frontend-modern';
process.chdir(root);
const artifacts = path.join(root, 'node_modules/history-status-proof');
fs.mkdirSync(artifacts, { recursive: true });
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5224, strictPort: true } });
let browser;
const observations = [];
const parent = process.argv.includes('--parent');
try {
await server.listen();
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
const errors = [];
page.on('pageerror', e => errors.push(e.message));
let mode = 'empty', calls = 0;
const pending = [];
await page.route('**/*', route => {
const url = new URL(route.request().url());
if (url.origin !== 'http://127.0.0.1:5224') return route.abort();
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname.includes('/metrics-store/history')) {
calls++;
if (mode === 'pending') { pending.push(route); return; }
if (mode === 'failed') return route.fulfill({status: 503, json: {error: 'Synthetic unavailable'}});
return route.fulfill({json: {points: mode === 'empty' ? [] : [0,1,2].map(i => ({timestamp: 1790942400000 + i*60000, value: 20+i*10, min: 20+i*10, max: 20+i*10})), source: 'store'}});
}
return route.fulfill({json: url.pathname === '/api/license/runtime-capabilities' ? {capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: {build:'community'}, blocked_capabilities: []} : {data: [], enabled: false}});
});
await page.clock.install();
await page.goto('http://127.0.0.1:5224/browser-tests/history-status.html');
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
const chart = page.getByRole('img', {name:'Usage chart', exact:true});
await chart.waitFor();
const description = page.locator('#' + await chart.getAttribute('aria-describedby'));
async function state(phase, expected) {
await page.waitForFunction(({id, expected}) => document.getElementById(id).textContent.includes(expected), {id: await chart.getAttribute('aria-describedby'), expected});
const actual = await description.textContent();
const dimensions = await page.evaluate(() => ({scroll:document.documentElement.scrollWidth, width:innerWidth}));
assert.ok(dimensions.scroll <= dimensions.width + 1);
await page.screenshot({path:path.join(artifacts, `${parent?'parent-':''}${name}-${phase}.png`),fullPage:true});
observations.push({name, version:browser.version(),phase,actual,dimensions,calls});
}
await state('empty', 'No 1-hour');
assert.equal(await page.getByText(parent ? 'Collecting data... History will appear here.' : 'No history samples in this time range.', {exact:true}).count(),1);
mode = 'populated';
await page.clock.runFor(10_000);
await state('loaded','40.0%');
mode = 'failed';
await page.clock.runFor(10_000);
if (parent) {
await page.waitForTimeout(100);
assert.ok(!(await description.textContent()).includes('Could not refresh'));
assert.equal(await page.getByText('Could not refresh history. Showing the last successful result.',{exact:true}).count(),0);
await state('silent-failure','40.0%');
await browser.close(); browser = null; break;
}
await state('refresh-failed','Could not refresh history');
assert.ok((await description.textContent()).includes('40.0%'));
assert.ok((await page.getByRole('status', {name:'History refresh status'}).textContent()).includes('last successful result'));
mode = 'pending';
await page.clock.runFor(10_000);
await state('retry-pending','Could not refresh history');
for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
assert.ok(pending.length > 0, 'pending refresh observed');
for (const route of pending.splice(0)) await route.fulfill({json:{points:[],source:'store'}});
await state('recovered-empty','No 1-hour');
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
mode = 'failed';
await page.clock.runFor(10_000);
await state('empty-refresh-failed','Could not refresh history');
mode = 'pending';
await page.getByRole('button',{name:'Select pool b'}).click();
await state('target-loading','Loading');
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
for (let i=0; !pending.length && i<100; i++) await new Promise(r=>setTimeout(r,20));
assert.ok(pending.length > 0, 'pending refresh observed');
for (const route of pending.splice(0)) await route.fulfill({status:503,json:{error:'Synthetic unavailable'}});
await state('initial-failed','could not be loaded');
assert.equal(await page.getByRole('status', {name:'History refresh status'}).textContent(),'');
assert.deepEqual(errors,[]);
await browser.close(); browser=null;
}
fs.writeFileSync(path.join(artifacts,parent?'parent.json':'result.json'),JSON.stringify({playwright:require('playwright/package.json').version,observations},null,2));
console.log(JSON.stringify({result:'passed',states:observations.length,parent}));
} finally { if(browser) await browser.close(); await server.close(); }
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History request states verification</title></head><body><div id="root"></div><script type="module" src="./history-status.tsx"></script></body></html>
@@ -0,0 +1,30 @@
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { HistoryChart } from '../src/components/shared/HistoryChart';
import '../src/index.css';
const Fixture = () => {
const [target, setTarget] = createSignal('a');
return (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History request states</h1>
<p>Synthetic transport; production History chart. Selected pool: {target()}</p>
<button class="min-h-11 border px-3" onClick={() => setTarget('b')}>
Select pool b
</button>
<section class="max-w-xl">
<HistoryChart
resourceType="storage"
resourceId={target()}
metric="usage"
label="Usage"
unit="%"
hideSelector
compact
range="1h"
height={140}
/>
</section>
</main>
);
};
render(() => <Fixture />, document.getElementById('root')!);
+19 -16
View File
@@ -1,20 +1,20 @@
{
"version": 1,
"base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a",
"verified_at": "2026-10-02T15:02:34.899181Z",
"base_sha": "65a36dd4e7a83e682b9dadc4bb624d79fcb10546",
"verified_at": "2026-10-02T15:52:23.739120+00:00",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/HistoryChart.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts",
"frontend-modern/src/components/shared/historyChartModel.ts"
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx",
"frontend-modern/src/components/shared/useHistoryChartState.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/HistoryChart.tsx": "24cd863fb6288bc2c13ef2d26e02aad95d3e4f54278f3078b1703fabf8785f8c",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "b1ebd93d5fee328b78f8f88f1837514a30d12be069d852d08f5d7f2b6ce999ec",
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751"
"frontend-modern/src/components/shared/HistoryChart.tsx": "a2ab0198a705e66ce9bea222066078709c8334fc342415916b8c179987b139e2",
"frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "4192731461a19ba7c33185c2e616c5023dfef58d93e3893cd3c4fceb58598391",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "8bebaa331f1e2dfc57587f517628bed1b0c4f7d9c9466e21597e7f9f7a4d7137"
},
"routes": [
"/browser-tests/history-keyboard.html"
"/browser-tests/history-status.html"
],
"viewports": [
{
@@ -27,22 +27,25 @@
}
],
"states": [
"Populated, empty and linked charts in Chromium light and WebKit phone dark; synthetic data, not native PBS acceptance",
"Visible keyboard focus, wrapped timestamp and reading contained within tooltip; no horizontal overflow or page errors"
"Empty successful result, loaded readings, failed refresh, pending recovery, recovered empty result, failed refresh of empty result, changed-target loading and initial failure",
"Chromium desktop light and WebKit phone dark; screenshots inspected, no horizontal overflow or page errors; synthetic transport, not native collection"
],
"interactions": [
"Tab into and out of both charts; arrows, Home/End, endpoint clamping, Escape; only focused chart announces keyboard readings",
"Parent Tab skips both charts; final empty chart invents no reading"
"Advance polling clock without altering production timers; fail and recover HTTP responses; switch pool target",
"Parent control reproduces misleading empty promise and silent failed refresh; final warning retains data and clears only on recovery or selection replacement"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs",
"command": "pulse-worker-browser frontend-modern/browser-tests/history-status.cjs",
"browser_versions": {
"playwright": "1.56.1",
"chromium": "141.0.7390.37",
"webkit": "26.0"
},
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/webkit-keyboard.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/chromium-keyboard.png"
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/parent.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/chromium-refresh-failed.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-refresh-failed.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-empty.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-d89v1n1d/browser/webkit-initial-failed.png"
]
}
@@ -28,6 +28,9 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
},
hoverGroup,
);
const refreshStatus = createMemo(() =>
chart.refreshFailed() ? 'Could not refresh history. Showing the last successful result.' : '',
);
const accessibleDescription = createMemo(() =>
getHistoryChartAccessibleDescription({
data: chart.data(),
@@ -51,6 +54,14 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
unit={props.unit}
/>
<p
role="status"
aria-label="History refresh status"
class="text-xs text-amber-700 dark:text-amber-300"
>
{refreshStatus()}
</p>
<div
class={`relative flex-1 w-full ${props.compact ? 'min-h-[120px]' : 'min-h-[200px]'}`}
ref={containerRef}
@@ -69,8 +80,8 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
onMouseLeave={chart.handleMouseLeave}
/>
<p id={descriptionId} class="sr-only">
{accessibleDescription()} Use Left and Right arrow keys to inspect readings, Home and End
for the first and last reading, and Escape to clear inspection.
{refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect
readings, Home and End for the first and last reading, and Escape to clear inspection.
</p>
<p class="sr-only" aria-live="polite" aria-atomic="true">
{chart.keyboardInspecting() && chart.hoveredPoint()
@@ -35,7 +35,7 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
<path d="M21 21v-5h-5" />
</svg>
</div>
<p class="text-sm text-slate-500">Collecting data... History will appear here.</p>
<p class="text-sm text-slate-500">No history samples in this time range.</p>
</div>
</div>
</Show>
@@ -104,7 +104,8 @@ describe('HistoryChart', () => {
expect(historyChartHeaderSource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
expect(historyChartOverlaySource).toContain('No history samples in this time range.');
expect(historyChartOverlaySource).not.toContain('History will appear here.');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days requires a higher license plan.',
);
@@ -117,25 +117,56 @@ describe('History request ownership', () => {
},
);
it('does not overlap polling and preserves matching samples on refresh failure', async () => {
const initial = deferred();
request.mockReturnValueOnce(initial.promise);
const { state } = mount();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
initial.resolve({ points: points(10), source: 'store' } as never);
await settle();
const refresh = deferred();
request.mockReturnValueOnce(refresh.promise);
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(2);
vi.spyOn(console, 'error').mockImplementation(() => {});
refresh.reject(new Error('refresh failed'));
await settle();
expect(state.data()).toEqual(points(10));
expect(state.error()).toBeNull();
expect(state.loading()).toBe(false);
});
it.each([{ samples: points(10) }, { samples: [] }])(
'retains the last result %j and exposes refresh failure until recovery',
async ({ samples }) => {
const initial = deferred();
request.mockReturnValueOnce(initial.promise);
const { state } = mount();
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(1);
initial.resolve({ points: samples, source: 'store' } as never);
await settle();
const refresh = deferred();
request.mockReturnValueOnce(refresh.promise);
vi.advanceTimersByTime(120_000);
expect(request).toHaveBeenCalledTimes(2);
vi.spyOn(console, 'error').mockImplementation(() => {});
refresh.reject(new Error('refresh failed'));
await settle();
expect(state.data()).toEqual(samples);
expect(state.error()).toBeNull();
expect(state.refreshFailed()).toBe(true);
expect(state.source()).toBe('store');
expect(state.loading()).toBe(false);
const recovery = deferred();
request.mockReturnValueOnce(recovery.promise);
vi.advanceTimersByTime(10_000);
expect(state.refreshFailed()).toBe(true);
recovery.resolve({ points: points(30), source: 'memory' } as never);
await settle();
expect(state.refreshFailed()).toBe(false);
expect(state.data()).toEqual(points(30));
expect(state.source()).toBe('memory');
},
);
it.each([{ resourceId: 'b' }, { range: '6h' }, { data: [] }, { range: '90d' }])(
'clears refresh failure on selection replacement %j',
async (next) => {
request.mockResolvedValueOnce({ points: points(10), source: 'store' } as never);
const { state, change } = mount();
await settle();
vi.spyOn(console, 'error').mockImplementation(() => {});
request.mockRejectedValueOnce(new Error('refresh failed'));
await vi.advanceTimersByTimeAsync(10_000);
expect(state.refreshFailed()).toBe(true);
request.mockReturnValueOnce(deferred().promise);
change(next as Partial<HistoryChartProps>);
expect(state.refreshFailed()).toBe(false);
expect(state.data()).toEqual([]);
},
);
it('cancels fetched data when supplied data takes ownership, including empty samples', async () => {
const old = deferred();
@@ -36,6 +36,7 @@ export function useHistoryChartState(
const [data, setData] = createSignal(props.data ?? []);
const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
const [loading, setLoading] = createSignal(false);
const [refreshFailed, setRefreshFailed] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
null,
@@ -119,6 +120,7 @@ export function useHistoryChartState(
setData(suppliedData ?? []);
setSource(suppliedData !== undefined ? 'live' : null);
setError(null);
setRefreshFailed(false);
setLoading(false);
const selection = JSON.stringify([
resourceType,
@@ -154,12 +156,13 @@ export function useHistoryChartState(
if (!active) return;
setData('points' in result ? (result.points ?? []) : []);
setSource(result.source ?? 'store');
setRefreshFailed(false);
hasLoaded = true;
} catch (err) {
if (!active) return;
console.error('Failed to fetch metrics history:', err);
if (!hasLoaded) setError('Failed to load history data');
setSource(null);
if (hasLoaded) setRefreshFailed(true);
else setError('Failed to load history data');
} finally {
if (active) {
pending = false;
@@ -416,6 +419,7 @@ export function useHistoryChartState(
dataMax,
dataMin,
error,
refreshFailed,
handleFocus,
handleBlur,
handleKeyDown,