diff --git a/docs/AI.md b/docs/AI.md index 520ac9525a..f23b965ece 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -230,10 +230,93 @@ Findings can be managed via the UI or API: - **Get help**: Chat with AI to troubleshoot the issue - **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces) -- **Dismiss**: Mark as expected behavior (creates suppression rule) +- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal +- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category Dismissed and resolved findings persist across Pulse restarts. +#### Remove a rule created by mistake + +In v6.4.5, **Create rule** has no corresponding rule-list or delete control in +the UI. **Reopen** undoes an individual finding's dismissal, not a separately +created rule. The existing API can list rules and delete one by its exact ID. +Deleting a created rule allows future matching findings to appear again; it +does not resolve a problem or automatically reopen previously dismissed +findings. It leaves the other rules and finding history intact. + +For a rule made with **Create rule**, sign in normally to Pulse in an +authorised administrator's browser session, select the correct organisation, +and open that Pulse tab's developer console. Review the following snippet +before running it. It lists only manually created rules, asks for one exact +ID, and confirms its resource, category and reason before deleting it. Cancel +either prompt to leave the rules unchanged. Keep the list private: resource +names and reasons can describe your infrastructure. + +No password, API token or session cookie needs to be copied or pasted. The +browser supplies its existing session; the snippet reads only the normal +CSRF and organisation cookies locally. Do not switch organisation while it +runs, use it on another website, disable authentication/CSRF protection, or +edit Pulse's data files. A token-only login or an expired session may not +support this workaround: stop on an authentication or missing-CSRF error +instead of inserting credentials into the snippet. + +```javascript +(async () => { + const cookie = (name) => { + const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`)); + return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : ''; + }; + const org = cookie('pulse_org_id') || 'default'; + const headers = { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Pulse-Org-ID': org, + }; + const readRules = async () => { + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const response = await fetch('/api/ai/patrol/suppressions', { + credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers, + }); + if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`); + const rules = await response.json(); + if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.'); + return rules.filter((rule) => rule && rule.created_from === 'manual'); + }; + const rules = await readRules(); + console.table(rules.map((rule) => ({ + id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource', + category: rule.category || 'Any category', reason: rule.description, + }))); + const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.'); + if (id === null || !id.trim()) return; + const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim()); + if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.'); + const rule = matches[0]; + if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return; + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const csrf = cookie('pulse_csrf'); + if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.'); + const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, { + method: 'DELETE', credentials: 'same-origin', redirect: 'error', + headers: { ...headers, 'X-CSRF-Token': csrf }, + }); + if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`); + if ((await readRules()).some((remaining) => remaining.id === rule.id)) { + throw new Error('Rule is still listed; removal was not verified.'); + } + console.info('Selected rule is no longer listed. Other rules were not deleted.'); +})().catch((error) => console.error(error.message)); +``` + +A failed or lost response is not confirmation of removal. Do not blindly run +the deletion again: refresh the rule list first (open +`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the +selected ID. If the rule is absent, it was already removed. If you cannot +verify the result, stop and report only the HTTP status or error, not cookies, +tokens or the full rule list. Entries whose `created_from` is `finding` or +`dismissed` are individual finding decisions and are deliberately excluded +from this snippet. + Every active finding shown or returned to a Patrol run must receive an explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an all-clear signal. `present` refreshes current evidence, `resolved` remains diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 21ebac450c..7269935c42 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-tests/patrol-rule-help.cjs b/frontend-modern/browser-tests/patrol-rule-help.cjs new file mode 100644 index 0000000000..1f5ff4f0fe --- /dev/null +++ b/frontend-modern/browser-tests/patrol-rule-help.cjs @@ -0,0 +1,154 @@ +// Run from the Pulse workspace root with pulse-worker-browser. +// This exercises the documented snippet against a synthetic same-origin API, +// not a deployed Pulse installation or the still-missing rule-management UI. +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +const guide = fs.readFileSync('docs/AI.md', 'utf8'); +const section = guide.split('#### Remove a rule created by mistake\n')[1]; +const snippet = section.match(/```javascript\n([\s\S]*?)\n```/)[1]; +const output = 'frontend-modern/test-results/patrol-rule-help'; +fs.mkdirSync(output, { recursive: true }); +const escape = (text) => text.replaceAll('&', '&').replaceAll('<', '<'); +const selected = { id: 'rule-one', created_from: 'manual', resource_name: 'Test VM', + resource_id: 'vm-1', category: 'backup', description: 'Created by mistake' }; +const other = { ...selected, id: 'rule-two', resource_id: 'vm-2', resource_name: 'Other VM' }; +let scenario; +const server = http.createServer((request, response) => { + if (request.url === '/') { + response.setHeader('Content-Type', 'text/html; charset=utf-8'); + response.setHeader('Set-Cookie', [ + 'pulse_session=synthetic-session; Path=/; HttpOnly; SameSite=Lax', + 'pulse_org_id=org-a; Path=/; SameSite=Lax', + ...(scenario.noCSRF ? [] : ['pulse_csrf=synthetic-csrf; Path=/; SameSite=Lax']), + ]); + response.end(`

Rule removal help — synthetic browser check

+

Cancel keeps both rules. A confirmed removal targets one exact ID.

+

No removal confirmed

+
${escape(snippet)}
`); + return; + } + if (!request.url.startsWith('/api/ai/patrol/suppressions')) { + response.writeHead(404).end(); + return; + } + const hasSession = (request.headers.cookie || '').includes('pulse_session=synthetic-session'); + const expectedOrg = request.headers['x-pulse-org-id'] === 'org-a'; + const validCSRF = request.headers['x-csrf-token'] === 'synthetic-csrf'; + scenario.requests.push({ method: request.method, path: request.url, + hasSession, expectedOrg, validCSRF }); + response.setHeader('Content-Type', 'application/json'); + if (!hasSession || !expectedOrg) { + response.writeHead(403).end('{}'); + return; + } + if (request.method === 'DELETE') { + assert.equal(request.url, '/api/ai/patrol/suppressions/rule-one'); + if (!validCSRF || scenario.rejectDelete) { + response.writeHead(403).end('{}'); + return; + } + scenario.removed = true; + response.end('{"success":true}'); + return; + } + assert.equal(request.method, 'GET'); + assert.equal(request.url, '/api/ai/patrol/suppressions'); + if (scenario.removed && scenario.readbackFailure) { + response.writeHead(503).end('{}'); + return; + } + response.end(JSON.stringify(scenario.removed ? [other] : [selected, other])); +}); + +(async () => { + let browser; + const receipts = []; + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${server.address().port}/`; + for (const [engineName, engine] of [['chromium', chromium], ['webkit', webkit]]) { + browser = await engine.launch(engineName === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const cases = engineName === 'chromium' + ? ['prompt-cancel', 'confirm-cancel', 'success', 'missing-csrf', 'changed-org', + 'delete-rejected', 'readback-failed'] + : ['prompt-cancel', 'success']; + for (const name of cases) { + scenario = { name, requests: [], removed: false, + noCSRF: name === 'missing-csrf', rejectDelete: name === 'delete-rejected', + readbackFailure: name === 'readback-failed' }; + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + const messages = []; + const dialogs = []; + page.on('console', (message) => { + if (['info', 'error'].includes(message.type())) { + messages.push({ kind: message.type(), message: message.text() }); + } + }); + page.on('dialog', async (dialog) => { + dialogs.push({ type: dialog.type(), message: dialog.message() }); + if (dialog.type() === 'prompt') { + if (name === 'changed-org') { + await context.addCookies([{ name: 'pulse_org_id', value: 'org-b', url }]); + } + if (name === 'prompt-cancel') await dialog.dismiss(); + else await dialog.accept('rule-one'); + } else { + if (name === 'confirm-cancel') await dialog.dismiss(); + else await dialog.accept(); + } + }); + await page.goto(url); + await page.evaluate((code) => window.eval(code), snippet); + const deletes = scenario.requests.filter((request) => request.method === 'DELETE'); + const success = messages.some((message) => message.kind === 'info'); + if (name === 'success') { + assert.equal(deletes.length, 1); + assert.ok(deletes[0].validCSRF); + assert.equal(scenario.requests.length, 3); + assert.ok(scenario.removed); + assert.ok(success); + } else { + assert.equal(success, false); + if (['delete-rejected', 'readback-failed'].includes(name)) { + assert.equal(deletes.length, 1); + } else { + assert.equal(deletes.length, 0); + } + } + assert.ok(scenario.requests.every((request) => request.hasSession && request.expectedOrg)); + assert.ok(messages.every((message) => !message.message.includes('synthetic-csrf'))); + assert.ok(dialogs.every((dialog) => !dialog.message.includes('synthetic-csrf'))); + await page.locator('#result').evaluate((element, text) => { element.textContent = text; }, + success ? 'Selected rule absent after readback; other rule retained' : `${name}: no removal confirmed`); + let screenshot; + if (['success', 'confirm-cancel', 'delete-rejected'].includes(name)) { + screenshot = path.join(output, `${engineName}-${name}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } + receipts.push({ engine: engineName, browserVersion: browser.version(), name, + requests: scenario.requests, messages, dialogs, removed: scenario.removed, screenshot }); + await context.close(); + } + await browser.close(); + browser = null; + } + const result = { guideSHA256: crypto.createHash('sha256').update(guide).digest('hex'), + playwrightVersion: require('playwright/package.json').version, + evidenceKind: 'synthetic same-origin browser/API workaround; no installed Pulse or rule-management UI', + scenarios: receipts }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify({ passed: receipts.length, guideSHA256: result.guideSHA256, + playwrightVersion: result.playwrightVersion, result: path.join(output, 'result.json') })); + } finally { + if (browser) await browser.close(); + await new Promise((resolve) => server.close(resolve)); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/resource-guest-safety.cjs b/frontend-modern/browser-tests/resource-guest-safety.cjs new file mode 100644 index 0000000000..d1d4a4a2b8 --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/resource-guest-safety.html b/frontend-modern/browser-tests/resource-guest-safety.html new file mode 100644 index 0000000000..b641a56e4b --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.html @@ -0,0 +1,12 @@ + + + + + + Canonical guest safety evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx new file mode 100644 index 0000000000..b307a6fd2e --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.tsx @@ -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 ( +
+

Canonical guest safety evidence

+

Synthetic local responses. No guest check or backup is sent.

+ +
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/cd3f858e636899f4.json b/frontend-modern/browser-verification/cd3f858e636899f4.json new file mode 100644 index 0000000000..77cb78f9e7 --- /dev/null +++ b/frontend-modern/browser-verification/cd3f858e636899f4.json @@ -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." + ] +} diff --git a/frontend-modern/browser-verification/e1e985a3fee492c0.json b/frontend-modern/browser-verification/e1e985a3fee492c0.json new file mode 100644 index 0000000000..f58aa5fdaf --- /dev/null +++ b/frontend-modern/browser-verification/e1e985a3fee492c0.json @@ -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." + ] +} diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md index 520ac9525a..f23b965ece 100644 --- a/frontend-modern/public/docs/AI.md +++ b/frontend-modern/public/docs/AI.md @@ -230,10 +230,93 @@ Findings can be managed via the UI or API: - **Get help**: Chat with AI to troubleshoot the issue - **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces) -- **Dismiss**: Mark as expected behavior (creates suppression rule) +- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal +- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category Dismissed and resolved findings persist across Pulse restarts. +#### Remove a rule created by mistake + +In v6.4.5, **Create rule** has no corresponding rule-list or delete control in +the UI. **Reopen** undoes an individual finding's dismissal, not a separately +created rule. The existing API can list rules and delete one by its exact ID. +Deleting a created rule allows future matching findings to appear again; it +does not resolve a problem or automatically reopen previously dismissed +findings. It leaves the other rules and finding history intact. + +For a rule made with **Create rule**, sign in normally to Pulse in an +authorised administrator's browser session, select the correct organisation, +and open that Pulse tab's developer console. Review the following snippet +before running it. It lists only manually created rules, asks for one exact +ID, and confirms its resource, category and reason before deleting it. Cancel +either prompt to leave the rules unchanged. Keep the list private: resource +names and reasons can describe your infrastructure. + +No password, API token or session cookie needs to be copied or pasted. The +browser supplies its existing session; the snippet reads only the normal +CSRF and organisation cookies locally. Do not switch organisation while it +runs, use it on another website, disable authentication/CSRF protection, or +edit Pulse's data files. A token-only login or an expired session may not +support this workaround: stop on an authentication or missing-CSRF error +instead of inserting credentials into the snippet. + +```javascript +(async () => { + const cookie = (name) => { + const row = document.cookie.split(';').find((part) => part.trim().startsWith(`${name}=`)); + return row ? decodeURIComponent(row.trim().slice(name.length + 1)) : ''; + }; + const org = cookie('pulse_org_id') || 'default'; + const headers = { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Pulse-Org-ID': org, + }; + const readRules = async () => { + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const response = await fetch('/api/ai/patrol/suppressions', { + credentials: 'same-origin', redirect: 'error', cache: 'no-store', headers, + }); + if (!response.ok) throw new Error(`Rule list failed (HTTP ${response.status}); stop.`); + const rules = await response.json(); + if (!Array.isArray(rules)) throw new Error('Unexpected rule list; stop.'); + return rules.filter((rule) => rule && rule.created_from === 'manual'); + }; + const rules = await readRules(); + console.table(rules.map((rule) => ({ + id: rule.id, resource: rule.resource_name || rule.resource_id || 'Any resource', + category: rule.category || 'Any category', reason: rule.description, + }))); + const id = prompt('Enter one exact rule ID from this list, or Cancel to keep all rules.'); + if (id === null || !id.trim()) return; + const matches = rules.filter((rule) => typeof rule.id === 'string' && rule.id === id.trim()); + if (matches.length !== 1) throw new Error('ID must match exactly one listed manual rule; stop.'); + const rule = matches[0]; + if (!confirm(`Delete only ${rule.id}?\nResource: ${rule.resource_name || rule.resource_id || 'Any resource'}\nCategory: ${rule.category || 'Any category'}\nReason: ${rule.description}\nFuture matching findings may appear again.`)) return; + if ((cookie('pulse_org_id') || 'default') !== org) throw new Error('Organisation changed; stop.'); + const csrf = cookie('pulse_csrf'); + if (!csrf) throw new Error('No browser CSRF token; stop and sign in normally, without pasting tokens.'); + const response = await fetch(`/api/ai/patrol/suppressions/${encodeURIComponent(rule.id)}`, { + method: 'DELETE', credentials: 'same-origin', redirect: 'error', + headers: { ...headers, 'X-CSRF-Token': csrf }, + }); + if (!response.ok) throw new Error(`Deletion not confirmed (HTTP ${response.status}); stop.`); + if ((await readRules()).some((remaining) => remaining.id === rule.id)) { + throw new Error('Rule is still listed; removal was not verified.'); + } + console.info('Selected rule is no longer listed. Other rules were not deleted.'); +})().catch((error) => console.error(error.message)); +``` + +A failed or lost response is not confirmation of removal. Do not blindly run +the deletion again: refresh the rule list first (open +`/api/ai/patrol/suppressions` in the same signed-in Pulse browser) and check the +selected ID. If the rule is absent, it was already removed. If you cannot +verify the result, stop and report only the HTTP status or error, not cookies, +tokens or the full rule list. Entries whose `created_from` is `finding` or +`dismissed` are individual finding decisions and are deliberately excluded +from this snippet. + Every active finding shown or returned to a Patrol run must receive an explicit `present`, `resolved`, or `uncertain` assessment. Silence is not an all-clear signal. `present` refreshes current evidence, `resolved` remains diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 23ea1a0de7..243092e9dd 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -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 = (props) => {

+ + {(message) => ( + +

{message()}

+

{GUEST_DRAWER_BACKUP_PRECAUTION}

+
+ )} +
+ = (props) => { canonicalResourceId={props.resource.id} commandsEnabled={drawer.agentMeta()?.commandsEnabled} showManualRunAction + runBlockReason={drawer.guestReadPrecaution()} /> )} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index d58b0d7cb3..75ac3bb823 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{ hostname={config().hostname} canonicalResourceId={props.resource.id} commandsEnabled={props.drawer.agentMeta()?.commandsEnabled} + runBlockReason={props.drawer.guestReadPrecaution()} /> diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index bfa830187a..f13a976d3e 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -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 = { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx new file mode 100644 index 0000000000..6651b5f197 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx @@ -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()), + 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()), + 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 => ({ + 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(() => ( + + + + )); +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[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)); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index c7c915a6e7..d9a5dbd404 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -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) + : 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; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 0131b0df70..66e329b8e3 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -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, diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 974a0b8f84..f78290928c 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -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-/, ''); diff --git a/internal/api/patrol_suppression_management_test.go b/internal/api/patrol_suppression_management_test.go new file mode 100644 index 0000000000..1add39ed89 --- /dev/null +++ b/internal/api/patrol_suppression_management_test.go @@ -0,0 +1,108 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// The rule-removal help uses existing session and CSRF protection, not a token +// pasted into a command. Exercise the actual handlers/store and Router middleware. +func TestPatrolSuppressionManagementBrowserSession(t *testing.T) { + router, session := newRouterWithSession(t) + handler := newTestAISettingsHandler(router.config, config.NewConfigPersistence(router.config.DataPath), nil) + handler.defaultAIService.SetStateProvider(&stubStateProvider{}) + t.Cleanup(handler.defaultAIService.Stop) + patrol := handler.defaultAIService.GetPatrolService() + if patrol == nil { + t.Fatal("expected Patrol service") + } + store := patrol.GetFindings() + selected := store.AddSuppressionRule("vm-1", "Test VM", ai.FindingCategoryBackup, "Test reason") + other := store.AddSuppressionRule("vm-2", "Other VM", ai.FindingCategoryCapacity, "Keep this rule") + router.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleGetSuppressionRules))) + router.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(router.config, RequireScope(config.ScopeAIExecute, handler.HandleDeleteSuppressionRule))) + + request := func(method, path, csrf string, authenticated bool) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.RemoteAddr = "127.0.0.1:12345" + req.Header.Set("User-Agent", "test-agent") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-Pulse-Org-ID", "default") + if authenticated { + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: session}) + } + if csrf != "" { + req.Header.Set("X-CSRF-Token", csrf) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + unauthenticated := request(http.MethodGet, "/api/ai/patrol/suppressions", "", false) + if unauthenticated.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated list status = %d, want 401", unauthenticated.Code) + } + list := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true) + if list.Code != http.StatusOK { + t.Fatalf("authenticated list status = %d, want 200", list.Code) + } + var rules []ai.SuppressionRule + if err := json.Unmarshal(list.Body.Bytes(), &rules); err != nil { + t.Fatal(err) + } + if len(rules) != 2 { + t.Fatalf("listed %d rules, want 2", len(rules)) + } + for _, rule := range rules { + if rule.CreatedFrom != "manual" { + t.Fatalf("created_from = %q, want manual", rule.CreatedFrom) + } + } + csrf := "" + for _, cookie := range list.Result().Cookies() { + if cookie.Name == "pulse_csrf" { + csrf = cookie.Value + } + } + if csrf == "" { + t.Fatal("authenticated GET must issue a browser CSRF cookie") + } + path := "/api/ai/patrol/suppressions/" + selected.ID + for _, token := range []string{"", "invalid-csrf"} { + rec := request(http.MethodDelete, path, token, true) + if rec.Code != http.StatusForbidden || len(store.GetSuppressionRules()) != 2 { + t.Fatalf("invalid-CSRF deletion status = %d; both rules must remain", rec.Code) + } + } + removed := request(http.MethodDelete, path, csrf, true) + if removed.Code != http.StatusOK { + t.Fatalf("deletion status = %d, want 200", removed.Code) + } + if store.MatchesSuppressionRule("vm-1", ai.FindingCategoryBackup) { + t.Fatal("selected rule still suppresses future matching findings") + } + if !store.MatchesSuppressionRule("vm-2", ai.FindingCategoryCapacity) { + t.Fatal("unselected rule must remain effective") + } + readback := request(http.MethodGet, "/api/ai/patrol/suppressions", "", true) + if readback.Code != http.StatusOK { + t.Fatalf("readback status = %d, want 200", readback.Code) + } + if err := json.Unmarshal(readback.Body.Bytes(), &rules); err != nil { + t.Fatal(err) + } + if len(rules) != 1 || rules[0].ID != other.ID { + t.Fatal("readback must retain only the unselected rule") + } + notFound := request(http.MethodDelete, path, csrf, true) + if notFound.Code != http.StatusNotFound || len(store.GetSuppressionRules()) != 1 { + t.Fatalf("already-removed deletion status = %d; remaining rule must survive", notFound.Code) + } +} diff --git a/scripts/tests/test_patrol_rule_help.py b/scripts/tests/test_patrol_rule_help.py new file mode 100644 index 0000000000..2db8927b59 --- /dev/null +++ b/scripts/tests/test_patrol_rule_help.py @@ -0,0 +1,158 @@ +"""Exercise the exact documented browser workaround with synthetic state only.""" + +import json +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDE = ROOT / "docs" / "AI.md" +SHIPPED_GUIDE = ROOT / "frontend-modern" / "public" / "docs" / "AI.md" + + +def removal_snippet(): + section = GUIDE.read_text().split("#### Remove a rule created by mistake\n", 1)[1] + return re.search(r"```javascript\n(.*?)\n```", section, re.S).group(1) + + +HARNESS = r""" +const vm = require('node:vm'); +let input = ''; +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', async () => { + const options = JSON.parse(input); + const calls = [], messages = [], tables = [], confirmations = []; + let cookie = options.cookie ?? 'pulse_org_id=org-a; pulse_csrf=synthetic-csrf'; + const context = { + document: { get cookie() { return cookie; } }, + prompt: () => options.id === undefined ? null : options.id, + confirm: message => { + confirmations.push(message); + if (options.changedOrg) cookie = 'pulse_org_id=org-b; pulse_csrf=synthetic-csrf'; + return options.confirm ?? true; + }, + fetch: async (path, init) => { + calls.push({path, ...init}); + const result = options.responses[calls.length - 1]; + if (!result) throw new Error('Unexpected additional request'); + if (result.networkFailure) throw new Error('Synthetic network failure'); + return {ok: result.status >= 200 && result.status < 300, + status: result.status, json: async () => result.body}; + }, + console: { + table: rows => tables.push(rows), + info: message => messages.push({kind: 'info', message}), + error: message => messages.push({kind: 'error', message}), + }, + }; + await vm.runInNewContext(options.snippet, context, {timeout: 1000}); + process.stdout.write(JSON.stringify({calls, messages, tables, confirmations})); +}); +""" + + +RULE = {"id": "rule-one", "created_from": "manual", "resource_id": "vm-1", + "resource_name": "Test VM", "category": "backup", "description": "Test reason"} +OTHER_RULE = {**RULE, "id": "rule-two", "resource_id": "vm-2"} +DISMISSAL = {**RULE, "id": "finding_one", "created_from": "dismissed"} + + +class PatrolRuleHelpTests(unittest.TestCase): + def run_snippet(self, **options): + options.setdefault("responses", [{"status": 200, "body": [RULE, OTHER_RULE, DISMISSAL]}]) + options["snippet"] = removal_snippet() + result = subprocess.run(["node", "-e", HARNESS], input=json.dumps(options), + text=True, capture_output=True, check=True, timeout=5) + return json.loads(result.stdout) + + def assert_no_delete(self, result): + self.assertFalse(any(call.get("method") == "DELETE" for call in result["calls"])) + self.assertFalse(any(message["kind"] == "info" for message in result["messages"])) + + def test_shipped_help_matches_source(self): + self.assertEqual(GUIDE.read_bytes(), SHIPPED_GUIDE.read_bytes()) + + def test_cancel_is_read_only_and_hides_finding_decisions(self): + result = self.run_snippet() + self.assert_no_delete(result) + self.assertEqual(len(result["calls"]), 1) + self.assertEqual([row["id"] for row in result["tables"][0]], ["rule-one", "rule-two"]) + + def test_unknown_blank_and_finding_ids_cannot_delete(self): + for rule_id in ["missing", " ", "finding_one"]: + with self.subTest(rule_id=rule_id): + self.assert_no_delete(self.run_snippet(id=rule_id)) + + def test_duplicate_id_cannot_delete(self): + self.assert_no_delete(self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, RULE]}])) + + def test_confirmation_cancel_is_read_only(self): + result = self.run_snippet(id="rule-one", confirm=False) + self.assert_no_delete(result) + self.assertIn("Test VM", result["confirmations"][0]) + self.assertIn("backup", result["confirmations"][0]) + self.assertIn("Test reason", result["confirmations"][0]) + + def test_missing_csrf_and_changed_organisation_stop(self): + for options in [{"cookie": "pulse_org_id=org-a"}, {"changedOrg": True}]: + with self.subTest(options=options): + self.assert_no_delete(self.run_snippet(id="rule-one", **options)) + + def test_failed_or_malformed_list_cannot_delete(self): + for response in [{"status": 401}, {"status": 403}, {"status": 200, "body": {}}, + {"networkFailure": True}]: + with self.subTest(response=response): + self.assert_no_delete(self.run_snippet(id="rule-one", responses=[response])) + + def test_delete_failure_or_lost_response_is_not_retried_or_reported_as_success(self): + for response in [{"status": 403}, {"status": 404}, {"status": 503}, + {"networkFailure": True}]: + with self.subTest(response=response): + result = self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, OTHER_RULE]}, response]) + self.assertEqual(len(result["calls"]), 2) + self.assertFalse(any(m["kind"] == "info" for m in result["messages"])) + + def test_readback_must_succeed_and_show_selected_rule_absent(self): + for response in [{"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 403}, + {"networkFailure": True}]: + with self.subTest(response=response): + result = self.run_snippet(id="rule-one", responses=[ + {"status": 200, "body": [RULE, OTHER_RULE]}, {"status": 200}, response]) + self.assertEqual(len(result["calls"]), 3) + self.assertFalse(any(m["kind"] == "info" for m in result["messages"])) + + def test_only_selected_rule_is_deleted_using_bound_browser_context(self): + rule = {**RULE, "id": "rule /?other"} + result = self.run_snippet(id=rule["id"], responses=[ + {"status": 200, "body": [rule, OTHER_RULE, DISMISSAL]}, {"status": 200}, + {"status": 200, "body": [OTHER_RULE, DISMISSAL]}]) + self.assertEqual(len(result["calls"]), 3) + delete = result["calls"][1] + self.assertEqual(delete["path"], "/api/ai/patrol/suppressions/rule%20%2F%3Fother") + self.assertEqual(delete["headers"]["X-CSRF-Token"], "synthetic-csrf") + for call in result["calls"]: + self.assertEqual(call["credentials"], "same-origin") + self.assertEqual(call["redirect"], "error") + self.assertEqual(call["headers"]["X-Pulse-Org-ID"], "org-a") + self.assertNotIn("X-API-Token", call["headers"]) + self.assertEqual(result["messages"][0]["kind"], "info") + self.assertNotIn("synthetic-csrf", json.dumps(result["tables"])) + self.assertNotIn("synthetic-csrf", json.dumps(result["messages"])) + + def test_wildcard_scope_is_explicit_in_list_and_confirmation(self): + rule = {**RULE, "resource_id": "", "resource_name": "", "category": ""} + result = self.run_snippet(id=rule["id"], confirm=False, + responses=[{"status": 200, "body": [rule]}]) + self.assert_no_delete(result) + self.assertEqual(result["tables"][0][0]["resource"], "Any resource") + self.assertEqual(result["tables"][0][0]["category"], "Any category") + self.assertIn("Any resource", result["confirmations"][0]) + self.assertIn("Any category", result["confirmations"][0]) + + +if __name__ == "__main__": + unittest.main()