mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
Keep canonical guest History on its filesystem producer
Use the existing Workloads fallback and reporting-lease rules for linked Pulse-agent filesystem aggregates on Proxmox VMs and containers. Preserve native stopped withdrawal, platform-list precedence, measured zero and stored observations. Add live/silent producer, power and mounted-drawer controls, plus own-parent desktop and phone browser evidence. This is presentation maintenance, not collection or native recovery. Change-source: pulse-maintainer
This commit is contained in:
@@ -38,6 +38,26 @@ covered filesystem, guest liveness and only-prior-active restoration belong to
|
||||
the native safety owners. These presentation checks do not set a release gate,
|
||||
prove a safe guest or authorise a diagnostic/restart.
|
||||
|
||||
## Canonical guest History filesystem ownership
|
||||
|
||||
Automated: `resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts`,
|
||||
`ResourceDetailDrawer.history.test.tsx`, the existing workload mapper and
|
||||
source-admission controls. Use
|
||||
`frontend-modern/browser-tests/linked-guest-history.cjs` to inspect the real
|
||||
canonical and Workloads drawers on desktop Chromium and phone WebKit.
|
||||
A live linked agent remains current despite a QEMU-only read failure; its
|
||||
silent report is explicitly last known even while the platform stays online.
|
||||
Check provider-list precedence (including an empty list), source transitions
|
||||
on the same mounted drawer, native stopped withdrawal and fresh zero.
|
||||
LXC platform storage remains usable independently of in-guest agent state.
|
||||
Load stored observations, then change agent freshness and native power without
|
||||
removing, relabelling as current or fabricating chart samples.
|
||||
|
||||
The fixture's controls and readings are synthetic, not installed guest
|
||||
recovery. Native thaw, writes to every covered filesystem, liveness, restart
|
||||
and prior-active restoration remain with the native owners; no release gate
|
||||
or safe-to-probe verdict follows from presentation proof.
|
||||
|
||||
## How To Use This Matrix
|
||||
|
||||
1. Run the automated proof first.
|
||||
|
||||
@@ -9403,3 +9403,28 @@ Verification connects `useThresholdsGuestData.test.ts`, both layouts in
|
||||
`ResourceTable.test.tsx`, and the production hook/table/editor browser fixture
|
||||
`guest-filesystem-thresholds.cjs`. Presentation proof is not native guest
|
||||
recovery, thaw/writes/liveness, alert delivery or installed/released acceptance.
|
||||
|
||||
### Canonical History follows the filesystem producer (12 October 2026)
|
||||
|
||||
On Proxmox VMs and system containers, canonical History uses the same
|
||||
filesystem-ownership rule as Workloads: Proxmox's filesystem list takes
|
||||
precedence (including an explicit empty list); the linked Pulse agent owns
|
||||
the fallback only when that list is absent and its own list is present.
|
||||
A current linked-agent reading is not deferred by an unrelated QEMU read
|
||||
failure, and a silent linked agent's retained aggregate is never current
|
||||
just because the platform keeps the resource online. Native stopped state
|
||||
still withdraws in-guest carriers from either producer; stopped LXC platform
|
||||
storage and unrelated platform metrics remain independent.
|
||||
|
||||
The existing aggregate is qualified, not recalculated from filesystem rows.
|
||||
Missing aggregates remain absent, measured zero remains usable, and neither
|
||||
Last seen nor a QEMU recovery renews a silent agent's report. Stored History,
|
||||
memory/CPU/network/IO, collection and guest commands are unchanged.
|
||||
|
||||
Verification: `resourceDetailDrawerMetricsHistoryModel.linked-filesystems.test.ts`
|
||||
and mounted `ResourceDetailDrawer.history.test.tsx` connect live/silent agents,
|
||||
provider precedence, stopping/recovery and zero. The local
|
||||
`linked-guest-history.cjs` browser fixture uses the production canonical drawer,
|
||||
Workloads snapshot adapter/drawer, History and CSS on desktop and phone, with
|
||||
synthetic GET responses. These controls do not establish native restart, thaw,
|
||||
covered-filesystem writes, installed recovery or containing software.
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
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/linked-guest-history/browser-final';
|
||||
const origin = 'http://127.0.0.1:5345';
|
||||
const runtime = [
|
||||
'frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts',
|
||||
'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
|
||||
'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
|
||||
'frontend-modern/src/hooks/useWorkloads.ts',
|
||||
'frontend-modern/src/utils/workloadGuestPresentation.ts',
|
||||
];
|
||||
const fixture = ['cjs', 'tsx', 'html'].map(
|
||||
(ext) => `frontend-modern/browser-tests/linked-guest-history.${ext}`,
|
||||
);
|
||||
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
(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)])),
|
||||
fixture_hashes: Object.fromEntries(fixture.map((p) => [p, hash('/workspace/' + p)])),
|
||||
cases: [],
|
||||
screenshots: [],
|
||||
cleanup: { browsers: false, server: false },
|
||||
limits:
|
||||
'Production canonical drawer and Workloads snapshot adapter/drawer, History, clients and CSS with synthetic snapshots/GET responses. No native filesystem, thaw, restart, guest action, installed or release 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',
|
||||
optimizeDeps: { entries: ['browser-tests/linked-guest-history.html'] },
|
||||
server: { host: '127.0.0.1', port: 5345, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engineName, engine, width, height, dark] of [
|
||||
['desktop-light', chromium, 1280, 900, false],
|
||||
['phone-dark', webkit, 390, 844, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
for (const lxc of [false, true])
|
||||
for (const guest of [false, true]) {
|
||||
const name = `${guest ? 'workloads' : 'canonical'}-${lxc ? 'lxc' : 'vm'}-${engineName}`;
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height },
|
||||
isMobile: width < 768,
|
||||
hasTouch: width < 768,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(12000);
|
||||
page.setDefaultNavigationTimeout(45000);
|
||||
const record = {
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
dark,
|
||||
lxc,
|
||||
guest,
|
||||
browser: browser.version(),
|
||||
checks: [],
|
||||
errors: [],
|
||||
requests: [],
|
||||
};
|
||||
result.cases.push(record);
|
||||
let stored = false;
|
||||
page.on('pageerror', (e) => record.errors.push(e.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const request = route.request(),
|
||||
url = new URL(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();
|
||||
assert.equal(request.method(), 'GET');
|
||||
record.requests.push({ path: url.pathname, method: request.method(), stored });
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
assert.equal(url.searchParams.get('resourceType'), lxc ? 'system-container' : 'vm');
|
||||
assert.equal(url.searchParams.get('resourceId'), 'site-a:pve1:105');
|
||||
const start = Date.parse('2026-10-12T00:00:00Z'),
|
||||
end = Date.parse('2026-10-12T01:00:00Z');
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: lxc ? 'system-container' : 'vm',
|
||||
resourceId: 'site-a:pve1:105',
|
||||
range: url.searchParams.get('range'),
|
||||
start,
|
||||
end,
|
||||
source: 'store',
|
||||
metrics: stored
|
||||
? {
|
||||
disk: [
|
||||
{ timestamp: start, value: 12, min: 12, max: 12 },
|
||||
{ timestamp: end, value: 30, min: 30, max: 30 },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.pathname === '/api/license/runtime-capabilities'
|
||||
? {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 90,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
data: [],
|
||||
alerts: [],
|
||||
anomalies: [],
|
||||
config: null,
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
available: false,
|
||||
audits: [],
|
||||
count: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.addInitScript((isDark) => {
|
||||
document.addEventListener('DOMContentLoaded', () =>
|
||||
document.documentElement.classList.toggle('dark', isDark),
|
||||
);
|
||||
}, dark);
|
||||
const suffix =
|
||||
'?' +
|
||||
new URLSearchParams({
|
||||
...(guest ? { guest: '1' } : {}),
|
||||
...(lxc ? { lxc: '1' } : {}),
|
||||
}).toString();
|
||||
await page.goto(origin + '/browser-tests/linked-guest-history.html' + suffix);
|
||||
const activate = async (locator) => {
|
||||
if (width < 768) await locator.tap();
|
||||
else {
|
||||
await locator.focus();
|
||||
await locator.press('Enter');
|
||||
}
|
||||
};
|
||||
await activate(page.getByRole('tab', { name: 'History', exact: true }));
|
||||
const chart = page.locator('[data-history-group="utilization"]');
|
||||
const current = () => chart.locator('[data-history-current="disk"]');
|
||||
const prior = () => chart.locator('[data-history-last-known="disk"]');
|
||||
const deferred = () => chart.locator('[data-history-deferred="disk"]');
|
||||
const set = (label) => activate(page.getByRole('button', { name: label, exact: true }));
|
||||
const check = async (label, fn) => {
|
||||
await fn();
|
||||
record.checks.push(label);
|
||||
};
|
||||
const text = async (locator, expression) => {
|
||||
await locator.waitFor();
|
||||
assert.match(await locator.innerText(), expression);
|
||||
};
|
||||
const missing = async (locator) => assert.equal(await locator.count(), 0);
|
||||
const capture = async (state) => {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true });
|
||||
result.screenshots.push({ file, name, state, sha256: hash(path.join(output, file)) });
|
||||
const geometry = await page.evaluate(() => ({
|
||||
width: document.documentElement.clientWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.ok(geometry.scroll <= geometry.width + 1, JSON.stringify(geometry));
|
||||
};
|
||||
await check('Live linked reading survives QGA failure', async () => {
|
||||
await text(current(), /62.0%\s*current/);
|
||||
await missing(deferred());
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.__chart = document.querySelector('[data-history-group="utilization"]');
|
||||
});
|
||||
await check('Silent agent is last known despite online platform', async () => {
|
||||
await set('Silent agent');
|
||||
await text(prior(), /62.0%\s*last known/);
|
||||
await missing(current());
|
||||
await text(deferred(), /The Pulse Agent in this guest stopped reporting/);
|
||||
});
|
||||
await capture('silent');
|
||||
await check('QGA recovery and Last seen cannot renew the agent report', async () => {
|
||||
await set('QGA recovered');
|
||||
await set('Advance snapshot');
|
||||
await text(prior(), /62.0%\s*last known/);
|
||||
await text(deferred(), /Pulse Agent/);
|
||||
});
|
||||
await check('Stopped in-guest reading is unavailable, not last known', async () => {
|
||||
await set('Stopped guest');
|
||||
await missing(current());
|
||||
await missing(prior());
|
||||
await text(deferred(), /unavailable/);
|
||||
});
|
||||
await capture('stopped');
|
||||
await check('Fresh linked-agent zero is current', async () => {
|
||||
await set('Fresh zero');
|
||||
await text(current(), /0.0%\s*current/);
|
||||
await missing(deferred());
|
||||
});
|
||||
await check('Missing aggregate is not fabricated from filesystem rows', async () => {
|
||||
await set('Missing aggregate');
|
||||
await missing(current());
|
||||
await missing(prior());
|
||||
});
|
||||
await check('Platform filesystem precedence uses its own read state', async () => {
|
||||
await set('Platform filesystems');
|
||||
if (lxc) {
|
||||
await text(current(), /62.0%\s*current/);
|
||||
await missing(deferred());
|
||||
} else {
|
||||
await text(prior(), /62.0%\s*last known/);
|
||||
await text(deferred(), /operation lock/);
|
||||
}
|
||||
});
|
||||
await check('Explicit empty platform list does not borrow agent freshness', async () => {
|
||||
await set('Empty platform list');
|
||||
if (lxc) {
|
||||
await text(current(), /62.0%\s*current/);
|
||||
} else {
|
||||
await text(prior(), /62.0%\s*last known/);
|
||||
await text(deferred(), /operation lock/);
|
||||
}
|
||||
});
|
||||
await check('Stopped LXC platform storage stays independent', async () => {
|
||||
await set('Stopped platform');
|
||||
if (lxc) {
|
||||
await text(current(), /62.0%\s*current/);
|
||||
await missing(deferred());
|
||||
} else {
|
||||
await missing(current());
|
||||
await missing(prior());
|
||||
await text(deferred(), /VM is stopped/);
|
||||
}
|
||||
});
|
||||
await check(
|
||||
'Stored observations remain intact through source/power changes',
|
||||
async () => {
|
||||
await set('Live agent');
|
||||
stored = true;
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
await chart.locator('path').waitFor();
|
||||
const before = await chart.locator('path').getAttribute('d');
|
||||
assert.ok(before);
|
||||
await set('Silent agent');
|
||||
await text(deferred(), /Pulse Agent/);
|
||||
assert.equal(await chart.locator('path').getAttribute('d'), before);
|
||||
await missing(prior());
|
||||
await set('Stopped guest');
|
||||
await text(deferred(), /unavailable/);
|
||||
assert.equal(await chart.locator('path').getAttribute('d'), before);
|
||||
await missing(current());
|
||||
assert.match(await chart.innerText(), /30.0%/);
|
||||
},
|
||||
);
|
||||
await check(
|
||||
'Same mounted chart, usable independent metrics and no fabricated samples',
|
||||
async () => {
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
window.__chart === document.querySelector('[data-history-group="utilization"]'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await text(chart.locator('[data-history-current="cpu"]'), /10.0%\s*current/);
|
||||
await text(chart.locator('[data-history-current="memory"]'), /0.0%\s*current/);
|
||||
assert.equal(await chart.locator('path').count(), 1);
|
||||
assert.deepEqual(record.errors, []);
|
||||
assert.ok(record.requests.every((r) => !r.blocked));
|
||||
},
|
||||
);
|
||||
await capture('stored-stopped');
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
browser = null;
|
||||
}
|
||||
result.result = 'passed';
|
||||
} catch (error) {
|
||||
result.error = String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
result.cleanup.browsers = true;
|
||||
await server.close();
|
||||
result.cleanup.server = true;
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
})().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>Linked guest filesystem History verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/linked-guest-history.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,146 @@
|
||||
// Only snapshots and the fixture controls are synthetic. Source mapping,
|
||||
// drawers, History, clients and CSS are production code; no guest action.
|
||||
import { createMemo, createSignal, For, Show } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||
import { useWorkloads } from '../src/hooks/useWorkloads';
|
||||
import { DarkModeContext, WebSocketContext, type WebSocketStore } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
|
||||
function Fixture() {
|
||||
const search = new URLSearchParams(location.search);
|
||||
const type = search.has('lxc') ? 'system-container' : 'vm';
|
||||
const [mode, setMode] = createSignal('Live agent');
|
||||
const [seen, setSeen] = createSignal(Date.parse('2026-10-12T01:00:00Z'));
|
||||
const disk = () => ({
|
||||
mountpoint: '/',
|
||||
total: 100,
|
||||
used: mode() === 'Fresh zero' ? 0 : 62,
|
||||
free: 38,
|
||||
});
|
||||
const stopped = () => ['Stopped guest', 'Stopped platform'].includes(mode());
|
||||
const platform = () =>
|
||||
['Platform filesystems', 'Empty platform list', 'Stopped platform'].includes(mode());
|
||||
const silent = () => ['Silent agent', 'QGA recovered'].includes(mode());
|
||||
const resource = createMemo<Resource>(() => ({
|
||||
id: 'site-a:pve1:105',
|
||||
type,
|
||||
name: 'Linked guest',
|
||||
displayName: 'Linked guest',
|
||||
platformId: 'site-a',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'hybrid',
|
||||
status: 'online',
|
||||
lastSeen: seen(),
|
||||
metricsTarget: { resourceType: type, resourceId: 'site-a:pve1:105' },
|
||||
cpu: { current: 10 },
|
||||
memory: {
|
||||
current: 0,
|
||||
total: 100,
|
||||
used: 0,
|
||||
observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' },
|
||||
},
|
||||
disk:
|
||||
mode() === 'Missing aggregate'
|
||||
? undefined
|
||||
: { current: disk().used, total: 100, used: disk().used },
|
||||
network: { rxBytes: 0, txBytes: 200 },
|
||||
proxmox: {
|
||||
nodeName: 'pve1',
|
||||
vmid: 105,
|
||||
instance: 'site-a',
|
||||
runtimeStatus: stopped() ? 'stopped' : 'running',
|
||||
diskStatusReason:
|
||||
mode() === 'QGA recovered' || stopped()
|
||||
? ''
|
||||
: platform()
|
||||
? 'prev-vm-locked'
|
||||
: 'agent-not-running',
|
||||
disks: platform() ? (mode() === 'Empty platform list' ? [] : [disk()]) : undefined,
|
||||
},
|
||||
agent: { agentId: 'in-guest', stale: silent(), disks: [disk()] },
|
||||
}));
|
||||
const mapped = useWorkloads(() => true, { resourceSnapshot: () => [resource()] });
|
||||
return (
|
||||
<main class="min-h-screen bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Linked guest History · {type}</h1>
|
||||
<p class="my-2 text-xs text-muted">
|
||||
Synthetic readings and controls. No guest command or backup runs.
|
||||
</p>
|
||||
<div class="mb-3 flex flex-wrap gap-2" aria-label="Fixture source controls">
|
||||
<For
|
||||
each={[
|
||||
'Live agent',
|
||||
'Silent agent',
|
||||
'QGA recovered',
|
||||
'Stopped guest',
|
||||
'Fresh zero',
|
||||
'Platform filesystems',
|
||||
'Empty platform list',
|
||||
'Stopped platform',
|
||||
'Missing aggregate',
|
||||
]}
|
||||
>
|
||||
{(value) => (
|
||||
<button
|
||||
class="min-h-11 rounded-sm border border-border px-3 text-xs"
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
class="min-h-11 rounded-sm border border-border px-3 text-xs"
|
||||
onClick={() => setSeen(seen() + 3600000)}
|
||||
>
|
||||
Advance snapshot
|
||||
</button>
|
||||
</div>
|
||||
<section class="mx-auto max-w-5xl" aria-label="Production drawer">
|
||||
<Show
|
||||
when={!search.has('guest')}
|
||||
fallback={
|
||||
<Show when={mapped.workloads()[0]}>
|
||||
{(guest) => <GuestDrawer guest={guest()} onClose={() => {}} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
|
||||
</Show>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={() => (
|
||||
<DarkModeContext.Provider
|
||||
value={() => document.documentElement.classList.contains('dark')}
|
||||
>
|
||||
<WebSocketContext.Provider
|
||||
value={
|
||||
{
|
||||
activeAlerts: {},
|
||||
state: { pmg: [] },
|
||||
connected: () => true,
|
||||
} as unknown as WebSocketStore
|
||||
}
|
||||
>
|
||||
<Fixture />
|
||||
</WebSocketContext.Provider>
|
||||
</DarkModeContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "f99c6f9e5c3aba2ad025bc24da7c6bd6c7e5a85a",
|
||||
"verified_at": "2026-10-12T07:58:01.128408Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "257ccee974aeab51c6d35360117986e9f4aed92891cb2a153b656e57e71a584f"
|
||||
},
|
||||
"routes": [
|
||||
"Existing canonical ResourceDetailDrawer / History",
|
||||
"Existing Workloads snapshot adapter -> GuestDrawer / History (production components in local fixture)"
|
||||
],
|
||||
"states": [
|
||||
"Current linked-agent VM disk aggregate survives unrelated QGA read failures",
|
||||
"Silent linked-agent VM/LXC aggregate is last known despite online platform; QGA recovery/Last seen do not renew it",
|
||||
"Stopped in-guest VM/LXC reading is unavailable, no last-known fallback; native stopped still wins",
|
||||
"Platform filesystem lists retain source precedence, including explicit empty lists; LXC platform storage stays independent",
|
||||
"Measured zero remains current, missing aggregate is not fabricated; stored paths and observations survive source/power updates",
|
||||
"Independent CPU/memory/network readings and same-mounted chart remain intact on desktop and phone"
|
||||
],
|
||||
"interactions": [
|
||||
"History and synthetic source controls activated with keyboard focus/Enter on desktop or touch on phone",
|
||||
"Same-ID source, reporting lease, power and aggregate snapshots update without remount",
|
||||
"Refresh history reads two actual synthetic stored points through production client; later source/power updates keep their path unchanged"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"name": "desktop-light",
|
||||
"width": 1280,
|
||||
"height": 900,
|
||||
"browser": "Chromium 141.0.7390.37"
|
||||
},
|
||||
{
|
||||
"name": "phone-dark",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"browser": "WebKit 26.0"
|
||||
}
|
||||
],
|
||||
"evidence": {
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/linked-guest-history.cjs",
|
||||
"playwright": "1.56.1",
|
||||
"integration_lock_version": "1.56.1",
|
||||
"completed_checks": 88,
|
||||
"cases": 8,
|
||||
"fixture_sha256": {
|
||||
"frontend-modern/browser-tests/linked-guest-history.cjs": "1767ebebd11d4db018b1f3bea7a89ad9fc7692e8cb084bd7c9d89fe87ef4ac0f",
|
||||
"frontend-modern/browser-tests/linked-guest-history.tsx": "feb16a0b04a82ae722b53f9e71969be89e70344188c5f2b0deb2853a5f96309f",
|
||||
"frontend-modern/browser-tests/linked-guest-history.html": "2a1bd63a32bc4be49d7ae9f0dc9c91932f11b3e4c3f0c8f38c7110fd0095ccae"
|
||||
},
|
||||
"runtime_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "257ccee974aeab51c6d35360117986e9f4aed92891cb2a153b656e57e71a584f",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "9e7ee3e3cbb5f8da38cf43e0aaecd105f43333d16df7338e849d3ccaf6804d8f",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "604deee9674aa3d97e45ccf7bc5b8c5dda27017dee2f235c107750911d2981b7",
|
||||
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127",
|
||||
"frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d",
|
||||
"frontend-modern/src/utils/workloadGuestPresentation.ts": "71a2d2e62411c70c0eb609e68cd4963dac483be59a5be6afcf96c1ce2191436f"
|
||||
},
|
||||
"captures": [
|
||||
{
|
||||
"file": "canonical-vm-desktop-light-silent.png",
|
||||
"name": "canonical-vm-desktop-light",
|
||||
"state": "silent",
|
||||
"sha256": "1b9c34d68c8cf24be7114c06d9976188f5bd9419af9e5b461d8a8ba484d50b4f",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-vm-desktop-light-stopped.png",
|
||||
"name": "canonical-vm-desktop-light",
|
||||
"state": "stopped",
|
||||
"sha256": "45fd35767c8246a298cd18957cdacf88396bce6c41327a6494bb94cd15567d7f",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-vm-desktop-light-stored-stopped.png",
|
||||
"name": "canonical-vm-desktop-light",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "c15788cb4d7f45b85c4644f6dd14578e863b0f1d7ebf507bd82e23ea3683a254",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-desktop-light-silent.png",
|
||||
"name": "workloads-vm-desktop-light",
|
||||
"state": "silent",
|
||||
"sha256": "dea3ac6a77870247ae166a2f60ce3da4266b6e70f40f8b23dbef08d06d0bf2ad",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-desktop-light-stopped.png",
|
||||
"name": "workloads-vm-desktop-light",
|
||||
"state": "stopped",
|
||||
"sha256": "876e1b63a462e7ff6bf9321f15c025b8709db3f101312a3f4dc8127ec8928aec",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-desktop-light-stored-stopped.png",
|
||||
"name": "workloads-vm-desktop-light",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "02bad262d41990992bcf008dfb5dbe4fbd97a7247fd4bbce48c2740ab8713dcc",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-desktop-light-silent.png",
|
||||
"name": "canonical-lxc-desktop-light",
|
||||
"state": "silent",
|
||||
"sha256": "4858ed050197194789d58205bfe4fa3ab54ac3bbdc7f6e041d00adb6ec2f6634",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-desktop-light-stopped.png",
|
||||
"name": "canonical-lxc-desktop-light",
|
||||
"state": "stopped",
|
||||
"sha256": "58cfcedff92ace012f61a0989ab7a0caba0a760736968da22485a6912320013d",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-desktop-light-stored-stopped.png",
|
||||
"name": "canonical-lxc-desktop-light",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "7d8eb01333adafbf4ab43dd5e5097dd39a42369dd0b0de9e9d3dfab4761bacd1",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-desktop-light-silent.png",
|
||||
"name": "workloads-lxc-desktop-light",
|
||||
"state": "silent",
|
||||
"sha256": "ed66a394887732f81eb3fd0cec0e7e4e1775ad79d6a9d080c8d7bec373760bcc",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-desktop-light-stopped.png",
|
||||
"name": "workloads-lxc-desktop-light",
|
||||
"state": "stopped",
|
||||
"sha256": "7acc180629fe474edd6126d7bd9fa4d5d133de46aaebbee32b1272ab1e1556e0",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-desktop-light-stored-stopped.png",
|
||||
"name": "workloads-lxc-desktop-light",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "19250fac504ee0811b84caeeea4acdc3dd4ced30d794d34c2d9a3a4b135e9189",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-vm-phone-dark-silent.png",
|
||||
"name": "canonical-vm-phone-dark",
|
||||
"state": "silent",
|
||||
"sha256": "c519511e99e6e24208e4f05a2da121756d76edeb514f209306faa4d5de5f9a3a",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-vm-phone-dark-stopped.png",
|
||||
"name": "canonical-vm-phone-dark",
|
||||
"state": "stopped",
|
||||
"sha256": "1085f3fb0a99654fcc807b3a5fb492704f2a3e150f0d996d8407c0ccd9fb173b",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-vm-phone-dark-stored-stopped.png",
|
||||
"name": "canonical-vm-phone-dark",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "4eedf5fcc6fb4c54c5a4be70d4ac24d18230b15b11ee9af8c8bbdd42d956d16a",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-phone-dark-silent.png",
|
||||
"name": "workloads-vm-phone-dark",
|
||||
"state": "silent",
|
||||
"sha256": "84561a90090f3d945b944c4298b9a2961550fe59e945922aa2f89951e690af79",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-phone-dark-stopped.png",
|
||||
"name": "workloads-vm-phone-dark",
|
||||
"state": "stopped",
|
||||
"sha256": "188c4e63560a61da7636165903e06e6733615c22042340ef852303d3128c7f79",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-vm-phone-dark-stored-stopped.png",
|
||||
"name": "workloads-vm-phone-dark",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "4899356a909fff6ffd12e2e4f1ac301e1e0fefa0f2032d61ddfa22a88459ca4c",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-phone-dark-silent.png",
|
||||
"name": "canonical-lxc-phone-dark",
|
||||
"state": "silent",
|
||||
"sha256": "1a3503060ef8060fe1165fccec8190abca8fe2c55c1866d4652c156c7d413402",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-phone-dark-stopped.png",
|
||||
"name": "canonical-lxc-phone-dark",
|
||||
"state": "stopped",
|
||||
"sha256": "6a0e50472158269e635c37c4151b63ccfd8cd4b8b8e351c2ae62c8abf420fd4a",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "canonical-lxc-phone-dark-stored-stopped.png",
|
||||
"name": "canonical-lxc-phone-dark",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "9bc76bcad4019b98a739d10a3e1ab1cc38aeae3e6bb2de01faa7f623c2ca5c20",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-phone-dark-silent.png",
|
||||
"name": "workloads-lxc-phone-dark",
|
||||
"state": "silent",
|
||||
"sha256": "d6a324d28b2e2af6175bd8f09c9b1b17949e03e0e5519e9c294c90395302e5fa",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-phone-dark-stopped.png",
|
||||
"name": "workloads-lxc-phone-dark",
|
||||
"state": "stopped",
|
||||
"sha256": "ae4204af814c04e9e8ffb49895edfe592e2e628f8fa85924b86044af682e7193",
|
||||
"viewed": true
|
||||
},
|
||||
{
|
||||
"file": "workloads-lxc-phone-dark-stored-stopped.png",
|
||||
"name": "workloads-lxc-phone-dark",
|
||||
"state": "stored-stopped",
|
||||
"sha256": "88225624fbc638302a2f210c9421e5a12df7f99fb561b7875911a5e4d9072e13",
|
||||
"viewed": true
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"browsers": true,
|
||||
"server": true
|
||||
},
|
||||
"result_sha256": "8c6dc1162c9191540362d1516bfc9eed33c2ef4aaec92f899c162b4b7ebd3516",
|
||||
"prior_attempt": "First browser attempt had a missing synthetic WebSocketContext.Provider; zero checks, client1, captured page error, browsers/server stopped. Fixture-only provider correction; runtime/deadlines/thresholds unchanged."
|
||||
},
|
||||
"limits": "Production canonical drawer and Workloads snapshot adapter/drawer, History, clients and CSS with synthetic snapshots/GET responses. No native filesystem, thaw, restart, guest action, installed or release acceptance."
|
||||
}
|
||||
+98
@@ -322,6 +322,104 @@ describe('ResourceDetailDrawer change history section', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['vm', 'system-container'] as const)(
|
||||
'keeps the mounted %s History on the selected filesystem producer',
|
||||
async (type) => {
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
const disks = [{ mountpoint: '/', total: 100, used: 62, free: 38 }];
|
||||
const source = baseResource({
|
||||
id: 'linked-filesystem-guest',
|
||||
type,
|
||||
platformType: 'proxmox-pve',
|
||||
status: 'online',
|
||||
metricsTarget: { resourceType: type, resourceId: 'linked:pve:105' },
|
||||
cpu: { current: 10 },
|
||||
memory: {
|
||||
current: 0,
|
||||
observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' },
|
||||
},
|
||||
disk: { current: 62 },
|
||||
proxmox: { runtimeStatus: 'running', diskStatusReason: 'agent-not-running' },
|
||||
agent: { agentId: 'in-guest', stale: false, disks },
|
||||
});
|
||||
const read = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue({
|
||||
resourceType: type,
|
||||
resourceId: 'linked:pve:105',
|
||||
range: '24h',
|
||||
start: Date.parse('2026-10-12T00:00:00Z'),
|
||||
end: Date.parse('2026-10-12T01:00:00Z'),
|
||||
source: 'store',
|
||||
metrics: {},
|
||||
});
|
||||
const [resource, setResource] = createSignal(source);
|
||||
const view = render(() => (
|
||||
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
|
||||
));
|
||||
try {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
const chart = view.container.querySelector('[data-history-group="utilization"]')!;
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'62.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull();
|
||||
setResource({
|
||||
...source,
|
||||
lastSeen: source.lastSeen + 3600000,
|
||||
agent: { ...source.agent, stale: true },
|
||||
});
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'62.0%last known',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'The Pulse Agent in this guest stopped reporting.',
|
||||
);
|
||||
setResource({
|
||||
...source,
|
||||
proxmox: { runtimeStatus: 'running', disks, diskStatusReason: 'prev-vm-locked' },
|
||||
});
|
||||
if (type === 'vm') {
|
||||
expect(chart.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'62.0%last known',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'VM operation lock',
|
||||
);
|
||||
} else {
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'62.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull();
|
||||
}
|
||||
setResource({ ...source, proxmox: { runtimeStatus: 'stopped' } });
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
|
||||
'unavailable',
|
||||
);
|
||||
setResource({ ...source, disk: { current: 0 }, status: 'offline' });
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'0.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
|
||||
'0.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
|
||||
'10.0%current',
|
||||
);
|
||||
expect(view.container.querySelector('[data-history-group="utilization"]')).toBe(chart);
|
||||
expect(chart.querySelector('path')).toBeNull();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
view.unmount();
|
||||
read.mockRestore();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps current readings separate from stored history samples', () => {
|
||||
expect(guestDrawerHistorySource).toContain('currentMetrics');
|
||||
expect(guestDrawerHistorySource).toContain('No stored history in this range');
|
||||
|
||||
+16
@@ -95,6 +95,22 @@ describe('canonical drawer History honours selected guest observation provenance
|
||||
},
|
||||
);
|
||||
|
||||
it('retains a legitimate linked-agent filesystem zero without calling it current', () => {
|
||||
const value = resource('current', 0);
|
||||
value.disk = { current: 0, used: 0, total: 100 };
|
||||
value.proxmox = { runtimeStatus: 'running', diskStatusReason: '' };
|
||||
value.agent = { stale: true, disks: [{ mountpoint: '/', used: 0, total: 100, free: 100 }] };
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value)).toMatchObject({
|
||||
disk: undefined,
|
||||
usage: undefined,
|
||||
memory: 0,
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).disk).toEqual({
|
||||
lastKnownValue: 0,
|
||||
message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps unavailable, true zero and independent current memory distinct from disk deferral', () => {
|
||||
expect(
|
||||
getResourceMetricsHistoryDeferredMetrics(resource('unavailable', 0)).memory.lastKnownValue,
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
import {
|
||||
getResourceMetricsHistoryCurrentMetrics as current,
|
||||
getResourceMetricsHistoryDeferredMetrics as deferred,
|
||||
} from '../resourceDetailDrawerMetricsHistoryModel';
|
||||
|
||||
const agentDisk = { mountpoint: '/', total: 100, used: 62, free: 38 };
|
||||
const guest = (patch: Partial<Resource> = {}): Resource => ({
|
||||
id: 'site-a:pve1:105',
|
||||
type: 'vm',
|
||||
name: 'Linked guest',
|
||||
displayName: 'Linked guest',
|
||||
platformId: 'site-a',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'hybrid',
|
||||
status: 'online',
|
||||
lastSeen: Date.parse('2026-10-12T01:00:00Z'),
|
||||
cpu: { current: 10 },
|
||||
memory: {
|
||||
current: 0,
|
||||
observation: { state: 'current', source: 'agent', observedAt: '2026-10-12T00:00:00Z' },
|
||||
},
|
||||
disk: { current: 62, total: 100, used: 62 },
|
||||
network: { rxBytes: 0, txBytes: 200 },
|
||||
proxmox: { runtimeStatus: 'running', diskStatusReason: 'agent-not-running' },
|
||||
agent: { agentId: 'in-guest', stale: false, disks: [agentDisk] },
|
||||
...patch,
|
||||
});
|
||||
|
||||
describe('canonical History filesystem source ownership', () => {
|
||||
it.each([
|
||||
'agent-not-running',
|
||||
'agent-error',
|
||||
'permission-denied',
|
||||
...guestDiskDeferrals.map(([r]) => r),
|
||||
])('uses the live linked agent instead of the unrelated QGA %s read failure', (reason) => {
|
||||
const value = guest({ proxmox: { runtimeStatus: 'running', diskStatusReason: reason } });
|
||||
expect(current(value)).toMatchObject({ disk: 62, usage: 62, cpu: 10, memory: 0, netin: 0 });
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
value.proxmox!.diskStatusReason = `prev-${reason}`;
|
||||
expect(current(value).disk).toBe(62);
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['vm', 'system-container'] as const)(
|
||||
'qualifies a silent linked agent on an online %s',
|
||||
(type) => {
|
||||
const value = guest({ type, agent: { stale: true, disks: [agentDisk] } });
|
||||
expect(current(value)).toMatchObject({
|
||||
disk: undefined,
|
||||
usage: undefined,
|
||||
cpu: 10,
|
||||
memory: 0,
|
||||
});
|
||||
expect(deferred(value).disk).toEqual({
|
||||
lastKnownValue: 62,
|
||||
message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.',
|
||||
});
|
||||
value.lastSeen += 3600000;
|
||||
value.proxmox!.diskStatusReason = '';
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk.lastKnownValue).toBe(62);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['stopped', ' STOPPED ', 'offline'])(
|
||||
'native %s wins over a live or silent linked agent',
|
||||
(runtimeStatus) => {
|
||||
for (const stale of [false, true]) {
|
||||
const value = guest({ proxmox: { runtimeStatus }, agent: { stale, disks: [agentDisk] } });
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk).toEqual({
|
||||
lastKnownValue: undefined,
|
||||
message: 'Guest filesystem stats unavailable while the VM is stopped.',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ disks: [] }, { disks: [agentDisk] }])(
|
||||
'does not borrow an agent when the platform owns disks (%j)',
|
||||
({ disks }) => {
|
||||
const value = guest({
|
||||
proxmox: { runtimeStatus: 'running', disks, diskStatusReason: 'prev-vm-locked' },
|
||||
});
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk.message).toContain('VM operation lock');
|
||||
value.agent!.stale = true;
|
||||
value.proxmox!.diskStatusReason = '';
|
||||
expect(current(value).disk).toBe(62);
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not infer filesystem ownership from a mere agent identity', () => {
|
||||
const value = guest({ agent: { agentId: 'in-guest', stale: false } });
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk.lastKnownValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves current linked-agent zero and does not fabricate an absent aggregate', () => {
|
||||
const value = guest({ disk: { current: 0, used: 0, total: 100 } });
|
||||
expect(current(value)).toMatchObject({ disk: 0, usage: 0 });
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
value.disk = undefined;
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
value.agent!.stale = true;
|
||||
expect(deferred(value).disk.lastKnownValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains LXC platform storage while stopped, but not its in-guest agent reading', () => {
|
||||
const value = guest({
|
||||
type: 'system-container',
|
||||
proxmox: { runtimeStatus: 'stopped', disks: [agentDisk] },
|
||||
});
|
||||
expect(current(value)).toMatchObject({ disk: 62, usage: 62 });
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
value.proxmox!.disks = undefined;
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk).toEqual({
|
||||
lastKnownValue: undefined,
|
||||
message: 'Filesystem usage is unavailable.',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['vm', 'system-container'] as const)(
|
||||
'keeps a linked-only %s in Proxmox scopes qualified',
|
||||
(type) => {
|
||||
const value = guest({
|
||||
type,
|
||||
platformType: 'agent',
|
||||
platformScopes: ['agent', 'proxmox-pve'],
|
||||
agent: { stale: true, disks: [agentDisk] },
|
||||
});
|
||||
expect(current(value).disk).toBeUndefined();
|
||||
expect(deferred(value).disk.lastKnownValue).toBe(62);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not impose QGA read metadata on a vSphere-only VM', () => {
|
||||
const value = guest({ platformType: 'vmware-vsphere', platformScopes: ['vmware-vsphere'] });
|
||||
expect(current(value)).toMatchObject({ disk: 62, usage: 62 });
|
||||
expect(deferred(value).disk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+7
-2
@@ -174,7 +174,7 @@ const memoryReading = (resource: Resource) =>
|
||||
);
|
||||
|
||||
const guestDiskReading = (resource: Resource) => {
|
||||
if (resource.type !== 'vm' || !isProxmoxGuest(resource)) return null;
|
||||
if (!isProxmoxGuest(resource)) return null;
|
||||
// A linked source can keep the canonical resource online while Proxmox
|
||||
// reports the VM stopped. Judge filesystem carriers by the native state,
|
||||
// using the same read admission as Workloads and Alerts, not Last seen.
|
||||
@@ -185,8 +185,13 @@ const guestDiskReading = (resource: Resource) => {
|
||||
{
|
||||
status: status === 'offline' ? 'stopped' : status,
|
||||
diskStatusReason: resource.proxmox?.diskStatusReason,
|
||||
// Match the Workloads projection: the linked agent owns filesystem
|
||||
// readings only when Proxmox supplied no filesystem list. Its own
|
||||
// reporting lease then qualifies them, not the unrelated QGA failure.
|
||||
disksFromAgent: resource.proxmox?.disks == null && resource.agent?.disks != null,
|
||||
agentStale: resource.agent?.stale === true,
|
||||
},
|
||||
true,
|
||||
resource.type === 'vm',
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user