Show failed History refreshes without hiding the last successful readings (#2397)

## What changed
History now distinguishes a successful empty response from a failed
background refresh. Empty charts no longer promise that collection is
running. If a refresh fails, the chart keeps its last successful
readings and source visible, with an accessible warning until recovery
or a target change. Initial load failures remain distinct.

This addresses two misleading states found while improving the existing
Storage/PBS History surface; it does not claim to repair native
collection or resolve every symptom in #1723.

## Included source
- Range:
`1a41c8f0e0c84ac9c9465df2918c88e6b11b48f7..6e410d8430c3aa4db26904005d9e4b61b75935f7`.
- Runtime candidate `c6edeee869`;
assertion and browser-evidence correction
`df18123e7d`.
- Additive integration `6e410d8430`
preserves those commits and incorporates published main. Its complete
tree equals the validated candidate tree.

## Validation
- Recorded offline source proof: 2,814 tests across 181 files,
typecheck, lint, production/embedded build and budgets passed, with
complete output and guest shutdown.
- Recorded Chromium desktop and WebKit phone checks cover 16 states,
including predecessor reproduction, failed/pending refresh, recovery,
target change and initial failure; screenshots were inspected by the
source owner.
- Publication checks: provenance, complete-range browser receipt/content
coverage, canonical contract guard, tree equality and whitespace checks
passed.
- Earlier obsolete-copy assertion, browser selector and timestamp
failures remain recorded; corrected proofs followed them.

These are synthetic source/presentation checks, not native PBS
acceptance or release qualification. Exact-head PR checks and review
remain required before landing. Release placement is separate.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot] 2026-10-02 17:07:55 +00:00 • committed by GitHub
commit 005eb08cb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 233 additions and 43 deletions

View file

@ -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.

View file

@ -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});

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History request states verification</title></head><body><div id="root"></div><script type="module" src="./history-status.tsx"></script></body></html>

View file

@ -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')!);

View file

@ -1,20 +1,20 @@
{
"version": 1,
"base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a",
"verified_at": "2026-10-02T15:02:34.899181Z",
"base_sha": "c6edeee869a11f9206390e855fc63da744fb394a",
"verified_at": "2026-10-02T15:53:54.725571Z",
"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"
]
}

View file

@ -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()

View file

@ -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>

View file

@ -5283,7 +5283,8 @@ describe('shared primitive guardrails', () => {
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.',
);

View file

@ -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.',
);

View file

@ -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();

View file

@ -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,

View file

@ -2452,7 +2452,8 @@ describe('frontend resource type boundaries', () => {
expect(historyChartModelSource).toContain('HISTORY_CHART_RANGES');
expect(historyChartModelSource).toContain('getHistoryChartScale');
expect(historyChartHeaderSource).toContain('formatHistoryChartTooltipValue');
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).not.toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).not.toContain(
'Unlock {props.chart.lockTierLabel()} Features',