diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 04a4e3fdf1..a646bca4a2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -320,6 +320,20 @@ renderer, cache readback and cancellation, not a mocked chart. The mock-backed PBS browser runner verifies delayed range and withdrawn-target reads at desktop and phone widths; neither proof establishes installed collection or #1723 relief. +Failed same-source history refreshes keep valid previously loaded observations +visible with an explicit warning, rather than hiding the entire chart. Initial +or uncached replacement failures show unavailable history, never borrowed points +or a collecting claim. The existing target/range owns every manual refresh; +locked or absent targets expose no refresh control. The control remains mounted +and focusable through retry and recovery, rejects activation while busy, and +updates a pre-mounted polite status region without exposing transport diagnostics. +The latest query read settles loading even when background polling supersedes +a foreground refresh; late superseded results remain inert. Verification: +`GuestDrawerHistory.refresh.test.tsx`, `createNonSuspendingQuery.test.tsx`, and +`browser-tests/pbs-history-refresh.cjs` (direct production History renderer, scripted failures, +keyboard retry, overlap, target withdrawal, phone/desktop and light/dark themes). +These proofs establish presentation/recovery, not installed collection or delivery. + Shared workload, node, Docker-host, and resource-drawer history presentation keeps current readings separate from stored samples. A current metric may populate the legend while history is still being collected, but it must never diff --git a/frontend-modern/browser-tests/pbs-history-refresh.cjs b/frontend-modern/browser-tests/pbs-history-refresh.cjs new file mode 100644 index 0000000000..2eb3133e97 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-history-refresh.cjs @@ -0,0 +1,268 @@ +// Direct production History renderer and query proof, not a complete PBS drawer or installed acceptance. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules/history-refresh-renderer-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-cache-')), + server: { host: '127.0.0.1', port: 5219, strictPort: true }, + }); + let browser; + const observations = []; + try { + await server.listen(); + browser = await chromium.launch({ + headless: true, + channel: 'chromium', + args: ['--no-sandbox'], + }); + for (const width of process.argv.includes('--phone') ? [390] : [1365]) { + for (const theme of ['light', 'dark']) { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + }); + page.setDefaultTimeout(40_000); + console.log(JSON.stringify({ width, theme, stage: 'opened context' })); + const requests = [], + errors = [], + failures = []; + let nextAction = 'success', + held, + historyValue = 42; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('requestfailed', (request) => { + if (request.url().includes('/api/metrics-store/history')) + failures.push({ url: request.url(), error: request.failure() }); + }); + const response = (id, range, value = historyValue) => ({ + resourceType: 'agent', + resourceId: id, + range, + start: 1_700_000_000_000, + end: 1_700_000_060_000, + source: 'store', + metrics: Object.fromEntries( + (id === 'pbs-three' + ? ['cpu', 'memory'] + : ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite'] + ).map((metric, i) => [ + metric, + [0, 1].map((step) => ({ + timestamp: 1_700_000_000_000 + step * 60_000, + value: value + i + step, + min: value, + max: value + i + step, + })), + ]), + ), + }); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5219') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const id = url.searchParams.get('resourceId'), + range = url.searchParams.get('range'); + assert.ok(['agent-three', 'pbs-three'].includes(id), `unexpected target ${id}`); + const action = nextAction; + nextAction = 'success'; + requests.push({ id, range, action, method: request.method() }); + if (action === 'hold') { + held = route; + return; + } + if (action === 'failure') + return route.fulfill({ status: 503, json: { error: 'Fixture unavailable' } }); + return route.fulfill({ json: response(id, range) }); + } + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community', label: 'Pulse Community runtime' }, + blocked_capabilities: [], + }, + }); + return route.fulfill({ json: { data: [], enabled: false } }); + }); + await page.goto('http://127.0.0.1:5219/browser-tests/pbs-history-refresh.html', { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + console.log(JSON.stringify({ width, theme, stage: 'page loaded' })); + const detail = page.getByTestId('history-refresh-fixture'); + const plots = detail.locator('[data-testid="guest-history-plot"] path'); + console.log(JSON.stringify({ width, theme, stage: 'renderer available' })); + const waitForPaths = async (count) => + page.waitForFunction( + (expected) => + document.querySelectorAll('[data-testid="guest-history-plot"] path').length === + expected, + count, + ); + await waitForPaths(7); + console.log(JSON.stringify({ width, theme, stage: 'history loaded' })); + const status = detail.getByRole('status', { name: 'History refresh status' }); + assert.equal(await status.count(), 1); + assert.equal(await status.innerText(), ''); + const paths = () => + plots.evaluateAll((items) => items.map((item) => item.getAttribute('d'))); + const original = await paths(); + const checkLayout = async () => { + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + inner: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + const button = detail.getByRole('button', { name: /^(Retry|Refresh) history$/ }); + const bounds = await button.boundingBox(); + assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= width + 1); + if (width === 390) assert.ok(bounds.height >= 44, JSON.stringify(bounds)); + return dimensions; + }; + const screenshot = (state) => + detail.screenshot({ path: path.join(artifacts, `${state}-${theme}-${width}.png`) }); + const waitForHeld = async () => { + for (let i = 0; !held && i < 100; i++) await page.waitForTimeout(10); + assert.ok(held, 'manual refresh was not intercepted'); + }; + + // A failed background read must keep this source's observations, labelled honestly. + nextAction = 'failure'; + await status + .getByText('History refresh failed. Showing previously loaded history.', { exact: true }) + .waitFor(); + assert.deepEqual(await paths(), original); + assert.equal(await status.getAttribute('aria-live'), 'polite'); + assert.equal(await status.getAttribute('aria-atomic'), 'true'); + await checkLayout(); + await screenshot('refresh-failed'); + console.log(JSON.stringify({ width, theme, stage: 'failure retained' })); + + // Enter starts one retry; focus and the old points survive until its successful response. + const retry = detail.getByRole('button', { name: 'Retry history', exact: true }); + nextAction = 'hold'; + held = undefined; + await retry.focus(); + await page.keyboard.press('Enter'); + await waitForHeld(); + assert.equal(await retry.getAttribute('aria-busy'), 'true'); + assert.equal(await retry.getAttribute('aria-disabled'), 'true'); + await page.keyboard.press('Enter'); + assert.equal(requests.filter((r) => r.action === 'hold').length, 1); + assert.deepEqual(await paths(), original); + historyValue = 12; + await held.fulfill({ json: response('agent-three', '24h') }); + await status.getByText(/History refresh failed/).waitFor({ state: 'hidden' }); + const refresh = detail.getByRole('button', { name: 'Refresh history', exact: true }); + assert.equal(await refresh.evaluate((button) => document.activeElement === button), true); + assert.notDeepEqual(await paths(), original); + assert.equal(await refresh.getAttribute('aria-busy'), 'false'); + await screenshot('retry-recovered'); + console.log(JSON.stringify({ width, theme, stage: 'retry recovered' })); + + // A slow manual refresh replaced by polling must release its loading ownership. + nextAction = 'hold'; + held = undefined; + await refresh.click(); + await waitForHeld(); + const obsoleteRoute = held; + historyValue = 25; + await page.waitForFunction( + () => document.querySelector('button[aria-busy="false"]') !== null, + ); + for (let i = 0; (await refresh.getAttribute('aria-busy')) !== 'false' && i < 100; i++) + await page.waitForTimeout(10); + assert.equal(await refresh.getAttribute('aria-busy'), 'false'); + assert.equal(await refresh.getAttribute('aria-disabled'), 'false'); + const latest = await paths(); + let lateResponse; + try { + await obsoleteRoute.fulfill({ json: response('agent-three', '24h', 99) }); + lateResponse = 'fulfilled'; + } catch (error) { + lateResponse = String(error); + } + await page.evaluate(() => new Promise(requestAnimationFrame)); + assert.deepEqual(await paths(), latest); + + // A different target has no right to the old host's history, including after a failure. + nextAction = 'failure'; + await page.getByRole('button', { name: 'Switch to service target', exact: true }).click(); + await status.getByText('Failed to load history data', { exact: true }).waitFor(); + assert.equal(await plots.count(), 0); + assert.equal(await detail.getByText('Collecting history', { exact: true }).count(), 0); + assert.equal(await detail.getByText(/previously loaded/).count(), 0); + await checkLayout(); + await screenshot('replacement-failed'); + await detail.getByRole('button', { name: 'Retry history', exact: true }).click(); + await waitForPaths(2); + assert.equal(requests.at(-1).id, 'pbs-three'); + assert.equal(requests.at(-1).range, '24h'); + assert.equal(await detail.locator('[data-history-group="network"] path').count(), 0); + assert.equal(await detail.locator('[data-history-group="disk-io"] path').count(), 0); + const dimensions = await checkLayout(); + const beforeLocked = requests.length; + await detail.getByTestId('guest-history-range-control').selectOption('14d'); + await detail.getByText(/14 days history requires a higher license plan/).waitFor(); + assert.equal( + await detail.getByRole('button', { name: /^(Retry|Refresh) history$/ }).count(), + 0, + ); + assert.equal(requests.length, beforeLocked); + assert.ok(requests.every((r) => r.method === 'GET')); + assert.deepEqual(errors, []); + observations.push({ width, theme, requests, failures, dimensions, lateResponse, errors }); + await page.close(); + } + } + const files = [ + 'src/components/Workloads/GuestDrawerHistory.tsx', + 'src/hooks/createNonSuspendingQuery.ts', + ]; + const result = { + result: 'passed', + browser: browser.version(), + playwright: require('playwright/package.json').version, + content_sha256: Object.fromEntries( + files.map((file) => [ + `frontend-modern/${file}`, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]), + ), + observations, + }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + for (const name of fs.readdirSync(artifacts)) { + const file = path.join(artifacts, name); + if (fs.statSync(file).isFile()) fs.chmodSync(file, 0o644); + } + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/pbs-history-refresh.html b/frontend-modern/browser-tests/pbs-history-refresh.html new file mode 100644 index 0000000000..f23cbfcdb3 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-history-refresh.html @@ -0,0 +1,12 @@ + + + + + + History refresh recovery verification + + +
+ + + diff --git a/frontend-modern/browser-tests/pbs-history-refresh.tsx b/frontend-modern/browser-tests/pbs-history-refresh.tsx new file mode 100644 index 0000000000..77f2886739 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-history-refresh.tsx @@ -0,0 +1,35 @@ +// Direct production renderer proof; this is not the complete PBS table/drawer. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import type { HistoryTimeRange } from '../src/api/charts'; +import { + GuestDrawerHistory, + GuestDrawerHistoryRangeSelect, +} from '../src/components/Workloads/GuestDrawerHistory'; +import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel'; +import '../src/index.css'; + +const Fixture = () => { + const [resourceId, setResourceId] = createSignal('agent-three'); + const [range, setRange] = createSignal('24h'); + return ( +
+

History refresh recovery verification

+ +
+
+ +
+ +
+
+ ); +}; +render(() => , document.getElementById('root') as HTMLElement); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 719decc0b4..4b2a3c0e1d 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -473,6 +473,70 @@ "Loaded /settings/system-relay and /alerts/notifications on the licensed stack at 1024px and 390px, checked the toggle and settings link stay inside the viewport with no horizontal overflow", "Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links" ] + }, + { + "version": 1, + "base_sha": "781cbfa98e04d7f83f0acb0bd45996a7a0de3bf3", + "verified_at": "2026-09-30T22:01:47Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "102bfe19203a3a333397c3a33f408ff40e43331c0b39bc399e548fcb28814903", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "64401ad5317013604145f9a1d3ccff74342e3810ea8b2288d86f940a9ba0de79" + }, + "routes": [ + "/browser-tests/pbs-history-refresh.html (direct production GuestDrawerHistory and shared query; synthetic host/service API responses, not the complete PBS table/drawer)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "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": [ + "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-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-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": "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." } ] } diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 369646de81..42a7d1435d 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -10,6 +10,9 @@ import { } from '@/api/charts'; import { FormSelect } from '@/components/shared/FormSelect'; import { filterSelectClass } from '@/components/shared/FilterToolbar'; +import { Button } from '@/components/shared/Button'; +import { InlineNotice } from '@/components/shared/InlineNotice'; +import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { HISTORY_CHART_RANGES, formatHistoryChartTooltipValue, @@ -447,7 +450,14 @@ export const GuestDrawerHistory: Component = (props) => const metrics = createMemo(() => historyQuery.value().metrics ?? {}); const groups = createMemo(() => props.groups ?? GUEST_DRAWER_HISTORY_GROUPS); - const errorText = createMemo(() => (historyQuery.error() ? 'Failed to load history data' : '')); + const hasHistoryPoints = createMemo(() => + groups().some((group) => + group.series.some( + (series) => + normalizeGuestDrawerHistoryPoints(metrics()[series.metric], series.unit).length > 0, + ), + ), + ); return ( = (props) => } > - - {errorText()} - - } - > +
+
+ + + {hasHistoryPoints() + ? 'History refresh failed. Showing previously loaded history.' + : 'Failed to load history data'} + + +
+ +
+
3 ? 'xl:grid-cols-4' : 'xl:grid-cols-3'}`}> {(group) => ( diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx new file mode 100644 index 0000000000..f6097b4c7a --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx @@ -0,0 +1,222 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from '@/api/charts'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import { GuestDrawerHistory } from '../GuestDrawerHistory'; +import type { GuestDrawerHistoryTarget } from '../guestDrawerModel'; + +vi.mock('@/stores/license', () => ({ + loadRuntimeCapabilities: vi.fn(async () => undefined), + maxHistoryDays: () => 7, + isRangeLocked: (range: HistoryTimeRange) => ['14d', '30d', '90d'].includes(range), +})); + +afterEach(() => { + cleanup(); + resetCreateNonSuspendingQueryCacheForTest(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'pbs-host' }; +const response = (value = 42, resourceId = target.resourceId): AllMetricsHistoryResponse => ({ + resourceType: 'agent', + resourceId, + range: '24h', + start: 1_700_000_000_000, + end: 1_700_000_060_000, + source: 'store', + metrics: { + cpu: [ + { timestamp: 1_700_000_000_000, value: value - 1, min: value - 1, max: value - 1 }, + { timestamp: 1_700_000_060_000, value, min: value, max: value }, + ], + }, +}); +const paths = (container: HTMLElement) => + container.querySelectorAll('[data-testid="guest-history-plot"] path'); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +describe('GuestDrawerHistory refresh recovery', () => { + it('offers one scoped retry after initial failure without claiming collection or exposing errors', async () => { + const pending = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockRejectedValueOnce(new Error('private diagnostic detail')) + .mockReturnValueOnce(pending.promise); + const { container } = render(() => ); + await screen.findByText('Failed to load history data'); + const retry = screen.getByRole('button', { name: 'Retry history' }); + expect(screen.queryByText('Collecting history')).not.toBeInTheDocument(); + expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument(); + expect(container).not.toHaveTextContent('private diagnostic detail'); + expect(paths(container)).toHaveLength(0); + fireEvent.click(retry); + fireEvent.click(retry); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(retry).toHaveAttribute('aria-disabled', 'true'); + expect(retry).toHaveAttribute('aria-busy', 'true'); + expect(fetch.mock.calls[1][0]).toMatchObject({ ...target, range: '24h' }); + pending.resolve(response()); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + expect(retry).toHaveAttribute('aria-disabled', 'false'); + expect(retry).toHaveAccessibleName('Refresh history'); + }); + + it('retains only the current history on a failed poll and updates its already-mounted status region', async () => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('Unavailable')); + const { container } = render(() => ); + const status = screen.getByRole('status', { name: 'History refresh status' }); + expect(status).toBeEmptyDOMElement(); + await vi.advanceTimersByTimeAsync(0); + const original = paths(container)[0].getAttribute('d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(paths(container)).toHaveLength(1); + expect(paths(container)[0]).toHaveAttribute('d', original); + expect(status).toHaveTextContent('History refresh failed. Showing previously loaded history.'); + expect(status).toHaveAttribute('aria-live', 'polite'); + expect(status).toHaveAttribute('aria-atomic', 'true'); + expect(screen.queryByText('Loading history')).not.toBeInTheDocument(); + }); + + it('keeps retry keyboard focus through successful recovery without losing retained points', async () => { + vi.useFakeTimers(); + const pending = deferred(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('Unavailable')) + .mockReturnValueOnce(pending.promise); + const { container } = render(() => ); + await vi.advanceTimersByTimeAsync(30_000); + const original = paths(container)[0].getAttribute('d'); + const retry = screen.getByRole('button', { name: 'Retry history' }); + retry.focus(); + fireEvent.click(retry); + expect(paths(container)[0]).toHaveAttribute('d', original); + expect(retry).toHaveFocus(); + pending.resolve(response(12)); + await vi.advanceTimersByTimeAsync(0); + expect(paths(container)[0]).not.toHaveAttribute('d', original); + expect(screen.getByRole('button', { name: 'Refresh history' })).toBe(retry); + expect(retry).toHaveFocus(); + expect(screen.getByRole('status', { name: 'History refresh status' })).toBeEmptyDOMElement(); + }); + + it('settles retry loading when a background poll supersedes a slow manual refresh', async () => { + vi.useFakeTimers(); + const pending = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(response(12)); + const { container } = render(() => ); + await vi.advanceTimersByTimeAsync(0); + const refresh = screen.getByRole('button', { name: 'Refresh history' }); + fireEvent.click(refresh); + expect(refresh).toHaveAttribute('aria-disabled', 'true'); + await vi.advanceTimersByTimeAsync(30_000); + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch.mock.calls[1][0].signal!.aborted).toBe(true); + expect(refresh).toHaveAttribute('aria-disabled', 'false'); + expect(refresh).toHaveAttribute('aria-busy', 'false'); + const latest = paths(container)[0].getAttribute('d'); + pending.resolve(response(99)); + await vi.advanceTimersByTimeAsync(0); + expect(paths(container)[0]).toHaveAttribute('d', latest); + }); + + it('retains the failure after a failed retry and allows another attempt', async () => { + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockRejectedValueOnce(new Error('Unavailable')) + .mockRejectedValueOnce(new Error('Still unavailable')) + .mockResolvedValueOnce(response()); + const { container } = render(() => ); + await screen.findByText('Failed to load history data'); + const retry = screen.getByRole('button', { name: 'Retry history' }); + fireEvent.click(retry); + await waitFor(() => expect(retry).toHaveAttribute('aria-disabled', 'false')); + expect(screen.getByText('Failed to load history data')).toBeInTheDocument(); + expect(paths(container)).toHaveLength(0); + fireEvent.click(retry); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + }); + + it.each(['resource', 'range'] as const)( + 'does not label a failed replacement %s as retained history', + async (change) => { + vi.useFakeTimers(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response()) + .mockRejectedValueOnce(new Error('Poll failed')) + .mockRejectedValueOnce(new Error('Replacement failed')) + .mockResolvedValueOnce(response(12, 'replacement')); + const [currentTarget, setTarget] = createSignal(target); + const [range, setRange] = createSignal('24h'); + const { container } = render(() => ( + + )); + await vi.advanceTimersByTimeAsync(30_000); + expect(paths(container)).toHaveLength(1); + if (change === 'resource') setTarget({ ...target, resourceId: 'replacement' }); + else setRange('6h'); + await vi.advanceTimersByTimeAsync(0); + expect(paths(container)).toHaveLength(0); + expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument(); + expect(screen.getByText('Failed to load history data')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Retry history' })); + await vi.advanceTimersByTimeAsync(0); + expect(fetch.mock.calls[3][0]).toMatchObject({ ...currentTarget(), range: range() }); + expect(paths(container)).toHaveLength(1); + }, + ); + + it.each(['empty', 'invalid'] as const)( + 'does not present %s history or current readings as previously loaded observations', + async (observation) => { + vi.useFakeTimers(); + vi.spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce({ + ...response(), + metrics: + observation === 'empty' + ? {} + : { cpu: [{ timestamp: NaN, value: NaN, min: NaN, max: NaN }] }, + }) + .mockRejectedValueOnce(new Error('Unavailable')); + const { container } = render(() => ( + + )); + await vi.advanceTimersByTimeAsync(30_000); + expect(screen.getByText('Failed to load history data')).toBeInTheDocument(); + expect(screen.queryByText(/previously loaded/)).not.toBeInTheDocument(); + expect(screen.queryByText('Collecting history')).not.toBeInTheDocument(); + expect(paths(container)).toHaveLength(0); + }, + ); + + it('offers no refresh when the target is absent or the range is locked', async () => { + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory'); + const [currentTarget, setTarget] = createSignal(null); + render(() => ); + expect(screen.queryByRole('button', { name: /history/i })).not.toBeInTheDocument(); + setTarget(target); + await screen.findByText(/14 days history requires a higher license plan/); + expect(screen.queryByRole('button', { name: /history/i })).not.toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx index d8442b46de..488876a2c2 100644 --- a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx +++ b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { createSignal } from 'solid-js'; import { createNonSuspendingQuery, @@ -265,3 +265,49 @@ describe('createNonSuspendingQuery', () => { }); }); }); + +it.each(['success', 'failure'] as const)( + 'settles a foreground loading state when its latest background replacement ends in %s', + async (outcome) => { + vi.useFakeTimers(); + let finishManual!: (value: string) => void; + const manual = new Promise((resolve) => { + finishManual = resolve; + }); + const fetcher = vi + .fn() + .mockResolvedValueOnce('first') + .mockReturnValueOnce(manual) + .mockImplementationOnce(() => + outcome === 'success' + ? Promise.resolve('latest') + : Promise.reject(new Error('Unavailable')), + ); + const Probe = () => { + const query = createNonSuspendingQuery({ + source: () => 'pbs-host', + fetcher, + initialValue: '', + pollMs: 30_000, + }); + return ( + <> + + {`${query.value()}|loading:${query.loading()}`} + + ); + }; + render(() => ); + await vi.advanceTimersByTimeAsync(0); + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + expect(screen.getByTestId('query')).toHaveTextContent('first|loading:true'); + await vi.advanceTimersByTimeAsync(30_000); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(fetcher.mock.calls[1][1].aborted).toBe(true); + const expected = outcome === 'success' ? 'latest|loading:false' : 'first|loading:false'; + expect(screen.getByTestId('query')).toHaveTextContent(expected); + finishManual('obsolete'); + await vi.advanceTimersByTimeAsync(0); + expect(screen.getByTestId('query')).toHaveTextContent(expected); + }, +); diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts index f1835606bd..39c88f8a79 100644 --- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts +++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts @@ -243,9 +243,9 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery value: value(), }); } - if (!runOptions.background) { - setLoading(false); - } + // A background poll can replace a pending foreground refresh. The + // latest read owns settlement, regardless of how that read started. + setLoading(false); } } };