mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed canonical VM Discovery safety repair
Preserve the exact Web candidate and parent-bound browser proof; backend admission and native recovery remain separate work. Change-source: pulse-maintainer
This commit is contained in:
@@ -23,6 +23,30 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
### Canonical Discovery honours VM guest-read deferrals
|
||||
|
||||
The canonical resource drawer uses the same fixed PVE VM lock, backup and
|
||||
read-deferral signals as the Workloads drawer. Typed and legacy PVE facets are
|
||||
checked independently: an available flag in one cannot cancel a pause in the
|
||||
other. Only the VM's own provider evidence is used; parent-node locks, an agent
|
||||
version and a Discovery target are not guest-liveness evidence. Containers,
|
||||
ordinary agents and unrelated provider facets retain their existing behaviour.
|
||||
|
||||
Both embedded Analysis and the dedicated Discovery entry pass this pause to the
|
||||
existing manual-run guard. Saved results remain readable; same-ID updates block
|
||||
or release the existing control without an automatic scan, invented timestamp
|
||||
or cancellation claim for already-dispatched work. The wrapping precaution and
|
||||
existing safety-guide link remain visible across drawer tabs. Clearance is not
|
||||
independent thaw, covered-filesystem writes or safe monitoring resumption.
|
||||
|
||||
`ResourceDetailDrawer.discovery.test.ts` covers both facets, every fixed reason,
|
||||
precedence and platform isolation. `ResourceDetailDrawer.guest-safety.test.tsx`
|
||||
mounts the real Discovery client/control, checks same-ID updates and no dispatch
|
||||
while paused, and preserves normal agent actions. The production browser fixture
|
||||
`browser-tests/resource-guest-safety.cjs` checks desktop keyboard and narrow touch
|
||||
with synthetic local responses. No backend, permission, collector or native
|
||||
recovery guarantee is added.
|
||||
|
||||
### Broadcast bulk reads capture resources and history coordinates together
|
||||
|
||||
`ListWithMetricsTargets` and the monitor adapter's matching optional read return
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
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 output = '/workspace/tmp/resource-guest-safety-settled-proof';
|
||||
const paths = [
|
||||
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
|
||||
'frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts',
|
||||
'frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts',
|
||||
'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx',
|
||||
'frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
|
||||
];
|
||||
const reasons = [
|
||||
'vm-locked',
|
||||
'lock-unverified',
|
||||
'agent-busy',
|
||||
'agent-cooldown',
|
||||
'agent-response-incomplete',
|
||||
'agent-capacity',
|
||||
'invalid-guest-key',
|
||||
'agent-timeout',
|
||||
];
|
||||
const saved = {
|
||||
id: 'vm:fixture-node-agent:101',
|
||||
resource_type: 'vm',
|
||||
resource_id: '101',
|
||||
target_id: 'fixture-node-agent',
|
||||
hostname: 'Backup guest',
|
||||
service_type: 'home-assistant',
|
||||
service_name: 'Saved Home Assistant',
|
||||
service_version: 'test',
|
||||
category: 'home_automation',
|
||||
cli_access: '',
|
||||
facts: [],
|
||||
config_paths: [],
|
||||
data_paths: [],
|
||||
log_paths: [],
|
||||
ports: [],
|
||||
user_notes: '',
|
||||
user_secrets: {},
|
||||
confidence: 0.9,
|
||||
ai_reasoning: 'Synthetic saved evidence',
|
||||
discovered_at: '2026-10-04T12:00:00Z',
|
||||
updated_at: '2026-10-04T12:00:00Z',
|
||||
discovery_engine_version: 1,
|
||||
};
|
||||
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const report = {
|
||||
runtime_sha256: Object.fromEntries(paths.map((p) => [p, hash(path.join('/workspace', p))])),
|
||||
playwright: require('playwright/package.json').version,
|
||||
groups: [],
|
||||
cleanup: {},
|
||||
limits:
|
||||
'Synthetic local snapshots and HTTP only. No native QGA, diagnostics, backup, thaw, filesystem write, service resumption, physical phone, publication or release acceptance.',
|
||||
};
|
||||
assert.equal(
|
||||
report.playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
const { createServer } =
|
||||
await import('/workspace/frontend-modern/node_modules/vite/dist/node/index.js');
|
||||
let server, browser, phase;
|
||||
try {
|
||||
for (const [name, engine, width, dark] of [
|
||||
['final-chromium', chromium, 1365, false],
|
||||
['final-webkit-phone', webkit, 320, true],
|
||||
]) {
|
||||
phase = name;
|
||||
const module = '/workspace/frontend-modern';
|
||||
process.chdir(module);
|
||||
server = await createServer({
|
||||
root: module,
|
||||
configFile: path.join(module, 'vite.config.ts'),
|
||||
cacheDir: path.join(output, name, 'vite'),
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5299,
|
||||
strictPort: true,
|
||||
watch: null,
|
||||
fs: { allow: ['/workspace'] },
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
const origin = 'http://127.0.0.1:5299';
|
||||
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',
|
||||
});
|
||||
page.setDefaultTimeout(10000);
|
||||
const group = {
|
||||
name,
|
||||
width,
|
||||
dark,
|
||||
browser: browser.version(),
|
||||
requests: [],
|
||||
errors: [],
|
||||
offOrigin: [],
|
||||
checks: [],
|
||||
screenshots: [],
|
||||
};
|
||||
report.groups.push(group);
|
||||
const check = (label) => group.checks.push(label);
|
||||
const capture = async (label) => {
|
||||
const file = path.join(output, `${name}-${label}.png`);
|
||||
await page.screenshot({ path: file, fullPage: true });
|
||||
group.screenshots.push({
|
||||
path: file.replace('/workspace/', ''),
|
||||
sha256: hash(file),
|
||||
bytes: fs.statSync(file).size,
|
||||
});
|
||||
};
|
||||
page.on('pageerror', (e) => group.errors.push(e.message));
|
||||
await page.routeWebSocket('**/ws*', () => {});
|
||||
await page.route('**/*', async (route) => {
|
||||
const req = route.request(),
|
||||
url = new URL(req.url());
|
||||
if (url.origin !== origin) {
|
||||
group.offOrigin.push(url.origin);
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
group.requests.push({
|
||||
path: url.pathname,
|
||||
query: url.search,
|
||||
method: req.method(),
|
||||
body: req.postData(),
|
||||
});
|
||||
if (
|
||||
url.pathname === '/api/discovery/vm/fixture-node-agent/101' ||
|
||||
url.pathname === '/api/discovery/agent/fixture-node-agent/101'
|
||||
)
|
||||
return route.fulfill({ json: saved });
|
||||
assert.equal(req.method(), 'GET');
|
||||
if (url.pathname.startsWith('/api/discovery/info/'))
|
||||
return route.fulfill({
|
||||
json: {
|
||||
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
|
||||
commands: [],
|
||||
command_categories: [],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/ai/agents')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
count: 1,
|
||||
agents: [
|
||||
{
|
||||
agent_id: 'fixture-node-agent',
|
||||
hostname: 'pve-a',
|
||||
commands_enabled: true,
|
||||
connected: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (url.pathname === '/api/license/runtime-capabilities')
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 7,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
},
|
||||
});
|
||||
return route.fulfill({
|
||||
json: {
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
counts: {},
|
||||
audits: [],
|
||||
count: 0,
|
||||
data: [],
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto(`${origin}/browser-tests/resource-guest-safety.html`, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 60000,
|
||||
});
|
||||
await page.evaluate((d) => document.documentElement.classList.toggle('dark', d), dark);
|
||||
const activate = async (locator) => (width < 500 ? locator.tap() : locator.press('Enter'));
|
||||
await activate(page.getByRole('tab', { name: 'Manage', exact: true }));
|
||||
const analysis = page.getByTestId('resource-access-analysis');
|
||||
await activate(analysis.getByRole('button', { name: 'Open analysis' }));
|
||||
await analysis.getByText('Saved Home Assistant', { exact: true }).waitFor();
|
||||
const button = analysis.getByRole('button', { name: 'Update Discovery' });
|
||||
await button.waitFor();
|
||||
const posts = () => group.requests.filter((r) => r.method === 'POST');
|
||||
const notice = page.getByTestId('resource-guest-read-precaution');
|
||||
await notice.waitFor();
|
||||
assert(await button.isDisabled());
|
||||
assert.match(await notice.innerText(), /An OK backup or a running VM does not prove thaw/);
|
||||
assert.equal(
|
||||
await notice.getByRole('link', { name: 'Backup safety guidance' }).getAttribute('href'),
|
||||
'/docs/VM_DISK_MONITORING',
|
||||
);
|
||||
const control = await button.elementHandle();
|
||||
const update = (evidence, legacy = false, type = 'vm') =>
|
||||
page.evaluate(
|
||||
({ evidence, legacy, type }) =>
|
||||
window.__resourceGuestSafety.update(evidence, legacy, type),
|
||||
{ evidence, legacy, type },
|
||||
);
|
||||
for (const legacy of [false, true]) {
|
||||
for (const reason of reasons) {
|
||||
for (const diskStatusReason of [reason, `prev-${reason}`]) {
|
||||
await update({ diskStatusReason, guestAgentStatus: 'available' }, legacy);
|
||||
assert(await button.isDisabled());
|
||||
assert(await notice.isVisible());
|
||||
assert.equal(posts().length, 0);
|
||||
}
|
||||
}
|
||||
for (const evidence of [
|
||||
{ lock: 'backup' },
|
||||
{ backupInProgress: true },
|
||||
{ guestAgentStatus: 'deferred' },
|
||||
]) {
|
||||
await update(evidence, legacy);
|
||||
assert(await button.isDisabled());
|
||||
}
|
||||
}
|
||||
assert(await button.evaluate((node, old) => node === old, control));
|
||||
assert.equal(await analysis.getByText('Saved Home Assistant', { exact: true }).count(), 1);
|
||||
check(
|
||||
'All 32 reason/facet variants and six operation variants block the same mounted rescan; saved evidence remains readable; zero POST.',
|
||||
);
|
||||
await update({ diskStatusReason: 'prev-agent-timeout' }, true);
|
||||
const geometry = await notice.boundingBox();
|
||||
assert(geometry && geometry.x >= 0 && geometry.x + geometry.width <= width);
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
|
||||
const describedBy = await button.getAttribute('aria-describedby');
|
||||
assert(describedBy && (await page.locator(`#${describedBy}`).isVisible()));
|
||||
await capture('paused-saved');
|
||||
check('Disabled rescan is described and narrow geometry has no horizontal overflow.');
|
||||
await update({ guestAgentStatus: 'available' });
|
||||
assert(await button.isEnabled());
|
||||
assert.equal(await notice.count(), 0);
|
||||
assert(await button.evaluate((node, old) => node === old, control));
|
||||
assert.equal(posts().length, 0);
|
||||
check(
|
||||
'Healthy same-VM evidence clears the guard without an automatic scan or remount; no thaw claim.',
|
||||
);
|
||||
await capture('cleared');
|
||||
const response = page.waitForResponse(
|
||||
(r) =>
|
||||
r.url() === `${origin}/api/discovery/vm/fixture-node-agent/101` &&
|
||||
r.request().method() === 'POST',
|
||||
);
|
||||
await activate(button);
|
||||
await response;
|
||||
assert.equal(posts().length, 1);
|
||||
assert.equal(posts()[0].path, '/api/discovery/vm/fixture-node-agent/101');
|
||||
check(
|
||||
'An explicit permitted keyboard/touch action sends exactly one synthetic local request to the unchanged VM target.',
|
||||
);
|
||||
await update({ lock: 'backup' });
|
||||
assert(await button.isDisabled());
|
||||
await activate(page.getByRole('tab', { name: 'Overview', exact: true }));
|
||||
assert(await notice.isVisible());
|
||||
check('Precaution and guide stay visible on Overview after leaving Analysis.');
|
||||
await update({ lock: 'backup' }, false, 'agent');
|
||||
await activate(page.getByRole('tab', { name: 'Discovery', exact: true }));
|
||||
const agentButton = page.getByRole('button', { name: 'Run Discovery', exact: true });
|
||||
await agentButton.waitFor();
|
||||
assert(await agentButton.isEnabled());
|
||||
assert.equal(await notice.count(), 0);
|
||||
check('Ordinary agent Discovery tab is not paused by parent-side PVE evidence.');
|
||||
assert.deepEqual(group.errors, []);
|
||||
assert.deepEqual(group.offOrigin, []);
|
||||
check('No page error, off-origin request, native target or background POST.');
|
||||
await browser.close();
|
||||
browser = null;
|
||||
await server.close();
|
||||
server = null;
|
||||
}
|
||||
} catch (error) {
|
||||
report.failure = { phase, message: error.message };
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
report.cleanup = { browser_closed: true, server_closed: true };
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2));
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
groups: report.groups.length,
|
||||
checks: report.groups.reduce((n, g) => n + g.checks.length, 0),
|
||||
screenshots: report.groups.reduce((n, g) => n + g.screenshots.length, 0),
|
||||
cleanup: report.cleanup,
|
||||
}),
|
||||
);
|
||||
})().catch((e) => {
|
||||
console.error(e.stack);
|
||||
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>Canonical guest safety evidence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/resource-guest-safety.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
// Real canonical drawer/state/Discovery clients; synthetic provider snapshots.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { Resource, ResourceProxmoxMeta } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: true });
|
||||
const healthy: ResourceProxmoxMeta = {
|
||||
nodeName: 'pve-a',
|
||||
vmid: 101,
|
||||
guestAgentStatus: 'available',
|
||||
lock: '',
|
||||
diskStatusReason: '',
|
||||
};
|
||||
const resource = (
|
||||
evidence: ResourceProxmoxMeta,
|
||||
legacy = false,
|
||||
type: Resource['type'] = 'vm',
|
||||
): Resource => ({
|
||||
id: 'fixture:pve-a:101',
|
||||
type,
|
||||
name: 'Backup guest',
|
||||
displayName: 'Backup guest',
|
||||
platformId: 'fixture',
|
||||
platformType: type === 'agent' ? 'agent' : 'proxmox-pve',
|
||||
platformScopes: type === 'agent' ? ['agent'] : ['proxmox-pve'],
|
||||
sources: type === 'agent' ? ['agent'] : ['proxmox'],
|
||||
sourceType: type === 'agent' ? 'agent' : 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
cpu: { current: 10 },
|
||||
memory: { current: 25, used: 1024, total: 4096 },
|
||||
discoveryTarget: {
|
||||
resourceType: type === 'agent' ? 'agent' : 'vm',
|
||||
agentId: 'fixture-node-agent',
|
||||
resourceId: '101',
|
||||
},
|
||||
proxmox: legacy ? healthy : { ...healthy, ...evidence },
|
||||
platformData: legacy ? { proxmox: evidence } : undefined,
|
||||
});
|
||||
function Fixture() {
|
||||
const [value, setValue] = createSignal(resource({ lock: 'backup' }));
|
||||
(window as any).__resourceGuestSafety = {
|
||||
update: (evidence: ResourceProxmoxMeta, legacy = false, type: Resource['type'] = 'vm') =>
|
||||
setValue(resource(evidence, legacy, type)),
|
||||
};
|
||||
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">Canonical guest safety evidence</h1>
|
||||
<p class="text-xs text-muted">Synthetic local responses. No guest check or backup is sent.</p>
|
||||
<ResourceDetailDrawer resource={value()} initialShowAccessContext />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<Fixture />
|
||||
</DarkModeContext.Provider>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "90a7d2e0ad08109132bed4747690868861f67e23",
|
||||
"verified_at": "2026-10-05T16:15:22.197327Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "3d89b12c95fc35bb93fa703a7d10beb06296fb4ed9b9f0c313f22afc0ff0b6e5",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "969e4051af443b50e45443241a8b4e0f9e66a8f7b51c43d64ab4fd9e5c3f9a84",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "7958a719302571e07dde329231554c59f2fa2d3d49852486b54e929df2856af8",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "8abfb347ac32bdd4cb88676999b523f69d8c8f1ea5febd7736a64ba7b57159b5",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "53dc922a8cc70d3e39f51554c581707cd496fa1086290c8ebd6f20fc226933ba"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/resource-guest-safety.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Real canonical resource drawer/state/Discovery client and CSS, synthetic local snapshots and HTTP only. Parent at the receipt base exposes enabled Update Discovery under backup lock; no parent POST was sent.",
|
||||
"Desktop Chromium and dark 320px touch WebKit cover every eight fixed reasons, retained variants and typed/legacy facets (32 each), plus backup/lock/deferred signals. A healthy alternate facet never cancels the pause. Same-ID updates retain the mounted control and saved service evidence.",
|
||||
"First browser attempt exited one on an early dispatch-count assertion after desktop pause/clearance checks; it did not establish dispatch or phone acceptance. Remaining dispatch checks synchronise on the actual POST response; the complete phone matrix and both explicit synthetic actions pass with zero page errors/off-origin requests and closed browser/server.",
|
||||
"All five captures inspected: parent gap, desktop paused/cleared and phone paused/cleared. Wrapping precaution/guide and saved results fit without horizontal overflow; the disabled rescan has a visible description. Agent Discovery remains usable outside the VM safety condition.",
|
||||
"This is not native QGA, diagnostics, backup, thaw, covered-filesystem writes, liveness, safe resumption, physical-phone, installation, publication or release acceptance."
|
||||
],
|
||||
"interactions": [
|
||||
"Desktop keyboard activation and phone taps use the actual Manage -> Access -> Analysis controls. All paused variants send zero POST; same-ID clearance neither remounts the control nor sends an automatic scan.",
|
||||
"An explicit healthy VM action sends exactly one synthetic local POST per engine to the existing VM/agent/101 identity. Pause remains visible when leaving Analysis for Overview; ordinary agent Discovery is not paused by host-side PVE evidence.",
|
||||
"Original desktop matrix/parent observations are retained rather than repeated in the remaining-proof run. Exact executed scripts, results, client exits and screenshot hashes are retained in the lane dossier; production content matches this receipt."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ce2111c089b451fb59f410f180b979ba95619b1c",
|
||||
"verified_at": "2026-10-05T16:59:02.182220Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "0b5db44ee2a4f393ebac64cc9900721a3dac880b10f0b0cf1fd43b9eb07f0304"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/resource-guest-safety.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Fresh final-content checks after the type-only shared input correction. Real canonical drawer/Discovery client and CSS with synthetic local HTTP only; Chromium 141.0.7390.37 and dark touch WebKit 26.0, Playwright 1.56.1 matches the integration lock.",
|
||||
"Both viewports cover empty saved data during backup, same-ID clearance with no automatic scan, then retained legacy timeout with reported availability re-pausing the same control. Three checks per engine, no POST, page errors or off-origin requests; browser/server closure confirmed.",
|
||||
"Both captures inspected: backup-safety notice and guide wrap beside a visible, disabled Discovery control; the 320px page has no horizontal overflow.",
|
||||
"Two earlier phone admissions returned gateway restarting before execution and no operation started. This successful fresh admission followed the completed corrected-source proof; it does not diagnose the restart or clear earlier stopped/unknown browser operations.",
|
||||
"The earlier own-parent receipt remains valid for the four unchanged canonical production files and their complete saved-result/explicit-action matrix. This receipt verifies only the changed shared helper final content.",
|
||||
"No native QGA, backup, thaw, every-covered-filesystem writes, liveness, prior-active-only resumption, physical phone, installation, publication or release acceptance."
|
||||
],
|
||||
"interactions": [
|
||||
"Keyboard Manage -> Open analysis; reactive same-ID provider snapshots retain the actual Discovery control and revoke/restore its disabled state without dispatch.",
|
||||
"Desktop and phone capture/DOM checks use the exact optional-evidence helper content hashed here. No earlier completed matrix or unavailable executed operation was replayed."
|
||||
]
|
||||
}
|
||||
@@ -31,6 +31,9 @@ import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
|
||||
import { MaintenanceVerificationSection } from './MaintenanceVerificationSection';
|
||||
import { ResourceActionHistory } from './ResourceActionHistory';
|
||||
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '@/features/docker/dockerCpuPresentation';
|
||||
import { InlineNotice } from '@/components/shared/InlineNotice';
|
||||
import { GUEST_DRAWER_BACKUP_PRECAUTION } from '@/components/Workloads/guestDrawerModel';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
|
||||
interface ResourceDetailDrawerProps {
|
||||
resource: Resource;
|
||||
@@ -159,6 +162,21 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.guestReadPrecaution()}>
|
||||
{(message) => (
|
||||
<InlineNotice
|
||||
tone="warning"
|
||||
role="status"
|
||||
data-testid="resource-guest-read-precaution"
|
||||
actionHref={getShippedDocUrl('VM_DISK_MONITORING.md')}
|
||||
actionLabel="Backup safety guidance"
|
||||
>
|
||||
<p>{message()}</p>
|
||||
<p>{GUEST_DRAWER_BACKUP_PRECAUTION}</p>
|
||||
</InlineNotice>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Resource detail sections"
|
||||
@@ -262,6 +280,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
canonicalResourceId={props.resource.id}
|
||||
commandsEnabled={drawer.agentMeta()?.commandsEnabled}
|
||||
showManualRunAction
|
||||
runBlockReason={drawer.guestReadPrecaution()}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{
|
||||
hostname={config().hostname}
|
||||
canonicalResourceId={props.resource.id}
|
||||
commandsEnabled={props.drawer.agentMeta()?.commandsEnabled}
|
||||
runBlockReason={props.drawer.guestReadPrecaution()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
+74
-1
@@ -5,7 +5,11 @@ import type { ResourceDiscovery } from '@/types/discovery';
|
||||
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
||||
import discoveryReadinessSource from '@/components/Discovery/discoveryReadiness.ts?raw';
|
||||
import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw';
|
||||
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import {
|
||||
getResourceGuestReadPrecaution,
|
||||
toDiscoveryConfig,
|
||||
} from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation';
|
||||
|
||||
const baseResource = (): Resource => ({
|
||||
@@ -28,6 +32,75 @@ const baseResource = (): Resource => ({
|
||||
},
|
||||
});
|
||||
|
||||
describe('canonical VM guest-read safety', () => {
|
||||
const vm = (): Resource => ({ ...baseResource(), type: 'vm' });
|
||||
|
||||
it.each(guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]))(
|
||||
'honours %s from typed and legacy PVE evidence independently',
|
||||
(diskStatusReason) => {
|
||||
for (const facet of ['typed', 'legacy']) {
|
||||
const evidence = { diskStatusReason, guestAgentStatus: 'available' };
|
||||
const value = vm();
|
||||
if (facet === 'typed') value.proxmox = evidence;
|
||||
else value.platformData = { proxmox: evidence };
|
||||
expect(getResourceGuestReadPrecaution(value)).toBeTruthy();
|
||||
expect(getResourceGuestReadPrecaution(value)).not.toContain(diskStatusReason);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])(
|
||||
'does not let a healthy alternate facet cancel %j',
|
||||
(paused) => {
|
||||
const healthy = { guestAgentStatus: 'available', diskStatusReason: '', lock: '' };
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({
|
||||
...vm(),
|
||||
proxmox: healthy,
|
||||
platformData: { proxmox: paused },
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({
|
||||
...vm(),
|
||||
proxmox: paused,
|
||||
platformData: { proxmox: healthy },
|
||||
}),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['agent', 'system-container', 'app-container'] as const)(
|
||||
'does not turn parent or container PVE evidence into a %s QGA pause',
|
||||
(type) => {
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({ ...vm(), type, proxmox: { lock: 'backup' } }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves healthy/unrelated VMs alone and withholds unknown raw provider text', () => {
|
||||
expect(getResourceGuestReadPrecaution(vm())).toBeNull();
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({ ...vm(), proxmox: { guestAgentStatus: 'available' } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({
|
||||
...vm(),
|
||||
platformType: 'vmware-vsphere',
|
||||
platformData: { vmware: { lock: 'backup' } },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getResourceGuestReadPrecaution({
|
||||
...vm(),
|
||||
proxmox: { lock: 'private provider text', diskStatusReason: 'private read reason' },
|
||||
}),
|
||||
).not.toMatch(/private provider text|private read reason/);
|
||||
expect(getResourceGuestReadPrecaution({ ...vm(), platformData: { proxmox: [] } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDiscoveryConfig', () => {
|
||||
it('prefers backend discoveryTarget over heuristic IDs', () => {
|
||||
const resource: Resource = {
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { createSignal, Suspense } from 'solid-js';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '../ResourceDetailDrawer';
|
||||
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
import * as discoveryApi from '@/api/discovery';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
|
||||
vi.mock('@/api/discovery', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/api/discovery')>()),
|
||||
getDiscovery: vi.fn(async () => null),
|
||||
getDiscoveryInfo: vi.fn(async () => ({
|
||||
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
|
||||
commands: [],
|
||||
command_categories: [],
|
||||
})),
|
||||
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
|
||||
triggerDiscovery: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock('@/api/resources', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/api/resources')>()),
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn(async () => ({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
counts: {},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: vi.fn(async () => null) } }));
|
||||
vi.mock('@/api/actionAudit', () => ({
|
||||
ActionAuditAPI: {
|
||||
listActionAudits: vi.fn(async () => ({ audits: [], count: 0, available: false })),
|
||||
},
|
||||
}));
|
||||
|
||||
const vm = (patch: Partial<Resource> = {}): Resource => ({
|
||||
id: 'fixture:pve1:101',
|
||||
type: 'vm',
|
||||
name: 'Backup guest',
|
||||
displayName: 'Backup guest',
|
||||
platformId: 'fixture',
|
||||
status: 'online',
|
||||
sourceType: 'api',
|
||||
platformType: 'proxmox-pve',
|
||||
lastSeen: 1,
|
||||
proxmox: { nodeName: 'pve1', vmid: 101, guestAgentStatus: 'available' },
|
||||
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '101' },
|
||||
...patch,
|
||||
});
|
||||
const mount = (value: () => Resource) =>
|
||||
render(() => (
|
||||
<Suspense>
|
||||
<ResourceDetailDrawer resource={value()} initialShowAccessContext />
|
||||
</Suspense>
|
||||
));
|
||||
const openAnalysis = async () => {
|
||||
fireEvent.click(await screen.findByRole('tab', { name: 'Manage' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open analysis' }));
|
||||
return within(screen.getByTestId('resource-access-analysis')).findByRole('button', {
|
||||
name: 'Run Discovery',
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAIRuntimeState();
|
||||
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
resetAIRuntimeState();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('canonical drawer real Discovery safety wiring', () => {
|
||||
it('blocks every retained/current deferral reactively without remounting or dispatching a scan', async () => {
|
||||
const [value, setValue] = createSignal(vm({ proxmox: { lock: 'backup' } }));
|
||||
mount(value);
|
||||
const button = await openAnalysis();
|
||||
await waitFor(() => expect(button).toBeDisabled());
|
||||
expect(screen.getByTestId('resource-guest-read-precaution')).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByTestId('resource-guest-read-precaution')).getByRole('link', {
|
||||
name: 'Backup safety guidance',
|
||||
}),
|
||||
).toHaveAttribute('href', getShippedDocUrl('VM_DISK_MONITORING.md'));
|
||||
for (const [reason] of guestDiskDeferrals) {
|
||||
for (const diskStatusReason of [reason, `prev-${reason}`]) {
|
||||
setValue(vm({ proxmox: { diskStatusReason, guestAgentStatus: 'available' } }));
|
||||
await waitFor(() => expect(button).toBeDisabled());
|
||||
fireEvent.click(button);
|
||||
}
|
||||
}
|
||||
setValue(vm({ platformData: { proxmox: { backupInProgress: true } } }));
|
||||
await waitFor(() => expect(button).toBeDisabled());
|
||||
setValue(vm());
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('resource-access-analysis')).getByRole('button', {
|
||||
name: 'Run Discovery',
|
||||
}),
|
||||
).toBe(button);
|
||||
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
|
||||
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '101', {
|
||||
force: true,
|
||||
hostname: 'Backup guest',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps ordinary agent Discovery tab actions usable despite host-side PVE locks', async () => {
|
||||
mount(() =>
|
||||
vm({
|
||||
id: 'node-agent',
|
||||
type: 'agent',
|
||||
sourceType: 'agent',
|
||||
platformType: 'agent',
|
||||
sources: ['agent'],
|
||||
proxmox: { lock: 'backup' },
|
||||
discoveryTarget: { resourceType: 'agent', agentId: 'node-agent', resourceId: 'node-agent' },
|
||||
}),
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
|
||||
const button = await screen.findByRole('button', { name: 'Run Discovery' });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getGuestDrawerGuestReadPrecaution } from '@/components/Workloads/guestDrawerModel';
|
||||
import type { ResourceType as DiscoveryResourceType } from '@/types/discovery';
|
||||
import {
|
||||
canonicalDiscoveryResourceType,
|
||||
@@ -64,6 +65,33 @@ type PlatformData = {
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
|
||||
// The canonical drawer has both typed and legacy provider facets. A healthy
|
||||
// facet cannot cancel a deferral still reported by the other one. Inspect only
|
||||
// this VM's own PVE evidence, not its parent, Discovery target or cached version.
|
||||
export const getResourceGuestReadPrecaution = (resource: Resource): string | null => {
|
||||
if (resource.type !== 'vm') return null;
|
||||
const legacy = resource.platformData?.proxmox;
|
||||
const facets = [
|
||||
resource.proxmox,
|
||||
legacy && typeof legacy === 'object' && !Array.isArray(legacy)
|
||||
? (legacy as Record<string, unknown>)
|
||||
: undefined,
|
||||
];
|
||||
for (const facet of facets) {
|
||||
if (!facet) continue;
|
||||
const precaution = getGuestDrawerGuestReadPrecaution({
|
||||
type: 'vm',
|
||||
platformScopes: ['proxmox-pve'],
|
||||
guestAgentStatus: asString(facet.guestAgentStatus),
|
||||
diskStatusReason: asString(facet.diskStatusReason),
|
||||
lock: asString(facet.lock),
|
||||
backupInProgress: facet.backupInProgress === true,
|
||||
});
|
||||
if (precaution) return precaution;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const asNumber = (value: unknown): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
|
||||
+6
-1
@@ -41,7 +41,10 @@ import {
|
||||
type KubernetesPlatformData,
|
||||
type PlatformData,
|
||||
} from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import {
|
||||
getResourceGuestReadPrecaution,
|
||||
toDiscoveryConfig,
|
||||
} from '@/components/Infrastructure/resourceDetailDiscoveryModel';
|
||||
import { getDiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation';
|
||||
import {
|
||||
buildPbsActiveTasks,
|
||||
@@ -298,6 +301,7 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow);
|
||||
const hasMergedSources = createMemo(() => mergedSources().length > 1);
|
||||
const discoveryConfig = createMemo(() => toDiscoveryConfig(resource()));
|
||||
const guestReadPrecaution = createMemo(() => getResourceGuestReadPrecaution(resource()));
|
||||
const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig()));
|
||||
const discoverySourceKey = createMemo(() => {
|
||||
if (!discoveryFeatureEnabled()) return null;
|
||||
@@ -517,6 +521,7 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
metricsHistoryGroups,
|
||||
hasMetricsHistory,
|
||||
hasDiscoveryTab,
|
||||
guestReadPrecaution,
|
||||
hasVMwareDetails,
|
||||
vmwareDetailsSummary,
|
||||
vmwareDetailSections,
|
||||
|
||||
@@ -439,6 +439,18 @@ export interface GuestDrawerGuestReadPresentation {
|
||||
precaution: boolean;
|
||||
}
|
||||
|
||||
// Both drawer shapes use the same provider-owned signals. A canonical resource
|
||||
// need not fabricate a complete workload (or infer liveness from metrics).
|
||||
export interface GuestDrawerGuestReadEvidence extends Pick<
|
||||
Guest,
|
||||
'type' | 'workloadType' | 'agentKind' | 'platformType' | 'platformScopes'
|
||||
> {
|
||||
diskStatusReason?: VM['diskStatusReason'];
|
||||
guestAgentStatus?: VM['guestAgentStatus'];
|
||||
lock?: VM['lock'];
|
||||
backupInProgress?: VM['backupInProgress'];
|
||||
}
|
||||
|
||||
export const GUEST_DRAWER_BACKUP_PRECAUTION =
|
||||
'Do not run live diagnostics or restart the guest agent during a backup, freeze/thaw or an unresponsive-guest incident. An OK backup or a running VM does not prove thaw. Confirm thaw and writes to the filesystems covered by the backup independently.';
|
||||
|
||||
@@ -457,9 +469,9 @@ const guestReadDeferrals = new Set([
|
||||
// parent-agent action target establishes QGA liveness. Use only the current
|
||||
// provider state and fixed read reasons; never print an unknown raw value.
|
||||
export const getGuestDrawerGuestReadPresentation = (
|
||||
guest: Guest,
|
||||
guest: GuestDrawerGuestReadEvidence,
|
||||
): GuestDrawerGuestReadPresentation | null => {
|
||||
if (!isGuestDrawerVM(guest)) return null;
|
||||
if (resolveWorkloadType(guest) !== 'vm') return null;
|
||||
const hasProxmoxEvidence =
|
||||
guest.type === 'qemu' ||
|
||||
guest.agentKind === 'qemu-guest' ||
|
||||
@@ -545,8 +557,9 @@ export const getGuestDrawerGuestReadPresentation = (
|
||||
|
||||
// A read-specific precaution is independent of the reported availability flag.
|
||||
// For example Proxmox can report QGA enabled while its last request timed out.
|
||||
export const getGuestDrawerGuestReadPrecaution = (guest: Guest): string | null => {
|
||||
if (!isGuestDrawerVM(guest)) return null;
|
||||
export const getGuestDrawerGuestReadPrecaution = (
|
||||
guest: GuestDrawerGuestReadEvidence,
|
||||
): string | null => {
|
||||
const presentation = getGuestDrawerGuestReadPresentation(guest);
|
||||
if (!presentation?.precaution) return null;
|
||||
const reason = (guest.diskStatusReason || '').replace(/^prev-/, '');
|
||||
|
||||
Reference in New Issue
Block a user