From 2dda2e66531eed4f812aaf6b514c90223cadadb3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:18:12 +0100 Subject: [PATCH] Scope fetched History to the active organisation Replace HistoryChart request and polling ownership on org changes, even when resource IDs match. Withdraw old readings and inspection without changing same-org outage retention or caller-supplied sample ownership. Preserve touch compatibility-focus suppression. Add original-source controls, production browser verification and the primitive contract. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 21 + frontend-modern/browser-tests/history-org.cjs | 388 ++++++++++++++++++ .../browser-tests/history-org.html | 12 + frontend-modern/browser-tests/history-org.tsx | 76 ++++ frontend-modern/browser-verification.json | 51 +-- .../shared/__tests__/HistoryChart.test.tsx | 78 ++++ .../__tests__/useHistoryChartState.test.tsx | 117 ++++++ .../components/shared/useHistoryChartState.ts | 12 + 8 files changed, 725 insertions(+), 30 deletions(-) create mode 100644 frontend-modern/browser-tests/history-org.cjs create mode 100644 frontend-modern/browser-tests/history-org.html create mode 100644 frontend-modern/browser-tests/history-org.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f99fcd1271..7f1fbe6186 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,27 @@ ## Purpose +### Organisation-scoped single-metric History + +Fetched HistoryChart observations belong to the active organisation as well as +the resource type/ID, metric and range. `org_switched` replaces the request owner +even when those chart props are unchanged: withdraw old samples, provenance, +errors and touch/keyboard/group inspection, abort superseded reads and replace +the polling timer before immediately requesting the same selection in the new +API context. Late success or failure from an earlier organisation cannot update +or settle that request. Disposal removes both timer and org subscription. + +Same-organisation transport/5xx failures still retain their own successful +observations with explicit refresh warnings. A failed first read in a new org +must not fall back to the previous org. Locked or missing targets do not fetch; +explicit supplied samples remain caller-owned, not silently converted to fetched +history. Matching same-org supplied-sample refreshes retain inspection. +Runtime ownership and keyboard/touch rendering regressions plus the production +`browser-tests/history-org.cjs` HTTP fixture verify these boundaries with the +real API org header and the existing org-safe batch reader as a control. This is +client presentation/isolation proof, not native tenancy, collector or release +acceptance. + ### Stable physical-disk History composition Disk detail composes shared HistoryChart owners from stable disk-family catalog diff --git a/frontend-modern/browser-tests/history-org.cjs b/frontend-modern/browser-tests/history-org.cjs new file mode 100644 index 0000000000..7955ed65e7 --- /dev/null +++ b/frontend-modern/browser-tests/history-org.cjs @@ -0,0 +1,388 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = parent + ? '/workspace/tmp/history-org-parent/frontend-modern' + : '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = `/workspace/tmp/history-org-${parent ? 'parent' : 'final'}-proof`; + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5264, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const results = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + page.setDefaultTimeout(30_000); + await page.clock.install({ time: new Date('2026-10-03T12:00:00Z') }); + const modes = { + 'org-a': { mode: 'success', value: 42, source: 'memory' }, + 'org-b': { mode: 'hold', value: 80, source: 'store' }, + 'org-c': { mode: '503' }, + 'org-d': { mode: 'success', value: 61, source: 'store' }, + }; + const requests = [], + held = [], + checks = [], + states = [], + errors = [], + offOrigin = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5264') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + const org = (await route.request().allHeaders())['x-pulse-org-id']; + let response = { ...modes[org] }; + const record = { ...query, org, responseMode: response.mode }; + requests.push(record); + if (response.mode === 'hold') { + response = await new Promise((resolve) => held.push({ org, resolve })); + } + const points = [0, 1].map((index) => ({ + timestamp: 1791028740000 + index * 60_000, + value: response.value - 1 + index, + min: response.value - 1 + index, + max: response.value - 1 + index, + })); + try { + await route.fulfill({ + status: response.mode === '503' ? 503 : 200, + json: + response.mode === '503' + ? { error: 'synthetic outage' } + : { + resourceType: query.resourceType, + resourceId: query.resourceId, + range: query.range, + metric: query.metric, + source: response.source, + start: 1791028740000, + end: 1791028800000, + ...(query.metric ? { points } : { metrics: { cpu: points, memory: points } }), + }, + }); + record.fulfilled = true; + } catch { + // Explicitly aborted/superseded browser requests can no longer be fulfilled. + record.fulfilled = false; + } + return; + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { enabled: false, data: [] }, + }); + }); + const check = async (label, fn) => { + try { + await fn(); + checks.push({ label, passed: true }); + } catch (error) { + checks.push({ label, passed: false, error: error.message }); + } + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: path.join(artifacts, file), + fullPage: true, + animations: 'disabled', + }); + states.push({ + state, + screenshot: file, + body: await page.locator('main').innerText(), + overflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + }); + }; + const release = (org, response) => { + const matches = held.filter((entry) => entry.org === org); + for (const entry of matches) { + held.splice(held.indexOf(entry), 1); + entry.resolve(response); + } + }; + const bootRequests = [], + failedRequests = []; + page.on('request', (r) => { + if (bootRequests.length < 300) bootRequests.push(r.url()); + }); + page.on('requestfailed', (r) => failedRequests.push({ url: r.url(), failure: r.failure() })); + try { + await page.goto('http://127.0.0.1:5264/browser-tests/history-org.html', { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }); + } catch (error) { + fs.writeFileSync( + path.join(artifacts, 'boot-error.json'), + JSON.stringify( + { + parent, + playwright, + name, + error: error.message, + bootRequests, + failedRequests, + errors, + requests, + body: await page + .locator('body') + .innerText() + .catch(() => ''), + }, + null, + 2, + ), + ); + throw error; + } + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const disk = page.getByTestId('disk'), + pool = page.getByTestId('pool'), + service = page.getByTestId('service'); + const thermal = disk.getByRole('img', { name: 'Temperature chart' }); + const description = () => + thermal.evaluate( + (el) => document.getElementById(el.getAttribute('aria-describedby')).textContent, + ); + const waitThermal = async (value) => { + await page.waitForFunction((reading) => { + const canvas = document.querySelector('[data-testid="disk"] canvas'); + return document + .getElementById(canvas.getAttribute('aria-describedby')) + .textContent.includes(reading); + }, `${value}°C`); + }; + const inspect = async () => { + if (phone) await thermal.tap({ position: { x: 100, y: 80 } }); + else { + await thermal.focus(); + await thermal.press('End'); + } + }; + await waitThermal(42); + await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor(); + await inspect(); + await check( + 'Initial org-a observations and provenance are real rendered reader results', + async () => { + assert.match(await description(), /42°C/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + assert.match(await disk.innerText(), /Buffer/i); + assert(requests.some((r) => r.org === 'org-a' && r.resourceType === 'disk')); + }, + ); + await screenshot('org-a-loaded'); + modes['org-a'] = { mode: 'hold', value: 99, source: 'memory' }; + await page.clock.fastForward(30_000); + await page.waitForTimeout(100); + await check( + 'Ordinary same-org pending polling retains inspection and stored readings', + async () => { + assert(held.some((entry) => entry.org === 'org-a')); + assert.match(await description(), /42°C/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + }, + ); + const beforeSwitch = requests.length; + await page.getByRole('button', { name: 'Switch to org-b' }).click(); + await page.waitForTimeout(150); + await check('All exact targets are immediately reread with the new org header', async () => { + const next = requests.slice(beforeSwitch); + for (const id of ['disk:nas:sda', 'storage:nas:pool', 'pbs-service']) { + assert(next.some((r) => r.org === 'org-b' && r.resourceId === id)); + } + assert(next.every((r) => r.org === 'org-b')); + }); + await check( + 'Pending org-b removes org-a values, min/max, provenance and grouped inspection', + async () => { + assert.doesNotMatch(await description(), /42°C/); + assert.doesNotMatch(await disk.innerText(), /Buffer|42°C/i); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 0); + assert.equal((await disk.locator('[aria-live="polite"]').innerText()).trim(), ''); + }, + ); + await screenshot('org-b-pending'); + release('org-a', { mode: 'success', value: 99, source: 'memory' }); + await page.waitForTimeout(150); + await check( + 'Late pre-switch completion cannot resurrect readings or settle the new org', + async () => { + assert.doesNotMatch(await description(), /42°C|99°C/); + assert.doesNotMatch(await disk.innerText(), /Buffer|99°C/i); + assert.equal(await service.getByRole('slider').count(), 0); + }, + ); + await screenshot('late-org-a'); + modes['org-b'] = { mode: 'success', value: 80, source: 'store' }; + release('org-b', modes['org-b']); + await page.waitForTimeout(150); + await check( + 'Only successful org-b observations restore History without old inspection', + async () => { + assert.match(await description(), /80°C/); + assert.doesNotMatch(await description(), /99°C|42°C/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 1); + }, + ); + // Allow the unchanged parent's next poll to catch up before testing the + // independent transient-outage control, instead of relying on its stale state. + await page.clock.fastForward(30_000); + await waitThermal(80); + await inspect(); + await screenshot('org-b-loaded'); + modes['org-b'] = { mode: '503' }; + await page.clock.fastForward(30_000); + await page.waitForTimeout(300); + await check( + 'A same-org 503 still retains its own successful readings with a warning', + async () => { + assert.match(await description(), /80°C/); + assert.match( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + /last successful result/, + ); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + }, + ); + await screenshot('org-b-outage'); + await page.getByRole('button', { name: 'Switch to org-c' }).click(); + await page.clock.fastForward(30_000); + await page.waitForTimeout(300); + await check( + 'Failure in org-c cannot fall back to org-b values or its refresh warning', + async () => { + assert.doesNotMatch(await description(), /80°C|99°C|42°C/); + assert.match(await disk.getByRole('alert').innerText(), /Failed to load history data/); + assert.equal( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 0); + }, + ); + await screenshot('org-c-failed'); + await page.getByRole('button', { name: 'Switch to org-d' }).click(); + await page.clock.fastForward(30_000); + await waitThermal(61); + await check( + 'New successful org-d data recovers; all requests keep their target/range/metric scope', + async () => { + assert.equal(await disk.getByRole('alert').count(), 0); + assert.equal( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + assert(requests.every((r) => r.range === '1h')); + assert( + requests + .filter((r) => r.resourceId === 'disk:nas:sda') + .every((r) => r.metric === 'smart_temp' && r.resourceType === 'disk'), + ); + assert( + requests + .filter((r) => r.resourceId === 'storage:nas:pool') + .every((r) => r.metric === 'disk' && r.resourceType === 'storage'), + ); + assert( + requests + .filter((r) => r.resourceId === 'pbs-service') + .every((r) => !r.metric && r.maxPoints === '240'), + ); + }, + ); + await screenshot('org-d-recovered'); + await check( + 'Light desktop/dark touch phone states fit, with no page errors or off-origin data', + async () => { + assert(states.every((state) => !state.overflow)); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + }, + ); + results.push({ + name, + engineVersion: browser.version(), + checks, + states, + requests, + errors, + offOrigin, + }); + for (const entry of held) entry.resolve({ mode: '503' }); + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ parent, playwright, results }, null, 2), + ); + const failed = results.flatMap((result) => result.checks.filter((check) => !check.passed)); + console.log( + JSON.stringify({ + parent, + checks: results.flatMap((r) => r.checks).length, + failed: failed.length, + artifacts, + }), + ); + if (failed.length) process.exitCode = 1; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/history-org.html b/frontend-modern/browser-tests/history-org.html new file mode 100644 index 0000000000..f972f7451c --- /dev/null +++ b/frontend-modern/browser-tests/history-org.html @@ -0,0 +1,12 @@ + + + + + + History organisation boundary verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-org.tsx b/frontend-modern/browser-tests/history-org.tsx new file mode 100644 index 0000000000..135b338be7 --- /dev/null +++ b/frontend-modern/browser-tests/history-org.tsx @@ -0,0 +1,76 @@ +// Production readers, org event/client and styles; all observations are synthetic. +// A stable resource ID across organisations must not preserve fetched History. +import { For } from 'solid-js'; +import { render } from 'solid-js/web'; +import { GuestDrawerHistory } from '../src/components/Workloads/GuestDrawerHistory'; +import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart'; +import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel'; +import { eventBus } from '../src/stores/events'; +import { setOrgID } from '../src/utils/apiClient'; +import '../src/index.css'; + +setOrgID('org-a'); + +function Fixture() { + const switchOrg = (org: string) => { + // Same ordering as useAppRuntimeState.handleOrgSwitch, with no authentication bypass. + setOrgID(org); + eventBus.emit('org_switched', org); + }; + return ( +
+

History organisation boundary verification

+
+ + {(org) => ( + + )} + +
+ +
+

Disk History

+ +
+
+

Pool History

+ +
+
+
+

PBS service History (existing org-safe batch control)

+ +
+
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 353d9f0ce2..03eafe7e2a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,17 @@ { "version": 1, "result": "passed", - "base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287", - "verified_at": "2026-10-03T01:44:27.336168Z", + "base_sha": "e862d622dd236824b42565c3e0c90182140be245", + "verified_at": "2026-10-03T03:18:08.489528Z", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", - "frontend-modern/src/components/shared/useHistoryChartState.ts", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts", - "frontend-modern/src/utils/apiAccessError.ts" + "frontend-modern/src/components/shared/useHistoryChartState.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7", - "frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730" + "frontend-modern/src/components/shared/useHistoryChartState.ts": "4f71e1b3856773530b9789b71d4fc1638c2bbf3f8d9272c6218da1e8952bb442" }, "routes": [ - "Existing PBS/Machine/resource drawer History batch reader", - "Existing Storage physical disk shared History reader" + "Existing Storage physical-disk and pool History shared reader", + "Existing PBS/Machine drawer History batch reader as unchanged org-safe control" ], "viewports": [ { @@ -32,25 +24,24 @@ } ], "states": [ - "Real same-origin 503 responses retain stored samples with explicit warnings in both production readers", - "Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends", - "Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow", - "The existing focusable scoped retry remains mounted and ignores duplicate activation while busy", - "Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending", - "Only a new successful read restores History and clears denial; old values do not return", - "Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests" + "Initial org-a fetched observations, provenance and keyboard/touch/grouped inspection", + "Same-org pending polling retains its readings and inspection", + "Org-b switch with identical resource IDs removes old samples, min/max, provenance, tooltip and announced inspection before the new response", + "Late org-a completion cannot repopulate or settle the new selection; exact targets use X-Pulse-Org-ID org-b immediately", + "Only successful org-b results restore History without old inspection; ordinary same-org 503 still retains observations with a visible warning", + "A failed org-c first read cannot fall back to org-b observations; fresh successful org-d read recovers", + "20 checks pass; all 14 final screenshots inspected in light desktop Chromium and dark touch phone WebKit; no page errors, off-origin data requests or horizontal overflow" ], "interactions": [ - "Inspect by desktop keyboard and phone tap", - "Poll through 503 then 403", - "Retry twice while busy", - "Change 24h/1h range", - "Unmount and remount batch History", - "Resume with newly successful observations" + "Inspect by End key and phone tap across shared hover group", + "Keep background reads pending, switch org with the same resource IDs", + "Release pre-switch responses while new-org reads remain pending", + "Release fresh new-org observations", + "Poll through same-org 503, switch into failed first read, then recover in another org" ], "limitations": [ - "Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.", - "401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.", - "Independently supplied current readings remain distinctly current during a new pending query; they are not stored History." + "Production readers, styles, API client and org event with synthetic same-origin HTTP responses; not full-application, native tenancy/collector, installed or release acceptance.", + "Supplied-data ownership, locking, disposal, bounded polling and transport-ignore-cancellation controls are runtime-tested, not browser-proven.", + "Parent browser corrected request had an unavailable capability result; only the unchanged-parent unit controls establish the original defect." ] } diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index ab9ba9d221..ea56a052df 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { ChartsAPI } from '@/api/charts'; +import { eventBus } from '@/stores/events'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw'; import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw'; @@ -572,6 +573,83 @@ describe('HistoryChart', () => { }); }); +describe('History organisation boundary rendering', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it.each(['keyboard', 'touch'])( + 'clears %s inspection and accessible values on org switch until a new read succeeds', + async (input) => { + vi.useFakeTimers(); + const request = vi.mocked(ChartsAPI.getMetricsHistory); + request.mockReset(); + request.mockResolvedValueOnce({ + points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }], + source: 'memory', + } as never); + let complete!: (response: Awaited>) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { container } = render(() => ( + + )); + await vi.advanceTimersByTimeAsync(0); + const canvas = screen.getByRole('img', { name: 'Temperature chart' }); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + toJSON: () => ({}), + }); + if (input === 'keyboard') fireEvent.focus(canvas); + else { + touchPointer(canvas, 'pointerdown'); + touchPointer(canvas, 'pointerup'); + } + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42°C'); + eventBus.emit('org_switched', 'org-b'); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe(''); + expect(container).not.toHaveTextContent('42°C'); + expect(container).not.toHaveTextContent('Buffer'); + expect(canvas).toHaveAccessibleDescription(/Loading 1-hour history data/); + complete({ + points: [{ timestamp: 1000, value: 80, min: 80, max: 80 }], + source: 'store', + } as never); + await vi.advanceTimersByTimeAsync(0); + expect(canvas).toHaveAccessibleDescription(/80°C/); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + if (input === 'touch') { + // Touch compatibility focus from the old chart must not reactivate its pinned reading. + fireEvent.focus(canvas); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + touchPointer(canvas, 'pointerdown'); + touchPointer(canvas, 'pointerup'); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('80°C'); + } + }, + ); +}); + describe('History access boundary rendering', () => { afterEach(() => { cleanup(); diff --git a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx index 571dcd49ac..3364d3f6af 100644 --- a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { cleanup, render } from '@solidjs/testing-library'; import { ChartsAPI } from '@/api/charts'; +import { eventBus } from '@/stores/events'; import type { HistoryChartProps } from '../historyChartModel'; import { useHistoryChartState, type HistoryChartState } from '../useHistoryChartState'; @@ -218,6 +219,122 @@ describe('History request ownership', () => { }); }); +describe('History organisation ownership', () => { + beforeEach(() => { + vi.useFakeTimers(); + request.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('withdraws loaded readings, provenance and inspection before reading the same target in a new org', async () => { + request.mockResolvedValueOnce({ points: points(42), source: 'memory' } as never); + const { state } = mount(); + await settle(); + state.handleFocus(); + expect(state.data()).toEqual(points(42)); + expect(state.keyboardInspecting()).toBe(true); + const next = deferred(); + request.mockReturnValueOnce(next.promise); + eventBus.emit('org_switched', 'org-b'); + expect(state.data()).toEqual([]); + expect(state.source()).toBeNull(); + expect(state.keyboardInspecting()).toBe(false); + expect(state.hoveredPoint()).toBeNull(); + expect(state.loading()).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][0]).toMatchObject({ + resourceType: 'agent', + resourceId: 'a', + metric: 'cpu', + range: '1h', + }); + next.resolve({ points: points(80), source: 'store' } as never); + await settle(); + expect(state.data()).toEqual(points(80)); + expect(state.source()).toBe('store'); + expect(state.keyboardInspecting()).toBe(false); + }); + + it.each(['success', 'failure'])( + 'aborts a pre-switch request and ignores its late %s without settling the new request', + async (completion) => { + const old = deferred(), + current = deferred(); + request.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + const { state } = mount(); + const oldSignal = request.mock.calls[0][0].signal!; + eventBus.emit('org_switched', 'org-b'); + expect(oldSignal.aborted).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + if (completion === 'success') old.resolve({ points: points(42), source: 'memory' } as never); + else old.reject(Object.assign(new Error('old org denied'), { status: 403 })); + await settle(); + expect(state.loading()).toBe(true); + expect(state.data()).toEqual([]); + expect(state.error()).toBeNull(); + current.resolve({ points: points(80), source: 'store' } as never); + await settle(); + expect(state.loading()).toBe(false); + expect(state.data()).toEqual(points(80)); + expect(state.error()).toBeNull(); + }, + ); + + it("does not retain another org's successful result when the new read fails", async () => { + request.mockResolvedValueOnce({ points: points(42), source: 'store' } as never); + const { state } = mount(); + await settle(); + request.mockRejectedValueOnce(new Error('new org unavailable')); + eventBus.emit('org_switched', 'org-b'); + await settle(); + expect(state.data()).toEqual([]); + expect(state.source()).toBeNull(); + expect(state.error()).toBe('Failed to load history data'); + expect(state.refreshFailed()).toBe(false); + }); + + it('replaces polling ownership on repeated switches and removes the subscription on disposal', async () => { + request.mockResolvedValue({ points: points(80), source: 'store' } as never); + const { state, unmount } = mount(); + await settle(); + eventBus.emit('org_switched', 'org-b'); + await settle(); + eventBus.emit('org_switched', 'org-c'); + await settle(); + expect(request).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(10_000); + expect(request).toHaveBeenCalledTimes(4); + expect(state.data()).toEqual(points(80)); + unmount(); + eventBus.emit('org_switched', 'org-d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(request).toHaveBeenCalledTimes(4); + }); + + it('preserves caller-owned supplied samples and does not fetch locked or missing targets on a switch', async () => { + request.mockReturnValueOnce(deferred().promise); + const view = mount(); + view.change({ data: points(42) }); + const initialCalls = request.mock.calls.length; + eventBus.emit('org_switched', 'org-b'); + expect(view.state.data()).toEqual(points(42)); + expect(view.state.source()).toBe('live'); + view.change({ data: undefined, range: '90d' }); + eventBus.emit('org_switched', 'org-c'); + expect(view.state.isLocked()).toBe(true); + expect(view.state.data()).toEqual([]); + view.change({ range: '1h', resourceId: '' }); + eventBus.emit('org_switched', 'org-d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(request).toHaveBeenCalledTimes(initialCalls); + }); +}); + describe('History access errors', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index fb02e328d9..b5aac2231e 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -1,6 +1,7 @@ import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { ChartsAPI, type HistoryTimeRange } from '@/api/charts'; import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; +import { eventBus } from '@/stores/events'; import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license'; import { calculateOptimalPoints } from '@/utils/downsample'; import { setupCanvasDPR } from '@/utils/canvasRenderQueue'; @@ -46,6 +47,7 @@ export function useHistoryChartState( const [localHoveredTimestamp, setLocalHoveredTimestamp] = createSignal(null); const [hoveredPoint, setHoveredPoint] = createSignal(null); const [chartWidth, setChartWidth] = createSignal(300); + const [orgVersion, setOrgVersion] = createSignal(0); const chartHeight = createMemo(() => props.height || 200); let chartLeftInset = HISTORY_CHART_MIN_LEFT_INSET; let chartRightInset = 0; @@ -55,6 +57,13 @@ export function useHistoryChartState( const hoveredTimestamp = hoverGroup?.hoveredTimestamp ?? localHoveredTimestamp; const setHoveredTimestamp = hoverGroup?.setHoveredTimestamp ?? setLocalHoveredTimestamp; + // Resource IDs can be identical in different organisations. A context switch + // replaces the request owner even when none of the chart props have changed. + const unsubscribeOrgSwitch = eventBus.on('org_switched', () => { + setOrgVersion((version) => version + 1); + }); + onCleanup(unsubscribeOrgSwitch); + const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range())); onMount(() => { @@ -109,6 +118,7 @@ export function useHistoryChartState( const pointsCap = maxPoints(); const locked = isLocked(); const interval = refreshIntervalMs(); + const orgSelection = orgVersion(); let active = true; let pending = false; let hasLoaded = false; @@ -127,6 +137,7 @@ export function useHistoryChartState( setRefreshFailed(false); setLoading(false); const selection = JSON.stringify([ + orgSelection, resourceType, resourceId, metric, @@ -140,6 +151,7 @@ export function useHistoryChartState( touchTimestamp = null; setHoveredPoint(null); setHoveredTimestamp(null); + setKeyboardInspecting(false); } previousSelection = selection; if (suppliedData !== undefined || locked || !resourceId || !resourceType) return;