mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Compose retained organisation-safe History with current main
Preserve exact 2dda2e6653 and the newer recovery, help and drawer fallback work. Fetched disk History must not retain observations across organisation switches with identical resource IDs. Keep the existing receipt unchanged; both original non-merge receipts remain bound to their own parents. The frontend dependency audit remains a separate unresolved delivery gate.
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>History organisation boundary verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/history-org.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 (
|
||||
<main class="mx-auto min-h-screen max-w-5xl space-y-5 bg-surface p-4 text-base-content">
|
||||
<h1 class="text-lg font-semibold">History organisation boundary verification</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={['org-b', 'org-c', 'org-d']}>
|
||||
{(org) => (
|
||||
<button
|
||||
class="min-h-11 rounded border border-border px-3"
|
||||
onClick={() => switchOrg(org)}
|
||||
>
|
||||
Switch to {org}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<HistoryChartHoverGroup>
|
||||
<section data-testid="disk" class="rounded border border-border p-3">
|
||||
<h2 class="mb-3 font-semibold">Disk History</h2>
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId="disk:nas:sda"
|
||||
metric="smart_temp"
|
||||
label="Temperature"
|
||||
unit="C"
|
||||
range="1h"
|
||||
height={160}
|
||||
hideSelector
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
<section data-testid="pool" class="rounded border border-border p-3">
|
||||
<h2 class="mb-3 font-semibold">Pool History</h2>
|
||||
<HistoryChart
|
||||
resourceType="storage"
|
||||
resourceId="storage:nas:pool"
|
||||
metric="disk"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
range="1h"
|
||||
height={160}
|
||||
hideSelector
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
</HistoryChartHoverGroup>
|
||||
<section data-testid="service" class="space-y-3">
|
||||
<h2 class="font-semibold">PBS service History (existing org-safe batch control)</h2>
|
||||
<GuestDrawerHistory
|
||||
target={{ resourceType: 'agent', resourceId: 'pbs-service' }}
|
||||
range="1h"
|
||||
groups={HOST_METRICS_HISTORY_GROUPS}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
@@ -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<ReturnType<typeof ChartsAPI.getMetricsHistory>>) => void;
|
||||
request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const { container } = render(() => (
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId="disk:nas:sda"
|
||||
metric="smart_temp"
|
||||
label="Temperature"
|
||||
unit="C"
|
||||
range="1h"
|
||||
/>
|
||||
));
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(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;
|
||||
|
||||
Reference in New Issue
Block a user