mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Integrate reviewed History response admission
Accept exact Web candidate 866da85c26 with parent controls, 379 connected tests, types, build, budgets and inspected browser evidence. Preserve its parent-bound frontend receipt alongside the compatible Core allocation improvement.
Change-source: pulse-maintainer
This commit is contained in:
@@ -20,6 +20,33 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Metrics History responses are bound to the issued selection
|
||||
|
||||
The shared History client admits HTTP200 JSON only when the response resource
|
||||
type, resource ID, range and single/all-metric shape match the issued request.
|
||||
Validation uses a captured selection, not a subsequently mutated params object.
|
||||
It preserves only the server's explicit compatibility rules: Kubernetes types
|
||||
use the `k8s` wire token, legacy pod IDs receive the `k8s:` prefix and IDs are
|
||||
trimmed. An omitted range accepts the empty or 24h default echo; explicit ranges
|
||||
and metrics must match exactly. No host, node or disk alias is guessed.
|
||||
|
||||
Metrics containers and point arrays must have the declared shape, finite
|
||||
readings/aggregate bounds and representable timestamps. Zero and observed empty
|
||||
arrays/maps are valid; omitted source remains compatible, and declared store,
|
||||
memory, live and mock_synthetic provenance is preserved. Finite but unusable
|
||||
window bounds still reach the existing observation-based window fallback.
|
||||
Rejected bodies produce a fixed diagnostic without their IDs, values or text;
|
||||
body status claims are not HTTP status. Real transport/access failures remain
|
||||
unchanged. No endpoint, server permission, collector or wire schema changes.
|
||||
|
||||
`chartsHistoryResponse.test.ts` covers the response matrix, legitimate protocol
|
||||
variants and mutable input; existing `chartsApi.test.ts` uses actual-shaped
|
||||
responses to verify request construction. `HistoryResponse.contract.test.tsx`
|
||||
exercises the real client with production drawer, shared chart-state and row
|
||||
warming consumers. The connected browser fixture verifies actual History
|
||||
components/CSS on desktop Chromium and phone WebKit, not native identity,
|
||||
appliance history, workload safety or released availability.
|
||||
|
||||
### Discovery detail reads preserve final access status
|
||||
|
||||
`getDiscovery` retains final HTTP401/403 in a typed client error before any
|
||||
|
||||
@@ -23,6 +23,24 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
### History consumers do not accept a mismatched or malformed success body
|
||||
|
||||
The common metrics History client validates the issued selection and response
|
||||
shape before a drawer, shared chart or opportunistic row-history cache can accept
|
||||
it. An invalid first HTTP200 is unavailable, not an observed empty series or a
|
||||
current-value substitute. A rejected same-target refresh may retain only that
|
||||
target's previously validated result with the existing refresh-failure notice;
|
||||
rejected values never populate remount or row caches. A valid subsequent read,
|
||||
including measured zero or observed empty history, restores normal presentation.
|
||||
HTTP401/403 still withdraws cached evidence rather than becoming format failure.
|
||||
|
||||
The mounted `HistoryResponse.contract.test.tsx` uses the actual client admission
|
||||
boundary, not a mocked ChartsAPI result. `browser-tests/history-response.cjs`
|
||||
drives the production PBS-service and disk History fixture with wrong-target,
|
||||
wrong-type/range/metric, malformed, denied, valid-zero and empty bodies, keyboard
|
||||
retry/inspection and remounts. These synthetic controls do not diagnose #1723,
|
||||
establish native panels/identity or prove #2439 thaw or workload liveness.
|
||||
|
||||
### Guest discovery evidence and UI state have exact owners
|
||||
|
||||
The guest drawer's passive discovery lookup changes only when its explicit
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// Actual History consumers, API client and CSS; synthetic same-origin bodies.
|
||||
// This verifies response admission, not a native appliance or shipped repair.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'node_modules/history-response-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
process.chdir(root);
|
||||
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: 5269, 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,
|
||||
});
|
||||
await page.clock.install({ time: new Date('2026-10-04T07:30:00Z') });
|
||||
let mode = 'wrong-target';
|
||||
let value = 42;
|
||||
const errors = [],
|
||||
requests = [],
|
||||
checks = [],
|
||||
screenshots = [],
|
||||
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:5269') {
|
||||
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);
|
||||
requests.push({ ...query, mode });
|
||||
if (mode === 'denied')
|
||||
return route.fulfill({ status: 403, json: { error: 'fixture-private-body' } });
|
||||
const points = [0, 1].map((i) => ({
|
||||
timestamp: 1791098940000 + i * 60_000,
|
||||
value: value - 1 + i,
|
||||
min: value - 1 + i,
|
||||
max: value - 1 + i,
|
||||
}));
|
||||
const response = {
|
||||
resourceType: mode === 'wrong-type' ? 'node' : query.resourceType,
|
||||
resourceId: mode === 'wrong-target' ? 'fixture-private-predecessor' : query.resourceId,
|
||||
range: mode === 'wrong-range' ? '7d' : query.range,
|
||||
start: 1791098940000,
|
||||
end: 1791099000000,
|
||||
source: 'store',
|
||||
};
|
||||
return route.fulfill({
|
||||
json: {
|
||||
...response,
|
||||
...(query.metric
|
||||
? {
|
||||
metric: mode === 'wrong-metric' ? 'cpu' : query.metric,
|
||||
points: mode === 'malformed' ? null : mode === 'empty' ? [] : points,
|
||||
}
|
||||
: {
|
||||
metrics:
|
||||
mode === 'malformed'
|
||||
? []
|
||||
: mode === 'empty'
|
||||
? {}
|
||||
: { cpu: points, temperature: points },
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
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: [] },
|
||||
});
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5269/browser-tests/history-access.html');
|
||||
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const service = page.getByTestId('service');
|
||||
const disk = page.getByTestId('disk');
|
||||
const plots = () => service.locator('[data-testid="guest-history-plot"] path');
|
||||
const chartDescription = () =>
|
||||
disk
|
||||
.locator('canvas')
|
||||
.getAttribute('aria-describedby')
|
||||
.then((id) => disk.locator(`#${id}`).innerText());
|
||||
const check = async (label, action) => {
|
||||
await action();
|
||||
checks.push(label);
|
||||
};
|
||||
const capture = async (state) => {
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
width: innerWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({
|
||||
path: path.join(artifacts, file),
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
screenshots.push({
|
||||
state,
|
||||
file,
|
||||
dimensions,
|
||||
serviceText: await service.innerText(),
|
||||
diskDescription: await chartDescription(),
|
||||
});
|
||||
};
|
||||
await service.getByText('Failed to load history data', { exact: true }).waitFor();
|
||||
await disk.getByText('Failed to load history data', { exact: true }).waitFor();
|
||||
await check(
|
||||
'wrong-target HTTP200 is unavailable, not empty or previous-host samples',
|
||||
async () => {
|
||||
assert.equal(await plots().count(), 0);
|
||||
assert.ok(!(await service.innerText()).includes('No stored history'));
|
||||
assert.ok(!(await page.locator('body').innerText()).includes('fixture-private'));
|
||||
assert.ok(!(await chartDescription()).includes('42'));
|
||||
},
|
||||
);
|
||||
await capture('wrong-target');
|
||||
mode = 'correct';
|
||||
await service.getByRole('button', { name: 'Retry history' }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await service.getByText('42.0%', { exact: true }).waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
await check(
|
||||
'keyboard retry and valid single-metric polling restore bound evidence',
|
||||
async () => {
|
||||
assert.ok((await chartDescription()).includes('42°C'));
|
||||
assert.equal(await plots().count(), 2);
|
||||
},
|
||||
);
|
||||
mode = 'wrong-range';
|
||||
value = 97;
|
||||
await service.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await service
|
||||
.getByText('History refresh failed. Showing previously loaded history.', { exact: true })
|
||||
.waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
await check('wrong-range refresh keeps only labelled validated evidence', async () => {
|
||||
assert.ok((await service.innerText()).includes('42.0%'));
|
||||
assert.ok(!(await service.innerText()).includes('97.0%'));
|
||||
assert.ok((await disk.innerText()).includes('last successful result'));
|
||||
assert.ok(!(await chartDescription()).includes('97°C'));
|
||||
});
|
||||
await capture('retained-valid');
|
||||
await service.getByRole('button', { name: 'Hide service History' }).click();
|
||||
await service.getByRole('button', { name: 'Show service History' }).click();
|
||||
await service
|
||||
.getByText('History refresh failed. Showing previously loaded history.', { exact: true })
|
||||
.waitFor();
|
||||
await check('remount cache has no rejected range response', async () => {
|
||||
assert.ok((await service.innerText()).includes('42.0%'));
|
||||
assert.ok(!(await service.innerText()).includes('97.0%'));
|
||||
});
|
||||
for (const bad of ['wrong-type', 'malformed', 'wrong-metric']) {
|
||||
mode = bad;
|
||||
await service.getByRole('button', { name: 'Retry history' }).click();
|
||||
// wrong-metric affects the single-metric reader only.
|
||||
if (bad === 'wrong-metric') await service.getByText('97.0%', { exact: true }).waitFor();
|
||||
else
|
||||
await service
|
||||
.getByText('History refresh failed. Showing previously loaded history.', {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
await check(
|
||||
`${bad} cannot replace the single-metric chart's validated samples`,
|
||||
async () => {
|
||||
assert.ok(!(await chartDescription()).includes('97°C'));
|
||||
if (bad !== 'wrong-metric') assert.ok(!(await service.innerText()).includes('97.0%'));
|
||||
},
|
||||
);
|
||||
}
|
||||
mode = 'denied';
|
||||
await service.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await service
|
||||
.getByText('Access denied. Check your permissions and license plan.', { exact: true })
|
||||
.waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
await check('HTTP403 still withdraws validated history and inspection', async () => {
|
||||
assert.equal(await plots().count(), 0);
|
||||
assert.equal(await service.getByRole('slider').count(), 0);
|
||||
assert.ok(!(await service.innerText()).includes('97.0%'));
|
||||
assert.ok(!(await chartDescription()).includes('42°C'));
|
||||
});
|
||||
await capture('denied');
|
||||
mode = 'correct';
|
||||
value = 0;
|
||||
const retry = service.getByRole('button', { name: 'Retry history' });
|
||||
await retry.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await service.getByText('0.0%', { exact: true }).waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
const canvas = disk.getByRole('img', { name: 'Temperature chart' });
|
||||
await canvas.focus();
|
||||
await page.keyboard.press('End');
|
||||
await check('measured zero is accepted, including keyboard canvas inspection', async () => {
|
||||
assert.ok((await chartDescription()).includes('0°C'));
|
||||
assert.equal(await plots().count(), 2);
|
||||
});
|
||||
await capture('zero-recovered');
|
||||
mode = 'empty';
|
||||
await service.getByRole('button', { name: 'Refresh history' }).click();
|
||||
await service.getByText('No stored history in this range', { exact: true }).first().waitFor();
|
||||
await page.clock.runFor(30_000);
|
||||
await check('valid empty success is distinct from the initial invalid body', async () => {
|
||||
assert.equal(await plots().count(), 0);
|
||||
assert.ok(!(await service.innerText()).includes('Failed to load'));
|
||||
assert.ok((await chartDescription()).includes('No 1-hour history data is available.'));
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(offOrigin, []);
|
||||
results.push({
|
||||
browser: name,
|
||||
version: browser.version(),
|
||||
checks,
|
||||
requests,
|
||||
screenshots,
|
||||
errors,
|
||||
offOrigin,
|
||||
});
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'result.json'),
|
||||
JSON.stringify({ result: 'passed', playwright, results }, null, 2),
|
||||
);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "9d5fd627f01cb176e5f28b1923a3407fc0867c3a",
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/charts.ts": "4a05bd62662ed2e812e02eaaa4f3da7470276e9cd4cfd260ab00ae9a69e6387d"
|
||||
},
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/charts.ts"
|
||||
],
|
||||
"routes": [
|
||||
"Existing PBS service History and disk History, through browser-tests/history-access.html production-component fixture"
|
||||
],
|
||||
"states": [
|
||||
"Initial HTTP200 with wrong target unavailable, not empty",
|
||||
"Valid response and measured zero recovery",
|
||||
"Wrong range/type/metric or malformed response cannot replace validated evidence",
|
||||
"Same-target remount retention with warning",
|
||||
"Real HTTP403 withdrawal",
|
||||
"Valid observed empty history"
|
||||
],
|
||||
"interactions": [
|
||||
"Keyboard retry using Enter",
|
||||
"History hide/remount",
|
||||
"Polling refresh",
|
||||
"Keyboard End canvas inspection"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"verified_at": "2026-10-04T07:31:31Z",
|
||||
"browsers": [
|
||||
"Chromium desktop",
|
||||
"WebKit phone emulation; not native iOS"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-response.cjs",
|
||||
"proof": "Production History components, real ChartsAPI and API client, synthetic same-origin HTTP; no native appliance or shipped acceptance."
|
||||
}
|
||||
@@ -78,7 +78,15 @@ describe('ChartsAPI', () => {
|
||||
});
|
||||
|
||||
it('builds metrics history query params including maxPoints', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
resourceType: 'agent',
|
||||
resourceId: 'agent-1',
|
||||
metric: 'cpu',
|
||||
range: '14d',
|
||||
start: 1,
|
||||
end: 2,
|
||||
points: [],
|
||||
});
|
||||
|
||||
await ChartsAPI.getMetricsHistory({
|
||||
resourceType: 'agent',
|
||||
@@ -94,7 +102,15 @@ describe('ChartsAPI', () => {
|
||||
});
|
||||
|
||||
it('builds canonical physical-disk history queries for 30m live I/O charts', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
resourceType: 'disk',
|
||||
resourceId: 'SERIAL884006359727',
|
||||
metric: 'diskread',
|
||||
range: '30m',
|
||||
start: 1,
|
||||
end: 2,
|
||||
points: [],
|
||||
});
|
||||
|
||||
await ChartsAPI.getMetricsHistory({
|
||||
resourceType: 'disk',
|
||||
@@ -109,7 +125,15 @@ describe('ChartsAPI', () => {
|
||||
});
|
||||
|
||||
it('passes through agent metrics history requests', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
resourceType: 'agent',
|
||||
resourceId: 'agent-1',
|
||||
metric: 'cpu',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 2,
|
||||
points: [],
|
||||
});
|
||||
|
||||
await ChartsAPI.getMetricsHistory({
|
||||
resourceType: 'agent',
|
||||
@@ -124,7 +148,15 @@ describe('ChartsAPI', () => {
|
||||
});
|
||||
|
||||
it('maps canonical kubernetes resource types to the backend k8s history token', async () => {
|
||||
apiFetchJSONMock.mockResolvedValueOnce({} as any);
|
||||
apiFetchJSONMock.mockResolvedValueOnce({
|
||||
resourceType: 'k8s',
|
||||
resourceId: 'k8s:cluster-a:pod:api-1',
|
||||
metric: 'cpu',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 2,
|
||||
points: [],
|
||||
});
|
||||
|
||||
await ChartsAPI.getMetricsHistory({
|
||||
resourceType: 'pod',
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChartsAPI, type MetricsHistoryParams, type ResourceType } from '@/api/charts';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn() }));
|
||||
|
||||
const request: MetricsHistoryParams = {
|
||||
resourceType: 'agent',
|
||||
resourceId: 'pbs-current',
|
||||
range: '24h',
|
||||
};
|
||||
const point = { timestamp: 1_700_000_000_000, value: 0, min: 0, max: 0 };
|
||||
const envelope = () => ({
|
||||
...request,
|
||||
start: point.timestamp,
|
||||
end: point.timestamp + 60_000,
|
||||
source: 'store',
|
||||
});
|
||||
const all = () => ({ ...envelope(), metrics: { cpu: [point] } });
|
||||
const single = () => ({ ...envelope(), metric: 'cpu', points: [point] });
|
||||
const read = async (body: unknown, params = request) => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(body);
|
||||
return ChartsAPI.getMetricsHistory(params);
|
||||
};
|
||||
|
||||
beforeEach(() => vi.mocked(apiFetchJSON).mockReset());
|
||||
|
||||
describe('metrics History response admission', () => {
|
||||
it.each([
|
||||
['another resource', { resourceId: 'private-predecessor' }],
|
||||
['another type', { resourceType: 'node' }],
|
||||
['another range', { range: '1h' }],
|
||||
['absent resource', { resourceId: undefined }],
|
||||
['absent type', { resourceType: undefined }],
|
||||
['absent range', { range: undefined }],
|
||||
['non-string resource', { resourceId: 1 }],
|
||||
['unknown source', { source: 'private-source' }],
|
||||
['null source', { source: null }],
|
||||
['absent start', { start: undefined }],
|
||||
['string end', { end: '1700000060000' }],
|
||||
['nonfinite end', { end: Infinity }],
|
||||
['null metrics', { metrics: null }],
|
||||
['array metrics', { metrics: [] }],
|
||||
['missing metrics', { metrics: undefined }],
|
||||
['null series', { metrics: { cpu: null } }],
|
||||
['object series', { metrics: { cpu: point } }],
|
||||
['mixed shapes', { points: [], metric: 'cpu' }],
|
||||
])('rejects %s rather than resolving empty or caching it', async (_label, change) => {
|
||||
await expect(read({ ...all(), ...change })).rejects.toThrow(
|
||||
'Invalid metrics history response.',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, [], 'private body', 42, {}])('rejects a non-envelope body %#', async (body) => {
|
||||
await expect(read(body)).rejects.toThrow('Invalid metrics history response.');
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
1,
|
||||
{},
|
||||
{ ...point, timestamp: '1700000000000' },
|
||||
{ ...point, timestamp: 9e15 },
|
||||
{ ...point, value: NaN },
|
||||
{ ...point, value: null },
|
||||
{ ...point, min: undefined },
|
||||
{ ...point, max: Infinity },
|
||||
])('rejects malformed samples in either response shape %#', async (invalid) => {
|
||||
await expect(read({ ...all(), metrics: { cpu: [point, invalid] } })).rejects.toThrow(
|
||||
'Invalid metrics history response.',
|
||||
);
|
||||
await expect(
|
||||
read({ ...single(), points: [point, invalid] }, { ...request, metric: 'cpu' }),
|
||||
).rejects.toThrow('Invalid metrics history response.');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ metric: 'memory' },
|
||||
{ metric: undefined },
|
||||
{ points: null },
|
||||
{ points: undefined },
|
||||
{ points: {} },
|
||||
{ metrics: {} },
|
||||
])('requires the requested single-metric shape %#', async (change) => {
|
||||
await expect(read({ ...single(), ...change }, { ...request, metric: 'cpu' })).rejects.toThrow(
|
||||
'Invalid metrics history response.',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['store', 'memory', 'live', 'mock_synthetic', undefined])(
|
||||
'preserves valid samples and source %s without manufacturing points',
|
||||
async (source) => {
|
||||
const body = { ...all(), source };
|
||||
expect(await read(body)).toBe(body);
|
||||
expect(await read({ ...single(), source }, { ...request, metric: 'cpu' })).toEqual({
|
||||
...single(),
|
||||
source,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts observed empty responses, zero readings and the existing invalid-window fallback', async () => {
|
||||
for (const metrics of [{}, { cpu: [] }, { cpu: [point] }]) {
|
||||
const body = { ...all(), start: 0, end: 0, metrics };
|
||||
expect(await read(body)).toBe(body);
|
||||
}
|
||||
expect(await read({ ...single(), points: [] }, { ...request, metric: 'cpu' })).toMatchObject({
|
||||
points: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', '24h'])(
|
||||
'accepts the default range echo %s only when range was omitted',
|
||||
async (range) => {
|
||||
expect(
|
||||
await read({ ...all(), range }, { resourceType: 'agent', resourceId: 'pbs-current' }),
|
||||
).toMatchObject({ range });
|
||||
await expect(
|
||||
read({ ...all(), range: '1h' }, { resourceType: 'agent', resourceId: 'pbs-current' }),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'node',
|
||||
'agent',
|
||||
'vm',
|
||||
'system-container',
|
||||
'oci-container',
|
||||
'app-container',
|
||||
'storage',
|
||||
'docker-host',
|
||||
'disk',
|
||||
] as ResourceType[])(
|
||||
'keeps the canonical %s type and server ID trimming',
|
||||
async (resourceType) => {
|
||||
const body = { ...all(), resourceType };
|
||||
expect(await read(body, { ...request, resourceType, resourceId: ' pbs-current ' })).toBe(
|
||||
body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['k8s-cluster', 'k8s-node', 'k8s-deployment', 'pod'] as ResourceType[])(
|
||||
'accepts the wire k8s token for %s, not an unrelated ID',
|
||||
async (resourceType) => {
|
||||
const body = { ...all(), resourceType: 'k8s', resourceId: 'k8s:cluster-a:pod:uid' };
|
||||
for (const resourceId of ['cluster-a:pod:uid', 'k8s:cluster-a:pod:uid']) {
|
||||
expect(await read(body, { ...request, resourceType, resourceId })).toBe(body);
|
||||
}
|
||||
await expect(
|
||||
read(body, { ...request, resourceType, resourceId: 'cluster-b:pod:uid' }),
|
||||
).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not treat body status claims as HTTP denial or expose wrong-target details', async () => {
|
||||
await expect(
|
||||
read({ ...all(), resourceId: 'private-resource', status: 403, error: 'private-message' }),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Invalid metrics history response.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([401, 403, 503])(
|
||||
'preserves real transport error %s without reclassifying it',
|
||||
async (status) => {
|
||||
const error = Object.assign(new Error('transport'), { status });
|
||||
vi.mocked(apiFetchJSON).mockRejectedValueOnce(error);
|
||||
await expect(ChartsAPI.getMetricsHistory(request)).rejects.toBe(error);
|
||||
},
|
||||
);
|
||||
|
||||
it('binds validation to the issued selection even if a caller mutates its params', async () => {
|
||||
let complete!: (value: unknown) => void;
|
||||
vi.mocked(apiFetchJSON).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const params = { ...request };
|
||||
const promise = ChartsAPI.getMetricsHistory(params);
|
||||
params.resourceId = 'successor';
|
||||
params.range = '1h';
|
||||
complete(all());
|
||||
expect(await promise).toMatchObject(request);
|
||||
});
|
||||
});
|
||||
@@ -284,6 +284,68 @@ export interface AllMetricsHistoryResponse {
|
||||
source?: 'store' | 'memory' | 'live' | 'mock_synthetic';
|
||||
}
|
||||
|
||||
const isHistoryObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isFiniteHistoryNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const isHistoryPointList = (value: unknown): value is AggregatedMetricPoint[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(point: unknown) =>
|
||||
isHistoryObject(point) &&
|
||||
isFiniteHistoryNumber(point.timestamp) &&
|
||||
Number.isFinite(new Date(point.timestamp).getTime()) &&
|
||||
isFiniteHistoryNumber(point.value) &&
|
||||
isFiniteHistoryNumber(point.min) &&
|
||||
isFiniteHistoryNumber(point.max),
|
||||
);
|
||||
|
||||
// A successful transport is not evidence for the requested selection. Validate
|
||||
// before any drawer, chart or hover cache can accept the response. Keep errors
|
||||
// fixed: a mismatched body can contain another resource's private details.
|
||||
function parseMetricsHistoryResponse(
|
||||
value: unknown,
|
||||
params: MetricsHistoryParams,
|
||||
): SingleMetricHistoryResponse | AllMetricsHistoryResponse {
|
||||
const resourceType = toMetricsHistoryAPIResourceType(params.resourceType);
|
||||
const requestedId = params.resourceId.trim();
|
||||
// This is the server's one explicit ID compatibility rule, not an alias
|
||||
// search: legacy Kubernetes pod IDs receive the canonical k8s: prefix.
|
||||
const resourceId =
|
||||
resourceType === 'k8s' && requestedId.includes(':pod:') && !requestedId.startsWith('k8s:')
|
||||
? `k8s:${requestedId}`
|
||||
: requestedId;
|
||||
if (
|
||||
!isHistoryObject(value) ||
|
||||
value.resourceType !== resourceType ||
|
||||
value.resourceId !== resourceId ||
|
||||
(params.range ? value.range !== params.range : value.range !== '' && value.range !== '24h') ||
|
||||
!isFiniteHistoryNumber(value.start) ||
|
||||
!isFiniteHistoryNumber(value.end) ||
|
||||
(value.source !== undefined &&
|
||||
!['store', 'memory', 'live', 'mock_synthetic'].includes(value.source as string))
|
||||
) {
|
||||
throw new Error('Invalid metrics history response.');
|
||||
}
|
||||
if (params.metric) {
|
||||
if (value.metric !== params.metric || 'metrics' in value || !isHistoryPointList(value.points)) {
|
||||
throw new Error('Invalid metrics history response.');
|
||||
}
|
||||
return value as unknown as SingleMetricHistoryResponse;
|
||||
}
|
||||
if (
|
||||
'metric' in value ||
|
||||
'points' in value ||
|
||||
!isHistoryObject(value.metrics) ||
|
||||
!Object.values(value.metrics).every(isHistoryPointList)
|
||||
) {
|
||||
throw new Error('Invalid metrics history response.');
|
||||
}
|
||||
return value as unknown as AllMetricsHistoryResponse;
|
||||
}
|
||||
|
||||
export type TimeRange = '5m' | '15m' | '30m' | '1h' | '4h' | '12h' | '24h' | '7d' | '30d';
|
||||
|
||||
export class ChartsAPI {
|
||||
@@ -396,25 +458,29 @@ export class ChartsAPI {
|
||||
static async getMetricsHistory(
|
||||
params: MetricsHistoryParams,
|
||||
): Promise<SingleMetricHistoryResponse | AllMetricsHistoryResponse> {
|
||||
const selection = { ...params };
|
||||
const searchParams = new URLSearchParams({
|
||||
resourceType: toMetricsHistoryAPIResourceType(params.resourceType),
|
||||
resourceId: params.resourceId,
|
||||
resourceType: toMetricsHistoryAPIResourceType(selection.resourceType),
|
||||
resourceId: selection.resourceId,
|
||||
});
|
||||
if (params.metric) {
|
||||
searchParams.set('metric', params.metric);
|
||||
if (selection.metric) {
|
||||
searchParams.set('metric', selection.metric);
|
||||
}
|
||||
if (params.range) {
|
||||
searchParams.set('range', params.range);
|
||||
if (selection.range) {
|
||||
searchParams.set('range', selection.range);
|
||||
}
|
||||
if (
|
||||
typeof params.maxPoints === 'number' &&
|
||||
Number.isFinite(params.maxPoints) &&
|
||||
params.maxPoints > 0
|
||||
typeof selection.maxPoints === 'number' &&
|
||||
Number.isFinite(selection.maxPoints) &&
|
||||
selection.maxPoints > 0
|
||||
) {
|
||||
searchParams.set('maxPoints', Math.round(params.maxPoints).toString());
|
||||
searchParams.set('maxPoints', Math.round(selection.maxPoints).toString());
|
||||
}
|
||||
const url = `${this.baseUrl}/metrics-store/history?${searchParams.toString()}`;
|
||||
return params.signal ? apiFetchJSON(url, { signal: params.signal }) : apiFetchJSON(url);
|
||||
const response: unknown = selection.signal
|
||||
? await apiFetchJSON(url, { signal: selection.signal })
|
||||
: await apiFetchJSON(url);
|
||||
return parseMetricsHistoryResponse(response, selection);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import { useHistoryChartState } from '@/components/shared/useHistoryChartState';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import { GuestDrawerHistory } from '../GuestDrawerHistory';
|
||||
import { useWorkloadTableMetricHistory } from '../useWorkloadTableMetricHistory';
|
||||
|
||||
// Mock the transport, not ChartsAPI: every production consumer uses the real
|
||||
// response-admission boundary and its existing retention/error policy.
|
||||
vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: vi.fn() }));
|
||||
vi.mock('@/stores/license', () => ({
|
||||
loadRuntimeCapabilities: vi.fn(),
|
||||
maxHistoryDays: () => 7,
|
||||
isRangeLocked: () => false,
|
||||
}));
|
||||
|
||||
const target = { resourceType: 'agent' as const, resourceId: 'pbs-current' };
|
||||
const body = (value = 42) => ({
|
||||
...target,
|
||||
range: '24h',
|
||||
start: 1_700_000_000_000,
|
||||
end: 1_700_000_060_000,
|
||||
source: 'store',
|
||||
metrics: {
|
||||
cpu: [0, 1].map((i) => ({
|
||||
timestamp: 1_700_000_000_000 + i * 60_000,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const transport = vi.mocked(apiFetchJSON);
|
||||
const settle = () => vi.advanceTimersByTimeAsync(0);
|
||||
const plots = (container: HTMLElement) =>
|
||||
container.querySelectorAll('[data-testid="guest-history-plot"] path');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
transport.mockReset();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('production History consumers reject unbound response evidence', () => {
|
||||
it.each([
|
||||
['wrong resource', { resourceId: 'private-predecessor' }],
|
||||
['wrong range', { range: '1h' }],
|
||||
['wrong type', { resourceType: 'node' }],
|
||||
['malformed metrics', { metrics: [] }],
|
||||
])(
|
||||
'keeps initial %s unavailable, offers retry and admits real zero/empty success',
|
||||
async (_label, change) => {
|
||||
transport.mockResolvedValueOnce({ ...body(97), ...change, error: 'private diagnostics' });
|
||||
const view = render(() => (
|
||||
<GuestDrawerHistory target={target} range="24h" currentMetrics={{ cpu: 99 }} />
|
||||
));
|
||||
await settle();
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toHaveTextContent(
|
||||
'Failed to load history data',
|
||||
);
|
||||
expect(plots(view.container)).toHaveLength(0);
|
||||
expect(view.container).not.toHaveTextContent('97.0%');
|
||||
expect(view.container).not.toHaveTextContent('99.0%');
|
||||
expect(view.container).not.toHaveTextContent('No stored history');
|
||||
expect(view.container).not.toHaveTextContent('private');
|
||||
transport.mockResolvedValueOnce(body(0));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
|
||||
await settle();
|
||||
expect(plots(view.container)).toHaveLength(1);
|
||||
expect(view.container).toHaveTextContent('0.0%');
|
||||
transport.mockResolvedValueOnce({ ...body(), metrics: {} });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await settle();
|
||||
expect(plots(view.container)).toHaveLength(0);
|
||||
expect(view.container).toHaveTextContent('No stored history in this range');
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toBeEmptyDOMElement();
|
||||
},
|
||||
);
|
||||
|
||||
it('retains only its validated same-target result on an invalid refresh, including remount, then recovers', async () => {
|
||||
transport.mockResolvedValueOnce(body());
|
||||
const view = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await settle();
|
||||
transport.mockResolvedValueOnce({ ...body(97), resourceId: 'wrong-pbs' });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await settle();
|
||||
expect(plots(view.container)).toHaveLength(1);
|
||||
expect(view.container).toHaveTextContent('42.0%');
|
||||
expect(view.container).not.toHaveTextContent('97.0%');
|
||||
expect(view.container).toHaveTextContent(
|
||||
'History refresh failed. Showing previously loaded history.',
|
||||
);
|
||||
view.unmount();
|
||||
transport.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const remount = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
expect(remount.container).toHaveTextContent('42.0%');
|
||||
expect(remount.container).not.toHaveTextContent('97.0%');
|
||||
await settle();
|
||||
transport.mockResolvedValueOnce(body(12));
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(remount.container).toHaveTextContent('12.0%');
|
||||
expect(remount.container).not.toHaveTextContent('42.0%');
|
||||
expect(screen.getByRole('status', { name: 'History refresh status' })).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('does not cache an invalid first response as empty history on remount', async () => {
|
||||
transport.mockResolvedValueOnce({ ...body(97), metrics: null });
|
||||
const first = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await settle();
|
||||
first.unmount();
|
||||
transport.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const second = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
expect(second.container).not.toHaveTextContent('No stored history');
|
||||
expect(second.container).toHaveTextContent('Failed to load history data');
|
||||
expect(plots(second.container)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps true denial separate from invalid HTTP200 and withdraws previously validated evidence', async () => {
|
||||
transport.mockResolvedValueOnce(body());
|
||||
const view = render(() => <GuestDrawerHistory target={target} range="24h" />);
|
||||
await settle();
|
||||
transport.mockResolvedValueOnce({ ...body(97), resourceId: 'wrong', status: 403 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
|
||||
await settle();
|
||||
expect(view.container).toHaveTextContent('previously loaded history');
|
||||
transport.mockRejectedValueOnce(Object.assign(new Error('private denial'), { status: 403 }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
|
||||
await settle();
|
||||
expect(plots(view.container)).toHaveLength(0);
|
||||
expect(view.container).not.toHaveTextContent('42.0%');
|
||||
expect(view.container).toHaveTextContent(
|
||||
'Access denied. Check your permissions and license plan.',
|
||||
);
|
||||
});
|
||||
|
||||
it('protects the shared single-metric chart across initial, refresh and selection failures', async () => {
|
||||
const valid = (value = 42) => {
|
||||
const { metrics, ...response } = body(value);
|
||||
return { ...response, metric: 'cpu', points: metrics.cpu };
|
||||
};
|
||||
const [resourceId, setResourceId] = createSignal(target.resourceId);
|
||||
function Probe() {
|
||||
const chart = useHistoryChartState(
|
||||
{
|
||||
get resourceId() {
|
||||
return resourceId();
|
||||
},
|
||||
resourceType: 'agent',
|
||||
metric: 'cpu',
|
||||
range: '24h',
|
||||
},
|
||||
{ getCanvas: () => undefined, getContainer: () => undefined },
|
||||
);
|
||||
return (
|
||||
<output>
|
||||
{JSON.stringify({
|
||||
values: chart.data().map((p) => p.value),
|
||||
error: chart.error(),
|
||||
refresh: chart.refreshFailed(),
|
||||
})}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
transport.mockResolvedValueOnce({ ...valid(97), metric: 'memory' });
|
||||
const view = render(() => <Probe />);
|
||||
await settle();
|
||||
expect(view.container).toHaveTextContent('Failed to load history data');
|
||||
expect(view.container).not.toHaveTextContent('97');
|
||||
transport.mockResolvedValueOnce(valid());
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(view.container).toHaveTextContent('"values":[42,42]');
|
||||
transport.mockResolvedValueOnce({ ...valid(97), points: null });
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(view.container).toHaveTextContent('"values":[42,42]');
|
||||
expect(view.container).toHaveTextContent('"refresh":true');
|
||||
transport.mockResolvedValueOnce(valid(97));
|
||||
setResourceId('pbs-replacement');
|
||||
await settle();
|
||||
expect(view.container).toHaveTextContent('"values":[]');
|
||||
expect(view.container).toHaveTextContent('Failed to load history data');
|
||||
});
|
||||
|
||||
it('keeps wrong-target or malformed hover warming out of the row cache and permits a later valid request', async () => {
|
||||
const guest = {
|
||||
id: 'vm-current',
|
||||
name: 'vm-current',
|
||||
type: 'qemu',
|
||||
workloadType: 'vm',
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'metrics-current' },
|
||||
} as WorkloadGuest;
|
||||
const [active, setActive] = createSignal<WorkloadGuest | null>(guest);
|
||||
function Probe() {
|
||||
const reader = useWorkloadTableMetricHistory({
|
||||
activeGuest: active,
|
||||
enabled: () => false,
|
||||
onDemand: () => true,
|
||||
range: () => '24h',
|
||||
selectedNode: () => null,
|
||||
});
|
||||
return (
|
||||
<output>
|
||||
{JSON.stringify(active() ? reader.getGuestMetricSeries(active()!, 'cpu') : [])}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
transport.mockResolvedValueOnce({ ...body(97), resourceType: 'vm', resourceId: 'wrong-vm' });
|
||||
const view = render(() => <Probe />);
|
||||
await settle();
|
||||
expect(view.container).not.toHaveTextContent('97');
|
||||
transport.mockResolvedValueOnce({
|
||||
...body(97),
|
||||
resourceType: 'vm',
|
||||
resourceId: 'metrics-current',
|
||||
metrics: [],
|
||||
});
|
||||
setActive(null);
|
||||
setActive(guest);
|
||||
await settle();
|
||||
expect(view.container).not.toHaveTextContent('97');
|
||||
transport.mockResolvedValueOnce({
|
||||
...body(0),
|
||||
resourceType: 'vm',
|
||||
resourceId: 'metrics-current',
|
||||
});
|
||||
setActive(null);
|
||||
setActive(guest);
|
||||
await settle();
|
||||
expect(view.container).toHaveTextContent('"value":0');
|
||||
expect(transport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user