mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Integrate reviewed guest-memory provenance and Pulse system-alert scope
Change-source: pulse-maintainer
This commit is contained in:
@@ -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.
|
||||
|
||||
+8
-2
@@ -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(
|
||||
|
||||
+261
@@ -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();
|
||||
});
|
||||
});
|
||||
+60
-1
@@ -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);
|
||||
|
||||
+11
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
+88
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' } }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user