mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
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:
commit
005eb08cb9
12 changed files with 233 additions and 43 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.
|
||||
|
|
|
|||
93
frontend-modern/browser-tests/history-status.cjs
Normal file
93
frontend-modern/browser-tests/history-status.cjs
Normal 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});
|
||||
1
frontend-modern/browser-tests/history-status.html
Normal file
1
frontend-modern/browser-tests/history-status.html
Normal 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>
|
||||
30
frontend-modern/browser-tests/history-status.tsx
Normal file
30
frontend-modern/browser-tests/history-status.tsx
Normal 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')!);
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue