Integrate reviewed guest-memory provenance and Pulse system-alert scope

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 20:51:54 +01:00
43 changed files with 2437 additions and 68 deletions
@@ -15,6 +15,25 @@
## Purpose
### Pulse system alerts retain their service scope in the existing UI
The `pulse-system-` identity prefix or explicit `systemAlert: true` metadata
marks a Pulse service condition, not a monitored resource. Overview retains
the warning, acknowledgement, snooze and occurrence timeline but offers no
resource link or monitoring-policy action. History preserves this scope for
active and retained conditions before any resource-name fallback: a machine
named Pulse is not evidence that it owns the service warning. Ordinary resource
alerts, including those called Pulse, retain their existing target affordances.
The Assistant explanation keeps the exact alert identity, message and
approval-required boundary, without invented metric values, a guest target or
resource Patrol. Reconstructed History explanations preserve the system marker;
new system types need no frontend allowlist. Connected overview/history/handoff
and investigation-button controls verify both authoritative markers, legacy
metadata, name collisions and ordinary resource controls. Browser acceptance
checks the actual components on desktop and narrow touch views. This changes
presentation only, not alert evaluation, delivery, API or operator permissions.
### Backup-age evaluation failures use the existing system-alert lifecycle
`pulse-system-backup-evaluation` is a warning about Pulse's inability to read
@@ -20,6 +20,16 @@
## Purpose
### Optional guest-memory observation reaches existing frontend consumers
Frontend Memory and ResourceMetric model the existing optional server-owned
`observation` fields (state, source, original observedAt). The shared wire
reader selects only those fields and never invents an annotation for a legacy
metric. API/canonical workload mapping preserves the selected annotation, not
that of a conflicting platform facet. Unknown states/times stay non-current in
the Proxmox guest drawer. Numeric selection, permissions and existing endpoint
shape remain unchanged; hook/adapter/History controls verify this consumer.
### Platform connection alerts share the constructed runtime owners
The default monitor's connection-alert snapshot callback is installed only after
@@ -20,6 +20,42 @@
## Purpose
### Guest drawers consume selected memory provenance
Existing guest drawers consume optional server-owned `memory.observation` and
selected canonical/API metric annotations without renewing their original
observation time from resource LastSeen, disk-read deferral or another platform
facet. `useWorkloads`, `useUnifiedResources` and the legacy resource adapter
preserve the selected annotation. A selected unannotated metric cannot borrow a
conflicting raw platform annotation. Raw-memory fallback retains its own
annotation only when its numeric reading is actually selected.
Proxmox VM/LXC Overview states current, last known, unavailable or freshness
unknown with the original known UTC time and an allowlisted source label.
Missing, unrecognised or unusable provenance is not current. Unavailable usage
keeps known capacity but never invents zero used/free/swap measurements. History
keeps retained/unknown numeric context separate from current legends and stored
points, dated inspection and denied source access. Same-guest source changes
update without resetting the drawer or refetching History. Independent valid
PVE/Pulse Agent readings remain current even while QGA disk reads are deferred;
unannotated unrelated platforms keep existing presentation.
Connected original-source/normalizer/adapter and mounted History controls plus
`browser-tests/guest-memory-provenance.cjs` exercise these boundaries. They do
not establish native thaw, covered-filesystem writes, liveness, reporter relief,
publication or an installed containing release. No route, collection, numeric
selection, retention, alert policy or diagnostic permission is added.
Pulse system-alert presentation uses the existing reserved ID/metadata markers,
not the displayed name or a platform fallback. Overview and both History layouts
keep service warnings, occurrence timelines and acknowledgement usable without
a false resource link/policy action. History retains the system marker when
reconstructing Assistant context; the explanation has no fabricated metric,
guest target or targeted Patrol. Ordinary monitored resources named Pulse keep
their original affordances. The shared scope helper, mounted alert controls and
desktop/narrow-touch browser fixture cover this boundary. No navigation entry,
page, API, alert policy or operator permission is introduced.
### Guest-agent coverage is not inferred from metadata
The existing workload drawer distinguishes assigned Pulse Agent action ownership,
@@ -39,8 +75,8 @@ requires independent thaw and writes to covered filesystems. Running power
state, an OK backup or reported agent availability establishes none of those.
Fresh same-guest evidence withdraws the precaution without a new diagnostic or
collector request. Existing filesystem provenance, metric values, History,
Discovery/action permissions and monitoring policy are unchanged. No disk-read
reason is used to invent memory provenance that the workload API does not expose.
Discovery/action permissions and monitoring policy are unchanged. Memory qualification uses its own authoritative observation; disk-read state
cannot qualify a different metric or renew its observation time.
Verification: `GuestDrawerOverview.agent-state.test.tsx` covers each current state
with/without cached version, unknown private fields, action ownership, platform
@@ -15,6 +15,18 @@
## Purpose
### Memory presentation does not collect or renew observations
Guest-memory provenance is projected from the already-owned snapshot in the
existing drawer. A same-guest provenance change updates the Overview/History
label without refetching metrics History, resetting the drawer, creating chart
points or running a diagnostic. Fixed source labels and the original known
time replace fabricated current freshness; there is no new timer, collector or
fleet-wide query. Connected hook and mounted sparse/provenance controls pin
unchanged numeric selection, source isolation and no-extra-History-read
behaviour. Browser comparisons are source/render acceptance, not native CPU,
RSS, backup safety or installed relief measurements.
### Guest read-state display does not start another read
The existing workload drawer's guest-read state and backup precaution derive
@@ -21,6 +21,18 @@
## Purpose
### Guest-memory read state stays independent of backup disk state
The existing guest drawer retains Core's selected memory provenance through
both resources-API and canonical snapshot consumers. Retained memory remains
last-known at its original observation time, even after a new poll or cleared
backup lock. Missing provenance is unknown, unavailable memory is not zero,
and an independent current Proxmox/Pulse Agent memory reading remains current
while guest-agent filesystem reads are deferred. History samples and its
access-denial boundary remain authoritative. This client repair does not run
backup/QGA operations or establish independent thaw, covered-filesystem writes,
liveness, resumption or delivery. Existing outage precautions remain effective.
### Memory provenance does not imply recoverability
Canonical metric values may carry optional selected guest memory observation
@@ -23,6 +23,32 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
### Guest drawers consume selected memory provenance
Existing guest drawers consume optional server-owned `memory.observation` and
selected canonical/API metric annotations without renewing their original
observation time from resource LastSeen, disk-read deferral or another platform
facet. `useWorkloads`, `useUnifiedResources` and the legacy resource adapter
preserve the selected annotation. A selected unannotated metric cannot borrow a
conflicting raw platform annotation. Raw-memory fallback retains its own
annotation only when its numeric reading is actually selected.
Proxmox VM/LXC Overview states current, last known, unavailable or freshness
unknown with the original known UTC time and an allowlisted source label.
Missing, unrecognised or unusable provenance is not current. Unavailable usage
keeps known capacity but never invents zero used/free/swap measurements. History
keeps retained/unknown numeric context separate from current legends and stored
points, dated inspection and denied source access. Same-guest source changes
update without resetting the drawer or refetching History. Independent valid
PVE/Pulse Agent readings remain current even while QGA disk reads are deferred;
unannotated unrelated platforms keep existing presentation.
Connected original-source/normalizer/adapter and mounted History controls plus
`browser-tests/guest-memory-provenance.cjs` exercise these boundaries. They do
not establish native thaw, covered-filesystem writes, liveness, reporter relief,
publication or an installed containing release. No route, collection, numeric
selection, retention, alert policy or diagnostic permission is added.
### Guest memory provenance follows metric selection
Raw Proxmox `memory.observation` preserves the guest builder's state, source
@@ -0,0 +1,418 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/guest-memory-provenance';
const origin = 'http://127.0.0.1:5329';
const runtime = [
'frontend-modern/src/types/api.ts',
'frontend-modern/src/types/resource.ts',
'frontend-modern/src/utils/memoryObservation.ts',
'frontend-modern/src/utils/resourceStateAdapters.ts',
'frontend-modern/src/hooks/useUnifiedResources.ts',
'frontend-modern/src/hooks/useWorkloads.ts',
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
];
const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const initial = () => ({
state: 'last-known',
source: 'guest-agent-meminfo',
observedAt: '2026-10-04T14:00:00Z',
usage: 25,
});
const resource = (read) => {
const capacity = 4 * 1024 ** 3;
return {
id: read.peer ? 'fixture-b-pve1-101' : 'fixture-a-pve1-101',
type: 'vm',
name: read.peer ? 'peer-guest' : 'backup-guest',
status: 'online',
lastSeen: read.lastSeen ?? '2026-10-04T17:00:00Z',
sources: ['proxmox', 'agent'],
platformScopes: ['proxmox-pve'],
metrics: {
cpu: { percent: 10 },
...(read.state === 'unavailable'
? {}
: {
memory: {
percent: read.usage,
used: (capacity * read.usage) / 100,
total: capacity,
...(read.state === undefined
? {}
: {
observation: {
state: read.state,
source: read.source ?? '',
...(read.observedAt ? { observedAt: read.observedAt } : {}),
},
}),
},
}),
disk: { percent: 50, total: capacity, used: capacity / 2 },
},
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: read.peer ? 'fixture-b' : 'fixture-a',
runtimeStatus: 'running',
guestAgentStatus: 'deferred',
guestAgentExpected: true,
diskStatusReason: 'prev-vm-locked',
lock: 'backup',
memory: {
total: capacity,
usageUnavailable: read.state === 'unavailable',
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' },
},
},
};
};
(async () => {
fs.mkdirSync(output, { recursive: true });
const result = {
result: 'incomplete',
playwright: require('playwright/package.json').version,
runtime_hashes: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])),
cases: [],
screenshots: [],
limits:
'Production drawer/history/model with real raw, API, unified API and canonical full/delta/fast consumers. Synthetic source payloads, History replies and disconnected websocket metadata. No full App, native QGA/thaw, filesystem writes, physical phone, reporter, installed or publication acceptance.',
};
assert.equal(
result.playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
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: output + '/cache',
server: { host: '127.0.0.1', port: 5329, strictPort: true },
});
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop-light', chromium, 1365, false],
['desktop-dark', chromium, 1365, true],
['phone-dark', webkit, 390, true],
['narrow-light', webkit, 320, false],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const transport of [
'raw',
'api',
'unified-api',
'canonical-full',
'canonical-delta',
'canonical-fast',
]) {
const page = await browser.newPage({
viewport: { width, height: width < 768 ? 844 : 900 },
isMobile: width < 768,
hasTouch: width < 768,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
let read = initial(),
mode = 'empty';
const record = {
name,
transport,
width,
dark,
browser: browser.version(),
errors: [],
requests: [],
states: [],
};
result.cases.push(record);
page.on('pageerror', (e) => record.errors.push(e.message));
await page.route('**/*', async (route) => {
const url = new URL(route.request().url());
if (url.origin !== origin) {
record.requests.push({ blocked: true, origin: url.origin });
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
record.requests.push({ path: url.pathname, method: route.request().method() });
assert.equal(route.request().method(), 'GET');
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: [resource(read)], meta: { totalPages: 1 } } });
if (url.pathname === '/api/metrics-store/history') {
if (mode === 'denied')
return route.fulfill({ status: 403, json: { error: 'private fixture refusal' } });
const start = Date.UTC(2026, 9, 4, 14),
end = Date.UTC(2026, 9, 4, 17),
point = (timestamp, value) => ({ timestamp, value, min: value, max: value });
return route.fulfill({
json: {
resourceType: url.searchParams.get('resourceType'),
resourceId: url.searchParams.get('resourceId'),
range: url.searchParams.get('range'),
start,
end,
metrics:
mode === 'stored'
? {
memory: [point(start, 20), point(end, 30)],
cpu: [point(start, 10), point(end, 15)],
}
: {},
source: 'store',
},
});
}
return route.fulfill({
json:
url.pathname === '/api/license/runtime-capabilities'
? {
capabilities: [],
limits: [],
max_history_days: 7,
hosted_mode: false,
runtime: { build: 'community' },
blocked_capabilities: [],
}
: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.addInitScript(
(value) =>
document.addEventListener('DOMContentLoaded', () => {
if (value) document.documentElement.classList.add('dark');
}),
dark,
);
await page.goto(
origin + '/browser-tests/guest-memory-provenance.html?transport=' + transport,
);
const drawer = page.getByRole('region', { name: 'Guest details' });
await drawer.getByText('Memory reading', { exact: true }).waitFor();
const overview = drawer.getByRole('tab', { name: 'Overview', exact: true });
const history = drawer.getByRole('tab', { name: 'History', exact: true });
const activate = async (control) => {
if (width < 768) await control.tap();
else {
await control.focus();
await page.keyboard.press('Enter');
}
};
// ObjectDrawerHeader deliberately extends its collapse hit area by
// four pixels into the surrounding padding. Its region scrollWidth is
// not a text-clipping measure. Check the actual reading cell and the
// page viewport instead, including retained and unknown long labels.
const checkReadingLayout = async (state) => {
const row = drawer.getByText('Memory reading', { exact: true }).locator('..');
const cell = row.locator('td').nth(1);
const layout = await cell.evaluate((element) => {
const text = element.querySelector('span');
const bounds = element.getBoundingClientRect();
const valueBounds = text.getBoundingClientRect();
const style = getComputedStyle(text);
return {
documentWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
cell: { left: bounds.left, right: bounds.right },
value: {
left: valueBounds.left,
right: valueBounds.right,
width: text.clientWidth,
scrollWidth: text.scrollWidth,
whiteSpace: style.whiteSpace,
textOverflow: style.textOverflow,
},
};
});
(record.layouts ??= []).push({ state, ...layout });
assert(layout.documentScrollWidth <= layout.documentWidth + 1);
assert(layout.value.scrollWidth <= layout.value.width + 1);
assert(layout.value.left >= layout.cell.left - 1);
assert(layout.value.right <= layout.cell.right + 1);
assert.equal(layout.value.whiteSpace, 'normal');
assert.notEqual(layout.value.textOverflow, 'ellipsis');
};
const capture = async (state) => {
if (transport !== 'unified-api') return;
const file = `${name}-${state}.png`;
await drawer.screenshot({ path: output + '/' + file });
result.screenshots.push({
file,
sha256: hash(output + '/' + file),
name,
transport,
state,
});
};
const note = async (state) =>
record.states.push({
state,
evidence: await page.evaluate(() => window.__guestMemory.evidence()),
});
const update = async (next) => {
read = next;
await page.evaluate((value) => window.__guestMemory.update(value), next);
};
assert.match(
await drawer.innerText(),
/Last known · QEMU guest agent · 2026-10-04 14:00:00 UTC/,
);
await checkReadingLayout('retained-overview');
await capture('retained-overview');
await activate(history);
const chart = drawer.locator('[data-history-group="utilization"]');
await chart.locator('[data-history-last-known="memory"]').waitFor();
assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0);
assert.match(
await chart.locator('[data-history-last-known="memory"]').innerText(),
/25\.0%.*last known/s,
);
assert.match(
await chart.locator('[data-history-deferred="memory"]').innerText(),
/14:00:00 UTC/,
);
assert.equal(await chart.locator('path').count(), 0);
const historyCalls = record.requests.filter(
(r) => r.path === '/api/metrics-store/history',
).length;
await page.evaluate(() => {
window.__chartIdentity = document.querySelector('[data-history-group="utilization"]');
});
await note('retained');
await capture('retained-history');
await update({ ...initial(), lastSeen: '2026-10-04T18:00:00Z' });
assert.doesNotMatch(
await chart.locator('[data-history-deferred="memory"]').innerText(),
/18:00:00/,
);
await update({
state: 'current',
source: 'status-mem',
observedAt: '2026-10-04T17:00:00Z',
usage: 35,
});
await chart.locator('[data-history-current="memory"]').waitFor();
assert.match(
await chart.locator('[data-history-current="memory"]').innerText(),
/35\.0%.*current/s,
);
assert.equal(await chart.locator('[data-history-deferred="memory"]').count(), 0);
assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1);
await note('independent-current-pve');
await update({ state: 'unavailable', source: 'unavailable', usage: 0 });
assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0);
assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0);
assert.match(await chart.innerText(), /Memory\s*-/);
await note('unavailable');
await update({ usage: 40 });
await chart.locator('[data-history-unknown="memory"]').waitFor();
assert.match(
await chart.locator('[data-history-unknown="memory"]').innerText(),
/40\.0%.*freshness unknown/s,
);
assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0);
assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0);
assert.equal(
await page.evaluate(
() =>
window.__chartIdentity ===
document.querySelector('[data-history-group="utilization"]'),
),
true,
);
assert.equal(
record.requests.filter((r) => r.path === '/api/metrics-store/history').length,
historyCalls,
);
await activate(overview);
assert.match(await drawer.innerText(), /Freshness unknown · Unknown source · time unknown/);
await checkReadingLayout('unknown-overview');
await capture('unknown-overview');
await note('legacy-unknown');
await activate(history);
await update({ state: 'last-known', source: 'private provider detail', usage: 0 });
await chart.locator('[data-history-last-known="memory"]').waitFor();
assert.match(
await chart.locator('[data-history-last-known="memory"]').innerText(),
/0\.0%.*last known/s,
);
assert.match(
await chart.locator('[data-history-deferred="memory"]').innerText(),
/Observation time unknown/,
);
assert.doesNotMatch(await drawer.innerText(), /private provider detail/);
await note('retained-zero-unknown-age');
mode = 'stored';
await activate(drawer.getByRole('button', { name: 'Refresh history', exact: true }));
await page.waitForFunction(
() => document.querySelectorAll('[data-history-group="utilization"] path').length === 2,
);
assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0);
assert.equal(await chart.locator('[data-history-last-known="memory"]').count(), 0);
assert.match(await chart.innerText(), /Memory\s*30\.0%/);
const slider = chart.getByRole('slider');
await slider.focus();
await page.keyboard.press('Home');
assert.match(await slider.getAttribute('aria-valuetext'), /Memory 20\.0%/);
await note('stored-keyboard-inspection');
mode = 'denied';
await activate(drawer.getByRole('button', { name: 'Refresh history', exact: true }));
await drawer.getByRole('button', { name: 'Retry history', exact: true }).waitFor();
assert.equal(await drawer.locator('[data-history-deferred]').count(), 0);
assert.equal(await drawer.locator('[data-history-group]').count(), 0);
assert.doesNotMatch(await drawer.innerText(), /private fixture refusal/);
mode = 'empty';
await activate(drawer.getByRole('button', { name: 'Retry history', exact: true }));
await chart.locator('[data-history-last-known="memory"]').waitFor();
await note('denied-and-recovered');
await update({
peer: true,
state: 'current',
source: 'agent',
observedAt: '2026-10-04T16:00:00Z',
usage: 70,
});
await drawer.getByText('peer-guest', { exact: true }).waitFor();
assert.equal(await overview.getAttribute('aria-selected'), 'true');
assert.match(await drawer.innerText(), /Current · Pulse Agent · 2026-10-04 16:00:00 UTC/);
assert.doesNotMatch(await drawer.innerText(), /14:00:00 UTC/);
await note('peer-identity-replacement');
await checkReadingLayout('peer-overview');
assert.deepEqual(record.errors, []);
assert(record.requests.every((r) => !r.blocked && r.method === 'GET'));
await page.close();
}
await browser.close();
browser = undefined;
}
result.result = 'passed';
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
process.stdout.write(
`Guest memory provenance: ${result.cases.length} transport/layout cases passed.\n`,
);
} catch (error) {
fs.writeFileSync(
output + '/failure.json',
JSON.stringify({ error: String(error), stack: error.stack, result }, null, 2) + '\n',
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
})();
@@ -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>Guest memory provenance evidence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/guest-memory-provenance.tsx"></script>
</body>
</html>
@@ -0,0 +1,213 @@
// Production hook/adapter/drawer consumers, synthetic source snapshots only.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { useWorkloads } from '../src/hooks/useWorkloads';
import { useUnifiedResources } from '../src/hooks/useUnifiedResources';
import {
mergeCanonicalResourceSnapshot,
mergeCanonicalResourceDeltaSnapshot,
} from '../src/utils/resourceStateAdapters';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { Resource } from '../src/types/resource';
import type { MemoryObservation } from '../src/types/api';
import type { WorkloadGuest } from '../src/types/workloads';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
interface Reading {
state?: string;
source?: string;
observedAt?: string;
usage: number;
lastSeen?: string;
peer?: boolean;
}
const initial: Reading = {
state: 'last-known',
source: 'guest-agent-meminfo',
observedAt: '2026-10-04T14:00:00Z',
usage: 25,
};
const observation = (read: Reading): MemoryObservation | undefined =>
read.state === undefined
? undefined
: {
state: read.state,
source: read.source ?? '',
...(read.observedAt ? { observedAt: read.observedAt } : {}),
};
const capacity = 4 * 1024 ** 3;
const id = (read: Reading) => (read.peer ? 'fixture-b-pve1-101' : 'fixture-a-pve1-101');
const nativeResource = (read: Reading): Resource => ({
id: id(read),
type: 'vm',
name: read.peer ? 'peer-guest' : 'backup-guest',
displayName: read.peer ? 'peer-guest' : 'backup-guest',
status: 'online',
sources: ['proxmox', 'agent'],
platformType: 'proxmox-pve',
platformScopes: ['proxmox-pve'],
sourceType: 'hybrid',
lastSeen: Date.parse(read.lastSeen ?? '2026-10-04T17:00:00Z'),
cpu: { current: 10 },
memory:
read.state === 'unavailable'
? undefined
: {
current: read.usage,
used: (capacity * read.usage) / 100,
total: capacity,
...(observation(read) ? { observation: observation(read) } : {}),
},
disk: { current: 50, used: capacity / 2, total: capacity },
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: read.peer ? 'fixture-b' : 'fixture-a',
runtimeStatus: 'running',
guestAgentStatus: 'deferred',
guestAgentExpected: true,
diskStatusReason: 'prev-vm-locked',
lock: 'backup',
memory: {
total: capacity,
used: capacity / 2,
free: capacity / 2,
usage: 50,
usageUnavailable: read.state === 'unavailable',
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' },
},
},
});
const rawGuest = (read: Reading): WorkloadGuest => ({
id: id(read),
vmid: 101,
name: read.peer ? 'peer-guest' : 'backup-guest',
instance: read.peer ? 'fixture-b' : 'fixture-a',
node: 'pve1',
type: 'qemu',
status: 'running',
cpu: 0.1,
cpus: 2,
memory: {
total: capacity,
used: (capacity * read.usage) / 100,
free: (capacity * (100 - read.usage)) / 100,
usage: read.usage,
usageUnavailable: read.state === 'unavailable',
...(observation(read) ? { observation: observation(read) } : {}),
},
disk: { usage: 50, total: capacity, used: capacity / 2 },
diskStatusReason: 'prev-vm-locked',
lock: 'backup',
guestAgentStatus: 'deferred',
guestAgentExpected: true,
networkIn: 10,
networkOut: 20,
diskRead: 30,
diskWrite: 40,
uptime: 3600,
template: false,
lastBackup: 0,
lastSeen: read.lastSeen ?? '2026-10-04T17:00:00Z',
tags: [],
});
// Only the websocket transport is synthetic. API conversion and owned hooks
// remain production code, with real requests fulfilled by the bounded script.
window.__pulseWsStore = {
state: { resources: [], activeAlerts: [], recentlyResolved: [] },
connected: () => false,
initialDataReceived: () => true,
resourceSnapshotReceived: () => false,
resourceChange: () => ({ version: 0, changedIds: null, changedKeys: null }),
changedResourceMetaSince: () => null,
changedResourceIdsSince: () => null,
shutdown: () => {},
} as unknown as NonNullable<typeof window.__pulseWsStore>;
function Fixture() {
const transport = new URLSearchParams(window.location.search).get('transport') ?? 'api';
const [read, setRead] = createSignal(initial);
const [snapshot, setSnapshot] = createSignal<Resource[]>([nativeResource(initial)]);
const unified =
transport === 'unified-api'
? useUnifiedResources({ query: 'type=vm', realtimeEnabled: () => false })
: null;
const workloads =
transport === 'raw'
? null
: useWorkloads(
() => true,
transport === 'api'
? {}
: { resourceSnapshot: unified ? () => unified.resources() : snapshot },
);
const guest = () => (transport === 'raw' ? rawGuest(read()) : workloads!.workloads()[0]);
(window as any).__guestMemory = {
update: async (next: Reading) => {
setRead(next);
if (transport === 'raw') return;
if (transport === 'api') {
await workloads!.refetch();
return;
}
if (unified) {
await unified.refetch();
return;
}
const incoming = nativeResource(next);
setSnapshot((previous) =>
transport === 'canonical-full' || next.peer
? mergeCanonicalResourceSnapshot([incoming], previous)
: mergeCanonicalResourceDeltaSnapshot(
[incoming],
previous,
new Set([incoming.id]),
transport === 'canonical-fast'
? new Map([[incoming.id, ['memory', 'proxmox', 'lastSeen']]])
: undefined,
),
);
},
evidence: () => ({
id: guest()?.id,
observation: guest()?.memory.observation,
usage: guest()?.memory.usage,
unavailable: guest()?.memory.usageUnavailable,
}),
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest memory evidence</h1>
<p class="text-xs text-muted">
Synthetic {transport} observations. No guest checks are sent.
</p>
<Show when={guest()}>
{() => (
<section aria-label="Guest details">
<GuestDrawer guest={guest()!} onClose={() => {}} />
</section>
)}
</Show>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,378 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/system-alert-scope-proof';
const runtime = [
'frontend-modern/src/utils/alertScope.ts',
'frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts',
'frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx',
'frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx',
'frontend-modern/src/features/alerts/alertHistoryModel.ts',
'frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx',
];
const systemTypes = [
'backup-evaluation',
'notification-delivery',
'deadman-delivery',
'deadman-monitoring-stalled',
'deadman-interruption',
'deadman-state',
];
const legacy = {
id: 'retained-legacy-system-id',
type: 'notification-delivery',
level: 'warning',
resourceId: 'vm-wrong',
resourceName: 'Pulse',
node: 'wrong-node',
metadata: { systemAlert: true, resourceType: 'vm' },
message: 'Synthetic retained notification delivery outage.',
startTime: '2026-10-04T14:00:00Z',
lastSeen: '2026-10-04T14:30:00Z',
acknowledged: true,
value: 0,
threshold: 0,
};
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((file) => [file, hash(path.join('/workspace', file))]),
);
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: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5297';
const results = [],
screenshots = [];
let browser,
phase = 'server';
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop-light', chromium, 1365, false],
['chromium-desktop-dark', chromium, 1365, true],
['webkit-phone-dark', webkit, 390, true],
['webkit-narrow-light', webkit, 320, false],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 500 ? 844 : 900 },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
requests = [],
offOrigin = [],
writes = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.addInitScript((dark) => {
localStorage.clear();
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
await page.routeWebSocket(/.*/, (socket) => socket.close());
await page.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, query: url.search, method: req.method() });
if (req.method() !== 'GET') {
writes.push({ path: url.pathname, method: req.method(), body: req.postDataJSON() });
assert.ok(
['/api/alerts/acknowledge', '/api/alerts/unacknowledge'].includes(url.pathname),
'unexpected write',
);
assert.equal(req.method(), 'POST');
assert.deepEqual(req.postDataJSON(), {
alertIdentifier: 'pulse-system-backup-evaluation',
});
return route.fulfill({ json: { success: true } });
}
if (url.pathname === '/api/alerts/history') return route.fulfill({ json: [legacy] });
if (url.pathname === '/api/alerts/incidents') return route.fulfill({ json: null });
if (url.pathname === '/api/notifications/health')
return route.fulfill({
json: {
overall_healthy: true,
queue: {
healthy: true,
status: 'healthy',
pending: 0,
sending: 0,
sent: 0,
failed: 0,
dlq: 0,
attention_required: 0,
reason_codes: [],
completed_retention_days: 7,
dead_letter_retention_days: 30,
counts_are_retention_bounded: true,
retry_attempts_affect_health: false,
terminal_failures_affect_health: true,
failure_classes_7d: {
authentication: 0,
rate_limited: 0,
connectivity: 0,
tls: 0,
configuration: 0,
rejected: 0,
server_error: 0,
unknown: 0,
},
failure_class_window_days: 7,
failure_classes_available: true,
},
},
});
if (url.pathname.includes('license'))
return route.fulfill({
json: { capabilities: [], entitlements: [], limits: [], max_history_days: 90 },
});
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: false,
sessionCapabilities: { assistantEnabled: true },
},
});
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
phase = `${name}: ${label}`;
await action();
checks.push({ label, passed: true });
};
const capture = async (locator, state) => {
const file = `${name}-${state}.png`;
await locator.screenshot({ path: path.join(output, file) });
screenshots.push({ file, state, width, dark, sha256: hash(path.join(output, file)) });
};
const assertSystemContext = async (id) => {
const context = await page.evaluate(() => window.__systemAlertScope.context());
assert.equal(context.targetId, undefined);
assert.equal(context.targetType, undefined);
assert.deepEqual(context.handoffResources, []);
assert.equal(context.autonomousMode, false);
assert.equal(context.context.guestName, undefined);
assert.equal(context.context.node, undefined);
assert.match(context.handoffContext, /Scope: Pulse itself \(not a monitored resource\)/);
assert.ok(context.handoffContext.includes(`Alert Identifier: ${id}`));
assert.ok(context.handoffContext.includes('Operator Boundary:'));
assert.doesNotMatch(
context.handoffContext,
/Current Value:|Threshold:|wrong-node|vm-wrong/,
);
assert.doesNotMatch(context.briefing.detailLines.join('\n'), /Current value|threshold/);
await page.evaluate(() => window.__systemAlertScope.closeExplanation());
};
try {
await page.goto(`${origin}/browser-tests/system-alert-scope.html`);
const overview = page.getByRole('region', { name: 'Existing alert overview' });
const history = page.getByRole('region', { name: 'Existing alert history' });
const backupCard = page.locator('#alert-pulse-system-backup-evaluation');
await check(
'canonical service warning has no false resource link or policy action',
async () => {
await backupCard.waitFor({ state: 'visible' });
assert.equal(
await overview
.getByText('Notification delivery status is unavailable', { exact: true })
.count(),
0,
);
assert.equal(await backupCard.getByRole('link').count(), 0);
assert.equal(
await backupCard.getByRole('button', { name: 'Have Patrol investigate' }).count(),
0,
);
assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/);
await page.waitForFunction(() => window.__systemAlertScope.historyRows().length === 3);
const rows = await page.evaluate(() => window.__systemAlertScope.historyRows());
for (const row of rows.filter((r) => r.systemAlert)) {
assert.equal(row.resourceType, 'Pulse');
assert.equal(row.resourceId, '');
}
assert.equal(rows.find((r) => r.id === 'resource-cpu').resourceType, 'vm');
},
);
await capture(overview, 'backup-warning');
await check('acknowledgement remains active, not recovery', async () => {
await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).click();
await backupCard.getByText('Acknowledged', { exact: true }).waitFor();
assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/);
await backupCard.getByRole('button', { name: 'Unacknowledge', exact: true }).click();
await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).waitFor();
});
await check('occurrence timeline keeps exact service identity', async () => {
await backupCard.getByRole('button', { name: 'Timeline', exact: true }).click();
await page.waitForFunction(() =>
document
.querySelector('#alert-pulse-system-backup-evaluation')
?.textContent.includes('No incident'),
);
const incident = requests.find((r) => r.path === '/api/alerts/incidents');
assert.ok(incident);
const query = new URLSearchParams(incident.query);
assert.equal(query.get('alertIdentifier'), 'pulse-system-backup-evaluation');
assert.equal(query.get('started_at'), '2026-10-04T15:00:00Z');
await backupCard.getByRole('button', { name: 'Hide Timeline', exact: true }).click();
});
for (const type of systemTypes) {
await check(`${type} explanation is service-scoped and model-only`, async () => {
await page.evaluate((type) => window.__systemAlertScope.updateSystem(type), type);
const card = page.locator(`#alert-pulse-system-${type}`);
await card.waitFor({ state: 'visible' });
assert.equal(await card.getByRole('link').count(), 0);
const button = card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true });
if (width > 500) {
await button.focus();
await page.keyboard.press('Enter');
} else await button.tap();
await assertSystemContext(`pulse-system-${type}`);
});
}
await check(
'legacy system metadata wins over conflicting resource/metric hints',
async () => {
await page.evaluate(() =>
window.__systemAlertScope.updateSystem('future-condition', true),
);
const card = page.locator('#alert-legacy-system-id');
await card.waitFor({ state: 'visible' });
assert.equal(await card.getByRole('link').count(), 0);
assert.doesNotMatch(await card.textContent(), /limit:|wrong-node/);
await card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true }).click();
await assertSystemContext('legacy-system-id');
},
);
await check(
'desktop and phone History reconstruct the retained service marker',
async () => {
const row =
width < 500
? history
.locator('[data-alert-history-row-key]')
.filter({ hasText: legacy.message })
: history.getByRole('row').filter({ hasText: legacy.message });
assert.equal(await row.locator('[data-alert-history-action="resource"]').count(), 0);
await row.getByRole('button', { name: 'Ask Pulse Assistant about this alert' }).click();
await assertSystemContext(legacy.id);
},
);
await capture(history, 'retained-history');
await check(
'monitored VM called Pulse retains its link and targeted affordances',
async () => {
const card = page.locator('#alert-resource-cpu');
assert.equal(
await card.getByRole('link', { name: 'Pulse', exact: true }).getAttribute('href'),
'/proxmox/overview',
);
assert.match(await card.textContent(), /limit: 80%/);
await card
.getByRole('button', { name: 'Have Patrol investigate' })
.waitFor({ state: 'visible' });
},
);
await check(
'authoritative recovery withdraws only the current service warning',
async () => {
await page.evaluate(() => window.__systemAlertScope.recover());
assert.equal(await overview.locator('[id^="alert-legacy"]').count(), 0);
assert.equal(await overview.locator('#alert-resource-cpu').count(), 1);
assert.ok(
(await page.evaluate(() => window.__systemAlertScope.historyRows())).some(
(r) => r.id === legacy.id,
),
);
},
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.equal(writes.length, 2);
assert.ok(
!requests.some((r) => /patrol\/run|execute|diagnostic|ai\/chat|guest-agent/.test(r.path)),
);
results.push({
name,
width,
dark,
browser_version: browser.version(),
checks,
requests,
writes,
errors,
offOrigin,
});
} finally {
await browser.close();
browser = null;
}
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{ passed: true, playwright_version: version, content_sha256: hashes, results, screenshots },
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
passed: true,
groups: results.length,
checks: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
playwright_version: version,
}),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failed.json'),
JSON.stringify(
{ passed: false, phase, error: error.message, results, screenshots },
null,
2,
) + '\n',
);
throw error;
} 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>System-alert scope verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./system-alert-scope.tsx"></script>
</body>
</html>
@@ -0,0 +1,107 @@
// Production alert components/state/router and real Assistant handoff store.
// Synthetic API evidence; no native service, model, diagnostic or command.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { OverviewTab } from '../src/features/alerts/OverviewTab';
import { AlertHistoryTableSection } from '../src/features/alerts/AlertHistoryTableSection';
import { useAlertHistoryState } from '../src/features/alerts/useAlertHistoryState';
import { makeSystemAlert } from '../src/features/alerts/__fixtures__/systemAlerts';
import { aiChatStore } from '../src/stores/aiChat';
import type { Alert } from '../src/types/api';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
aiChatStore.setEnabled(true);
const pulseVM = { id: 'vm-pulse', name: 'Pulse', type: 'vm' } as Resource;
const control = makeSystemAlert('cpu', {
id: 'resource-cpu',
resourceId: pulseVM.id,
metadata: { resourceType: 'vm' },
value: 92,
threshold: 80,
message: 'Synthetic CPU reading on the monitored VM called Pulse.',
});
function Fixture() {
const initial = makeSystemAlert();
const [alerts, setAlerts] = createSignal<Record<string, Alert>>({
[initial.id]: initial,
[control.id]: control,
});
const [showAcknowledged, setShowAcknowledged] = createSignal(true);
const history = useAlertHistoryState({
activeAlerts: alerts,
getResource: () => pulseVM,
allResources: () => [pulseVM],
});
(window as any).__systemAlertScope = {
updateSystem: (type: string, legacy = false) => {
const next = makeSystemAlert(
type,
legacy
? {
id: 'legacy-system-id',
resourceId: 'vm-wrong',
node: 'wrong-node',
metadata: { systemAlert: true, resourceType: 'vm' },
threshold: 80,
}
: {},
);
setAlerts({ [next.id]: next, [control.id]: control });
},
recover: () => setAlerts({ [control.id]: control }),
alerts,
historyRows: history.alertData,
context: () => aiChatStore.context,
closeExplanation: () => aiChatStore.close(),
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Pulse system-alert evidence</h1>
<p class="text-xs text-muted">
Synthetic observations. No workload, diagnostic or model is contacted.
</p>
<section aria-label="Existing alert overview">
<OverviewTab
activeAlerts={alerts()}
overrides={[]}
updateAlert={(id, updates) =>
setAlerts((previous) => ({ ...previous, [id]: { ...previous[id], ...updates } }))
}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
showQuickTip={() => false}
dismissQuickTip={() => {}}
alertsDisabled={() => false}
/>
</section>
<section aria-label="Existing alert history">
<h2 class="mb-2 font-semibold">History</h2>
<AlertHistoryTableSection state={history} />
</section>
<Show when={aiChatStore.isOpenSignal()}>
<section
aria-label="Captured explanation context"
class="rounded-md border border-border p-3"
>
<p class="text-sm">
Test capture of the real explanation store; no Assistant request is executed.
</p>
<pre class="whitespace-pre-wrap break-all text-xs">
{JSON.stringify(aiChatStore.context, null, 2)}
</pre>
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,54 @@
{
"version": 1,
"base_sha": "63443e8712dbb6d3866bdc5fce9f2b89c13be51c",
"verified_at": "2026-10-04T16:33:49Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx",
"frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
"frontend-modern/src/features/alerts/alertHistoryModel.ts",
"frontend-modern/src/utils/alertScope.ts"
],
"content_sha256": {
"frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx": "f68c7c887abd83ee4b8c022874951b6be06d5b9975bb2a78a0f782d3d5419001",
"frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts": "01eaea04cf8730fca4342d12b2be4e2fefd6e31b530ab1e416a937557cb4d664",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx": "9e7bf38b1f3e57f4a8b178ee48770561e5eef7f93440c190d3001ed95526e765",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "198eecb8d3f94aaf14020686c494f343db687d49c0933589005adb795b35bc8b",
"frontend-modern/src/features/alerts/alertHistoryModel.ts": "69e754d21e4b700bd8e7308e72082786373b1f25f0e2d4ea4434a2d4ba804a1a",
"frontend-modern/src/utils/alertScope.ts": "b10844bcf4a3a2a829d49c385369a0559102477b84688f27c311de02ce931065"
},
"routes": ["/browser-tests/system-alert-scope.html"],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"states": [
"Production Overview and desktop/mobile History with synthetic canonical system-alert payloads; no native service or full-App acceptance.",
"Backup evaluation unavailable, all six current system types, metadata-only future/legacy type with conflicting resource/metric hints.",
"Monitored VM named Pulse remains resource scoped; independent synthetic notification queue health is complete and healthy.",
"Acknowledged service warning remains active; explicit source recovery removes only current service warning and retains historical occurrence.",
"Light/dark desktop and 390px/320px WebKit touch layouts; eight final screenshots inspected."
],
"interactions": [
"Acknowledgement and unacknowledgement retain exact canonical service alert identifier and do not resolve its condition.",
"Occurrence timeline fetch preserves service identifier and original start timestamp.",
"Six real explanation-button handoffs use keyboard Enter on desktop and touch tap on phones; no metric values, resource targets, Patrol, model or diagnostic calls.",
"History explanation reconstructs metadata-only system scope; resource link/Monitoring/Patrol remain only on actual VM control."
],
"browser_command": "pulse-worker-browser frontend-modern/browser-tests/system-alert-scope.cjs",
"playwright_version": "1.56.1",
"passed_groups": 4,
"passed_checks": 52
}
@@ -0,0 +1,77 @@
{
"version": 1,
"result": "passed",
"base_sha": "3d846f76d724b1853a3ea58201cfc6eb7e25b084",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/types/api.ts",
"frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/memoryObservation.ts",
"frontend-modern/src/utils/resourceStateAdapters.ts"
],
"content_sha256": {
"frontend-modern/src/types/api.ts": "f5805a599793431a45c5a88341cc8af4ad1a05787ce51d75b9fbb75f5dcf5606",
"frontend-modern/src/types/resource.ts": "daffee073bb11699518d9c45aeb8075d4e53535ee6f385b0dffb8d06a05c670d",
"frontend-modern/src/utils/memoryObservation.ts": "a9ef178ff58cf9ab3e622af3a6cceb9e9da9f42e03b2d246fe73ab22e8a3bf1e",
"frontend-modern/src/utils/resourceStateAdapters.ts": "f55c5ff10daafad9b6c577f68777696329c69e0b0bb2c13ec662c7c7d59e4e29",
"frontend-modern/src/hooks/useUnifiedResources.ts": "e4ea180cd2e372eab77414ae96d9c180ad706b67b2619a67f29bb046e38a55ef",
"frontend-modern/src/hooks/useWorkloads.ts": "0915943dd0cbb65940ab75a3e1143a80eda53c32db485181d56948e640f9e66d",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "c4e4d5f2ecd0d65cba73b14d400af0f8eacae10965d1ad9b499dfc1b03ac658d",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "2c72e3b40e311c91ee789724ac72cde01ebe36166c1f23a462a320d1a7f03add",
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "5d592a9c3422a20e4596d25dc673f894219663c80392240ea1094ccd282e0848"
},
"routes": [
"/browser-tests/guest-memory-provenance.html?transport=raw",
"/browser-tests/guest-memory-provenance.html?transport=api",
"/browser-tests/guest-memory-provenance.html?transport=unified-api",
"/browser-tests/guest-memory-provenance.html?transport=canonical-full",
"/browser-tests/guest-memory-provenance.html?transport=canonical-delta",
"/browser-tests/guest-memory-provenance.html?transport=canonical-fast"
],
"states": [
"retained QGA memory with original observation time",
"repeated deferral without renewing age or History reads",
"independent current PVE memory despite disk deferral",
"unavailable memory without fabricated zero/free measurement",
"legacy unknown memory without borrowing conflicting platform provenance",
"retained measured zero with unknown source/time",
"stored points and dated keyboard inspection",
"403 concealment followed by permitted recovery",
"same-guest replacement without chart remount; peer identity replacement resets drawer"
],
"interactions": [
"keyboard Enter and touch select real drawer tabs and Refresh/Retry",
"Home key inspects stored history through the accessible slider",
"production raw/API/unified/canonical full/delta/fast consumer updates",
"reading-cell wrapping and page-width checks at all viewports",
"only bounded synthetic GET responses; no mutations or guest diagnostics",
"twelve captured and inspected light/dark Overview/History images, including 390px and 320px"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"verified_at": "2026-10-04T19:16:25Z",
"browsers": [
"141.0.7390.37",
"26.0"
],
"playwright_version": "1.56.1",
"proof_script": "frontend-modern/browser-tests/guest-memory-provenance.cjs",
"proof_cases": 24,
"limits": "Production drawer/history/model with real raw, API, unified API and canonical full/delta/fast consumers. Synthetic source payloads, History replies and disconnected websocket metadata. No full App, native QGA/thaw, filesystem writes, physical phone, reporter, installed or publication acceptance."
}
@@ -7,6 +7,7 @@ import { notificationStore } from '@/stores/notifications';
import { triggerPatrolRun } from '@/api/patrol';
import { t } from '@/i18n';
import type { Alert } from '@/types/api';
import { isPulseSystemAlert } from '@/utils/alertScope';
import { useUpgradeNavigation } from '@/components/shared/useUpgradeNavigation';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
@@ -54,7 +55,10 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
}
const showPatrolOption = () =>
props.patrolOption === true && !isLocked() && !!(props.alert.resourceId || '').trim();
props.patrolOption === true &&
!isLocked() &&
!isPulseSystemAlert(props.alert) &&
!!(props.alert.resourceId || '').trim();
const usesPatrolPrimary = () => showPatrolOption() && props.variant !== 'icon';
const buttonTitle = () =>
isLocked()
@@ -97,7 +101,7 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
const startPatrolInvestigation = async () => {
const resourceId = (props.alert.resourceId || '').trim();
if (!resourceId) {
if (isPulseSystemAlert(props.alert) || !resourceId) {
notificationStore.warning(t('alerts.assistant.patrol.noResource'));
return;
}
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
import type { Alert } from '@/types/api';
import { makeSystemAlert } from '@/features/alerts/__fixtures__/systemAlerts';
import { getPublicPricingUrl } from '@/utils/pricingHandoff';
// ---------------------------------------------------------------------------
@@ -155,6 +156,44 @@ beforeEach(() => {
notificationStoreMock.info.mockReset();
});
describe('system-alert investigation', () => {
it('retains resource Patrol for a monitored VM named Pulse', async () => {
const alert = makeAlert({ resourceName: 'Pulse' });
render(() => <InvestigateAlertButton alert={alert} variant="full" patrolOption />);
fireEvent.click(screen.getByRole('button', { name: 'Have Patrol investigate' }));
await waitFor(() => expect(triggerPatrolRunMock).toHaveBeenCalledTimes(1));
expect(triggerPatrolRunMock).toHaveBeenCalledWith({
resource_ids: ['vm-101'],
alert_identifier: alert.id,
alert_type: 'cpu',
});
expect(explainMock).not.toHaveBeenCalled();
});
it('retains metadata-only system scope on a historical ID', () => {
const alert = makeSystemAlert('future-condition', { id: 'legacy-id', resourceId: 'vm-wrong' });
render(() => <InvestigateAlertButton alert={alert} variant="full" patrolOption />);
fireEvent.click(screen.getByRole('button', { name: 'Ask Pulse Assistant about this alert' }));
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
expect(openedContext().targetId).toBeUndefined();
expect(openedContext().handoffResources).toEqual([]);
});
it.each([undefined, { systemAlert: true, resourceType: 'vm' }])(
'opens explanation, never resource Patrol, for a system alert with metadata %j',
(metadata) => {
const alert = makeSystemAlert('backup-evaluation', { resourceId: 'vm-wrong', metadata });
render(() => <InvestigateAlertButton alert={alert} variant="full" patrolOption />);
fireEvent.click(screen.getByRole('button', { name: 'Ask Pulse Assistant about this alert' }));
expect(explainMock).toHaveBeenCalledTimes(1);
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
expect(openedContext().targetId).toBeUndefined();
expect(openedContext().handoffResources).toEqual([]);
expect(formatAlertValueMock).not.toHaveBeenCalled();
},
);
});
// ---------------------------------------------------------------------------
// Rendering / visibility
// ---------------------------------------------------------------------------
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Alert } from '@/types/api';
import { buildAlertAssistantHandoff } from '../alertAssistantHandoffModel';
import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '@/features/alerts/__fixtures__/systemAlerts';
function makeAlert(overrides: Partial<Alert> = {}): Alert {
return {
@@ -22,6 +23,39 @@ function makeAlert(overrides: Partial<Alert> = {}): Alert {
}
describe('alertAssistantHandoffModel', () => {
it.each(SYSTEM_ALERT_TYPES)(
'explains Pulse %s without fabricated metric or resource context',
(type) => {
const alert = makeSystemAlert(type);
const { context } = buildAlertAssistantHandoff({ alert, resourceType: 'vm', vmid: 101 });
expect(context.targetType).toBeUndefined();
expect(context.targetId).toBeUndefined();
expect(context.handoffResources).toEqual([]);
expect(context.autonomousMode).toBe(false);
expect(context.handoffContext).toContain('Scope: Pulse itself (not a monitored resource)');
expect(context.handoffContext).toContain(alert.message);
expect(context.handoffContext).toContain(`Alert Identifier: ${alert.id}`);
expect(context.handoffContext).toContain('Operator Boundary:');
expect(context.handoffContext).not.toMatch(/Current Value:|Threshold:|Resource ID:|Node:/);
expect(context.context?.guestName).toBeUndefined();
expect(context.context?.vmid).toBeUndefined();
expect(context.briefing?.detailLines?.join('\n')).not.toMatch(/Current value|threshold/);
},
);
it('retains system scope for future types and legacy IDs carrying system metadata', () => {
const { context } = buildAlertAssistantHandoff({
alert: makeSystemAlert('future-condition', {
id: 'legacy-system-id',
resourceId: 'vm-wrong',
node: 'wrong-node',
metadata: { systemAlert: true, resourceType: 'vm' },
}),
});
expect(context.targetId).toBeUndefined();
expect(context.handoffResources).toEqual([]);
expect(context.handoffContext).not.toMatch(/wrong-node|vm-wrong/);
});
it('builds a visible approval-required alert handoff without command payloads', () => {
const handoff = buildAlertAssistantHandoff({
alert: makeAlert(),
@@ -4,6 +4,7 @@ import { DEFAULT_LOCALE, t, type SupportedLocale } from '@/i18n';
import { getCanonicalAlertId } from '@/features/alerts/identity';
import { formatAlertValue } from '@/utils/alertFormatters';
import { isMetricAlertType } from '@/utils/alerts';
import { isPulseSystemAlert } from '@/utils/alertScope';
import { resolveAlertTargetType } from '@/utils/alertTargetTypes';
interface BuildAlertAssistantHandoffInput {
@@ -32,23 +33,27 @@ export function buildAlertAssistantHandoff({
// threshold 0.0%" in operator-facing copy is misleading default-zero
// noise. Suppress those fields and rely on alert.type + alert.message
// to convey what's wrong.
const hasMetricValues = isMetricAlertType(alert.type);
const systemScoped = isPulseSystemAlert(alert);
const hasMetricValues = !systemScoped && isMetricAlertType(alert.type);
const currentValue = hasMetricValues ? formatAlertValue(alert.value, alert.type) : '';
const thresholdValue = hasMetricValues ? formatAlertValue(alert.threshold, alert.type) : '';
const nodeLabel = alert.node ? alert.nodeDisplayName || alert.node : '';
const nodeLabel = !systemScoped && alert.node ? alert.nodeDisplayName || alert.node : '';
const levelLabel = formatAlertLevel(alert.level);
const modelLevelLabel = formatAlertLevel(alert.level, DEFAULT_LOCALE);
const targetType = resolveAlertTargetType({
alertType: alert.type,
resourceType,
metadataResourceType:
typeof alert.metadata?.resourceType === 'string'
? (alert.metadata.resourceType as string)
: undefined,
resourceId: alert.resourceId,
});
const targetType = systemScoped
? undefined
: resolveAlertTargetType({
alertType: alert.type,
resourceType,
metadataResourceType:
typeof alert.metadata?.resourceType === 'string'
? (alert.metadata.resourceType as string)
: undefined,
resourceId: alert.resourceId,
});
const handoffContext = buildAlertAssistantModelContext({
alert,
systemScoped,
alertIdentifier,
currentValue,
thresholdValue,
@@ -60,17 +65,19 @@ export function buildAlertAssistantHandoff({
return {
context: {
targetType,
targetId: alert.resourceId,
targetId: systemScoped ? undefined : alert.resourceId,
autonomousMode: false,
handoffContext,
handoffResources: [
{
id: alert.resourceId,
name: alert.resourceName,
type: targetType,
node: alert.node,
},
],
handoffResources: systemScoped
? []
: [
{
id: alert.resourceId,
name: alert.resourceName,
type: targetType,
node: alert.node,
},
],
briefing: {
sourceLabel: t('alerts.assistant.sourceLabel'),
title: t('alerts.assistant.title'),
@@ -103,9 +110,9 @@ export function buildAlertAssistantHandoff({
alertType: alert.type,
alertLevel: alert.level,
alertMessage: alert.message,
guestName: alert.resourceName,
node: alert.node,
vmid,
guestName: systemScoped ? undefined : alert.resourceName,
node: systemScoped ? undefined : alert.node,
vmid: systemScoped ? undefined : vmid,
},
},
};
@@ -113,6 +120,7 @@ export function buildAlertAssistantHandoff({
function buildAlertAssistantModelContext({
alert,
systemScoped,
alertIdentifier,
currentValue,
thresholdValue,
@@ -121,6 +129,7 @@ function buildAlertAssistantModelContext({
levelLabel,
}: {
alert: Alert;
systemScoped: boolean;
alertIdentifier: string;
currentValue: string;
thresholdValue: string;
@@ -135,8 +144,9 @@ function buildAlertAssistantModelContext({
formatContextLine('Alert Type', alert.type),
formatContextLine('Alert Level', levelLabel),
formatContextLine('Alert Status', 'active'),
formatContextLine('Resource', alert.resourceName),
formatContextLine('Resource ID', alert.resourceId),
systemScoped ? 'Scope: Pulse itself (not a monitored resource)' : undefined,
formatContextLine(systemScoped ? 'Service' : 'Resource', alert.resourceName),
systemScoped ? undefined : formatContextLine('Resource ID', alert.resourceId),
formatContextLine('Current Value', currentValue),
formatContextLine('Threshold', thresholdValue),
formatContextLine('Duration', durationText),
@@ -309,14 +309,22 @@ describe('GuestDrawer', () => {
);
expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
setGuest({ ...guest(), diskStatusReason: '', disk: { total: 100, used: 75, usage: 75 } });
expect(history.querySelector('[data-history-last-known]')).toBeNull();
expect(history.querySelector('[data-history-deferred]')).toBeNull();
expect(history.querySelector('[data-history-last-known="disk"]')).toBeNull();
expect(history.querySelector('[data-history-deferred="disk"]')).toBeNull();
expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent(
'Disk75.0%current',
);
// Fresh disk evidence cannot qualify the independent legacy memory value.
expect(history.querySelector('[data-history-current="memory"]')).toBeNull();
expect(history.querySelector('[data-history-unknown="memory"]')).toHaveTextContent(
'freshness unknown',
);
expect(history.querySelector('[data-history-deferred="memory"]')).toHaveTextContent(
'Not a current measurement.',
);
expect(history.querySelector('path')).toBeNull();
// Existing drawer target recomputation follows each guest snapshot.
// This presentation repair does not change that request ownership.
// Same target/range snapshots retain the chart and original History read.
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1);
for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) {
expect(request).toMatchObject({
resourceType: 'vm',
@@ -328,7 +328,8 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
deferred,
descriptionId: `${inspectionId}-deferred-${index}`,
isCurrent: isFallback && !deferred,
isLastKnown: isFallback && Boolean(deferred),
isLastKnown: isFallback && Boolean(deferred) && !deferred?.valueLabel,
isUnknown: isFallback && deferred?.valueLabel === 'freshness unknown',
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: activeTimestamp() !== null
@@ -393,6 +394,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
class="inline-flex items-center gap-1"
data-history-current={item.isCurrent ? item.metric : undefined}
data-history-last-known={item.isLastKnown ? item.metric : undefined}
data-history-unknown={item.isUnknown ? item.metric : undefined}
aria-describedby={item.deferred ? item.descriptionId : undefined}
>
<svg aria-hidden="true" class="h-2.5 w-2.5 shrink-0" viewBox="0 0 10 10">
@@ -403,6 +405,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<Show when={item.isCurrent}>
<span>current</span>
</Show>
<Show when={item.isUnknown}>
<span>freshness unknown</span>
</Show>
<Show when={item.isLastKnown}>
<span>last known</span>
</Show>
@@ -419,7 +424,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
class="mb-2 text-xs leading-relaxed text-muted"
data-history-deferred={item.metric}
>
{item.label} live reading: {item.deferred?.message}
{item.label} reading: {item.deferred?.message}
</p>
)}
</For>
@@ -637,11 +642,10 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
});
const locked = createMemo(() => isRangeLocked(props.range));
const historyQuery = createNonSuspendingQuery<
AllMetricsHistoryResponse,
GuestDrawerHistoryQueryKey
>({
source: () => {
// Snapshot replacement often creates a new target object with unchanged
// coordinates. Only a real target/range/access change owns a new read.
const historySource = createMemo<GuestDrawerHistoryQueryKey | null>(
() => {
const target = props.target;
if (!target || locked()) return null;
return {
@@ -650,6 +654,24 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
range: props.range,
};
},
null,
{
equals: (previous, next) =>
previous === next ||
Boolean(
previous &&
next &&
previous.resourceType === next.resourceType &&
previous.resourceId === next.resourceId &&
previous.range === next.range,
),
},
);
const historyQuery = createNonSuspendingQuery<
AllMetricsHistoryResponse,
GuestDrawerHistoryQueryKey
>({
source: historySource,
fetcher: async (key, signal) =>
normalizeHistoryResponse(
await ChartsAPI.getMetricsHistory({
@@ -23,6 +23,7 @@ import { buildWorkloadsDiskPresentation } from './diskListModel';
import {
getGuestDrawerAlertMessage,
getGuestDrawerMemoryRows,
getGuestDrawerMemoryReading,
getGuestDrawerGuestReadPresentation,
isGuestDrawerVM,
} from './guestDrawerModel';
@@ -94,6 +95,9 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
tone: guestReadPresentation()?.tone,
wrap: true,
}),
makeDetailRow('Memory reading', getGuestDrawerMemoryReading(props.guest)?.summary, {
wrap: true,
}),
makeDetailRow('Primary IP', props.ipAddresses[0]),
// Starting or ending an operation is not evidence of a completed
// backup. Keep its activity separate from the last completed age.
@@ -30,7 +30,13 @@ const guest = (reason: string, usage = 50): WorkloadGuest => ({
status: 'running',
cpu: 0.1,
cpus: 2,
memory: { total: 100, used: 25, free: 75, usage: 25 },
memory: {
total: 100,
used: 25,
free: 75,
usage: 25,
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T03:00:00Z' },
},
disk: { total: 100, used: usage, usage },
networkIn: 100,
networkOut: 200,
@@ -86,7 +92,7 @@ describe('GuestDrawerHistory filesystem provenance', () => {
const retained = chart.querySelector('[data-history-last-known="disk"]')!;
expect(retained).toHaveTextContent('Disk50.0%last known');
expect(retained).toHaveAccessibleDescription(
`Disk live reading: Using last known disk stats. ${message}`,
`Disk reading: Using last known disk stats. ${message}`,
);
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
@@ -0,0 +1,261 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import type { WorkloadGuest } from '@/types/workloads';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
import {
getGuestDrawerCurrentMetrics,
getGuestDrawerDeferredMetrics,
getGuestDrawerMemoryRows,
getGuestDrawerMemoryReading,
} from '../guestDrawerModel';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
maxHistoryDays: () => 90,
isRangeLocked: () => false,
}));
afterEach(() => {
cleanup();
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
const original = '2026-10-04T14:00:00Z';
const later = '2026-10-04T17:00:00Z';
const annotation = (
state: string,
observedAt: string | undefined = original,
source = 'guest-agent-meminfo',
) => ({ state, source, ...(observedAt === undefined ? {} : { observedAt }) });
const guest = (observation?: ReturnType<typeof annotation>, usage = 25): WorkloadGuest => {
const memory = {
total: 100,
used: usage,
free: 100 - usage,
usage,
...(observation ? { observation } : {}),
};
return {
id: 'fixture:pve1:101',
vmid: 101,
name: 'backup-guest',
node: 'pve1',
instance: 'fixture',
type: 'qemu',
status: 'running',
cpu: 0.1,
cpus: 2,
memory,
disk: { total: 100, used: 50, usage: 50 },
networkIn: 100,
networkOut: 200,
diskRead: 300,
diskWrite: 400,
uptime: 3600,
template: false,
lastBackup: 0,
tags: [],
lock: '',
lastSeen: later,
};
};
const response = (
metrics: AllMetricsHistoryResponse['metrics'] = {},
): AllMetricsHistoryResponse => ({
resourceType: 'vm',
resourceId: 'fixture:pve1:101',
range: '1h',
start: Date.parse(original),
end: Date.parse(later),
source: 'store',
metrics,
});
const mount = (value: () => WorkloadGuest) =>
render(() => (
<GuestDrawerHistory
target={{ resourceType: 'vm', resourceId: 'fixture:pve1:101' }}
range="1h"
currentMetrics={getGuestDrawerCurrentMetrics(value())}
deferredMetrics={getGuestDrawerDeferredMetrics(value())}
/>
));
const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
describe('guest memory original-observation provenance', () => {
it.each(['qemu', 'lxc'] as const)(
'retains %s memory as evidence without inventing current freshness',
(type) => {
const value = { ...guest(annotation('last-known')), type };
expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(value).memory).toMatchObject({
lastKnownValue: 25,
message: expect.stringContaining('2026-10-04 14:00:00 UTC'),
});
expect(getGuestDrawerMemoryReading(value)?.summary).toContain('Last known');
expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({ cpu: 10, disk: 50, netin: 100 });
},
);
it('never borrows disk deferral or refreshed guest LastSeen for memory provenance', () => {
const retained = {
...guest(annotation('last-known')),
diskStatusReason: undefined,
lock: '',
lastSeen: later,
};
expect(getGuestDrawerCurrentMetrics(retained).memory).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(retained).memory.message).toContain('14:00:00 UTC');
expect(getGuestDrawerDeferredMetrics(retained).memory.message).not.toContain('17:00:00');
const pve = {
...guest(annotation('current', original, 'status-mem')),
diskStatusReason: 'prev-vm-locked',
lock: 'backup',
};
expect(getGuestDrawerCurrentMetrics(pve).memory).toBe(25);
expect(getGuestDrawerDeferredMetrics(pve).memory).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(pve).disk.lastKnownValue).toBe(50);
});
it('keeps unavailable numbers out of current and retained legends and usage breakdown', () => {
const value = guest(annotation('unavailable'), 0);
expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(value).memory.lastKnownValue).toBeUndefined();
expect(getGuestDrawerMemoryRows(value)).toContainEqual({
label: 'Usage',
value: 'Unavailable',
});
expect(getGuestDrawerMemoryRows(value).some((row) => row.label === 'Free')).toBe(false);
expect(getGuestDrawerMemoryRows(value)).toContainEqual({ label: 'Total', value: '100 B' });
});
it.each([
undefined,
annotation('private raw state'),
annotation('current', 'invalid'),
annotation('current', '0001-01-01T00:00:00Z'),
annotation('current', '2999-01-01T00:00:00Z'),
])('does not promote unknown legacy or unusable memory provenance %#', (observation) => {
const value = guest(observation);
expect(getGuestDrawerCurrentMetrics(value).memory).toBeUndefined();
expect(getGuestDrawerDeferredMetrics(value).memory).toMatchObject({
lastKnownValue: 25,
valueLabel: 'freshness unknown',
});
expect(getGuestDrawerDeferredMetrics(value).memory.message).not.toMatch(
/private raw state|2999|0001/,
);
});
it.each([-1, NaN, Infinity, 101])('does not expose invalid retained memory %s', (usage) => {
expect(
getGuestDrawerDeferredMetrics(guest(annotation('last-known'), usage)).memory.lastKnownValue,
).toBeUndefined();
});
it('retains unknown original age without exposing a raw source string', () => {
const value = guest({ state: 'last-known', source: 'private provider detail' });
expect(getGuestDrawerMemoryReading(value)?.summary).toBe(
'Last known · Unknown source · time unknown',
);
expect(getGuestDrawerDeferredMetrics(value).memory.message).toContain(
'Observation time unknown.',
);
expect(getGuestDrawerDeferredMetrics(value).memory.message).not.toContain(
'private provider detail',
);
});
it('retains measured zero and leaves unannotated non-Proxmox data unchanged', () => {
expect(getGuestDrawerDeferredMetrics(guest(annotation('last-known'), 0)).memory).toBeDefined();
expect(
getGuestDrawerDeferredMetrics(guest(annotation('last-known'), 0)).memory.lastKnownValue,
).toBe(0);
const vsphere = {
...guest(),
vmid: 0,
instance: '',
node: '',
platformScopes: ['vmware-vsphere'],
};
expect(getGuestDrawerCurrentMetrics(vsphere).memory).toBe(25);
expect(getGuestDrawerDeferredMetrics(vsphere)).toEqual({});
});
it('updates same-guest retained, unavailable and independent live source without remount or refetch', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
const [value, setValue] = createSignal(guest(annotation('last-known')));
mount(value);
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
const chart = utilization();
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
expect(chart.querySelector('[data-history-last-known="memory"]')).toHaveTextContent(
'Memory25.0%last known',
);
expect(chart.querySelector('[data-history-deferred="memory"]')).toHaveTextContent(
'14:00:00 UTC',
);
setValue({ ...value(), lastSeen: later, lock: 'backup', diskStatusReason: 'prev-vm-locked' });
expect(chart.querySelector('[data-history-deferred="memory"]')).not.toHaveTextContent(
'17:00:00',
);
setValue(guest(annotation('unavailable'), 0));
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
expect(chart.querySelector('[data-history-last-known="memory"]')).toBeNull();
expect(chart).toHaveTextContent('Memory-');
setValue(guest(annotation('current', later, 'agent'), 35));
expect(utilization()).toBe(chart);
expect(chart.querySelector('[data-history-deferred="memory"]')).toBeNull();
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
'Memory35.0%current',
);
expect(chart.querySelector('path')).toBeNull();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('keeps stored points and dated inspection distinct from retained live memory', async () => {
const point = (timestamp: string, value: number) => ({
timestamp: Date.parse(timestamp),
value,
min: value,
max: value,
});
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
response({ memory: [point(original, 20), point(later, 30)] }),
);
mount(() => guest(annotation('last-known'), 25));
await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(1));
const chart = utilization();
expect(chart).toHaveTextContent('Memory30.0%');
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
expect(chart.querySelector('[data-history-last-known="memory"]')).toBeNull();
const slider = within(chart).getByRole('slider');
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(chart).toHaveTextContent('Memory20.0%');
expect(slider).toHaveAttribute('aria-valuetext', expect.stringContaining('Memory 20.0%.'));
});
it('withdraws retained annotations on denied History without leaking raw errors', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory')
.mockResolvedValueOnce(response())
.mockRejectedValueOnce(Object.assign(new Error('private backend refusal'), { status: 403 }));
mount(() => guest(annotation('last-known')));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
'aria-busy',
'false',
),
);
fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(),
);
expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument();
expect(document.querySelector('[data-history-deferred]')).toBeNull();
expect(screen.queryByText('private backend refusal')).not.toBeInTheDocument();
});
});
@@ -49,6 +49,65 @@ const group = (id = 'utilization') =>
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
it('reads again only for changed coordinates, not a fresh same-target snapshot object', async () => {
const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
const [source, setSource] = createSignal(target);
const [usage, setUsage] = createSignal(25);
render(() => (
<GuestDrawerHistory target={source()} range="1h" currentMetrics={{ memory: usage() }} />
));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
'aria-busy',
'false',
),
);
const chart = group();
setSource({ ...target });
setUsage(35);
await waitFor(() =>
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
'35.0%current',
),
);
expect(fetch).toHaveBeenCalledTimes(1);
expect(group()).toBe(chart);
setSource({ ...target, resourceId: 'peer-host' });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(fetch.mock.calls[1][0]).toMatchObject({ resourceId: 'peer-host' });
});
it('keeps unknown freshness distinct from current, last-known and stored readings', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
render(() => (
<GuestDrawerHistory
target={target}
range="1h"
currentMetrics={{ memory: 99 }}
deferredMetrics={{
memory: {
lastKnownValue: 25,
valueLabel: 'freshness unknown',
message: 'Observation time unknown. Not a current measurement.',
},
}}
/>
));
await waitFor(() =>
expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
);
expect(group().querySelector('[data-history-unknown="memory"]')).toHaveTextContent(
'Memory25.0%freshness unknown',
);
expect(group().querySelector('[data-history-unknown="memory"]')).toHaveAccessibleDescription(
'Memory reading: Observation time unknown. Not a current measurement.',
);
expect(group().querySelector('[data-history-current]')).toBeNull();
expect(group().querySelector('[data-history-last-known]')).toBeNull();
expect(group().querySelector('path')).toBeNull();
expect(dots()).toHaveLength(0);
});
it('gives explicit deferred evidence precedence over a numeric current fallback', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
render(() => (
@@ -67,7 +126,7 @@ describe('GuestDrawerHistory sparse observations and current provenance', () =>
'Disk50.0%last known',
);
expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription(
'Disk live reading: Guest reads paused.',
'Disk reading: Guest reads paused.',
);
expect(group().querySelector('path')).toBeNull();
expect(dots()).toHaveLength(0);
@@ -80,7 +80,17 @@ describe('guestDrawerModel (branch coverage)', () => {
it('scales the canonical workload cpu ratio by 100 and returns finite metric values', () => {
const guest = makeGuest({
cpu: 1.0,
memory: { total: 100, used: 40, free: 60, usage: 0.4 },
memory: {
total: 100,
used: 40,
free: 60,
usage: 0.4,
observation: {
state: 'current',
source: 'status-mem',
observedAt: '2026-10-04T03:00:00Z',
},
},
disk: { total: 100, used: 30, free: 70, usage: 0.3 },
networkIn: 100,
networkOut: 200,
@@ -65,6 +65,7 @@ export interface GuestDrawerHistoryTimeBounds {
export interface GuestDrawerHistoryDeferredMetric {
lastKnownValue?: number;
valueLabel?: 'freshness unknown';
message: string;
}
@@ -106,18 +107,106 @@ const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
};
export interface GuestDrawerMemoryReading {
state: 'current' | 'last-known' | 'unavailable' | 'unknown';
summary: string;
message: string;
}
const memorySourceLabels: Record<string, string> = {
'guest-agent-meminfo': 'QEMU guest agent',
'guest-agent-meminfo-derived': 'QEMU guest agent',
agent: 'Pulse Agent',
'available-field': 'Proxmox',
'derived-free-buffers-cached': 'Proxmox',
'derived-total-minus-used': 'Proxmox',
'status-mem': 'Proxmox',
'status-freemem': 'Proxmox',
'cluster-resources': 'Proxmox',
'previous-snapshot': 'Previous snapshot',
};
const getGuestDrawerMemoryUsage = (guest: Guest): number | undefined => {
const usage = guest.memory?.usage;
return guest.telemetryAvailability?.memory !== false &&
!guest.memory?.usageUnavailable &&
typeof usage === 'number' &&
Number.isFinite(usage) &&
usage >= 0 &&
usage <= 100
? usage
: undefined;
};
export const getGuestDrawerMemoryReading = (guest: Guest): GuestDrawerMemoryReading | null => {
const observation = guest.memory?.observation;
const nonProxmoxVMware =
guest.platformScopes?.includes('vmware-vsphere') &&
!guest.platformScopes.includes('proxmox-pve');
const proxmoxGuest =
!nonProxmoxVMware &&
(guest.type === 'qemu' ||
guest.type === 'lxc' ||
guest.platformScopes?.includes('proxmox-pve') ||
(guest.vmid > 0 && Boolean(guest.node && guest.instance)));
// Unannotated unrelated platforms retain their existing behaviour. Legacy
// Proxmox readings lack authority to assert current freshness.
if (!proxmoxGuest && !observation) return null;
const timestamp =
typeof observation?.observedAt === 'string' ? Date.parse(observation.observedAt) : NaN;
const validTime = Number.isFinite(timestamp) && timestamp > 0 && timestamp <= Date.now();
const observed = validTime
? new Date(timestamp).toISOString().replace('T', ' ').replace('.000Z', 'Z').replace('Z', ' UTC')
: null;
const state =
getGuestDrawerMemoryUsage(guest) === undefined || observation?.state === 'unavailable'
? 'unavailable'
: observation?.state === 'last-known'
? 'last-known'
: observation?.state === 'current' && validTime
? 'current'
: 'unknown';
const label =
state === 'current'
? 'Current'
: state === 'last-known'
? 'Last known'
: state === 'unavailable'
? 'Unavailable'
: 'Freshness unknown';
const source =
observation && Object.hasOwn(memorySourceLabels, observation.source)
? memorySourceLabels[observation.source]
: 'Unknown source';
const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.';
return {
state,
summary: `${label} · ${source}${observed ? ` · ${observed}` : ' · time unknown'}`,
message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}`,
};
};
export const getGuestDrawerDeferredMetrics = (
guest: Guest,
): Record<string, GuestDrawerHistoryDeferredMetric> => {
if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {};
return {
disk: {
const metrics: Record<string, GuestDrawerHistoryDeferredMetric> = {};
if (isGuestDrawerVM(guest) && guest.diskStatusReason) {
metrics.disk = {
lastKnownValue: guest.diskStatusReason.startsWith('prev-')
? getGuestDrawerDiskUsage(guest)
: undefined,
message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
},
};
};
}
const memory = getGuestDrawerMemoryReading(guest);
if (memory && memory.state !== 'current') {
metrics.memory = {
lastKnownValue: memory.state === 'unavailable' ? undefined : getGuestDrawerMemoryUsage(guest),
...(memory.state === 'unknown' ? { valueLabel: 'freshness unknown' as const } : {}),
message: memory.message,
};
}
return metrics;
};
// Current-value metrics displayed beside history legends while the metrics
@@ -128,8 +217,11 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record<string, numbe
const available = (metric: keyof NonNullable<Guest['telemetryAvailability']>): boolean =>
availability?.[metric] ?? true;
const cpuPercent = available('cpu') ? getWorkloadCPUPercent(guest.cpu) : undefined;
const memoryReading = getGuestDrawerMemoryReading(guest);
const memUsage =
available('memory') && !guest.memory?.usageUnavailable ? guest.memory?.usage : undefined;
available('memory') && (!memoryReading || memoryReading.state === 'current')
? getGuestDrawerMemoryUsage(guest)
: undefined;
// A paused filesystem read is not current, even if the snapshot still
// carries a numeric summary. Retained evidence has its own labelled path.
const diskUsage =
@@ -497,11 +589,12 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] =
if (!memory) return [];
const rows: GuestDrawerMemoryRow[] = [];
const reading = getGuestDrawerMemoryReading(guest);
const total = memory.total ?? 0;
const used = memory.used ?? 0;
const cache = memory.cache ?? 0;
if (memory.usageUnavailable) {
if (memory.usageUnavailable || reading?.state === 'unavailable') {
rows.push({ label: 'Usage', value: 'Unavailable' });
if (total > 0) {
rows.push({ label: 'Total', value: formatBytes(total) });
@@ -524,7 +617,12 @@ export const getGuestDrawerMemoryRows = (guest: Guest): GuestDrawerMemoryRow[] =
rows.push({ label: 'Balloon', value: formatBytes(memory.balloon) });
}
if (memory.swapTotal && memory.swapTotal > 0) {
if (
!memory.usageUnavailable &&
reading?.state !== 'unavailable' &&
memory.swapTotal &&
memory.swapTotal > 0
) {
rows.push({
label: 'Swap',
value: `${formatBytes(memory.swapUsed ?? 0)} / ${formatBytes(memory.swapTotal)}`,
@@ -91,6 +91,7 @@ export function AlertHistoryItemActions(props: AlertHistoryItemActionsProps) {
startTime: props.alert.startTime,
lastSeen: props.alert.startTime,
acknowledged: props.alert.status === 'acknowledged',
metadata: props.alert.systemAlert ? { systemAlert: true } : undefined,
}}
resourceType={props.alert.resourceType}
variant="icon"
@@ -31,6 +31,7 @@ import type { AlertOverviewState } from './useAlertOverviewState';
import { ResourceMonitoringPolicyAction } from './ResourceMonitoringPolicyAction';
import { AlertSnoozeAction } from './AlertSnoozeAction';
import { isAlertSnoozed } from './useAlertSnoozeState';
import { isPulseSystemAlert } from '@/utils/alertScope';
interface AlertOverviewAlertCardProps {
alert: Alert;
@@ -40,6 +41,8 @@ interface AlertOverviewAlertCardProps {
export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
const alertKey = () => getCanonicalAlertId(props.alert);
const hasResource = () =>
!isPulseSystemAlert(props.alert) && Boolean(props.alert.resourceId?.trim());
const processing = () =>
props.state.processingAlerts().has(alertKey()) ||
props.state.snoozeProcessingAlerts().has(alertKey());
@@ -124,20 +127,29 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<A
href={resourceLink()}
class={`${alertCardPresentation().resourceClassName} hover:underline cursor-pointer`}
title="View resource"
<Show
when={hasResource()}
fallback={
<span class={alertCardPresentation().resourceClassName}>
{props.alert.resourceName}
</span>
}
>
{props.alert.resourceName}
</A>
<A
href={resourceLink()}
class={`${alertCardPresentation().resourceClassName} hover:underline cursor-pointer`}
title="View resource"
>
{props.alert.resourceName}
</A>
</Show>
<span class="text-xs text-muted">({alertTypeDisplayLabel(props.alert.type)})</span>
<Show when={!props.alert.acknowledged}>
<span class={getAlertSeverityBadgeClass(props.alert.level)}>
{formatAlertSeverityLabel(props.alert.level)}
</span>
</Show>
<Show when={props.alert.node}>
<Show when={!isPulseSystemAlert(props.alert) && props.alert.node}>
<span class="text-xs text-muted">
{getAlertOverviewNodeLabel(props.alert.nodeDisplayName || props.alert.node)}
</span>
@@ -158,7 +170,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
<p class={getAlertOverviewStartedAtClass()}>
{getAlertOverviewStartedAtLabel(new Date(props.alert.startTime).toLocaleString())}
</p>
<Show when={props.alert.threshold > 0}>
<Show when={!isPulseSystemAlert(props.alert) && props.alert.threshold > 0}>
<span class="text-xs text-muted">
limit: {props.alert.threshold}
{props.alert.type === 'temperature' || props.alert.type === 'diskTemperature'
@@ -231,7 +243,7 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
props.timelineState.expandedIncidents().has(alertKey()),
)}
</button>
<Show when={(props.alert.resourceId || '').trim()}>
<Show when={hasResource()}>
<ResourceMonitoringPolicyAction
resourceId={props.alert.resourceId}
resourceName={props.alert.resourceName || props.alert.resourceId}
@@ -0,0 +1,35 @@
import type { Alert } from '@/types/api';
// Synthetic payloads shaped like Manager.RaiseSystemAlert. No native services.
export const SYSTEM_ALERT_TYPES = [
'backup-evaluation',
'notification-delivery',
'deadman-delivery',
'deadman-monitoring-stalled',
'deadman-interruption',
'deadman-state',
] as const;
export function makeSystemAlert(
type: string = 'backup-evaluation',
overrides: Partial<Alert> = {},
): Alert {
return {
id: `pulse-system-${type}`,
type,
resourceId: '',
resourceName: 'Pulse',
node: '',
instance: '',
level: 'warning',
message:
"Backup-age alerts were not evaluated because recovery data could not be read. Existing backup alerts have been kept. Check Pulse's logs.",
value: 0,
threshold: 0,
startTime: '2026-10-04T15:00:00Z',
lastSeen: '2026-10-04T15:01:00Z',
acknowledged: false,
metadata: { systemAlert: true, systemAlertType: type },
...overrides,
};
}
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import type { AlertOverviewState } from '../useAlertOverviewState';
import type { AlertIncidentTimelineState } from '../useAlertIncidentTimelineState';
import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '../__fixtures__/systemAlerts';
vi.mock('@solidjs/router', () => ({
A: (props: any) => (
<a href={props.href} title={props.title}>
{props.children}
</a>
),
}));
vi.mock('@/components/Alerts/InvestigateAlertButton', () => ({
InvestigateAlertButton: () => null,
}));
vi.mock('../ResourceMonitoringPolicyAction', () => ({
ResourceMonitoringPolicyAction: () => <button>Resource monitoring policy</button>,
}));
vi.mock('../AlertSnoozeAction', () => ({ AlertSnoozeAction: () => null }));
import { AlertOverviewAlertCard } from '../AlertOverviewAlertCard';
const acknowledge = vi.fn();
const toggleTimeline = vi.fn();
const state = {
processingAlerts: () => new Set(),
snoozeProcessingAlerts: () => new Set(),
deliveryDiagnoses: () => ({}),
handleAlertAcknowledgement: acknowledge,
} as unknown as AlertOverviewState;
const timelineState = {
expandedIncidents: () => new Set(),
toggleIncidentTimeline: toggleTimeline,
} as unknown as AlertIncidentTimelineState;
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('Pulse system-alert overview scope', () => {
it.each(SYSTEM_ALERT_TYPES)('does not link %s to a fictional Proxmox resource', (type) => {
const alert = makeSystemAlert(type);
render(() => (
<AlertOverviewAlertCard alert={alert} state={state} timelineState={timelineState} />
));
expect(screen.getByText('Pulse').closest('a')).toBeNull();
expect(screen.getByText(alert.message)).toBeInTheDocument();
expect(screen.queryByText('Resource monitoring policy')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Acknowledge' }));
expect(acknowledge).toHaveBeenCalledWith(alert);
fireEvent.click(screen.getByRole('button', { name: 'Timeline' }));
expect(toggleTimeline).toHaveBeenCalledWith(alert.id, alert.id, alert.startTime);
});
it('ignores conflicting metric and resource hints on a marked system alert', () => {
const alert = makeSystemAlert('future-system-condition', {
id: 'legacy-id',
resourceId: 'vm-wrong',
threshold: 80,
metadata: { systemAlert: true, resourceType: 'vm' },
});
render(() => (
<AlertOverviewAlertCard alert={alert} state={state} timelineState={timelineState} />
));
expect(screen.getByText('Pulse').closest('a')).toBeNull();
expect(screen.queryByText(/limit:/)).toBeNull();
expect(screen.queryByText('Resource monitoring policy')).toBeNull();
});
it('preserves the actual link and metric limit for a monitored resource called Pulse', () => {
const alert = makeSystemAlert('cpu', {
id: 'resource-cpu',
resourceId: 'vm-pulse',
value: 92,
threshold: 80,
metadata: undefined,
});
render(() => (
<AlertOverviewAlertCard alert={alert} state={state} timelineState={timelineState} />
));
expect(screen.getByRole('link', { name: 'Pulse' })).toHaveAttribute(
'href',
'/proxmox/overview',
);
expect(screen.getByText('limit: 80%')).toBeInTheDocument();
expect(screen.getByText('Resource monitoring policy')).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { buildAlertHistoryItems } from '../alertHistoryModel';
import { makeSystemAlert, SYSTEM_ALERT_TYPES } from '../__fixtures__/systemAlerts';
const pulseVM = { id: 'vm-pulse', name: 'Pulse', type: 'vm' } as Resource;
describe('Pulse system-alert history scope', () => {
it.each(SYSTEM_ALERT_TYPES)(
'keeps active and resolved %s separate from a resource named Pulse',
(type) => {
const getResource = vi.fn(() => pulseVM);
const active = makeSystemAlert(type);
const resolved = makeSystemAlert(type, { id: `${active.id}-older`, acknowledged: true });
const items = buildAlertHistoryItems({
activeAlerts: { [active.id]: active },
alertHistory: [active, resolved],
getResource,
allResources: [pulseVM],
});
expect(items).toHaveLength(2);
for (const item of items) {
expect(item.resourceType).toBe('Pulse');
expect(item).toHaveProperty('systemAlert', true);
expect(item.resourceId).toBe('');
}
expect(getResource).not.toHaveBeenCalled();
},
);
it('prioritises explicit system metadata over conflicting resource hints', () => {
const alert = makeSystemAlert('future-system-condition', {
id: 'legacy-system-id',
resourceId: pulseVM.id,
metadata: { systemAlert: true, resourceType: 'vm' },
});
const [item] = buildAlertHistoryItems({
activeAlerts: { [alert.id]: alert },
alertHistory: [],
getResource: () => pulseVM,
allResources: [pulseVM],
});
expect(item.resourceType).toBe('Pulse');
expect(item.resourceId).toBe('');
expect(item).toHaveProperty('systemAlert', true);
});
it('does not turn a monitored VM called Pulse into a system alert', () => {
const alert = makeSystemAlert('cpu', {
id: 'resource-cpu',
resourceId: pulseVM.id,
metadata: { systemAlert: false },
});
const [item] = buildAlertHistoryItems({
activeAlerts: { [alert.id]: alert },
alertHistory: [],
getResource: () => pulseVM,
allResources: [pulseVM],
});
expect(item.resourceType).toBe('VM');
expect(item.resourceId).toBe(pulseVM.id);
expect(item.systemAlert).not.toBe(true);
});
});
@@ -1,5 +1,6 @@
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { isPulseSystemAlert } from '@/utils/alertScope';
import { alertTypeDisplayLabel, unifiedTypeToAlertDisplayType } from './helpers';
@@ -25,6 +26,7 @@ export interface HistoryItem {
rawAlertType?: string;
description?: string;
acknowledged?: boolean;
systemAlert?: boolean;
}
export interface AlertTrendSeries {
@@ -121,15 +123,18 @@ export function resolveAlertHistoryResourceType({
resourceName,
metadata,
resourceId,
alertId,
getResource,
allResources,
}: {
resourceName: string;
metadata?: Record<string, unknown>;
resourceId?: string;
alertId?: string;
getResource: (resourceId: string) => Resource | undefined;
allResources: Resource[];
}) {
if (isPulseSystemAlert({ id: alertId || '', metadata })) return 'Pulse';
const metadataType =
typeof metadata?.resourceType === 'string' ? (metadata.resourceType as string) : undefined;
if (metadataType && metadataType.trim().length > 0) {
@@ -169,6 +174,7 @@ export function buildAlertHistoryItems({
const items: HistoryItem[] = [];
Object.values(activeAlerts).forEach((alert) => {
const systemScoped = isPulseSystemAlert(alert);
items.push({
id: alert.id,
source: 'alert',
@@ -177,15 +183,17 @@ export function buildAlertHistoryItems({
duration: formatAlertHistoryDuration(alert.startTime, undefined, now),
resourceName: alert.resourceName,
resourceType: resolveAlertHistoryResourceType({
alertId: alert.id,
resourceName: alert.resourceName,
metadata: alert.metadata,
resourceId: alert.resourceId,
getResource,
allResources,
}),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
resourceId: systemScoped ? '' : alert.resourceId,
node: systemScoped ? undefined : alert.node,
nodeDisplayName: systemScoped ? undefined : alert.nodeDisplayName,
...(systemScoped ? { systemAlert: true } : {}),
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
@@ -197,6 +205,7 @@ export function buildAlertHistoryItems({
const activeAlertIds = new Set(Object.keys(activeAlerts));
alertHistory.forEach((alert) => {
if (activeAlertIds.has(alert.id)) return;
const systemScoped = isPulseSystemAlert(alert);
items.push({
id: alert.id,
@@ -207,15 +216,17 @@ export function buildAlertHistoryItems({
duration: formatAlertHistoryDuration(alert.startTime, alert.lastSeen, now),
resourceName: alert.resourceName,
resourceType: resolveAlertHistoryResourceType({
alertId: alert.id,
resourceName: alert.resourceName,
metadata: alert.metadata,
resourceId: alert.resourceId,
getResource,
allResources,
}),
resourceId: alert.resourceId,
node: alert.node,
nodeDisplayName: alert.nodeDisplayName,
resourceId: systemScoped ? '' : alert.resourceId,
node: systemScoped ? undefined : alert.node,
nodeDisplayName: systemScoped ? undefined : alert.nodeDisplayName,
...(systemScoped ? { systemAlert: true } : {}),
severity: alert.level,
title: alertTypeDisplayLabel(alert.type),
rawAlertType: alert.type,
@@ -155,6 +155,53 @@ describe('useUnifiedResources', () => {
let resolveIncrementalResourcePatchIndices: UseUnifiedResourcesModule['resolveIncrementalResourcePatchIndices'];
let eventBus: (typeof import('@/stores/events'))['eventBus'];
it('keeps API-selected memory annotation through the canonical normalizer', async () => {
setWsConnected(false);
setWsState('resources', []);
const observation = {
state: 'last-known',
source: 'guest-agent-meminfo',
observedAt: '2026-10-04T14:00:00Z',
};
apiFetchMock.mockResolvedValue(
resourceResponse([
{
...v2Resource,
metrics: { ...v2Resource.metrics, memory: { ...v2Resource.metrics.memory, observation } },
},
]),
);
let dispose = () => {};
let result!: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']>;
createRoot((d) => {
dispose = d;
result = useUnifiedResources();
});
try {
await waitForResourceCount(() => result.resources().length);
expect(result.resources()[0].memory).toMatchObject({ current: 50, observation });
apiFetchMock.mockResolvedValue(
resourceResponse([
{
...v2Resource,
lastSeen: '2026-10-04T17:00:00Z',
metrics: {
...v2Resource.metrics,
memory: { ...v2Resource.metrics.memory, observation },
},
},
]),
);
await result.refetch();
expect(result.resources()[0].memory).toMatchObject({ current: 50, observation });
apiFetchMock.mockResolvedValue(resourceResponse([v2Resource]));
await result.refetch();
expect(result.resources()[0].memory).not.toHaveProperty('observation');
} finally {
dispose();
}
});
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
@@ -489,6 +489,77 @@ describe('useWorkloads', () => {
dispose();
});
it.each(['api', 'snapshot'])(
'keeps selected memory provenance through %s without borrowing a raw facet',
async (transport) => {
const original = {
state: 'last-known',
source: 'guest-agent-meminfo',
observedAt: '2026-10-04T14:00:00Z',
};
const raw = { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' };
const resource = {
...sampleResource,
proxmox: {
vmid: 101,
nodeName: 'pve1',
instance: 'cluster-a',
memory: { observation: raw },
},
metrics: {
...sampleResource.metrics,
memory: { ...sampleResource.metrics.memory, observation: original },
},
memory: { current: 50, total: 4096, used: 2048, observation: original },
};
apiFetchJSONMock.mockResolvedValue({ data: [resource], meta: { totalPages: 1 } });
const [snapshot, setSnapshot] = createSignal([resource]);
let dispose = () => {};
let result!: ReturnType<UseWorkloadsModule['useWorkloads']>;
createRoot((d) => {
dispose = d;
result = useWorkloads(
() => true,
transport === 'snapshot' ? { resourceSnapshot: snapshot as any } : {},
);
});
try {
await waitForWorkloadCount(() => result.workloads().length);
expect(result.workloads()[0].memory).toMatchObject({ usage: 50, observation: original });
const legacy = {
...resource,
metrics: { ...resource.metrics, memory: sampleResource.metrics.memory },
memory: { current: 50, total: 4096, used: 2048 },
};
if (transport === 'snapshot') setSnapshot([legacy] as any);
else {
apiFetchJSONMock.mockResolvedValue({ data: [legacy], meta: { totalPages: 1 } });
await result.refetch();
}
await flushAsync();
expect(result.workloads()[0].memory).not.toHaveProperty('observation');
expect(result.workloads()[0].memory.usage).toBe(50);
const live = {
...resource,
metrics: {
...resource.metrics,
memory: { ...resource.metrics.memory, observation: raw },
},
memory: { ...resource.memory, observation: raw },
};
if (transport === 'snapshot') setSnapshot([live]);
else {
apiFetchJSONMock.mockResolvedValue({ data: [live], meta: { totalPages: 1 } });
await result.refetch();
}
await flushAsync();
expect(result.workloads()[0].memory).toMatchObject({ usage: 50, observation: raw });
} finally {
dispose();
}
},
);
it('tracks canonical hybrid VM memory without substituting nested agent readings', async () => {
// #1962: synthetic snapshots exercise Web reactivity, not poller recovery.
const buildVM = (instance: string, percent?: number) =>
@@ -1,3 +1,5 @@
import { readMemoryObservation } from '@/utils/memoryObservation';
import type { MemoryObservation } from '@/types/api';
import { batch, createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
import { createStore, reconcile, unwrap } from 'solid-js/store';
import { canonicalizeMetricsHistoryTargetType } from '@/api/charts';
@@ -69,6 +71,7 @@ const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200;
const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250;
type APIMetricValue = {
observation?: MemoryObservation;
value?: number;
used?: number;
total?: number;
@@ -793,9 +796,11 @@ const metricToResourceMetric = (metric?: APIMetricValue) => {
const used = metric.used ?? undefined;
const total = metric.total ?? undefined;
const current = metric.percent ?? metric.value ?? 0;
const observation = readMemoryObservation(metric.observation);
const free = total !== undefined && used !== undefined ? total - used : undefined;
return {
current,
...(observation ? { observation } : {}),
total,
used,
free,
@@ -1,3 +1,5 @@
import { readMemoryObservation } from '@/utils/memoryObservation';
import type { MemoryObservation } from '@/types/api';
import { onCleanup, createEffect, createSignal, untrack, type Accessor } from 'solid-js';
import { apiFetchJSON, getOrgID } from '@/utils/apiClient';
import { normalizeOrgScope } from '@/utils/orgScope';
@@ -40,6 +42,7 @@ const WORKLOADS_MAX_PAGES = 100;
const WORKLOADS_CACHE_MAX_AGE_MS = 15_000;
type APIMetricValue = {
observation?: MemoryObservation;
value?: number;
used?: number;
total?: number;
@@ -559,8 +562,10 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => {
memory: (() => {
const base = buildMetric(resource.metrics?.memory);
const cache = resource.proxmox?.memoryCache ?? 0;
const observation = readMemoryObservation(resource.metrics?.memory?.observation);
return {
...base,
...(observation ? { observation } : {}),
usageUnavailable: !memoryAvailable,
// buildMetric derives free as total-used (available); carve the
// reclaimable cache back out so free means truly-free pages.
@@ -688,6 +693,7 @@ const metricToAPI = (metric?: Resource['cpu']): APIMetricValue | undefined =>
metric
? {
percent: metric.current,
...(metric.observation ? { observation: metric.observation } : {}),
used: metric.used,
total: metric.total,
}
+10
View File
@@ -1170,6 +1170,15 @@ export interface PBSJobHealthEvidence {
error?: string;
}
/** Server-owned provenance of the selected guest-memory reading. */
export interface MemoryObservation {
/** current, last-known or unavailable. Unrecognised/absent states are unknown. */
state: string;
source: string;
/** Original source time, never the refreshed resource LastSeen. */
observedAt?: string;
}
export interface Memory {
total: number;
used: number;
@@ -1178,6 +1187,7 @@ export interface Memory {
cache?: number;
/** Total is known, but no cache-aware source can establish current usage. */
usageUnavailable?: boolean;
observation?: MemoryObservation;
usage: number;
balloon?: number;
swapUsed?: number;
+3 -1
View File
@@ -16,6 +16,7 @@ import type {
HostSensorSummary,
HostRAIDArray,
Memory,
MemoryObservation,
DockerRuntimeCommand,
PBSBackupJob,
PBSGarbageJob,
@@ -119,7 +120,8 @@ export interface ResourceHealth {
// Metric value with optional limits
export interface ResourceMetric {
current: number; // Current value (percentage or bytes)
current: number; // Selected numeric value (percentage or bytes), not a freshness assertion.
observation?: MemoryObservation;
total?: number; // Total capacity (bytes) - null for percentages
used?: number; // Used amount (bytes)
free?: number; // Free amount (bytes)
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { isPulseSystemAlert } from '../alertScope';
describe('Pulse system-alert scope markers', () => {
it('accepts the canonical ID even when metadata is absent', () => {
expect(isPulseSystemAlert({ id: 'pulse-system-future-condition' })).toBe(true);
});
it('accepts explicit metadata on a retained legacy ID', () => {
expect(isPulseSystemAlert({ id: 'legacy-id', metadata: { systemAlert: true } })).toBe(true);
});
it.each([undefined, false, 'true', 1])(
'does not infer system scope from metadata %j',
(marker) => {
expect(isPulseSystemAlert({ id: 'vm-pulse', metadata: { systemAlert: marker } })).toBe(false);
},
);
});
@@ -52,6 +52,37 @@ const createServiceResource = (
}) as Resource;
describe('resourceStateAdapters nodeFromResource', () => {
it('keeps selected memory provenance independent of a different raw platform reading', () => {
const observation = {
state: 'last-known',
source: 'guest-agent-meminfo',
observedAt: '2026-10-04T14:00:00Z',
};
const raw = { state: 'current', source: 'status-mem', observedAt: '2026-10-04T17:00:00Z' };
const resource = {
...createNodeResource({}),
memory: { current: 25, total: 1024, used: 256, observation },
proxmox: { memory: { total: 1024, used: 768, usage: 75, observation: raw } },
} as Resource;
expect(nodeFromResource(resource)?.memory).toMatchObject({ usage: 25, observation });
expect(
nodeFromResource({ ...resource, memory: { current: 25, total: 1024, used: 256 } })?.memory,
).not.toHaveProperty('observation');
expect(nodeFromResource({ ...resource, memory: undefined })?.memory).toMatchObject({
usage: 75,
observation: raw,
});
expect(
nodeFromResource({
...resource,
memory: {
...resource.memory!,
observation: { state: 'current', source: 'agent', observedAt: '2026-10-04T16:00:00Z' },
},
})?.memory.observation?.source,
).toBe('agent');
});
it('keeps Proxmox metric coordinates without inventing an agent from discovery routing', () => {
const resource = {
...createNodeResource({ proxmox: { nodeName: 'pve-node-1' } }),
+10
View File
@@ -0,0 +1,10 @@
import type { Alert } from '@/types/api';
/**
* Mirror the existing system-alert identity/metadata contract. Names and alert
* types are not scope evidence: a monitored machine can also be called Pulse,
* and future system conditions must not need a frontend type allowlist.
*/
export function isPulseSystemAlert(alert: Pick<Alert, 'id' | 'metadata'>): boolean {
return alert.id.startsWith('pulse-system-') || alert.metadata?.systemAlert === true;
}
@@ -0,0 +1,13 @@
import type { MemoryObservation } from '@/types/api';
// Keep only the existing server-owned wire fields. Missing/future state is not
// replaced by a resource poll time or a platform facet from another metric.
export const readMemoryObservation = (value: unknown): MemoryObservation | undefined => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
return {
state: typeof record.state === 'string' ? record.state : '',
source: typeof record.source === 'string' ? record.source : '',
...(typeof record.observedAt === 'string' ? { observedAt: record.observedAt } : {}),
};
};
@@ -1,3 +1,4 @@
import { readMemoryObservation } from '@/utils/memoryObservation';
import type {
Disk,
HostNetworkInterface,
@@ -1326,6 +1327,9 @@ const buildMemory = (
proxmoxMeta?: Record<string, unknown>,
): Memory => {
const total = metric?.total ?? asNumber(fallback?.total) ?? 0;
const observation = readMemoryObservation(
metric == null ? fallback?.observation : metric.observation,
);
const usageUnavailable = metric == null && asBoolean(fallback?.usageUnavailable) === true;
const used = metric?.used ?? asNumber(fallback?.used) ?? 0;
const cache = asNumber(proxmoxMeta?.memoryCache) ?? asNumber(fallback?.cache) ?? 0;
@@ -1344,6 +1348,7 @@ const buildMemory = (
free,
usage,
usageUnavailable,
...(observation ? { observation } : {}),
cache: cache > 0 ? cache : undefined,
swapUsed: asNumber(proxmoxMeta?.swapUsed) ?? asNumber(fallback?.swapUsed),
swapTotal: asNumber(proxmoxMeta?.swapTotal) ?? asNumber(fallback?.swapTotal),