mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed PBS machine identity boundaries
Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// Offline verification of the production table, drawer and History requests.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'node_modules/pbs-identity-boundary-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
fs.chmodSync(artifacts, 0o755);
|
||||
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'),
|
||||
server: { host: '127.0.0.1', port: 5211, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
const results = [];
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
channel: 'chromium',
|
||||
args: ['--no-sandbox'],
|
||||
});
|
||||
for (const width of [1365, 390]) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||
});
|
||||
page.setDefaultTimeout(20_000);
|
||||
const pageErrors = [];
|
||||
const requests = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5211') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
const type = url.searchParams.get('resourceType');
|
||||
const id = url.searchParams.get('resourceId');
|
||||
requests.push({ method: route.request().method(), target: `${type}:${id}` });
|
||||
assert.ok(!id.startsWith('unrelated-'), `wrong-host History requested: ${id}`);
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: type,
|
||||
resourceId: id,
|
||||
range: url.searchParams.get('range'),
|
||||
start: Date.now() - 3_600_000,
|
||||
end: Date.now(),
|
||||
source: 'store',
|
||||
// PBS API supplies only service CPU/memory, not invented host series.
|
||||
metrics: Object.fromEntries(
|
||||
(id.startsWith('pbs-')
|
||||
? ['cpu', 'memory']
|
||||
: ['cpu', 'memory', 'disk', 'netin', 'netout', 'diskread', 'diskwrite']
|
||||
).map((metric) => [
|
||||
metric,
|
||||
[30, 20, 10].map((minutes, index) => ({
|
||||
timestamp: Date.now() - minutes * 60_000,
|
||||
value: 10 + index * 5,
|
||||
min: 10 + index * 5,
|
||||
max: 10 + index * 5,
|
||||
})),
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === '/api/license/runtime-capabilities') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community', label: 'Pulse Community runtime' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({ json: { data: [], enabled: false } });
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5211/browser-tests/pbs-identity-boundary.html', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 120_000,
|
||||
});
|
||||
const observations = [];
|
||||
const inspect = async (suffix, expected, state, open = true) => {
|
||||
const detail = page.locator(`[data-inline-platform-resource-detail-for="pbs-${suffix}"]`);
|
||||
if (open) {
|
||||
const toggle = page.getByRole('button', {
|
||||
name: `Expand details for backup-connection-${suffix}`,
|
||||
exact: true,
|
||||
});
|
||||
if (width === 1365) {
|
||||
await toggle.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
} else await page.locator(`td[title="backup-connection-${suffix} · tank"]`).click();
|
||||
}
|
||||
await detail.waitFor();
|
||||
const overview = detail.getByRole('tab', { name: 'Overview', exact: true });
|
||||
await overview.click();
|
||||
const identity = detail
|
||||
.locator('[data-testid="resource-identity-section"] tr')
|
||||
.filter({ hasText: 'Metrics Target' });
|
||||
await identity.getByText(expected, { exact: true }).waitFor();
|
||||
assert.ok(!(await detail.innerText()).includes('/WRONG-HOST'), 'wrong-host disks rendered');
|
||||
await detail.getByRole('tab', { name: 'History', exact: true }).click();
|
||||
await detail.locator('[data-testid="guest-history-plot"] path').first().waitFor();
|
||||
assert.equal(
|
||||
await detail
|
||||
.getByRole('tab', { name: 'History', exact: true })
|
||||
.getAttribute('aria-selected'),
|
||||
'true',
|
||||
);
|
||||
assert.ok(
|
||||
requests.some((request) => request.target === expected),
|
||||
`missing History read for ${expected}`,
|
||||
);
|
||||
const targetStart = requests.length;
|
||||
await detail.getByTestId('guest-history-range-control').selectOption('6h');
|
||||
// Force a fresh read even when the open drawer already has 6h selected.
|
||||
await detail.getByTestId('guest-history-range-control').selectOption('1h');
|
||||
await page.waitForFunction(() => !document.body.innerText.includes('Loading history'));
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
inner: innerWidth,
|
||||
}));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
observations.push({
|
||||
state,
|
||||
suffix,
|
||||
expected,
|
||||
dimensions,
|
||||
newTargets: requests.slice(targetStart),
|
||||
});
|
||||
console.log(JSON.stringify({ width, state, expected }));
|
||||
};
|
||||
for (const suffix of ['one', 'two', 'three'])
|
||||
await inspect(suffix, `agent:pbs-${suffix}`, 'no-link-label-collisions');
|
||||
await page.screenshot({
|
||||
path: path.join(artifacts, `unlinked-${width}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Corroborate links', exact: true }).click();
|
||||
await inspect('three', 'agent:agent-three', 'explicit-link', false);
|
||||
await inspect('one', 'vm:vm-one', 'same-Agent-guest-deduplication');
|
||||
await inspect('two', 'vm:vm-two', 'second-guest');
|
||||
await inspect('three', 'agent:agent-three', 'standalone-host');
|
||||
await page.screenshot({ path: path.join(artifacts, `linked-${width}.png`), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Omit linked host rows', exact: true }).click();
|
||||
await inspect('three', 'agent:agent-three', 'unchanged-link-host-omission', false);
|
||||
await page.getByRole('button', { name: 'Restore linked host rows', exact: true }).click();
|
||||
await inspect('three', 'agent:agent-three', 'restored-host', false);
|
||||
await page.getByRole('button', { name: 'Withdraw third link', exact: true }).click();
|
||||
await inspect('three', 'agent:pbs-three', 'withdrawn-link-with-hosts-still-present', false);
|
||||
assert.ok(
|
||||
!(
|
||||
await page.locator('[data-inline-platform-resource-detail-for="pbs-three"]').innerText()
|
||||
).includes('/real-three'),
|
||||
'withdrawn host disks remained visible',
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(artifacts, `withdrawn-${width}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Report exact node hostname', exact: true }).click();
|
||||
await inspect('one', 'vm:vm-one', 'exact-reported-machine-hostname');
|
||||
await inspect('three', 'agent:pbs-three', 'remaining-uncorroborated-host');
|
||||
assert.ok(requests.length > 0);
|
||||
assert.ok(requests.every((request) => request.method === 'GET'));
|
||||
assert.deepEqual(pageErrors, []);
|
||||
results.push({ width, observations, requests, pageErrors });
|
||||
await page.close();
|
||||
}
|
||||
const file = 'src/features/proxmox/ProxmoxBackupServersTable.tsx';
|
||||
const result = {
|
||||
result: 'passed',
|
||||
browser: browser.version(),
|
||||
playwright: '1.56.1',
|
||||
content_sha256: {
|
||||
[`frontend-modern/${file}`]: createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(root, file)))
|
||||
.digest('hex'),
|
||||
},
|
||||
results,
|
||||
};
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
for (const name of fs.readdirSync(artifacts)) fs.chmodSync(path.join(artifacts, name), 0o644);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PBS identity boundary verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/pbs-identity-boundary.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
// Synthetic bounded Backups/Overview resources, not installed host evidence.
|
||||
import { createMemo, createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
|
||||
import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
const seen = Date.now();
|
||||
const suffixes = ['one', 'two', 'three'];
|
||||
const services = suffixes.map((suffix, index): Resource => ({
|
||||
id: `pbs-${suffix}`,
|
||||
type: 'pbs',
|
||||
name: `backup-connection-${suffix}`,
|
||||
displayName: `backup-connection-${suffix}`,
|
||||
platformId: `pbs-${suffix}`,
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
sources: ['pbs'],
|
||||
status: 'online',
|
||||
lastSeen: seen,
|
||||
cpu: { current: 10 + index },
|
||||
memory: { current: 40 },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: `pbs-${suffix}` },
|
||||
canonicalIdentity: { hostname: `10.2.0.${13 + index}` },
|
||||
pbs: {
|
||||
instanceId: `pbs-${suffix}`,
|
||||
hostname: `10.2.0.${13 + index}`,
|
||||
connectionHealth: 'healthy',
|
||||
version: '3.2.1',
|
||||
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
|
||||
},
|
||||
}));
|
||||
const hosts = suffixes.map((suffix): Resource => ({
|
||||
id: `agent-${suffix}`,
|
||||
type: 'agent',
|
||||
name: `Host label ${suffix}`,
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent', 'pbs'],
|
||||
status: 'online',
|
||||
lastSeen: seen,
|
||||
agent: {
|
||||
agentId: `agent-${suffix}`,
|
||||
hostname: `host-${suffix}.example`,
|
||||
disks: [{ mountpoint: `/real-${suffix}`, total: 1000, used: 250, free: 750, usage: 25 }],
|
||||
},
|
||||
metricsTarget: { resourceType: 'agent', resourceId: `agent-${suffix}` },
|
||||
}));
|
||||
const guests = hosts.slice(0, 2).map((host, index): Resource => ({
|
||||
...host,
|
||||
id: `vm-${suffixes[index]}`,
|
||||
type: 'vm',
|
||||
sources: ['proxmox', 'agent'],
|
||||
metricsTarget: { resourceType: 'vm', resourceId: `vm-${suffixes[index]}` },
|
||||
}));
|
||||
const unrelated = Array.from({ length: 6 }, (_, index): Resource => ({
|
||||
...hosts[index % 3],
|
||||
id: `unrelated-${index}`,
|
||||
type: index < 3 ? 'vm' : 'agent',
|
||||
name: `backup-connection-${suffixes[index % 3]}`,
|
||||
displayName: `backup-connection-${suffixes[index % 3]}`,
|
||||
identity: { ips: [`10.9.0.${13 + index}`] },
|
||||
agent: {
|
||||
agentId: `unrelated-${index}`,
|
||||
hostname: `backup-connection-${suffixes[index % 3]}`,
|
||||
disks: [{ mountpoint: '/WRONG-HOST', total: 1000, used: 999, free: 1, usage: 99.9 }],
|
||||
},
|
||||
metricsTarget: { resourceType: 'vm', resourceId: `unrelated-${index}` },
|
||||
}));
|
||||
|
||||
const Fixture = () => {
|
||||
const [mode, setMode] = createSignal('unlinked');
|
||||
const servers = createMemo(() => [
|
||||
...services.map((server, index): Resource => ({
|
||||
...server,
|
||||
pbs: {
|
||||
...server.pbs!,
|
||||
linkedAgentId:
|
||||
['linked', 'omitted', 'withdrawn'].includes(mode()) &&
|
||||
!(mode() === 'withdrawn' && index === 2)
|
||||
? `agent-${suffixes[index]}`
|
||||
: undefined,
|
||||
nodeName: mode() === 'node' && index === 0 ? 'host-one.example' : undefined,
|
||||
},
|
||||
})),
|
||||
...(mode() === 'omitted' ? [] : [...hosts, ...guests]),
|
||||
...unrelated,
|
||||
]);
|
||||
return (
|
||||
<main class="p-4 space-y-3">
|
||||
<h1>PBS identity boundary verification</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setMode('linked')}>
|
||||
Corroborate links
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('omitted')}>
|
||||
Omit linked host rows
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('linked')}>
|
||||
Restore linked host rows
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('withdrawn')}>
|
||||
Withdraw third link
|
||||
</button>
|
||||
<button type="button" onClick={() => setMode('node')}>
|
||||
Report exact node hostname
|
||||
</button>
|
||||
</div>
|
||||
<output aria-label="Evidence state">{mode()}</output>
|
||||
<ProxmoxBackupServersTable servers={servers()} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
render(() => <Fixture />, document.getElementById('root') as HTMLElement);
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
|
||||
"verified_at": "2026-09-29T23:04:22Z",
|
||||
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
|
||||
"verified_at": "2026-09-30T01:40:13Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "ed7207456ebbc990ada34b0c739e29565d90cfe1998415a781d87f3067d69c36"
|
||||
},
|
||||
"routes": [
|
||||
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
|
||||
"/browser-tests/pbs-identity-boundary.html (production PBS table, resource drawer and History with bounded synthetic resource props)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -23,26 +23,77 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
|
||||
"host rows omitted in a transient refresh while the backend link is unchanged",
|
||||
"third backend link withdrawn while the Agent row remains omitted"
|
||||
"three unlinked PBS services alongside six label/IP-colliding Agents",
|
||||
"explicit backend links for two PVE guests and a standalone host, with same-Agent guest/host deduplication",
|
||||
"unchanged links with host rows omitted and restored",
|
||||
"withdrawn third link with real and colliding host rows still present",
|
||||
"fresh exact PBS nodeName to Agent hostname match with mismatched display labels"
|
||||
],
|
||||
"interactions": [
|
||||
"open all three PBS drawers and inspect their Metrics Target identity",
|
||||
"omit host rows and confirm targets stay on the correlated hosts",
|
||||
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
|
||||
"desktop keyboard disclosure and phone row activation for all three PBS drawers",
|
||||
"inspect Metrics Target, reject wrong-host disks, select History and 6h/1h ranges",
|
||||
"join, omit, restore and withdraw links while keeping the drawer open; verify only service History remains after withdrawal",
|
||||
"24 target/layout observations; 76 History GETs, zero unrelated targets, page errors or horizontal overflow"
|
||||
],
|
||||
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-identity-boundary.cjs; Vite 6.4.3, Playwright 1.56.1, Chromium 141.0.7390.37",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-identity-boundary.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/unlinked-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/linked-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-f2zda9of/pbs-browser-final-artifacts/withdrawn-390.png"
|
||||
],
|
||||
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series.",
|
||||
"notes": "All six screenshots inspected. Mock-backed UI proof, not installed topology, actual persisted host History or full #1723 relief. First browser failure retained separately; source snapshot isolation fixes that observed revocation failure.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c3e8b6dd6dff36b5396ba2eb147d4e58d23001a6",
|
||||
"verified_at": "2026-09-29T23:04:22Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "7172690587f8e642ee30492481abbe76ee79d277aad290c3b50310dd1c0b282b"
|
||||
},
|
||||
"routes": [
|
||||
"/pbs-correlation-proof.html (production PBS backup-server table and resource drawer with scripted, source-scoped resources)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"three PBS services with distinct corroborated Agent links, including two PVE guests and one standalone host",
|
||||
"host rows omitted in a transient refresh while the backend link is unchanged",
|
||||
"third backend link withdrawn while the Agent row remains omitted"
|
||||
],
|
||||
"interactions": [
|
||||
"open all three PBS drawers and inspect their Metrics Target identity",
|
||||
"omit host rows and confirm targets stay on the correlated hosts",
|
||||
"withdraw the third link and confirm its drawer returns to agent:pbs-three, with no page errors or horizontal overflow"
|
||||
],
|
||||
"command": "pulse-worker-browser pbs-browser-proof.cjs from assigned workspace root; Vite 6.4.3, Playwright 1.56.1, Chromium desktop/phone",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-final.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-1365-withdrawn.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-linked.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-sfkzldum/pbs-browser-proof-artifacts/pbs-390-withdrawn.png"
|
||||
],
|
||||
"notes": "Mock-backed proof of the real PBS table and drawer; all four screenshots inspected. It does not establish installed #1723 relief or observed Agent History series."
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "91d8822d76c5a7214995c0e370be637b6e6336e3",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { For, Show, createMemo, type Accessor, type JSX } from 'solid-js';
|
||||
import { unwrap } from 'solid-js/store';
|
||||
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
} from '@/features/platformPage/PlatformResourceDetailTableRow';
|
||||
import type { PBSBackup } from '@/types/api';
|
||||
import type { Resource, ResourcePBSDatastore } from '@/types/resource';
|
||||
import { getNormalizedIdentityLookupVariants } from '@/utils/resourceIdentity';
|
||||
import { getPlatformAgentRecord } from '@/utils/agentResources';
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
|
||||
@@ -103,27 +104,19 @@ function usageToneClass(pct: number | undefined): string {
|
||||
return 'text-base-content';
|
||||
}
|
||||
|
||||
const identityValues = (resource: Resource): Array<string | undefined> => [
|
||||
resource.canonicalIdentity?.hostname,
|
||||
resource.canonicalIdentity?.platformId,
|
||||
resource.identity?.hostname,
|
||||
...(resource.identity?.ips ?? []),
|
||||
resource.agent?.hostname,
|
||||
resource.pbs?.hostname,
|
||||
// The node hostname PBS reports about itself is machine identity. The
|
||||
// connection may be configured by an IP or DNS alias the agent never
|
||||
// reports, so without this the host correlation can drop out between
|
||||
// snapshots and the drawer falls back to the service history target
|
||||
// (#1723).
|
||||
resource.pbs?.nodeName,
|
||||
resource.pbs?.instanceId,
|
||||
resource.platformId,
|
||||
resource.name,
|
||||
resource.displayName,
|
||||
];
|
||||
// Only the hostname PBS reports about itself can corroborate a host without
|
||||
// a backend link. Connection labels, instance IDs, canonical presentation
|
||||
// fields and configured endpoints are not machine identity. In particular,
|
||||
// the shared dotted-token helper would make unrelated IPs/FQDNs collide.
|
||||
const machineHostname = (value: unknown): string =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase().replace(/\.$/, '') : '';
|
||||
|
||||
const identityTokens = (resource: Resource): Set<string> =>
|
||||
new Set(identityValues(resource).flatMap((value) => getNormalizedIdentityLookupVariants(value)));
|
||||
const correlationEvidenceKey = (server: Resource): string | undefined => {
|
||||
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
|
||||
if (linkedAgentId) return `agent:${linkedAgentId}`;
|
||||
const nodeName = machineHostname(server.pbs?.nodeName);
|
||||
return nodeName ? `node:${nodeName}` : undefined;
|
||||
};
|
||||
|
||||
const stringValues = (...candidates: unknown[]): string[] =>
|
||||
candidates.flatMap((candidate) =>
|
||||
@@ -156,14 +149,32 @@ const isHostWithAgent = (candidate: Resource): boolean =>
|
||||
(candidate.type === 'agent' || isGuestWithAgent(candidate)) &&
|
||||
Boolean(candidate.agent ?? candidate.platformData?.agent);
|
||||
|
||||
// Host telemetry can be merged into a PVE guest rather than a standalone
|
||||
// agent. Keep the unique-identity check and require an actual agent facet.
|
||||
const isCorrelationCandidate = (serverTokens: Set<string>, candidate: Resource): boolean => {
|
||||
if (!isHostWithAgent(candidate)) return false;
|
||||
for (const token of identityTokens(candidate)) {
|
||||
if (serverTokens.has(token)) return true;
|
||||
const matchingAgentHosts = (server: Resource, candidates: readonly Resource[]): Resource[] => {
|
||||
// Endpoint/interface and provider guest links belong to the backend. An
|
||||
// explicit link also vetoes same-name Agents with a different identity.
|
||||
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
|
||||
if (linkedAgentId) {
|
||||
return candidates.filter(
|
||||
(candidate) => isHostWithAgent(candidate) && correlatedAgentKey(candidate) === linkedAgentId,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
const nodeName = machineHostname(server.pbs?.nodeName);
|
||||
if (!nodeName) return [];
|
||||
return candidates.filter((candidate) => {
|
||||
if (!isHostWithAgent(candidate) || !correlatedAgentKey(candidate)) return false;
|
||||
const agent = candidate.agent ?? getPlatformAgentRecord(candidate);
|
||||
// Do not let stale host observations undo a withdrawn backend link.
|
||||
const reportSeen =
|
||||
typeof agent?.lastReportAt === 'string' ? Date.parse(agent.lastReportAt) : candidate.lastSeen;
|
||||
const delta = Math.abs(server.lastSeen - reportSeen);
|
||||
return (
|
||||
agent?.stale !== true &&
|
||||
server.lastSeen > 0 &&
|
||||
reportSeen > 0 &&
|
||||
delta <= PBS_CORRELATION_RETENTION_MAX_STALENESS_MS &&
|
||||
machineHostname(agent?.hostname) === nodeName
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const preferredCorrelatedHost = (matches: readonly Resource[]): Resource | undefined =>
|
||||
@@ -175,22 +186,7 @@ const uniquelyCorrelatedAgent = (
|
||||
server: Resource,
|
||||
candidates: readonly Resource[],
|
||||
): Resource | undefined => {
|
||||
// The backend can corroborate a PBS connection with a unique agent from
|
||||
// its reported interface IP even when the PBS token cannot read nodeName
|
||||
// and the configured connection name differs from the PVE/agent hostname.
|
||||
// An explicit link also vetoes same-name agents with a different ID.
|
||||
const linkedAgentId = server.pbs?.linkedAgentId?.trim();
|
||||
if (linkedAgentId) {
|
||||
return preferredCorrelatedHost(
|
||||
candidates.filter(
|
||||
(candidate) =>
|
||||
isHostWithAgent(candidate) && correlatedAgentKey(candidate) === linkedAgentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
const serverTokens = identityTokens(server);
|
||||
if (serverTokens.size === 0) return undefined;
|
||||
const matches = candidates.filter((candidate) => isCorrelationCandidate(serverTokens, candidate));
|
||||
const matches = matchingAgentHosts(server, candidates);
|
||||
if (matches.length === 0) return undefined;
|
||||
if (matches.length === 1) return matches[0];
|
||||
|
||||
@@ -221,11 +217,8 @@ const uniquelyCorrelatedAgent = (
|
||||
// distinction matters for correlation retention: a snapshot that simply omits
|
||||
// the host row is a transient refresh gap, while an ambiguous snapshot is a
|
||||
// deliberate decline that must not be papered over with a remembered guess.
|
||||
const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource[]): boolean => {
|
||||
const serverTokens = identityTokens(server);
|
||||
if (serverTokens.size === 0) return false;
|
||||
return candidates.some((candidate) => isCorrelationCandidate(serverTokens, candidate));
|
||||
};
|
||||
const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource[]): boolean =>
|
||||
matchingAgentHosts(server, candidates).length > 0;
|
||||
|
||||
// A live refresh can briefly omit the correlated host row (for example while a
|
||||
// realtime snapshot replaces the merged estate), which used to flip the Backups
|
||||
@@ -236,7 +229,7 @@ const hasCorrelationCandidate = (server: Resource, candidates: readonly Resource
|
||||
// indefinitely. A host that is present but ambiguous still declines.
|
||||
interface RetainedPbsCorrelation {
|
||||
agent: Resource;
|
||||
linkedAgentId?: string;
|
||||
evidenceKey?: string;
|
||||
}
|
||||
|
||||
export type PbsCorrelationRetention = Map<string, RetainedPbsCorrelation>;
|
||||
@@ -306,7 +299,7 @@ export function buildBackupServerRows(
|
||||
if (agent) {
|
||||
retention?.set(server.id, {
|
||||
agent,
|
||||
linkedAgentId: server.pbs?.linkedAgentId?.trim() || undefined,
|
||||
evidenceKey: correlationEvidenceKey(server),
|
||||
});
|
||||
return mergePBSAgentPresentation(server, agent);
|
||||
}
|
||||
@@ -315,8 +308,7 @@ export function buildBackupServerRows(
|
||||
// A changed or withdrawn backend link is evidence that the old host
|
||||
// correlation is no longer trusted. A transiently missing row is not
|
||||
// enough to keep advertising that host's History after revocation.
|
||||
const linkedAgentId = server.pbs?.linkedAgentId?.trim() || undefined;
|
||||
if (linkedAgentId !== retained.linkedAgentId) {
|
||||
if (correlationEvidenceKey(server) !== retained.evidenceKey) {
|
||||
retention?.delete(server.id);
|
||||
return server;
|
||||
}
|
||||
@@ -325,7 +317,9 @@ export function buildBackupServerRows(
|
||||
if (fresh && !hasCorrelationCandidate(server, servers)) {
|
||||
return mergePBSAgentPresentation(server, retained.agent);
|
||||
}
|
||||
if (!fresh) retention?.delete(server.id);
|
||||
// An ambiguous match revokes the remembered choice too; a later
|
||||
// omission must not resurrect an identity we already declined.
|
||||
retention?.delete(server.id);
|
||||
}
|
||||
return server;
|
||||
})
|
||||
@@ -343,7 +337,6 @@ export function buildBackupServerRows(
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
const memoryTotal = server.memory?.total ?? 0;
|
||||
const host = {
|
||||
resource: server,
|
||||
serverName: server.name,
|
||||
online: serverIsOnline(server),
|
||||
connectionLabel: connectionLabel(server),
|
||||
@@ -359,14 +352,20 @@ export function buildBackupServerRows(
|
||||
memoryTotal: memoryTotal > 0 ? memoryTotal : undefined,
|
||||
uptimeSeconds: server.uptime ?? server.pbs?.uptimeSeconds,
|
||||
};
|
||||
// Each keyed row has a reconciled Solid store. Give it its own JSON DTO
|
||||
// snapshot: otherwise joining an Agent mutates the source's service
|
||||
// metricsTarget (or another datastore row), defeating later revocation.
|
||||
// Unwrap reactive source DTOs before cloning; never clone their proxies.
|
||||
const resourceSnapshot = () => structuredClone(unwrap(server));
|
||||
if (datastores.length === 0) {
|
||||
rows.push({ key: server.id, ...host, backupCount: 0 });
|
||||
rows.push({ key: server.id, ...host, resource: resourceSnapshot(), backupCount: 0 });
|
||||
continue;
|
||||
}
|
||||
for (const datastore of datastores) {
|
||||
rows.push({
|
||||
key: `${server.id}:${datastore.name}`,
|
||||
...host,
|
||||
resource: resourceSnapshot(),
|
||||
// Row stores reconcile independently from the nested PBS snapshot. Do
|
||||
// not alias its datastore object: a reordered snapshot reconciles that
|
||||
// array by position and would overwrite this row's datastore identity.
|
||||
|
||||
+79
-2
@@ -46,6 +46,7 @@ const makePbsResource = (): Resource =>
|
||||
memory: { current: 40, total: 8_000, used: 3_200, free: 4_800 },
|
||||
pbs: {
|
||||
instanceId: 'pbs-main',
|
||||
nodeName: 'pbs-main.local',
|
||||
version: '3.2.1',
|
||||
connectionHealth: 'healthy',
|
||||
datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600, usagePercent: 40 }],
|
||||
@@ -187,6 +188,70 @@ describe('ProxmoxBackupServersTable details', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an open drawer to the service target when the link is withdrawn beside a colliding guest', async () => {
|
||||
const pbs = makePbsResource();
|
||||
pbs.agent = undefined;
|
||||
pbs.platformData = { sources: ['pbs'] };
|
||||
pbs.pbs = { ...pbs.pbs!, nodeName: undefined, linkedAgentId: 'agent-pbs-1' };
|
||||
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
|
||||
const host = {
|
||||
...pbs,
|
||||
id: 'agent-pbs-1',
|
||||
type: 'agent',
|
||||
pbs: undefined,
|
||||
agent: { agentId: 'agent-pbs-1', hostname: 'real-host' },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' },
|
||||
} as Resource;
|
||||
const unrelated = {
|
||||
...host,
|
||||
id: 'vm-other',
|
||||
type: 'vm',
|
||||
name: pbs.name,
|
||||
agent: { agentId: 'agent-other', hostname: pbs.name },
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
|
||||
} as Resource;
|
||||
const [servers, setServers] = createSignal([pbs, host, unrelated]);
|
||||
render(() => <ProxmoxBackupServersTable servers={servers()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
|
||||
const detail = screen.getByTestId('pbs-resource-detail');
|
||||
expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1');
|
||||
|
||||
setServers([{ ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: undefined } }, host, unrelated]);
|
||||
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main'));
|
||||
expect(detail).not.toHaveAttribute('data-agent-id');
|
||||
expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not mutate reusable service/Agent snapshots while joining and withdrawing host History', async () => {
|
||||
const pbs = makePbsResource();
|
||||
pbs.pbs = { ...pbs.pbs!, nodeName: undefined };
|
||||
pbs.platformData = { sources: ['pbs'] };
|
||||
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-main' };
|
||||
const host = {
|
||||
...pbs,
|
||||
id: 'agent-pbs-1',
|
||||
type: 'agent',
|
||||
pbs: undefined,
|
||||
agent: { agentId: 'agent-pbs-1', hostname: 'real-host' },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'agent-pbs-1' },
|
||||
} as Resource;
|
||||
const original = JSON.stringify([pbs, host]);
|
||||
const [servers, setServers] = createSignal([pbs, host]);
|
||||
render(() => <ProxmoxBackupServersTable servers={servers()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand details for pbs-main' }));
|
||||
const detail = screen.getByTestId('pbs-resource-detail');
|
||||
expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main');
|
||||
|
||||
setServers([{ ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: 'agent-pbs-1' } }, host]);
|
||||
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'agent-pbs-1'));
|
||||
expect(JSON.stringify([pbs, host])).toBe(original);
|
||||
setServers([pbs, host]);
|
||||
await waitFor(() => expect(detail).toHaveAttribute('data-metrics-resource-id', 'pbs-main'));
|
||||
expect(detail).not.toHaveAttribute('data-agent-id');
|
||||
expect(JSON.stringify([pbs, host])).toBe(original);
|
||||
expect(resourceDetailDrawerMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['agent', 'vm'] as const)(
|
||||
'does not guess when an agent and %s share the PBS hostname',
|
||||
(type) => {
|
||||
@@ -283,11 +348,22 @@ describe('ProxmoxBackupServersTable details', () => {
|
||||
pbs.name = 'proxback';
|
||||
pbs.displayName = 'proxback';
|
||||
pbs.platformId = 'pbs-1';
|
||||
pbs.pbs = { ...pbs.pbs!, instanceId: 'proxback', hostname: 'proxback-vm' };
|
||||
// The reported node, not the configured endpoint, corroborates this host.
|
||||
pbs.pbs = {
|
||||
...pbs.pbs!,
|
||||
instanceId: 'proxback',
|
||||
hostname: 'proxback-vm',
|
||||
nodeName: 'proxback-vm',
|
||||
};
|
||||
pbs.metricsTarget = { resourceType: 'agent', resourceId: 'pbs-1' };
|
||||
pbs.platformData = {
|
||||
sources: ['pbs'],
|
||||
pbs: { instanceId: 'proxback', hostname: 'proxback-vm', datastoreCount: 1 },
|
||||
pbs: {
|
||||
instanceId: 'proxback',
|
||||
hostname: 'proxback-vm',
|
||||
nodeName: 'proxback-vm',
|
||||
datastoreCount: 1,
|
||||
},
|
||||
};
|
||||
const sharedAgent = { agentId: 'agent-proxback', hostname: 'proxback-vm' };
|
||||
const guest = {
|
||||
@@ -347,6 +423,7 @@ describe('ProxmoxBackupServersTable details', () => {
|
||||
pbs.pbs = {
|
||||
instanceId: 'proxback',
|
||||
hostname: 'proxback-vm',
|
||||
linkedAgentId: 'agent-proxback',
|
||||
version: '3.2.1',
|
||||
connectionHealth: 'healthy',
|
||||
datastores: [{ name: 'tank', total: 1000, used: 400, available: 600, usagePercent: 40 }],
|
||||
|
||||
@@ -121,6 +121,7 @@ const makeCorrelatablePbs = (lastSeen = 1_700_000_000_000): Resource =>
|
||||
pbs: {
|
||||
instanceId: 'proxback',
|
||||
hostname: 'proxback-vm',
|
||||
nodeName: 'proxback-vm',
|
||||
connectionHealth: 'healthy',
|
||||
datastores: [{ name: 'tank', total: 1_000, used: 400, available: 600 }],
|
||||
},
|
||||
@@ -140,11 +141,215 @@ const makeCorrelatedAgent = (overrides: Partial<Resource> = {}): Resource =>
|
||||
sources: ['agent', 'pbs'],
|
||||
status: 'online',
|
||||
lastSeen: 1_700_000_000_000,
|
||||
agent: { agentId: 'agent-proxback', hostname: 'proxback' },
|
||||
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm' },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'agent-proxback' },
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('buildBackupServerRows PBS machine-identity boundary', () => {
|
||||
it.each(['name', 'displayName', 'platformId', 'instanceId', 'canonicalIdentity', 'identity'])(
|
||||
'does not import an unrelated guest History or host disks from a PBS %s collision',
|
||||
(field) => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.name = 'backup-connection';
|
||||
pbs.displayName = 'Backup connection';
|
||||
pbs.platformId = 'pbs-service';
|
||||
pbs.pbs = {
|
||||
...pbs.pbs!,
|
||||
instanceId: 'pbs-service',
|
||||
hostname: '10.2.0.13',
|
||||
nodeName: undefined,
|
||||
};
|
||||
if (field === 'instanceId') pbs.pbs.instanceId = 'other-host';
|
||||
else if (field === 'canonicalIdentity') {
|
||||
pbs.canonicalIdentity = { hostname: 'other-host', platformId: 'other-host' };
|
||||
} else if (field === 'identity')
|
||||
pbs.identity = { hostname: 'other-host', ips: ['10.9.0.13'] };
|
||||
else pbs[field as 'name' | 'displayName' | 'platformId'] = 'other-host';
|
||||
const unrelated = makeCorrelatedAgent({
|
||||
id: 'vm-other',
|
||||
type: 'vm',
|
||||
name: 'other-host',
|
||||
agent: {
|
||||
agentId: 'agent-other',
|
||||
hostname: 'other-host',
|
||||
disks: [{ mountpoint: '/wrong-host' }],
|
||||
},
|
||||
disk: { current: 99 },
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
|
||||
});
|
||||
|
||||
const row = buildBackupServerRows([pbs, unrelated])[0];
|
||||
expect(row.resource.metricsTarget).toEqual(pbs.metricsTarget);
|
||||
expect(row.resource.agent).toBeUndefined();
|
||||
expect(row.resource.disk).toBeUndefined();
|
||||
expect(row.resource.sourceType).toBe('api');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not correlate different endpoint IPs through their first dotted token', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.name = 'backup-connection';
|
||||
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', nodeName: undefined };
|
||||
const unrelated = makeCorrelatedAgent({
|
||||
name: 'other-host',
|
||||
agent: { agentId: 'agent-other', hostname: 'other-host' },
|
||||
identity: { ips: ['10.9.0.13'] },
|
||||
});
|
||||
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use a guessed endpoint-to-Agent match without backend corroboration', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', nodeName: undefined };
|
||||
const unrelated = makeCorrelatedAgent({ identity: { ips: ['10.2.0.13'] } });
|
||||
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a present label-colliding guest undo backend link withdrawal', () => {
|
||||
const retention = createPbsCorrelationRetention();
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.pbs = { ...pbs.pbs!, hostname: '10.2.0.13', linkedAgentId: 'agent-proxback' };
|
||||
buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention);
|
||||
const withdrawn = { ...pbs, pbs: { ...pbs.pbs!, linkedAgentId: undefined } };
|
||||
const unrelated = makeCorrelatedAgent({
|
||||
id: 'vm-other',
|
||||
type: 'vm',
|
||||
agent: { agentId: 'agent-other', hostname: pbs.name },
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'vm-other' },
|
||||
disk: { current: 99 },
|
||||
});
|
||||
for (let refresh = 0; refresh < 2; refresh++) {
|
||||
const row = buildBackupServerRows([withdrawn, unrelated], [], retention);
|
||||
expect(row[0].resource.metricsTarget).toEqual(pbs.metricsTarget);
|
||||
expect(row[0].resource.agent).toBeUndefined();
|
||||
expect(row[0].resource.disk).toBeUndefined();
|
||||
expect(retention.size).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match a reported PBS node to a guest display name instead of its Agent hostname', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.pbs = { ...pbs.pbs!, nodeName: 'real-pbs.example' };
|
||||
const unrelated = makeCorrelatedAgent({
|
||||
name: 'real-pbs.example',
|
||||
agent: { agentId: 'agent-other', hostname: 'other-host.example' },
|
||||
});
|
||||
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat different qualified machine names as the same host', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.pbs = { ...pbs.pbs!, nodeName: 'backup.production.example' };
|
||||
const unrelated = makeCorrelatedAgent({
|
||||
agent: { agentId: 'agent-other', hostname: 'backup.test.example' },
|
||||
});
|
||||
expect(buildBackupServerRows([pbs, unrelated])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['direct', 'platform'])(
|
||||
'preserves a fresh reported node-to-%s-Agent hostname match, ignoring display labels',
|
||||
(facet) => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
pbs.pbs = { ...pbs.pbs!, nodeName: ' PROXBACK-VM.EXAMPLE. ' };
|
||||
const agent = makeCorrelatedAgent({
|
||||
name: 'Custom host label',
|
||||
displayName: 'Custom display label',
|
||||
agent:
|
||||
facet === 'direct'
|
||||
? { agentId: 'agent-proxback', hostname: 'proxback-vm.example' }
|
||||
: undefined,
|
||||
platformData:
|
||||
facet === 'platform'
|
||||
? {
|
||||
agent: { agentId: 'agent-proxback', hostname: 'proxback-vm.example' },
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
|
||||
agent.metricsTarget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('deduplicates a genuinely matching node/Agent surfaced as both guest and host', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
const host = makeCorrelatedAgent();
|
||||
const guest = {
|
||||
...host,
|
||||
id: 'vm-proxback',
|
||||
type: 'vm',
|
||||
metricsTarget: {
|
||||
resourceType: 'vm',
|
||||
resourceId: 'vm-proxback',
|
||||
},
|
||||
} as Resource;
|
||||
expect(buildBackupServerRows([pbs, host, guest])[0].resource.metricsTarget).toEqual(
|
||||
guest.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([-6, 6])('declines a node-name match with a %s-minute observation gap', (minutes) => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
const agent = makeCorrelatedAgent({ lastSeen: pbs.lastSeen + minutes * 60_000 });
|
||||
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['stale', 'old-report', 'invalid-report'])(
|
||||
'does not let a recent PVE row hide %s Agent evidence',
|
||||
(state) => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
const agent = makeCorrelatedAgent({
|
||||
type: 'vm',
|
||||
agent: {
|
||||
agentId: 'agent-proxback',
|
||||
hostname: 'proxback-vm',
|
||||
stale: state === 'stale',
|
||||
lastReportAt:
|
||||
state === 'old-report'
|
||||
? new Date(pbs.lastSeen - 6 * 60_000).toISOString()
|
||||
: state === 'invalid-report'
|
||||
? 'invalid'
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('declines an observed hostname without a concrete Agent identity', () => {
|
||||
const pbs = makeCorrelatablePbs();
|
||||
const agent = makeCorrelatedAgent({ agent: { hostname: 'proxback-vm' } });
|
||||
expect(buildBackupServerRows([pbs, agent])[0].resource.metricsTarget).toEqual(
|
||||
pbs.metricsTarget,
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a retained node-name match when the reported machine identity disappears', () => {
|
||||
const retention = createPbsCorrelationRetention();
|
||||
const pbs = makeCorrelatablePbs();
|
||||
buildBackupServerRows([pbs, makeCorrelatedAgent()], [], retention);
|
||||
expect(retention.size).toBe(1);
|
||||
const uncorroborated = { ...pbs, pbs: { ...pbs.pbs!, nodeName: undefined } };
|
||||
expect(
|
||||
buildBackupServerRows([uncorroborated], [], retention)[0].resource.metricsTarget,
|
||||
).toEqual(pbs.metricsTarget);
|
||||
expect(retention.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBackupServerRows PBS host correlation retention', () => {
|
||||
it('keeps three PBS guests on their own History targets across PVE and VirtualBox hosts', () => {
|
||||
const pbsServers = ['one', 'two', 'three'].map((suffix) => {
|
||||
@@ -335,12 +540,16 @@ describe('buildBackupServerRows PBS host correlation retention', () => {
|
||||
const otherAgent = makeCorrelatedAgent({
|
||||
id: 'agent-other',
|
||||
platformId: 'agent-other',
|
||||
agent: { agentId: 'agent-other', hostname: 'proxback' },
|
||||
agent: { agentId: 'agent-other', hostname: 'proxback-vm' },
|
||||
metricsTarget: { resourceType: 'agent', resourceId: 'agent-other' },
|
||||
});
|
||||
const ambiguous = buildBackupServerRows([pbs, agent, otherAgent], [], retention);
|
||||
|
||||
expect(ambiguous[0].resource.metricsTarget?.resourceId).toBe('proxback');
|
||||
expect(retention.size).toBe(0);
|
||||
expect(buildBackupServerRows([pbs], [], retention)[0].resource.metricsTarget?.resourceId).toBe(
|
||||
'proxback',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a remembered host once it is stale relative to the server', () => {
|
||||
|
||||
Reference in New Issue
Block a user