Pause manual guest discovery during backup read deferrals

Guard every run control and the retained handler with the current guest precaution. Keep saved discovery readable, same-guest selection stable and already-dispatched completion visible; clearing a pause neither proves thaw nor starts a scan. Desktop and phone Chromium/WebKit rendering verified with synthetic APIs.

Contract-Neutral: Existing drawer-only manual discovery safety gating; no canonical resource, API, identity, permission or polling contract delta.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 16:42:30 +01:00
parent 2cb3f6a904
commit ea6a52d43c
9 changed files with 777 additions and 75 deletions
@@ -0,0 +1,329 @@
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/discovery-safety-proof/browser';
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
'frontend-modern/src/components/Discovery/DiscoveryTab.tsx',
'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts',
];
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const initial = {
state: 'deferred',
reason: 'prev-vm-locked',
lock: 'backup',
backup: true,
version: '6.4.5',
assignment: true,
diskUsed: 5 * 1024 ** 3,
};
const healthy = { ...initial, state: 'available', reason: '', lock: '', backup: false };
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-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
discovery_engine_version: 1,
};
(async () => {
fs.mkdirSync(output, { recursive: true });
const report = {
runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash(path.join('/workspace', p))])),
playwright: require('playwright/package.json').version,
groups: [],
screenshots: [],
cleanup: {},
};
assert.equal(
report.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: 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';
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 = [],
checks = [];
report.groups.push({
name,
width,
dark,
browser: browser.version(),
checks,
requests,
errors,
offOrigin,
});
await page.routeWebSocket('**/ws*', () => {});
let hold = false,
release;
page.on('pageerror', (e) => errors.push(e.message));
await page.addInitScript((dark) => {
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
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(),
body: req.postData(),
});
if (url.pathname === '/api/discovery/vm/fixture-node-agent/101') {
if (req.method() === 'POST') {
if (hold)
await new Promise((resolve) => {
release = resolve;
});
return route.fulfill({ json: saved });
}
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',
version: '6.4.5',
platform: 'linux',
},
],
},
});
if (url.pathname.includes('metrics-store/history'))
return route.fulfill({
json: {
resourceType: 'vm',
resourceId: url.searchParams.get('resourceId'),
range: '24h',
start: Date.UTC(2026, 9, 3, 12),
end: Date.UTC(2026, 9, 4, 12),
metrics: {},
source: 'store',
},
});
if (url.pathname === '/api/resources')
return route.fulfill({ json: { data: [], total: 0, page: 1, limit: 500 } });
if (url.pathname.includes('license'))
return route.fulfill({ json: { capabilities: [], limits: [], max_history_days: 90 } });
if (url.pathname.includes('metadata')) return route.fulfill({ json: {} });
return route.fulfill({
json: { success: true, data: [], alerts: [], anomalies: [], config: null },
});
});
await page.goto(`${origin}/browser-tests/guest-discovery-safety.html`, {
waitUntil: 'domcontentloaded',
});
await page.waitForFunction(() => Boolean(window.__guestDiscoverySafety));
const row = page.locator('[data-guest-id]').first();
if (width > 500) {
await row.getByRole('button', { name: 'Expand backup-guest', exact: true }).focus();
await page.keyboard.press('Enter');
} else await row.tap();
await page.getByRole('tab', { name: 'Discovery', exact: true }).click();
const run = page.getByRole('button', { name: 'Run Discovery', exact: true });
await run.waitFor();
const update = async (value) =>
page.evaluate((v) => window.__guestDiscoverySafety.update(v), value);
const postCount = () => requests.filter((r) => r.method === 'POST').length;
const block = page.getByTestId('discovery-run-block');
const capture = async (state) => {
const filename = `${name}-${state}.png`,
file = path.join(output, filename);
await run.scrollIntoViewIfNeeded();
await page.screenshot({ path: file, fullPage: true });
report.screenshots.push({ filename, sha256: hash(file) });
};
const layout = async () => {
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
for (const loc of [block, page.getByTestId('guest-read-precaution')]) {
const box = await loc.boundingBox();
assert(box && box.x >= 0 && box.x + box.width <= width + 1);
assert(await loc.evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
}
};
await page.getByText('Saved Home Assistant', { exact: true }).first().waitFor();
assert(await run.isDisabled());
assert.equal(postCount(), 0);
assert((await block.innerText()).includes('does not prove thaw'));
assert(await run.getAttribute('aria-describedby'));
assert.equal(await block.getAttribute('id'), await run.getAttribute('aria-describedby'));
await layout();
await capture('locked-saved');
checks.push(
'Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason',
);
await page.evaluate(() => {
window.__originalSafetyButton = document.querySelector(
'[aria-describedby^="discovery-run-block-"]',
);
});
for (const reason of [
'vm-locked',
'lock-unverified',
'agent-busy',
'agent-cooldown',
'agent-response-incomplete',
'agent-capacity',
'invalid-guest-key',
'agent-timeout',
]) {
for (const prefix of ['', 'prev-']) {
await update({ ...healthy, reason: prefix + reason });
assert(await run.isDisabled());
assert.equal(postCount(), 0);
assert(
await page.evaluate(
() =>
document.querySelector('[aria-describedby^="discovery-run-block-"]') ===
window.__originalSafetyButton,
),
);
}
}
await update({ ...healthy, reason: 'prev-agent-timeout' });
await capture('uncertain-saved');
await page.getByRole('tab', { name: 'History', exact: true }).click();
assert(await page.getByTestId('guest-read-precaution').isVisible());
await page.getByRole('tab', { name: 'Discovery', exact: true }).click();
assert(await run.isDisabled());
checks.push(
'All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan',
);
await update(healthy);
assert(await run.isEnabled());
assert.equal(await block.count(), 0);
assert.equal(postCount(), 0);
await capture('ready-manual');
await run.focus();
await page.keyboard.press('Enter');
await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!'));
assert.equal(postCount(), 1);
const first = requests.find((r) => r.method === 'POST');
assert.deepEqual(JSON.parse(first.body), { force: true, hostname: 'backup-guest' });
checks.push(
'Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing',
);
await page.waitForFunction(() => !document.body.textContent.includes('Discovery complete!'));
hold = true;
const secondPost = page.waitForRequest(
(req) =>
req.method() === 'POST' &&
new URL(req.url()).pathname === '/api/discovery/vm/fixture-node-agent/101',
);
if (width < 500) await run.tap();
else await run.click();
await secondPost;
await page.waitForFunction(() => document.body.textContent.includes('Scanning...'));
assert.equal(postCount(), 2);
await update({ ...initial, reason: 'agent-timeout', lock: '', backup: false });
assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled());
assert(await block.isVisible());
assert((await page.locator('body').innerText()).includes('Scanning...'));
assert.equal(postCount(), 2);
assert(release);
release();
await page.waitForFunction(() => document.body.textContent.includes('Discovery complete!'));
assert(await run.isDisabled());
assert.equal(postCount(), 2);
await capture('inflight-completed-blocked');
checks.push(
'A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent',
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
await browser.close();
browser = null;
}
report.result = 'passed';
report.verified_at = new Date().toISOString();
} catch (error) {
report.result = 'failed';
report.phase = phase;
report.error = String(error.stack || error);
throw error;
} finally {
if (browser) await browser.close();
report.cleanup.browser_closed = true;
await server.close();
report.cleanup.server_closed = true;
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2) + '\n');
}
})().catch((error) => {
console.error(error.stack || String(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>Guest Discovery safety verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-discovery-safety.tsx"></script>
</body>
</html>
@@ -0,0 +1,126 @@
// Production canonical resource projection, row, drawer, router and Docs.
// Synthetic observations only; no native QGA, diagnostic or backup operation.
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 { mergeCanonicalResourceSnapshot } from '../src/utils/resourceStateAdapters';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import Docs from '../src/pages/Docs';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: true });
interface Observation {
state?: string;
reason?: string;
lock?: string;
backup?: boolean;
version?: string;
assignment?: boolean;
diskUsed?: number;
}
const initial: Observation = {
state: 'deferred',
reason: 'prev-vm-locked',
lock: 'backup',
backup: true,
version: '6.4.5',
assignment: true,
diskUsed: 5 * 1024 ** 3,
};
const nativeResource = (value: Observation): Resource => ({
id: 'fixture-pve:pve-a:101',
type: 'vm',
name: 'backup-guest',
displayName: 'backup-guest',
platformType: 'proxmox-pve',
platformScopes: ['proxmox-pve'],
sourceType: 'api',
status: 'online',
sources: ['proxmox', 'agent'],
lastSeen: Date.UTC(2026, 9, 4, 14),
cpu: { current: 10 },
memory: { current: 25, used: 1024 ** 3 / 4, total: 1024 ** 3 },
disk: {
current: ((value.diskUsed ?? 0) / (10 * 1024 ** 3)) * 100,
used: value.diskUsed,
total: 10 * 1024 ** 3,
},
proxmox: {
instance: 'fixture-pve',
nodeName: 'pve-a',
vmid: 101,
runtimeStatus: 'running',
template: false,
guestAgentStatus: value.state,
diskStatusReason: value.reason,
lock: value.lock,
guestAgentExpected: true,
backupInProgress: value.backup ?? false,
lastBackup: '2026-10-04T06:00:00Z',
disks: [],
},
agent: {
agentId: 'fixture-pulse-agent',
agentVersion: value.version || '',
hostname: 'backup-guest',
osName: 'Linux',
},
discoveryTarget: value.assignment
? { resourceType: 'vm', agentId: 'fixture-node-agent', resourceId: '101' }
: undefined,
});
function Fixture() {
const [observation, setObservation] = createSignal(initial);
const [snapshot, setSnapshot] = createSignal<Resource[]>([nativeResource(initial)]);
const state = useWorkloads(() => true, { resourceSnapshot: snapshot });
const guest = () => state.workloads()[0]!;
const [expanded, setExpanded] = createSignal(false);
(window as any).__guestDiscoverySafety = {
update: (next: Observation) => {
setObservation(next);
setSnapshot((previous) => mergeCanonicalResourceSnapshot([nativeResource(next)], previous));
},
observation,
identity: () => guest().id,
};
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 Discovery safety evidence</h1>
<p class="text-xs text-muted">Synthetic observations. No guest check is sent.</p>
<Show when={Boolean(guest())}>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
isExpanded={expanded()}
onClick={() => setExpanded(!expanded())}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
<Show when={expanded()}>
<section aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => setExpanded(false)} />
</section>
</Show>
</Show>
</main>
);
}
render(
() => (
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
<Router>
<Route path="/docs/*docPath" component={Docs} />
<Route path="/*" component={Fixture} />
</Router>
</DarkModeContext.Provider>
),
document.getElementById('root')!,
);
@@ -0,0 +1,67 @@
{
"version": 1,
"result": "passed",
"base_sha": "2cb3f6a9043e0bab9c54c5b5ef57b3e1cb994781",
"changed_paths": [
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
"frontend-modern/src/components/Workloads/GuestDrawer.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawer.tsx": "d8ba9c14cbfac0756f8df461bea07c40a39ef56ba907301b71e92286e410facc",
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "01e2d687067df69d4f3eb6ea74742c9ce478f278976ee87c96c3567ff1b82896",
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "19388b7e163d5fff79416f4bcb9c47ba0718cede9a56dbdf331065ab09cc00ee"
},
"routes": [
"/browser-tests/guest-discovery-safety.html: production canonical resource projection, Workloads row, GuestDrawer and DiscoveryTab with synthetic APIs"
],
"states": [
"Desktop light/dark and 390px/320px WebKit phone: locked guest and saved results",
"Every declared deferral and retained variant; same-ID/tab changes",
"Healthy manual action; uncertain read; in-flight scan completion while further runs are blocked"
],
"interactions": [
"Keyboard expand/tab/explicit run and touch expand/run; accessible disabled reason; wrapping warning/saved service inspection",
"No POST on blocked/unlock/snapshot/tab changes; exactly one original-body POST per explicit enabled run",
"No remount, cancellation or false in-flight-stop; original completion remains visible and additional run is blocked"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"verified_at": "2026-10-04T15:40:18.829Z",
"script": "frontend-modern/browser-tests/guest-discovery-safety.cjs",
"browsers": [
{
"group": "chromium-desktop-light",
"version": "141.0.7390.37"
},
{
"group": "chromium-desktop-dark",
"version": "141.0.7390.37"
},
{
"group": "webkit-phone-dark",
"version": "26.0"
},
{
"group": "webkit-narrow-light",
"version": "26.0"
}
],
"limitations": [
"Synthetic backend responses, not a native guest/backup or all-path backend admission proof.",
"Vite-transformed production components; production build is separately checked in the source-proof VM.",
"Independent thaw/covered-filesystem writes remain necessary; an unlocked snapshot does not prove them."
]
}
@@ -1,4 +1,4 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { Component, For, Show, createMemo, createUniqueId } from 'solid-js';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
import type { ResourceType } from '../../types/discovery';
@@ -33,6 +33,7 @@ import {
import { ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { InlineNotice } from '@/components/shared/InlineNotice';
import { useDiscoveryTabState } from './useDiscoveryTabState';
import { orderFactsByActionability } from './factOrdering';
import { deriveCliCommand } from './cliCommand';
@@ -54,6 +55,8 @@ interface DiscoveryTabProps {
commandsEnabled?: boolean;
/** Show the primary run action at the top of embedded drawer contexts. */
showManualRunAction?: boolean;
/** Safety pause for live manual scans, independent of command permissions. */
runBlockReason?: string | null;
}
export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
@@ -75,6 +78,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
liveElapsedSeconds,
notesText,
mutateDiscovery,
runBlockReason,
saveError,
scanError,
scanProgress,
@@ -90,6 +94,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
startEditingNotes,
validDiscovery,
} = useDiscoveryTabState(props);
const runBlockReasonId = `discovery-run-block-${createUniqueId()}`;
const suggestedURLReasonText = createMemo(() => {
const current = discovery();
@@ -225,6 +230,19 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</div>
</Show>
<Show when={runBlockReason()}>
{(reason) => (
<InlineNotice
id={runBlockReasonId}
tone="warning"
role="status"
data-testid="discovery-run-block"
>
{reason()}
</InlineNotice>
)}
</Show>
<Show when={showManualRunAction()}>
<InfoCardFrame>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
type="button"
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="inline-flex min-h-9 items-center justify-center gap-1.5 rounded-sm border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Show
@@ -633,6 +652,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isScanning() ? (
@@ -708,6 +728,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isScanning() ? (
@@ -1192,6 +1213,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<button
onClick={() => handleTriggerDiscovery(true)}
disabled={isScanning() || !canTriggerDiscovery()}
aria-describedby={runBlockReason() ? runBlockReasonId : undefined}
class="px-3 py-1.5 bg-surface-hover text-base-content text-xs rounded-sm hover:bg-surface-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
<Show
@@ -20,21 +20,43 @@ vi.mock('@/api/discovery', async (importOriginal) => ({
}));
const reason = 'Discovery paused for this guest. Saved results remain available.';
const target = { resourceType: 'vm' as const, agentId: 'node-agent', resourceId: '100', hostname: 'guest' };
const target = {
resourceType: 'vm' as const,
agentId: 'node-agent',
resourceId: '100',
hostname: 'guest',
};
const saved = {
id: 'vm:node-agent:100', resource_type: 'vm', resource_id: '100', target_id: 'node-agent',
hostname: 'guest', service_type: 'home-assistant', service_name: 'Saved service', category: 'app',
facts: [], config_paths: [], data_paths: [], log_paths: [], ports: [], user_notes: '',
discovered_at: '2026-10-03T12:00:00Z', updated_at: '2026-10-03T12:00:00Z', confidence: 0.9,
id: 'vm:node-agent:100',
resource_type: 'vm',
resource_id: '100',
target_id: 'node-agent',
hostname: 'guest',
service_type: 'home-assistant',
service_name: 'Saved service',
category: 'home_automation',
facts: [],
config_paths: [],
data_paths: [],
log_paths: [],
ports: [],
user_notes: '',
discovered_at: '2026-10-03T12:00:00Z',
updated_at: '2026-10-03T12:00:00Z',
confidence: 0.9,
} as ResourceDiscovery;
beforeEach(() => {
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null);
vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(null);
vi.mocked(discoveryApi.triggerDiscovery).mockResolvedValue(saved);
});
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.clearAllMocks();
});
afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); });
describe('manual discovery safety', () => {
it.each([
@@ -42,25 +64,38 @@ describe('manual discovery safety', () => {
['absent', async () => null],
['empty', async () => ({ ...saved, service_type: '', service_name: '', confidence: 0 })],
['saved', async () => saved],
] as const)('blocks all run affordances with %s discovery while retaining passive reads', async (_name, lookup) => {
vi.mocked(discoveryApi.getDiscovery).mockImplementation(lookup);
const props = { ...target, runBlockReason: reason };
render(() => <Suspense><DiscoveryTab {...props} /></Suspense>);
await waitFor(() => expect(discoveryApi.getDiscoveryInfo).toHaveBeenCalled());
await waitFor(() => {
const buttons = screen.getAllByRole('button', { name: /^(Run|Re-scan|Update) Discovery/ });
for (const button of buttons) expect(button).toBeDisabled();
});
expect(screen.getByTestId('discovery-run-block')).toHaveTextContent(reason);
expect(discoveryApi.getDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '100');
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
});
] as const)(
'blocks all run affordances with %s discovery while retaining passive reads',
async (_name, lookup) => {
vi.mocked(discoveryApi.getDiscovery).mockImplementation(lookup);
const props = { ...target, runBlockReason: reason };
render(() => <DiscoveryTab {...props} />);
await waitFor(() => expect(discoveryApi.getDiscoveryInfo).toHaveBeenCalled());
await waitFor(() => {
const buttons = screen.getAllByRole('button', { name: /^(Run|Re-scan|Update) Discovery/ });
for (const button of buttons) expect(button).toBeDisabled();
});
expect(screen.getByTestId('discovery-run-block')).toHaveTextContent(reason);
expect(discoveryApi.getDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '100');
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('keeps saved evidence mounted and restores only the explicit run action, not an automatic scan', async () => {
vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(saved);
const [block, setBlock] = createSignal<string | null>(reason);
const props = { ...target, get runBlockReason() { return block(); }, showManualRunAction: true };
render(() => <Suspense><DiscoveryTab {...props} /></Suspense>);
const props = {
...target,
get runBlockReason() {
return block();
},
showManualRunAction: true,
};
render(() => (
<Suspense>
<DiscoveryTab {...props} />
</Suspense>
));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
const evidence = await screen.findByText('Saved service');
@@ -76,9 +111,17 @@ describe('manual discovery safety', () => {
it('checks the current block inside the handler even when a caller retained the old enabled verdict', async () => {
const [block, setBlock] = createSignal<string | null>(null);
const props = { ...target, get runBlockReason() { return block(); } };
const props = {
...target,
get runBlockReason() {
return block();
},
};
let state!: ReturnType<typeof useDiscoveryTabState>;
render(() => { state = useDiscoveryTabState(props); return <span>state</span>; });
render(() => {
state = useDiscoveryTabState(props);
return <span>state</span>;
});
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
setBlock(reason);
await state.handleTriggerDiscovery(true);
@@ -88,21 +131,37 @@ describe('manual discovery safety', () => {
});
it('does not cancel or hide an in-flight scan when a new snapshot blocks further runs', async () => {
let finish!: (value: ResourceDiscovery | null) => void;
vi.mocked(discoveryApi.triggerDiscovery).mockReturnValue(new Promise((resolve) => { finish = resolve; }));
const finishes: Array<(value: ResourceDiscovery) => void> = [];
vi.mocked(discoveryApi.triggerDiscovery).mockImplementation(
() =>
new Promise((resolve) => {
finishes.push(resolve);
}),
);
const [block, setBlock] = createSignal<string | null>(null);
const props = { ...target, get runBlockReason() { return block(); } };
const props = {
...target,
get runBlockReason() {
return block();
},
};
let state!: ReturnType<typeof useDiscoveryTabState>;
render(() => { state = useDiscoveryTabState(props); return <span>state</span>; });
render(() => {
state = useDiscoveryTabState(props);
return <span>state</span>;
});
await waitFor(() => expect(state.canTriggerDiscovery()).toBe(true));
const original = state.handleTriggerDiscovery(true);
expect(state.isScanning()).toBe(true);
setBlock(reason);
await state.handleTriggerDiscovery(true);
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
expect(state.isScanning()).toBe(true);
finish(saved);
await original;
const second = state.handleTriggerDiscovery(true);
try {
expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1);
expect(state.isScanning()).toBe(true);
} finally {
for (const finish of finishes) finish(saved);
await Promise.all([original, second]);
}
expect(state.isScanning()).toBe(false);
expect(state.discovery()).toBe(saved);
expect(state.scanSuccess()).toBe(true);
@@ -24,6 +24,8 @@ export interface DiscoveryTabStateProps {
resourceId: string;
hostname: string;
commandsEnabled?: boolean;
/** Snapshot-based safety pause for manual runs; saved reads remain available. */
runBlockReason?: string | null;
}
const makeResourceId = (type: ResourceType, agentId: string, resourceId: string) =>
@@ -120,8 +122,13 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
// the tab-wide banner already tells the user to configure one before
// scanning. (Command/connectivity gaps are surfaced separately, not blocked
// here, since their backend semantics are murkier.)
const runBlockReason = createMemo(() => props.runBlockReason?.trim() || null);
const canTriggerDiscovery = createMemo(
() => discoveryFeatureEnabled() && aiProviderConfigured() && Boolean(targetAgentId()),
() =>
discoveryFeatureEnabled() &&
aiProviderConfigured() &&
Boolean(targetAgentId()) &&
!runBlockReason(),
);
const [discovery, { refetch, mutate }] = createResource(
@@ -185,6 +192,10 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
});
const handleTriggerDiscovery = async (force = false) => {
// Check the current snapshot at the dispatch boundary as well as disabling
// buttons. A retained handler must not use an earlier enabled verdict.
// Do not cancel an already dispatched scan or hide its eventual outcome.
if (runBlockReason() || isScanning()) return;
if (!discoveryFeatureEnabled()) {
setScanError('Service context is disabled in Settings -> Pulse Intelligence -> Assistant.');
return;
@@ -347,6 +358,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) {
notesText,
mutateDiscovery: mutate,
refetchDiscovery: refetch,
runBlockReason,
saveError,
scanError,
scanProgress,
@@ -198,6 +198,11 @@ const GuestDrawerContent: Component<GuestDrawerProps> = (props) => {
hostname={props.guest.name}
canonicalResourceId={props.guest.id}
showManualRunAction
runBlockReason={
guestReadPrecaution()
? 'Discovery is paused during a backup or guest-read deferral. Saved results remain available. Clearing this pause does not prove thaw.'
: null
}
/>
</Suspense>
</div>
@@ -13,56 +13,104 @@ vi.mock('@/api/discovery', async (importOriginal) => ({
getDiscovery: vi.fn(async () => null),
getDiscoveryInfo: vi.fn(async () => ({
ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' },
commands: [], command_categories: [],
commands: [],
command_categories: [],
})),
getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })),
triggerDiscovery: vi.fn(async () => null),
}));
vi.mock('@/api/charts', async (importOriginal) => ({
...(await importOriginal<typeof import('@/api/charts')>()),
ChartsAPI: { getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })) },
ChartsAPI: {
getMetricsHistory: vi.fn(async () => ({ metrics: {}, start: 1, end: 2, source: 'store' })),
},
}));
vi.mock('@/stores/license', () => ({
isRangeLocked: () => false,
loadRuntimeCapabilities: vi.fn(),
maxHistoryDays: () => 90,
}));
vi.mock('@/stores/license', () => ({ isRangeLocked: () => false, loadRuntimeCapabilities: vi.fn(), maxHistoryDays: () => 90 }));
const guest = (patch: Partial<WorkloadGuest> = {}): WorkloadGuest => ({
id: 'inst:node:100', instance: 'inst', node: 'node', vmid: 100, name: 'Backup guest', type: 'qemu',
status: 'running', cpu: 0.25, cpus: 2, memory: { total: 1024, used: 512, free: 512, usage: 50 },
disk: { total: 1024, used: 512, free: 512, usage: 50 },
networkIn: 1, networkOut: 2, diskRead: 3, diskWrite: 4, uptime: 100, template: false,
lastBackup: 0, tags: [], lock: '', lastSeen: '2026-10-04T12:00:00Z', guestAgentStatus: 'available',
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' }, ...patch,
} as WorkloadGuest);
const guest = (patch: Partial<WorkloadGuest> = {}): WorkloadGuest =>
({
id: 'inst:node:100',
instance: 'inst',
node: 'node',
vmid: 100,
name: 'Backup guest',
type: 'qemu',
status: 'running',
cpu: 0.25,
cpus: 2,
memory: { total: 1024, used: 512, free: 512, usage: 50 },
disk: { total: 1024, used: 512, free: 512, usage: 50 },
networkIn: 1,
networkOut: 2,
diskRead: 3,
diskWrite: 4,
uptime: 100,
template: false,
lastBackup: 0,
tags: [],
lock: '',
lastSeen: '2026-10-04T12:00:00Z',
guestAgentStatus: 'available',
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' },
...patch,
}) as WorkloadGuest;
beforeEach(() => {
resetAIRuntimeState();
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<typeof syncAIRuntimeSettings>[0]);
resetCreateNonSuspendingQueryCacheForTest();
});
afterEach(() => { cleanup(); resetAIRuntimeState(); vi.clearAllMocks(); });
afterEach(() => {
cleanup();
resetAIRuntimeState();
vi.clearAllMocks();
});
describe('guest drawer and real Discovery safety wiring', () => {
it.each([
...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]),
])('does not offer a live scan for %s, even with an assigned node agent and available metadata', async (reason) => {
render(() => <Suspense><GuestDrawer guest={guest({ diskStatusReason: reason })} onClose={vi.fn()} /></Suspense>);
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
fireEvent.click(button);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
expect(screen.getByTestId('guest-read-precaution')).toBeVisible();
});
it.each([...guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`])])(
'does not offer a live scan for %s, even with an assigned node agent and available metadata',
async (reason) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest({ diskStatusReason: reason })} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
fireEvent.click(button);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
expect(screen.getByTestId('guest-read-precaution')).toBeVisible();
},
);
it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])('guards the native operation signal %j without fabricating a disk read reason', async (patch) => {
render(() => <Suspense><GuestDrawer guest={guest(patch)} onClose={vi.fn()} /></Suspense>);
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled());
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
});
it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])(
'guards the native operation signal %j without fabricating a disk read reason',
async (patch) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Run Discovery' })).toBeDisabled(),
);
expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled();
},
);
it('updates the existing same-guest button without remounting the tab or automatically scanning', async () => {
const [value, setValue] = createSignal(guest({ lock: 'backup' }));
render(() => <Suspense><GuestDrawer guest={value()} onClose={vi.fn()} /></Suspense>);
render(() => (
<Suspense>
<GuestDrawer guest={value()} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeDisabled());
@@ -78,14 +126,36 @@ describe('guest drawer and real Discovery safety wiring', () => {
it.each([
{ type: 'qemu' },
{ type: 'lxc', lock: 'backup', diskStatusReason: 'prev-agent-timeout', discoveryTarget: { resourceType: 'system-container', agentId: 'node-agent', resourceId: '100' } },
{ type: 'vm', platformType: 'vmware', guestAgentStatus: '', lock: '', discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' } },
] as const)('preserves ordinary manual scans outside the Proxmox guest safety condition %j', async (patch) => {
render(() => <Suspense><GuestDrawer guest={guest(patch)} onClose={vi.fn()} /></Suspense>);
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
});
{
type: 'lxc',
lock: 'backup',
diskStatusReason: 'prev-agent-timeout',
discoveryTarget: {
resourceType: 'system-container',
agentId: 'node-agent',
resourceId: '100',
},
},
{
type: 'vm',
platformType: 'vmware',
guestAgentStatus: '',
lock: '',
discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '100' },
},
] as const)(
'preserves ordinary manual scans outside the Proxmox guest safety condition %j',
async (patch) => {
render(() => (
<Suspense>
<GuestDrawer guest={guest(patch)} onClose={vi.fn()} />
</Suspense>
));
fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' }));
const button = await screen.findByRole('button', { name: 'Run Discovery' });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1));
},
);
});