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) => (
+
+ )}
+
+
+
+
+
+
+
+ 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;