mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 20:59:36 +00:00
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:
parent
21a35494ae
commit
4ba830bfd3
5 changed files with 568 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
231
frontend-modern/browser-tests/pbs-history-source-isolation.cjs
Normal file
231
frontend-modern/browser-tests/pbs-history-source-isolation.cjs
Normal 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;
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue