diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5e59ab13b..04a4e3fdf 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -307,6 +307,19 @@ group's rows mid-scroll. The controller samples several leading siblings and keeps the tallest, so uniform tables still measure their real row height while mixed group/content lists keep a content-scale estimate. +Shared workload, node, Docker-host, and resource-drawer history presentation +must scope retained observations to the exact resource type, resource ID and +range. An uncached target or range change clears the former points while its +read is pending, including when a PBS host link is withdrawn. A failed +replacement must never cache former-host points under the new target. Matching +cached reads and same-source background polls retain their chart without a +loading flash. Superseded, locked, unavailable and unmounted requests propagate +the query's abort signal to the Charts API; late results cannot replace current +observations. `GuestDrawerHistory.source-isolation.test.tsx` exercises the real +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. + 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-source-isolation.cjs b/frontend-modern/browser-tests/pbs-history-source-isolation.cjs new file mode 100644 index 000000000..27ba7086b --- /dev/null +++ b/frontend-modern/browser-tests/pbs-history-source-isolation.cjs @@ -0,0 +1,231 @@ +// Mock-backed proof of the production PBS table/drawer, not 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 phaseFile = path.join(root, 'node_modules/history-proof-phase.txt'); + const phase = fs.existsSync(phaseFile) ? fs.readFileSync(phaseFile, 'utf8').trim() : 'candidate'; + const artifacts = path.join(root, `node_modules/history-source-isolation-${phase}`); + 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'), + server: { host: '127.0.0.1', port: 5218, 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 [1365, 390]) { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + }); + page.setDefaultTimeout(20_000); + const requests = [], + failures = [], + errors = []; + const held = new Map(); + let holdOldRange = true, + holdService = true; + 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(), failure: request.failure() }); + }); + const response = (id, range) => ({ + 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: (id === 'pbs-three' ? 12 : range === '1h' ? 31 : 88) + i + step, + min: 10, + max: 99, + })), + ]), + ), + }); + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5218') 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'); + requests.push({ method: request.method(), id, range }); + assert.ok(['agent-three', 'pbs-three'].includes(id), `unexpected target ${id}`); + if (id === 'agent-three' && range === '6h' && holdOldRange) { + holdOldRange = false; + held.set('range', route); + return; + } + if (id === 'pbs-three' && range === '1h' && holdService) { + holdService = false; + held.set('service', route); + return; + } + 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:5218/browser-tests/pbs-identity-boundary.html', { + waitUntil: 'domcontentloaded', + timeout: 120_000, + }); + await page.getByRole('button', { name: 'Corroborate links', exact: true }).click(); + const toggle = page.getByRole('button', { + name: 'Expand details for backup-connection-three', + exact: true, + }); + if (width === 1365) { + await toggle.focus(); + await page.keyboard.press('Enter'); + } else await page.locator('td[title="backup-connection-three ยท tank"]').click(); + const detail = page.locator('[data-inline-platform-resource-detail-for="pbs-three"]'); + const plots = detail.locator('[data-testid="guest-history-plot"] path'); + await detail.getByRole('tab', { name: 'History', exact: true }).click(); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 7, + ); + const originalPaths = await plots.evaluateAll((paths) => + paths.map((p) => p.getAttribute('d')), + ); + const checkLayout = async () => { + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + inner: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions)); + assert.equal( + await detail + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + ); + return dimensions; + }; + const screenshot = async (state) => + page.screenshot({ path: path.join(artifacts, `${state}-${width}.png`), fullPage: true }); + await screenshot('host-loaded'); + await detail.getByTestId('guest-history-range-control').selectOption('6h'); + await page.waitForFunction( + () => document.querySelector('[data-testid="guest-history-range-control"]').value === '6h', + ); + // Wait for the route, not an arbitrary sleep or the very assertion under test. + for (let attempt = 0; !held.has('range') && attempt < 100; attempt++) + await page.waitForTimeout(10); + assert.ok(held.has('range'), 'range request was not intercepted'); + await screenshot('range-loading'); + assert.equal(await plots.count(), 0, 'old range plotted while 6h replacement is loading'); + assert.ok((await detail.getByText('Loading history', { exact: true }).count()) > 0); + await checkLayout(); + await detail.getByTestId('guest-history-range-control').selectOption('1h'); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 7, + ); + const currentPaths = await plots.evaluateAll((paths) => + paths.map((p) => p.getAttribute('d')), + ); + assert.notDeepEqual(currentPaths, originalPaths); + let lateResponse; + try { + await held.get('range').fulfill({ json: response('agent-three', '6h') }); + lateResponse = 'fulfilled'; + } catch (error) { + lateResponse = String(error); + } + // A marker evaluation after the obsolete response; the unit proof also + // deliberately resolves a mock which ignores AbortSignal entirely. + await page.evaluate(() => new Promise(requestAnimationFrame)); + assert.deepEqual( + await plots.evaluateAll((paths) => paths.map((p) => p.getAttribute('d'))), + currentPaths, + ); + await page.getByRole('button', { name: 'Withdraw third link', exact: true }).click(); + for (let attempt = 0; !held.has('service') && attempt < 100; attempt++) + await page.waitForTimeout(10); + assert.ok(held.has('service'), 'withdrawal request was not intercepted'); + await screenshot('target-loading'); + assert.equal(await plots.count(), 0, 'former host plotted while revoked target is loading'); + assert.ok((await detail.getByText('Loading history', { exact: true }).count()) > 0); + await held + .get('service') + .fulfill({ status: 503, json: { error: 'Fixture replacement unavailable' } }); + await detail.getByText('Failed to load history data', { exact: true }).waitFor(); + assert.equal(await plots.count(), 0); + await screenshot('target-failed'); + await detail.getByTestId('guest-history-range-control').selectOption('6h'); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-plot"] path').length === 2, + ); + 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); + assert.equal(await detail.getByText('Loading history', { exact: true }).count(), 0); + await screenshot('service-loaded'); + 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 plots.count(), 0); + assert.equal(requests.length, beforeLocked); + assert.ok(requests.every((r) => r.method === 'GET')); + assert.deepEqual(errors, []); + observations.push({ width, requests, failures, lateResponse, dimensions, errors }); + await page.close(); + } + const file = 'src/components/Workloads/GuestDrawerHistory.tsx'; + const result = { + result: 'passed', + phase, + browser: browser.version(), + playwright: require('playwright/package.json').version, + content_sha256: { + [`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)) fs.chmodSync(path.join(artifacts, name), 0o644); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5578b0c1b..46f3f9af2 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,16 @@ { "version": 1, - "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", - "verified_at": "2026-09-30T01:40:13Z", + "base_sha": "21a35494aecb335755de773dd099e1364e65b3e0", + "verified_at": "2026-09-30T20:12:09Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67" }, "routes": [ - "/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)" + "/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)" ], "viewports": [ { @@ -23,32 +23,89 @@ } ], "states": [ - "three unlinked PBS services alongside six label/IP-colliding Agents", - "explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication", - "unchanged links with host rows omitted and restored", - "withdrawn third link with real and colliding host rows still present", - "fresh exact PBS nodeName to Agent hostname match with mismatched display labels" + "corroborated host with seven observed metric paths", + "uncached range replacement pending with no old paths", + "new range loaded before the old range response", + "withdrawn host link with replacement target pending", + "replacement target returns 503 with no former-host history", + "service-only response retains CPU/memory, not host disk/network/I/O", + "locked 14-day range issues no history read" ], "interactions": [ - "desktop keyboard disclosure and phone row activation for all three PBS drawers", - "inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges", - "join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal", - "24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow" + "desktop keyboard Enter / phone row click to open PBS details", + "select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request", + "withdraw third host link while service GET is held, then return 503", + "select 6h after failure and inspect the service-only response", + "select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37", + "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1", "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png" + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png" ], - "notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure.", + "notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately.", "prior_verifications": [ + { + "version": 1, + "base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764", + "verified_at": "2026-09-30T01:40:13Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36" + }, + "routes": [ + "/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "three unlinked PBS services alongside six label/IP-colliding Agents", + "explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication", + "unchanged links with host rows omitted and restored", + "withdrawn third link with real and colliding host rows still present", + "fresh exact PBS nodeName to Agent hostname match with mismatched display labels" + ], + "interactions": [ + "desktop keyboard disclosure and phone row activation for all three PBS drawers", + "inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges", + "join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal", + "24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37", + "artifacts": [ + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png" + ], + "notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure." + }, { "version": 1, "base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6", diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 42de4f2f2..369646de8 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -427,17 +427,21 @@ export const GuestDrawerHistory: Component = (props) => range: props.range, }; }, - fetcher: async (key) => + fetcher: async (key, signal) => normalizeHistoryResponse( await ChartsAPI.getMetricsHistory({ resourceType: key.resourceType, resourceId: key.resourceId, range: key.range, maxPoints: GUEST_DRAWER_HISTORY_MAX_POINTS, + signal, }), ), initialValue: EMPTY_HISTORY_RESPONSE, cacheKey: (key) => `guest-drawer-history:${key.resourceType}:${key.resourceId}:${key.range}`, + // Former-host or former-range observations are not evidence for this + // target. Matching cached reads and same-source polling remain retained. + retainPreviousValueOnSourceChange: false, pollMs: GUEST_DRAWER_HISTORY_POLL_MS, }); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx new file mode 100644 index 000000000..e5c16ba3d --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.source-isolation.test.tsx @@ -0,0 +1,237 @@ +import { cleanup, 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 { + createNonSuspendingQuery, + 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 response = ( + resourceId: string, + range = '24h', + value = 88, + resourceType = 'agent', +): AllMetricsHistoryResponse => ({ + resourceType, + resourceId, + range, + 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 }, + ], + }, +}); + +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 }; +} + +const paths = (container: HTMLElement) => + container.querySelectorAll('[data-testid="guest-history-plot"] path'); + +describe('GuestDrawerHistory source isolation', () => { + it.each(['resource', 'resource type', 'range'] as const)( + 'clears old points while an uncached %s replacement is loading', + async (change) => { + const pending = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response('host-old')) + .mockReturnValueOnce(pending.promise); + const [target, setTarget] = createSignal({ + resourceType: 'agent', + resourceId: 'host-old', + }); + const [range, setRange] = createSignal('24h'); + const { container } = render(() => ); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + + if (change === 'resource') setTarget({ resourceType: 'agent', resourceId: 'pbs-service' }); + if (change === 'resource type') setTarget({ resourceType: 'vm', resourceId: 'host-old' }); + if (change === 'range') setRange('6h'); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + expect(paths(container)).toHaveLength(0); + expect(screen.getAllByText('Loading history').length).toBeGreaterThan(0); + pending.resolve(response(target().resourceId, range(), 12, target().resourceType)); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + expect(screen.queryByText('Loading history')).not.toBeInTheDocument(); + }, + ); + + it('does not cache former-host points under a failed replacement target', async () => { + const pending = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response('host-old')) + .mockReturnValueOnce(pending.promise) + .mockImplementation(() => new Promise(() => {})); + const [target, setTarget] = createSignal({ + resourceType: 'agent', + resourceId: 'host-old', + }); + const first = render(() => ); + await waitFor(() => expect(paths(first.container)).toHaveLength(1)); + setTarget({ resourceType: 'agent', resourceId: 'pbs-service' }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + pending.reject(new Error('Replacement unavailable')); + await screen.findByText('Failed to load history data'); + first.unmount(); + + // Read the same public query/cache API as a remount. A cached failure may + // be retained, but it must not contain former-host observations. + const CacheProbe = () => { + const query = createNonSuspendingQuery({ + source: () => 'pbs-service', + cacheKey: (id) => `guest-drawer-history:agent:${id}:24h`, + fetcher: () => new Promise(() => {}), + initialValue: { ...response(''), metrics: {} }, + }); + return {query.value().resourceId}; + }; + render(() => ); + expect(screen.getByTestId('cached-resource')).toBeEmptyDOMElement(); + }); + + it('aborts a superseded read and ignores late success and failure from earlier targets', async () => { + const old = deferred(); + const middle = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(middle.promise) + .mockResolvedValueOnce(response('current-host', '24h', 12)); + const [target, setTarget] = createSignal({ + resourceType: 'agent', + resourceId: 'old-host', + }); + const { container } = render(() => ); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const firstSignal = fetch.mock.calls[0][0].signal; + expect(firstSignal).toBeInstanceOf(AbortSignal); + setTarget({ resourceType: 'agent', resourceId: 'middle-host' }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(firstSignal!.aborted).toBe(true); + setTarget({ resourceType: 'agent', resourceId: 'current-host' }); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + const currentPath = paths(container)[0].getAttribute('d'); + old.resolve(response('old-host')); + middle.reject(new Error('Late failure')); + await old.promise; + await middle.promise.catch(() => undefined); + await Promise.resolve(); + + expect(paths(container)[0]).toHaveAttribute('d', currentPath); + expect(screen.queryByText('Failed to load history data')).not.toBeInTheDocument(); + expect(fetch.mock.calls[1][0].signal!.aborted).toBe(true); + }); + + it('keeps only matching cached history when revisiting a resource and range', async () => { + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response('host-old')) + .mockResolvedValueOnce(response('host-new', '24h', 12)) + .mockImplementation(() => new Promise(() => {})); + const [target, setTarget] = createSignal({ + resourceType: 'agent', + resourceId: 'host-old', + }); + const { container } = render(() => ); + await waitFor(() => expect(paths(container)).toHaveLength(1)); + const oldPath = paths(container)[0].getAttribute('d'); + setTarget({ resourceType: 'agent', resourceId: 'host-new' }); + await waitFor(() => expect(paths(container)[0]).not.toHaveAttribute('d', oldPath)); + setTarget({ resourceType: 'agent', resourceId: 'host-old' }); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(3)); + + expect(paths(container)[0]).toHaveAttribute('d', oldPath); + expect(screen.queryByText('Loading history')).not.toBeInTheDocument(); + }); + + it('retains same-source points during background polling without a loading flash', async () => { + vi.useFakeTimers(); + const pending = deferred(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockResolvedValueOnce(response('host-old')) + .mockReturnValueOnce(pending.promise); + const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'host-old' }; + const { container } = render(() => ); + await vi.advanceTimersByTimeAsync(0); + expect(paths(container)).toHaveLength(1); + const originalPath = paths(container)[0].getAttribute('d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(fetch).toHaveBeenCalledTimes(2); + expect(paths(container)[0]).toHaveAttribute('d', originalPath); + expect(screen.queryByText('Loading history')).not.toBeInTheDocument(); + pending.resolve(response('host-old', '24h', 12)); + await vi.advanceTimersByTimeAsync(0); + expect(paths(container)[0]).not.toHaveAttribute('d', originalPath); + }); + + it('aborts and rejects late points when the history target becomes unavailable', async () => { + const pending = deferred(); + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockReturnValue(pending.promise); + const [target, setTarget] = createSignal({ + resourceType: 'agent', + resourceId: 'host-old', + }); + const { container } = render(() => ); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + setTarget(null); + await screen.findByText('History unavailable'); + expect(fetch.mock.calls[0][0].signal!.aborted).toBe(true); + pending.resolve(response('host-old')); + await pending.promise; + await Promise.resolve(); + expect(paths(container)).toHaveLength(0); + expect(screen.getByText('History unavailable')).toBeInTheDocument(); + }); + + it('aborts a pending read when its range is locked or its drawer unmounts', async () => { + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockImplementation(() => new Promise(() => {})); + const [range, setRange] = createSignal('24h'); + const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'host-old' }; + const view = render(() => ); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + setRange('14d'); + await screen.findByText(/14 days history requires a higher license plan/); + expect(fetch.mock.calls[0][0].signal).toBeInstanceOf(AbortSignal); + expect(fetch.mock.calls[0][0].signal!.aborted).toBe(true); + expect(fetch).toHaveBeenCalledTimes(1); + setRange('24h'); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + const active = fetch.mock.calls[1][0].signal; + expect(active!.aborted).toBe(false); + view.unmount(); + expect(active!.aborted).toBe(true); + }); +});