Keep drawer History scoped to its current target and range

Use the existing non-suspending query's source-isolation option so an uncached target or range cannot display or cache former-host observations. Forward AbortSignal to the Charts API without changing matching-cache or same-source polling behaviour.

The assigned-base VM regression has six failures, including former-host cache contamination; the final mock-backed PBS browser check covers delayed reads, withdrawal, failure, late response and locked ranges at desktop and phone widths. Update the shared frontend contract with real renderer/cancellation regressions. Installed #1723 acceptance and the separate exact-graph security repair remain unresolved.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-30 21:12:44 +01:00
parent 21a35494ae
commit 4ba830bfd3
5 changed files with 568 additions and 26 deletions

View file

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

View file

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

View file

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

View file

@ -427,17 +427,21 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (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,
});

View file

@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response('host-old'))
.mockReturnValueOnce(pending.promise);
const [target, setTarget] = createSignal<GuestDrawerHistoryTarget>({
resourceType: 'agent',
resourceId: 'host-old',
});
const [range, setRange] = createSignal<HistoryTimeRange>('24h');
const { container } = render(() => <GuestDrawerHistory target={target()} range={range()} />);
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<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response('host-old'))
.mockReturnValueOnce(pending.promise)
.mockImplementation(() => new Promise(() => {}));
const [target, setTarget] = createSignal<GuestDrawerHistoryTarget>({
resourceType: 'agent',
resourceId: 'host-old',
});
const first = render(() => <GuestDrawerHistory target={target()} range="24h" />);
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<AllMetricsHistoryResponse, string>({
source: () => 'pbs-service',
cacheKey: (id) => `guest-drawer-history:agent:${id}:24h`,
fetcher: () => new Promise(() => {}),
initialValue: { ...response(''), metrics: {} },
});
return <output data-testid="cached-resource">{query.value().resourceId}</output>;
};
render(() => <CacheProbe />);
expect(screen.getByTestId('cached-resource')).toBeEmptyDOMElement();
});
it('aborts a superseded read and ignores late success and failure from earlier targets', async () => {
const old = deferred<AllMetricsHistoryResponse>();
const middle = deferred<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(middle.promise)
.mockResolvedValueOnce(response('current-host', '24h', 12));
const [target, setTarget] = createSignal<GuestDrawerHistoryTarget>({
resourceType: 'agent',
resourceId: 'old-host',
});
const { container } = render(() => <GuestDrawerHistory target={target()} range="24h" />);
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<GuestDrawerHistoryTarget>({
resourceType: 'agent',
resourceId: 'host-old',
});
const { container } = render(() => <GuestDrawerHistory target={target()} range="24h" />);
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<AllMetricsHistoryResponse>();
const fetch = vi
.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response('host-old'))
.mockReturnValueOnce(pending.promise);
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'host-old' };
const { container } = render(() => <GuestDrawerHistory target={target} range="24h" />);
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<AllMetricsHistoryResponse>();
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockReturnValue(pending.promise);
const [target, setTarget] = createSignal<GuestDrawerHistoryTarget | null>({
resourceType: 'agent',
resourceId: 'host-old',
});
const { container } = render(() => <GuestDrawerHistory target={target()} range="24h" />);
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<HistoryTimeRange>('24h');
const target: GuestDrawerHistoryTarget = { resourceType: 'agent', resourceId: 'host-old' };
const view = render(() => <GuestDrawerHistory target={target} range={range()} />);
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);
});
});