diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 270d15e838..8578f16dd9 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -101,48 +101,114 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client, -prepare the private Pulse header file as described in the -[API authentication guide](API.md). The Pulse API token authenticates to -Pulse; the separate TrueNAS API key belongs in the connection's JSON file. -Do not put either credential in shell arguments, URLs or a public thread. +Prefer **Settings → Infrastructure → Platform connections** for setup. These +API examples are for an authorised administrator using curl 7.76 or later, +not a way to diagnose stale polling. Testing makes a live system-information +request to TrueNAS; saving creates a connection and starts ordinary polling. +Do not use either during a backup, freeze/thaw or an unresponsive-host incident. -Create or protect the connection file before opening it in a trusted editor: +First prepare a private Pulse header file using the +[API token file procedure](API.md#api-token-recommended), with the admin access +needed for connection management. The Pulse token authenticates to Pulse; +the separate TrueNAS API key belongs in the connection's JSON file. Enter +credentials only in your local editor, never in arguments, URLs or a thread. + +On a trusted machine, as the account running curl, prepare the connection +file below. Stop if either file-preparation step fails. This preserves an +existing file, restricts the directory and refuses symlinked credential paths +or a non-regular connection file before opening the editor: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/truenas-connection.json" -chmod 600 "$HOME/.config/pulse/truenas-connection.json" -vi "$HOME/.config/pulse/truenas-connection.json" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + connection_file="$config_dir/truenas-connection.json" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then + printf 'Connection file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$connection_file" + chmod 600 "$connection_file" + vi "$connection_file" +) ``` -In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), -`username` (the key owner) and `apiKey`. Keep both credential files private; -do not paste their contents into a terminal command or upload them. +In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private and +outside repositories and diagnostics. Do not upload them. -Test the connection before saving it: +The following examples run **on the Pulse host**, using its direct loopback +address. They ignore curl configuration, do not follow redirects or retry, +and retain each response in a new private local file. Responses and errors can +contain identifying details or echoed credentials; review them locally, not in +a public terminal recording or thread. Keep `--disable` first so local curl +configuration cannot enable tracing or change the request. + +Test once without saving: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections/test +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections/test) + printf 'HTTP %s\n' "$status" + [ "$status" = 200 ] +) ``` -After checking the test response, save the connection with: +Require exit 0, HTTP **200** and `success: true` in that private test response. +A redirect, HTTP error or `000` is not a successful test. Test success is not +inventory, metric or guest-thaw acceptance. + +Only after reviewing the test response, create the connection once: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections) + printf 'HTTP %s\n' "$status" + [ "$status" = 201 ] +) ``` -These Pulse URLs are loopback-only examples. For a remote Pulse server, use -its trusted HTTPS origin without disabling certificate verification. +A successful save returns HTTP **201** and the new connection's `id`, not +proof of a complete poll. If saving times out or loses its response, it may +already have created the connection. Check **Platform connections** or +`/api/truenas/connections` in your existing signed-in Pulse browser before +trying again; do not blindly repeat POST and create duplicates. Continue with +the [ordinary polling checks](#stale-truenas-data) for collection acceptance. + +For remote Pulse access, replace the loopback origin in the relevant command +with your trusted **HTTPS** origin and change `--proto '=http'` to +`--proto '=https'`. Keep certificate verification enabled. For a private CA, +add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from +your administrator, not from the failing connection. Do not add `--insecure`, +`--location`, verbose/trace flags or automatic retries. Share only the relevant +HTTP status and a manually redacted error, never the full response or headers. ## Troubleshooting diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index f51a3f5a9f..d441026bb5 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -15,6 +15,35 @@ ## Purpose +### Retained guest observations are not alert evidence + +Guest memory marked `last-known`, `unavailable` or an unknown observation state +cannot start, advance, refresh or resolve a memory incident. A VM filesystem +read with a nonempty `diskStatusReason`, including retained `prev-*` values, +or unavailable aggregate usage is likewise not current disk evidence. It cannot +evaluate either aggregate or per-filesystem thresholds, replace an aggregate +incident with filesystem incidents, or clear filesystem alerts as if a failed +or expired inventory were authoritative removal. Metric filters do not match +these unavailable memory/disk values, including a fabricated healthy zero. + +Existing incidents retain their last trusted value, occurrence, acknowledgement, +observation time and lifecycle/delivery evidence until a current observation +resumes evaluation. Successful current inventory still resolves removed +filesystems. Explicit disablement, suppression and stopped-guest behavior remain +effective independently of telemetry, including per-filesystem disable overrides +on retained identities. Legacy unannotated memory and independent +current PVE/Pulse-agent memory/disks keep their existing policy, even while QGA +is deferred; current CPU and I/O continue to evaluate. Producers own freshness; +no new age threshold, QGA request, payload, alert family or UI surface is added. + +`guest_observation_test.go` pins typed/pointer VM/LXC observation admission, +delayed breaches, canonical incident/acknowledgement/ledger continuity, filters, +fresh recovery and removal, independent metrics and deliberate disablement. +`TestGuestBackupDeferralAlertObservationLifecycle` connects actual HTTP client, +poll builder, retained snapshot, unified view, History and the production alert +call. These are synthetic source proofs, not native backup/thaw, containing +release, installed delivery or external recipient acceptance. + ### Pulse system alerts retain their service scope in the existing UI The `pulse-system-` identity prefix or explicit `systemAlert: true` metadata diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 49baf2e21a..ef1326e310 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -249,6 +249,24 @@ Fresh linked Pulse-agent disk evidence with no reason remains unmarked, and non-VM rows do not inherit QGA guidance. The cue adds no action or tab stop; touch on it continues through the existing row disclosure to the full reason. +The opened guest Overview applies the same admission to every filesystem, not +only the aggregate row or its status sentence. A valid `prev-` value is labelled +**Last known** beside its mount identity and used/capacity bytes; retained values +do not receive current utilization bars or threshold colours. Other VM read +reasons and explicit disk telemetry unavailability show **Usage unavailable**, +retaining identity and known capacity without promoting old used bytes. Missing +or invalid retained usage is unavailable, not an empty healthy filesystem. The +read explanation precedes the filesystem breakdown and remains visible before +any sample. Clearing a lock alone cannot clear a still-supplied reason. A fresh +same-guest read restores the normal accessible utilization bar; independent LXC +and linked-agent readings with no VM reason retain their existing semantics. + +Verification: `GuestDrawerOverview.filesystem-provenance.test.tsx`, the existing +filesystem regressions and builder controls in `DiskList.test.tsx`, plus +`browser-tests/guest-filesystem-provenance.cjs` using the full production guest +drawer on desktop/light/dark and touch WebKit. These are synthetic presentation +and input proofs, not native backup/thaw, covered-filesystem writes or resumption. + Verification: mounted `GuestRow.test.tsx` and `MetricMiniSparkline.test.tsx` regressions and `browser-tests/guest-disk-provenance.cjs` exercise retained and pre-sample states, display/lens changes, current-value semantics and same-row @@ -8308,3 +8326,34 @@ chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS, 403/503 controls and pending recovery. Unit controls separately cover current request ownership and disposal. No mutation, route, navigation entry, queue policy, permission or recipient-delivery contract is added by these states. + +### Drawer History does not interpolate across missing observations + +The shared `GuestDrawerHistory` renderer connects a series only across +consecutive stored times in that configured panel. If another series has a +stored observation at an intervening time and this series does not, its line +stops. Each isolated observation, including a measured zero, remains visible +as a point; separate continuous segments retain all their original values, +timestamps and common-window coordinates. Independently sampled series must +not imply simultaneous observations or a continuous trend through absent data. + +A visible, SVG-described explanation names the affected configured series and +states the limit: missing readings do not identify an outage's cause or duration. +There is no elapsed-time/cadence heuristic, synthetic zero, invented observation, +new API field or collector change. Times outside the configured panel cannot +split its series. Entirely absent series retain the existing missing/fallback +policy. Live or retained snapshot carriers cannot repair a stored gap; only a +matching stored response can. Source/range/access isolation, transient retained +reads and exact-time keyboard/pointer/touch inspection remain in force. + +`GuestDrawerHistory.gaps.test.tsx` and `guestHistorySegments.test.ts` cover +split lines, singleton recovery, zero rates, interleaved sampling, multiple +segments, configured-group isolation, invalid points, elapsed-time controls, +live updates, stored refresh and denied/transient reads. `GuestDrawer.test.tsx` +checks the full drawer's live-state recovery and unchanged request budget. +Updated pointer/sparse +regressions preserve their original missing-value and fallback obligations. +`browser-tests/history-observation-gaps.cjs` exercises the production full guest +drawer with synthetic APIs in Chromium/WebKit desktop and 390/320-pixel light +and dark views. This is presentation acceptance, not native backup/QGA safety, +provider outage attribution, installed recovery or release availability. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a6bbfb099d..1a49ebcb1e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1690,6 +1690,15 @@ without contention or cross-delivery. lifecycle. Request handlers may reuse the cached manager but must not reopen or remigrate SQLite on each role or assignment request, and shutdown must close the owned manager set. +8. Guest Overview filesystem provenance is local presentation over the already + supplied snapshot: no additional query, timer, poll, History fetch or guest + command may be added to establish it. Its existing linear filesystem mapping + may label retained values and omit current utilization bars; it must preserve + ordinary current-value builder behaviour and must not mutate the snapshot. + `DiskList.test.tsx` verifies the pure builder's retained/unavailable/current + controls; mounted drawer regressions and the full-drawer browser proof cover + the shipped path. No performance or native workload improvement follows from + the presentation proof alone. ## Current State @@ -3671,3 +3680,17 @@ backend, metrics-store, performance threshold or latency claim changes here. including selection retention while a same-source window advances. Browser receipts exercise the actual PBS table/drawer renderer with synthetic APIs, not installed collection or a performance benchmark. + +### Stored drawer History segmentation stays bounded + +Missing-observation rendering uses the existing normalized, configured-group +timestamps and the unchanged maximum of 240 requested points per metric. The +segment builder indexes those timestamps once per series and walks its stored +points without nested nearest-point searches. It adds no request, polling timer, +collector or synthetic reading. Separate singletons/segments retain the source +data and shared time window; live updates cannot fill absent stored evidence. +The mounted `GuestDrawerHistory.gaps.test.tsx` controls pin one initial read and +only explicit matching refreshes; the full `GuestDrawer.test.tsx` pins the +unchanged cap and no live-recovery read. `guestHistorySegments.test.ts` pins +complete point preservation at the existing request bound. Neither claims a +timing benchmark, fleet CPU attribution or native incident relief. diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.cjs b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs new file mode 100644 index 0000000000..3d7238fbab --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.cjs @@ -0,0 +1,301 @@ +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-ownership-proof/browser'; +const origin = 'http://127.0.0.1:5333'; +const runtime = [ + 'frontend-modern/src/components/Discovery/DiscoveryTab.tsx', + 'frontend-modern/src/components/Discovery/useDiscoveryTabState.ts', + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const saved = (id, service = `Saved Home Assistant ${id}`) => ({ + id: `vm:fixture-node-agent:${id}`, + resource_type: 'vm', + resource_id: id, + target_id: 'fixture-node-agent', + hostname: `guest-${id}`, + service_type: 'home-assistant', + service_name: service, + 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 = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_sha256: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])), + groups: [], + screenshots: [], + cleanup: {}, + limits: + 'Production DiscoveryTab/state/CSS, synthetic API and production event-bus progress. No native QGA, automatic collector, original freeze cause, thaw, covered filesystem writes, installed, physical-phone or release acceptance.', + }; + 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: output + '/cache', + server: { host: '127.0.0.1', port: 5333, strictPort: true, watch: null }, + }); + let browser, + phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop-light', chromium, 1365, false], + ['desktop-dark', chromium, 1365, true], + ['phone-dark', webkit, 390, true], + ['narrow-light', webkit, 320, false], + ]) { + 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 group = { + name, + width, + dark, + browser: browser.version(), + checks: [], + requests: [], + errors: [], + offOrigin: [], + }; + report.groups.push(group); + page.on('pageerror', (error) => group.errors.push(error.message)); + await page.routeWebSocket('**/ws*', () => {}); + await page.addInitScript((dark) => { + const apply = () => document.documentElement.classList.toggle('dark', dark); + if (document.documentElement) apply(); + else document.addEventListener('DOMContentLoaded', apply, { once: true }); + }, dark); + let fail = false; + const releases = new Map(); + const arrivals = new Map(); + const stored = new Map(); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.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, + method: request.method(), + body: request.postData(), + }); + const target = url.pathname.match(/^\/api\/discovery\/vm\/fixture-node-agent\/(101|102)$/); + if (target) { + const id = target[1]; + if (request.method() === 'GET') + return route.fulfill({ json: stored.get(id) || saved(id) }); + assert.equal(request.method(), 'POST'); + if (fail) + return route.fulfill({ + status: 409, + json: { error: 'Guest execution paused: backup lock observed' }, + }); + await new Promise((resolve) => { + releases.set(id, resolve); + arrivals.get(id)?.(); + }); + const result = saved(id, id === '101' ? 'Old guest result' : 'Current guest result'); + stored.set(id, result); + return route.fulfill({ json: result }); + } + assert.equal(request.method(), 'GET'); + const responses = { + '/api/discovery/info/vm': { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + '/api/ai/agents': { + count: 1, + agents: [{ agent_id: 'fixture-node-agent', hostname: 'pve1' }], + }, + '/api/ai/settings': { discovery_enabled: true, enabled: false }, + '/api/license/runtime-capabilities': { + capabilities: [], + limits: [], + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }; + return route.fulfill({ json: responses[url.pathname] || {} }); + }); + const capture = async (suffix) => { + await page.screenshot({ path: `${output}/${name}-${suffix}.png`, fullPage: true }); + report.screenshots.push(`${name}-${suffix}.png`); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + }; + const emit = (progress) => + page.evaluate((progress) => window.discoveryOwnership.progress(progress), progress); + const run = page.getByRole('button', { name: 'Run Discovery', exact: true }); + const pressRun = async () => { + if (width >= 500) { + await run.focus(); + await page.keyboard.press('Enter'); + } else await run.tap(); + }; + await page.goto(origin + '/browser-tests/discovery-outcome-ownership.html'); + await page.getByText('Saved Home Assistant 101', { exact: true }).waitFor(); + await run.waitFor(); + await page.waitForFunction( + () => + !Array.from(document.querySelectorAll('button')).find( + (b) => b.textContent.trim() === 'Run Discovery', + ).disabled, + ); + const before = group.requests.length; + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'running', + current_step: 'Collecting guest evidence', + percent_complete: 20, + }); + await page.getByText('Collecting guest evidence', { exact: true }).waitFor(); + await emit({ + resource_id: 'vm:fixture-node-agent:101', + status: 'completed', + error: 'Guest execution paused: backup lock observed', + current_step: 'Guest execution paused', + percent_complete: 20, + }); + const alert = page.getByRole('alert'); + await alert.waitFor(); + await page.waitForTimeout(650); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert.equal(group.requests.length, before); + assert(await page.getByText('Saved Home Assistant 101', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await capture('retained-background-pause'); + group.checks.push( + 'Background completed-with-error remains an accessible failure with saved evidence and no retry or refresh', + ); + const dismiss = page.getByRole('button', { name: 'Dismiss Discovery error' }); + await dismiss.focus(); + await page.keyboard.press('Enter'); + assert.equal(await alert.count(), 0); + group.checks.push('The error dismiss action has a keyboard-accessible name'); + const firstPost = new Promise((resolve) => arrivals.set('101', resolve)); + await pressRun(); + await firstPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + await page.evaluate(() => window.discoveryOwnership.target('102')); + await page.getByText('Saved Home Assistant 102', { exact: true }).waitFor(); + const secondPost = new Promise((resolve) => arrivals.set('102', resolve)); + await pressRun(); + await secondPost; + await page.waitForFunction(() => document.body.textContent.includes('Scanning...')); + assert(releases.has('101')); + assert(releases.has('102')); + releases.get('101')(); + await page.waitForTimeout(150); + assert.equal(await page.getByText('Old guest result', { exact: true }).count(), 0); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + await emit({ + resource_id: 'vm:fixture-node-agent:102', + status: 'completed', + percent_complete: 100, + }); + assert(await page.getByRole('button', { name: 'Scanning...', exact: true }).isDisabled()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + releases.get('102')(); + await page.getByText('Current guest result', { exact: true }).waitFor(); + await page.getByText('Discovery complete!', { exact: true }).waitFor(); + group.checks.push( + 'Late old-target HTTP and scanner completion cannot replace the current guest or finish its active HTTP request', + ); + const posts = group.requests.filter((r) => r.method === 'POST'); + assert.equal(posts.length, 2); + assert.deepEqual( + posts.map((p) => JSON.parse(p.body)), + [ + { force: true, hostname: 'guest-101' }, + { force: true, hostname: 'guest-102' }, + ], + ); + await capture('current-guest-result'); + await emit({ resource_id: 'vm:fixture-node-agent:102', status: 'completed' }); + await page.waitForTimeout(650); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 2); + group.checks.push( + 'Later completion cannot strand the old HTTP success banner or issue an automatic scan', + ); + fail = true; + await pressRun(); + await alert.waitFor(); + assert((await alert.innerText()).includes('Guest execution paused: backup lock observed')); + assert(await page.getByText('Current guest result', { exact: true }).isVisible()); + assert.equal(await page.getByText('Discovery complete!', { exact: true }).count(), 0); + await page.waitForTimeout(650); + assert.equal(group.requests.filter((r) => r.method === 'POST').length, 3); + group.checks.push( + 'A failed explicit run retains prior saved evidence, visible failure and no automatic retry', + ); + assert.deepEqual(group.errors, []); + assert.deepEqual(group.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(output + '/result.json', JSON.stringify(report, null, 2) + '\n'); + } +})().catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.html b/frontend-modern/browser-tests/discovery-outcome-ownership.html new file mode 100644 index 0000000000..7b3a737737 --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.html @@ -0,0 +1,12 @@ + + + + + + Discovery outcome ownership evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/discovery-outcome-ownership.tsx b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx new file mode 100644 index 0000000000..c9fe99348c --- /dev/null +++ b/frontend-modern/browser-tests/discovery-outcome-ownership.tsx @@ -0,0 +1,54 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DiscoveryTab } from '../src/components/Discovery/DiscoveryTab'; +import { eventBus } from '../src/stores/events'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { DiscoveryProgress } from '../src/types/discovery'; +import '../src/index.css'; + +declare global { + interface Window { + discoveryOwnership: { + target: (id: string) => void; + progress: (progress: DiscoveryProgress) => void; + enabled: (enabled: boolean) => void; + }; + } +} + +function Fixture() { + const [id, setId] = createSignal('101'); + const enabled = (discovery_enabled: boolean) => + syncAIRuntimeSettings({ discovery_enabled } as Parameters[0]); + enabled(true); + window.discoveryOwnership = { + target: setId, + progress: (progress) => eventBus.emit('ai_discovery_progress', progress), + enabled, + }; + return ( +
+

Discovery outcome ownership evidence

+

Synthetic guest responses. No guest command is sent.

+

Guest {id()}

+ +
+ ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/guest-discovery-completion.cjs b/frontend-modern/browser-tests/guest-discovery-completion.cjs new file mode 100644 index 0000000000..25bb445ee3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-completion.cjs @@ -0,0 +1,19 @@ +const { spawnSync } = require('node:child_process'); +for (const script of ['guest-discovery-safety.cjs', 'discovery-outcome-ownership.cjs']) { + const result = spawnSync( + process.execPath, + ['/workspace/frontend-modern/browser-tests/' + script], + { stdio: 'inherit' }, + ); + if (result.status !== 0) { + console.error( + script, + 'did not complete successfully', + result.status, + result.signal, + result.error?.message, + ); + process.exit(result.status || 1); + } +} +console.log('Combined guest Discovery pause and outcome ownership browser proof passed.'); diff --git a/frontend-modern/browser-tests/guest-discovery-safety.cjs b/frontend-modern/browser-tests/guest-discovery-safety.cjs new file mode 100644 index 0000000000..8629e6cc4e --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/guest-discovery-safety.html b/frontend-modern/browser-tests/guest-discovery-safety.html new file mode 100644 index 0000000000..74a7217db4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.html @@ -0,0 +1,12 @@ + + + + + + Guest Discovery safety verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-discovery-safety.tsx b/frontend-modern/browser-tests/guest-discovery-safety.tsx new file mode 100644 index 0000000000..dfeb8e8018 --- /dev/null +++ b/frontend-modern/browser-tests/guest-discovery-safety.tsx @@ -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([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 ( +
+

Guest Discovery safety evidence

+

Synthetic observations. No guest check is sent.

+ + + + setExpanded(!expanded())} + workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'} + /> + +
+ +
+ setExpanded(false)} /> +
+
+
+
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/guest-filesystem-provenance.cjs b/frontend-modern/browser-tests/guest-filesystem-provenance.cjs new file mode 100644 index 0000000000..4494b27263 --- /dev/null +++ b/frontend-modern/browser-tests/guest-filesystem-provenance.cjs @@ -0,0 +1,136 @@ +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/filesystem-read-provenance/browser-final'; +const origin = 'http://127.0.0.1:5324'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', + 'frontend-modern/src/components/Workloads/diskListModel.ts', +]; +const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const settle = page => page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); +}); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map(file => [file, hash('/workspace/' + file)])), + cases: [], screenshots: [], cleanup: { browser: false, server: false }, + limits: 'Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter or release acceptance.', + }; + assert.equal(result.playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({root, configFile: path.join(root, 'vite.config.ts'), cacheDir: output + '/cache', server: { host: '127.0.0.1', port: 5324, strictPort: true }}); + let browser; + 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], + ]) { + browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const context = await browser.newContext({viewport: { width, height: width <= 768 ? 844 : 900 }, isMobile: width <= 768, hasTouch: width <= 768, locale: 'en-GB', timezoneId: 'UTC'}); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + const record = {name, browser: browser.version(), width, dark, errors: [], requests: [], checks: [], states: []}; + result.cases.push(record); + page.on('pageerror', error => record.errors.push(error.message)); + await page.route('**/*', route => { + const request = route.request(); const url = new URL(request.url()); + if (url.origin !== origin) { record.requests.push({ method: request.method(), path: url.pathname, disposition: 'off-origin blocked' }); return route.abort(); } + if (url.pathname.startsWith('/api/')) { + record.requests.push({ method: request.method(), path: url.pathname, disposition: 'synthetic' }); + return route.fulfill({status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: [], alerts: [], anomalies: [], config: null })}); + } + return route.continue(); + }); + await page.addInitScript(isDark => { + document.addEventListener('DOMContentLoaded', () => { if (isDark) document.documentElement.classList.add('dark'); }); + window.__trustedInput = []; + document.addEventListener('click', event => window.__trustedInput.push({ type: 'click', trusted: event.isTrusted }), true); + document.addEventListener('keydown', event => window.__trustedInput.push({ type: event.key, trusted: event.isTrusted }), true); + }, dark); + await page.goto(`${origin}/browser-tests/guest-disk-provenance.html`); + await page.waitForFunction(() => window.__guestDiskProvenance); + const apply = async next => { await page.evaluate(next => window.__guestDiskProvenance.apply(next), next); await settle(page); }; + await apply({ reason: 'prev-vm-locked', data: true, usage: 90, lock: 'backup', kind: 'qemu' }); + const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]'); + const disclosure = row.locator('[data-row-action]'); + if (width <= 768) await row.locator('[data-workload-disk-read-status]').tap(); + else { await disclosure.focus(); await page.keyboard.press('Enter'); } + const drawer = page.getByRole('region', {name: 'Guest details'}); + await drawer.waitFor({state: 'visible'}); + const technical = drawer.getByTestId('guest-technical-details'); + const filesystem = technical.getByText('/data', {exact: true}).locator('xpath=ancestor::tr'); + const progress = filesystem.getByRole('progressbar'); + const text = async () => (await filesystem.innerText()).replace(/\s+/g, ' ').trim(); + const capture = async state => { + await technical.scrollIntoViewIfNeeded(); await settle(page); + const file = `${name}-${state}.png`; + await technical.screenshot({path: path.join(output, file)}); + result.screenshots.push({file, sha256: hash(path.join(output, file)), name, state}); + }; + assert.match(await text(), /Last known 90% · 9\.00 GB\/10\.0 GB · EXT4/); + assert.equal(await progress.count(), 0); + record.checks.push('Retained quantitative values are labelled last known beside their filesystem identity, with no current utilization bar.'); + record.states.push({state: 'retained', text: await text()}); + await capture('retained'); + await apply({ lock: '' }); + assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0); + record.checks.push('Clearing only the operation lock cannot promote a deferred reading.'); + const reasons = await page.evaluate(() => window.__guestDiskProvenance.deferrals.map(item => item[0])); + for (const reason of reasons) { + await apply({reason: 'prev-' + reason}); + assert.match(await text(), /Last known 90%/); assert.equal(await progress.count(), 0); + await apply({reason}); + assert.match(await text(), /Usage unavailable · \?\/10\.0 GB · EXT4/); + assert.doesNotMatch(await text(), /90%|9\.00 GB/); assert.equal(await progress.count(), 0); + } + record.checks.push('All eight fixed read reasons distinguish retained data from unavailable numeric carriers.'); + record.states.push({state: 'unavailable', text: await text()}); + await capture('unavailable'); + await apply({reason: '', usage: 30, lock: ''}); + assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/); + assert.equal(await progress.getAttribute('aria-valuenow'), '30'); + assert.equal(await progress.getAttribute('aria-label'), 'Filesystem /data utilization'); + assert.equal(await drawer.isVisible(), true); + record.checks.push('A same-guest fresh read restores the correct numeric value and accessible utilization bar without closing the drawer.'); + record.states.push({state: 'fresh', text: await text()}); + await capture('fresh'); + await apply({reason: 'prev-vm-locked', kind: 'lxc'}); + assert.match(await text(), /30% · 3\.00 GB\/10\.0 GB · EXT4/); assert.equal(await progress.getAttribute('aria-valuenow'), '30'); + record.checks.push('Independent LXC filesystem evidence does not inherit a VM-only reason.'); + await apply({kind: 'qemu', data: false, reason: 'vm-locked'}); + assert.equal(await technical.getByText('/data', {exact: true}).count(), 0); + assert.match(await technical.innerText(), /Guest reads paused while Proxmox reports a VM operation lock/); + record.checks.push('With no prior filesystem sample, the readable explanation remains and no filesystem value is invented.'); + const overflow = await technical.evaluate(el => ({width: el.clientWidth, scrollWidth: el.scrollWidth, documentWidth: document.documentElement.clientWidth, documentScroll: document.documentElement.scrollWidth})); + assert.ok(overflow.scrollWidth <= overflow.width + 1, JSON.stringify(overflow)); + record.layout = overflow; + await drawer.getByRole('button', {name: 'Collapse backup-guest details'}).click(); + assert.equal(await drawer.count(), 0); + record.input = await page.evaluate(() => window.__trustedInput); + assert.ok(record.input.some(event => event.trusted)); + assert.deepEqual(record.errors, []); + assert.equal(record.requests.filter(request => !['GET','HEAD'].includes(request.method)).length, 0); + record.checks.push('Trusted keyboard/touch disclosure and close work; no uncaught page error, overflow or mutating request.'); + await context.close(); await browser.close(); browser = null; + } + result.result = 'passed'; + } catch (error) { + result.error = String(error.stack || error); throw error; + } finally { + if (browser) await browser.close(); result.cleanup.browser = true; + await server.close(); result.cleanup.server = true; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/history-observation-gaps.cjs b/frontend-modern/browser-tests/history-observation-gaps.cjs new file mode 100644 index 0000000000..8fa4c98ba9 --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.cjs @@ -0,0 +1,339 @@ +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/history-observation-gaps/browser'; +const origin = 'http://127.0.0.1:5317'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', +]; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const settle = (page) => + page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])), + cases: [], + screenshots: [], + limits: + 'Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance.', + }; + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: output + '/cache', + server: { host: '127.0.0.1', port: 5317, strictPort: true }, + }); + let browser; + 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], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 768 ? 844 : 900 }, + isMobile: width < 768, + hasTouch: width < 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(12000); + page.setDefaultNavigationTimeout(60000); + const record = { + name, + width, + dark, + browser: browser.version(), + errors: [], + requests: [], + checks: [], + }; + result.cases.push(record); + let mode = 'gaps'; + const start = Date.UTC(2026, 9, 4, 12); + const point = (minute, value) => ({ + timestamp: start + minute * 60_000, + value, + min: value, + max: value, + }); + const times = [0, 15, 30, 45, 60]; + const metrics = () => ({ + cpu: times.map((minute, index) => point(minute, 10 + index * 5)), + memory: (mode === 'complete' ? times : [0, 15, 60]).map((minute) => + point(minute, 20 + minute / 6), + ), + disk: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 50)), + netin: times.map((minute) => point(minute, 100)), + netout: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)), + diskread: (mode === 'complete' ? times : [0, 60]).map((minute) => point(minute, 0)), + diskwrite: times.map((minute) => point(minute, 200)), + }); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== origin) { + record.requests.push({ origin: url.origin, blocked: true }); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + record.requests.push({ path: url.pathname, method: route.request().method(), mode }); + if (url.pathname === '/api/metrics-store/history') { + assert.equal(route.request().method(), 'GET'); + assert.equal(url.searchParams.get('resourceId'), 'fixture:pve:101'); + assert.equal(url.searchParams.get('maxPoints'), '240'); + if (mode === '503' || mode === '403') + return route.fulfill({ + status: Number(mode), + json: { error: 'private-fixture-detail' }, + }); + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: url.searchParams.get('resourceId'), + range: url.searchParams.get('range'), + start, + end: start + 60 * 60_000, + metrics: metrics(), + source: 'store', + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 90, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { success: true, data: [], alerts: [], anomalies: [], config: null }, + }); + }); + await page.addInitScript((isDark) => { + document.addEventListener('DOMContentLoaded', () => + document.documentElement.classList.toggle('dark', isDark), + ); + window.__trustedInputs = []; + for (const type of ['click', 'input', 'keydown']) + document.addEventListener( + type, + (event) => window.__trustedInputs.push({ type, trusted: event.isTrusted }), + true, + ); + }, dark); + await page.goto(origin + '/browser-tests/history-observation-gaps.html'); + const activate = async (locator) => (width < 768 ? locator.tap() : locator.click()); + await activate(page.getByRole('tab', { name: 'History', exact: true })); + const chart = page.locator('[data-history-group="utilization"]'); + const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' }); + await slider.waitFor(); + await settle(page); + await page.evaluate(() => { + window.__originalHistoryChart = document.querySelector( + '[data-history-group="utilization"]', + ); + }); + const memoryPath = chart.locator('path[stroke="#f59e0b"]'); + assert.equal(await memoryPath.count(), 1); + assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 1); + assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1); + assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0); + assert.equal(await chart.locator('[data-history-observation="disk"]').count(), 2); + assert.equal( + (await chart.locator('path[stroke="#8b5cf6"]').getAttribute('d')).match(/L/g).length, + 4, + ); + assert.match( + await chart.locator('[data-history-gaps]').innerText(), + /Missing observations: Memory, Disk\./, + ); + assert.equal(await page.locator('[data-history-gaps]').count(), 3); + const description = await chart + .getByRole('img', { name: 'Utilization history' }) + .evaluate((el) => + el + .getAttribute('aria-describedby') + .split(' ') + .map((id) => document.getElementById(id).textContent) + .join(' '), + ); + assert.match(description, /Missing observations: Memory, Disk\./); + assert.match(description, /does not identify the cause or duration/); + const noteGeometry = await chart.locator('[data-history-gaps]').evaluate((el) => ({ + width: el.clientWidth, + scrollWidth: el.scrollWidth, + colour: getComputedStyle(el).color, + })); + assert.ok(noteGeometry.scrollWidth <= noteGeometry.width + 1); + record.noteGeometry = noteGeometry; + record.checks.push( + 'Actual stored gap splits only affected metrics; recovered singleton/zero samples stay visible; visible/accessibly linked explanation fits.', + ); + const screenshot = async (suffix) => { + await settle(page); + const file = `${name}-${suffix}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + result.screenshots.push({ + file, + sha256: hash(path.join(output, file)), + name, + state: suffix, + }); + }; + await screenshot('gaps'); + + await slider.focus(); + await page.keyboard.press('Home'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + assert.equal(await slider.inputValue(), '2'); + assert.match( + await slider.getAttribute('aria-valuetext'), + /Memory no observation\. Disk no observation\./, + ); + assert.match(await chart.innerText(), /Memory\s*-/); + assert.equal(await chart.locator('circle[r="3"]').count(), 1); + record.checks.push( + 'Trusted Home/Arrow keys inspect the missing stored time without borrowing nearby/live memory or disk.', + ); + if (width < 768) { + await slider.scrollIntoViewIfNeeded(); + const box = await slider.boundingBox(); + await page.touchscreen.tap(box.x + box.width * 0.75, box.y + box.height / 2); + assert.notEqual(await slider.inputValue(), '2'); + record.checks.push('Trusted touch operates the native inspector.'); + } else { + await page.getByRole('heading', { name: 'Drawer History missing observations' }).click(); + const box = await chart.getByRole('img', { name: 'Utilization history' }).boundingBox(); + await page.mouse.move(box.x + box.width * 0.537, box.y + box.height / 2); + assert.match( + await chart.locator('[data-testid="guest-history-hover-time"]').innerText(), + /12:30/, + ); + assert.equal(await chart.locator('circle[r="3"]').count(), 1); + record.checks.push( + 'Trusted pointer matches the exact stored time, not a synthetic line value.', + ); + } + const reads = () => + record.requests.filter((r) => r.path === '/api/metrics-store/history').length; + const before = reads(); + await activate(page.getByRole('button', { name: 'Simulate live recovery' })); + assert.equal(reads(), before); + assert.equal(await chart.locator('[data-history-gaps]').count(), 1); + assert.equal(await chart.locator('[data-history-observation="memory"]').count(), 1); + assert.equal( + await page.evaluate( + () => + window.__originalHistoryChart === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + record.checks.push( + 'Live status recovery cannot fill missing stored evidence, refetch or remount the chart.', + ); + + mode = '503'; + await activate(page.getByRole('button', { name: 'Refresh history' })); + await page.getByRole('button', { name: 'Retry history' }).waitFor(); + assert.equal(await chart.locator('[data-history-gaps]').count(), 1); + assert.equal(await chart.locator('path[stroke="#10b981"]').count(), 0); + assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/); + record.checks.push('Transient read failure retains the gap and does not leak the raw error.'); + mode = 'complete'; + await activate(page.getByRole('button', { name: 'Retry history' })); + await page.waitForFunction( + () => document.querySelectorAll('[data-history-gaps]').length === 0, + ); + assert.equal(await chart.locator('[data-history-observation]').count(), 0); + assert.equal((await memoryPath.getAttribute('d')).match(/L/g).length, 4); + assert.equal( + await page.evaluate( + () => + window.__originalHistoryChart === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + record.checks.push( + 'Matching complete stored refresh joins actual consecutive observations; chart identity stays stable.', + ); + await screenshot('complete'); + mode = '403'; + await activate(page.getByRole('button', { name: 'Refresh history' })); + await page.waitForFunction( + () => document.querySelectorAll('[data-testid="guest-history-group-chart"]').length === 0, + ); + assert.equal(await page.locator('[data-history-gaps]').count(), 0); + assert.doesNotMatch(await page.locator('body').innerText(), /private-fixture-detail/); + record.checks.push('Access denial withdraws all stored chart/gap/inspection evidence.'); + assert.equal(reads(), before + 3); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + true, + ); + assert.equal( + record.requests.some((r) => r.method && r.method !== 'GET'), + false, + ); + assert.equal( + record.requests.some((r) => r.blocked), + false, + ); + assert.deepEqual(record.errors, []); + assert.equal( + await page.evaluate(() => window.__trustedInputs.every((entry) => entry.trusted)), + true, + ); + record.checks.push( + 'No page error, horizontal overflow, external request or mutation; unchanged user-request read budget.', + ); + await page.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + } catch (error) { + result.failure = error.stack || String(error); + throw error; + } finally { + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + if (browser) await browser.close(); + await server.close(); + } + process.stdout.write(JSON.stringify(result) + '\n'); +})().catch((error) => { + process.stderr.write(error.stack + '\n'); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/history-observation-gaps.html b/frontend-modern/browser-tests/history-observation-gaps.html new file mode 100644 index 0000000000..e73184c18e --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.html @@ -0,0 +1,12 @@ + + + + + + Drawer History missing-observation verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-observation-gaps.tsx b/frontend-modern/browser-tests/history-observation-gaps.tsx new file mode 100644 index 0000000000..20eac8f05d --- /dev/null +++ b/frontend-modern/browser-tests/history-observation-gaps.tsx @@ -0,0 +1,87 @@ +// Real production drawer/CSS. The browser supplies synthetic stored readings; +// no collector, guest-agent, backup, thaw or installation operation is run. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { WorkloadGuest } from '../src/types/workloads'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const [recovered, setRecovered] = createSignal(false); + const guest = (): WorkloadGuest => ({ + id: 'fixture:pve:101', + vmid: 101, + name: 'backup-guest', + node: 'pve', + instance: 'fixture', + status: 'running', + type: 'qemu', + cpu: 0.1, + cpus: 2, + memory: { + total: 1024 ** 3, + used: 256 * 1024 ** 2, + free: 768 * 1024 ** 2, + usage: 25, + observation: { + state: recovered() ? 'current' : 'last-known', + source: 'status-mem', + observedAt: '2026-10-04T12:01:00Z', + }, + }, + disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + diskStatusReason: recovered() ? '' : 'prev-vm-locked', + lock: recovered() ? '' : 'backup', + guestAgentStatus: recovered() ? 'available' : 'deferred', + backupInProgress: !recovered(), + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lastSeen: '2026-10-04T12:04:00Z', + }); + return ( +
+

Drawer History missing observations

+

+ Synthetic readings. No backup or guest-agent command is sent. +

+ +
+ {}} /> +
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/1014496b3e63eb6a.json b/frontend-modern/browser-verification/1014496b3e63eb6a.json new file mode 100644 index 0000000000..e10fb62980 --- /dev/null +++ b/frontend-modern/browser-verification/1014496b3e63eb6a.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "664b29f930b19f91b74f673914c85e3b806b54f7", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/diskListModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "16ccfd4c8a865c02d4ab3580d7872518cb599715245fb10ebbe3dfbe375cb104", + "frontend-modern/src/components/Workloads/diskListModel.ts": "8b43eeb3e08dff191039ee19ea74baa89703d6b8f89d801a5ff0f8cdef09dffc" + }, + "routes": [ + "/browser-tests/guest-disk-provenance.html \u2014 production Workloads GuestRow/full GuestDrawer; synthetic APIs, not the full App route" + ], + "states": [ + "Retained filesystem usage with each of eight prev- reasons: labelled last known, no current bars/threshold colours", + "Same eight non-retained read failures: unavailable used bytes, identity/capacity retained", + "Lock-only clearance retains the label; same-guest fresh read restores accessible 30% utilization", + "LXC independent reading and no-sample readable VM status" + ], + "interactions": [ + "Trusted desktop Enter disclosure and close", + "Trusted WebKit phone/narrow touch on the row provenance cue and close", + "Synthetic same-guest poll replacements while keeping the drawer open", + "Inspected every retained/unavailable/fresh screenshot in all four browser/theme/width groups" + ], + "viewports": [ + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-light", + "theme": "light" + }, + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-dark", + "theme": "dark" + }, + { + "width": 390, + "height": 844, + "browser": "webkit-phone-dark", + "theme": "dark" + }, + { + "width": 320, + "height": 844, + "browser": "webkit-narrow-light", + "theme": "light" + } + ], + "verified_at": "2026-10-04T21:47:04Z", + "playwright_version": "1.56.1", + "checks": 28, + "screenshots_inspected": 12, + "script": "frontend-modern/browser-tests/guest-filesystem-provenance.cjs", + "limits": "Real GuestRow/full GuestDrawer/DetailSectionTable and CSS; synthetic guest observations and APIs. No native QGA, backup, cause, thaw, writes, installed/reporter or release acceptance." +} diff --git a/frontend-modern/browser-verification/430ebb1acb72552b.json b/frontend-modern/browser-verification/430ebb1acb72552b.json new file mode 100644 index 0000000000..ce1175ad3d --- /dev/null +++ b/frontend-modern/browser-verification/430ebb1acb72552b.json @@ -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." + ] +} diff --git a/frontend-modern/browser-verification/51fcee98af1964fd.json b/frontend-modern/browser-verification/51fcee98af1964fd.json new file mode 100644 index 0000000000..c0d206a73c --- /dev/null +++ b/frontend-modern/browser-verification/51fcee98af1964fd.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "0ad41e4c9a309ce459dd717d16d343b46efcac8a", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "8b94c3d9409f46bedf812b00ff9f3156da9ace6885ec04456ea5fed59d842127", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "79e176dd5640190f6bf11d3a6bbddc63fcd18c37b19e4e7098163a95d542b0a5" + }, + "routes": [ + "/browser-tests/history-observation-gaps.html \u2014 production full GuestDrawer/History/client/CSS, not the full App route" + ], + "states": [ + "Stored CPU is continuous while memory and disk miss intervening group times; preserve split segments and recovery singleton/zero readings.", + "Live recovery alone preserves stored gaps and chart identity without a read.", + "Transient 503 retains data/gaps; matching complete stored refresh restores actual contiguous lines; denied 403 withdraws all history." + ], + "interactions": [ + "Trusted desktop click, Home/Arrow keyboard inspection and pointer at exact missing stored time.", + "Trusted WebKit 390/320 px native inspector touch, keyboard arrows and refresh/retry.", + "Trusted live-recovery action does not alter stored points, query budget or chart identity.", + "All eight captures visually inspected for gaps, isolated points, contiguous recovery, light/dark readability and 390/320 px wrapping." + ], + "viewports": [ + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-light", + "theme": "light" + }, + { + "width": 1365, + "height": 900, + "browser": "chromium-desktop-dark", + "theme": "dark" + }, + { + "width": 390, + "height": 844, + "browser": "webkit-phone-dark", + "theme": "dark" + }, + { + "width": 320, + "height": 844, + "browser": "webkit-narrow-light", + "theme": "light" + } + ], + "verified_at": "2026-10-04T22:39:45.443984Z", + "playwright_version": "1.56.1", + "checks": 32, + "screenshots_inspected": 8, + "script": "frontend-modern/browser-tests/history-observation-gaps.cjs", + "limits": "Production full GuestDrawer/History, client and CSS with synthetic local APIs. Not native guest/QGA/backup/thaw, installed recovery, physical-phone or release acceptance." +} diff --git a/frontend-modern/browser-verification/69011d6c032c31d5.json b/frontend-modern/browser-verification/69011d6c032c31d5.json new file mode 100644 index 0000000000..1c7e86b268 --- /dev/null +++ b/frontend-modern/browser-verification/69011d6c032c31d5.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "482a2614e32f875990ee84662dfcbf3010ca7181", + "verified_at": "2026-10-04T23:18:40.484Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawer.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "a1d197b12aa0bed084664472aeeca1d4b0415911a8aa94c8aa3742e59ce77789" + }, + "routes": [ + "/proxmox/overview (production drawer and canonical projection in offline synthetic fixture)" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Combined published saved-URL header action and reviewed guest read/Discovery precaution on exact production bytes. Playwright 1.56.1 Chromium desktop light and WebKit phone dark; eight screenshots inspected.", + "Locked and uncertain retained readings keep saved results visible but live Discovery disabled; healthy clear permits only explicit dispatch. In-flight completion remains attributed when observation becomes deferred.", + "Header Open retains safe href, target _blank, noopener noreferrer, desktop 32px/phone 44px target and expanded drawer. Missing and unsafe URLs expose no link. No horizontal overflow, page errors or external requests." + ], + "interactions": [ + "Saved header Open retains safe href/target/rel, phone target and expansion; absent/unsafe URLs expose no link, without sending Discovery", + "Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason", + "All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan", + "Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing", + "A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent", + "Saved header Open retains safe href/target/rel, phone target and expansion; absent/unsafe URLs expose no link, without sending Discovery", + "Native canonical projection, row and real drawer/Discovery: assigned node agent, saved results readable, backup blocks live run with associated wrapping reason", + "All eight read deferrals, retained variants and same-ID tab/snapshot updates block without remount, POST or automatic rescan", + "Explicit healthy keyboard run sends exactly the original target/body once; clearing safety pause alone sends nothing", + "A newly blocked snapshot neither hides nor cancels dispatched work; original completion is shown and no further run is sent" + ] +} diff --git a/frontend-modern/browser-verification/c45de66a92dcfc91.json b/frontend-modern/browser-verification/c45de66a92dcfc91.json new file mode 100644 index 0000000000..88b556048b --- /dev/null +++ b/frontend-modern/browser-verification/c45de66a92dcfc91.json @@ -0,0 +1,79 @@ +{ + "version": 1, + "base_sha": "bc3f4d81d14873caae6f769dfa670d16fa13d518", + "verified_at": "2026-10-04T20:32:45.762Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "2e93209a2b3830f77a0ad94d02bd59ea01509932f8d51e594d60f55d1f5895d0", + "frontend-modern/src/components/Discovery/useDiscoveryTabState.ts": "e600bb993b80e85b6df48a9e070f1bc2db51d7bb8e5ffd6216e3861d610def14" + }, + "routes": [ + "/browser-tests/guest-discovery-safety.html: production Workloads row, GuestDrawer and DiscoveryTab with synthetic canonical snapshots", + "/browser-tests/discovery-outcome-ownership.html: reused production DiscoveryTab/state, synthetic per-guest APIs and production event-bus progress" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Guest operation lock and all current guest-read deferrals with/without retained data; healthy explicit action", + "Original HTTP result retained after a same-target precaution without cancellation or another scan", + "Background completed-with-error retains an accessible failed outcome and saved evidence", + "Late old-target HTTP result cannot overwrite the current guest or stop its manual request", + "Scanner completion during HTTP does not claim saved success; later terminal event cannot strand earlier success", + "Current explicit failure remains visible without an automatic retry" + ], + "interactions": [ + "Keyboard and touch drawer expansion, Discovery tabs, explicit enabled runs and associated disabled reasons", + "No POST on blocked/unlock/tab/snapshot change and exactly one target/body POST per explicit healthy action", + "Keyboard-labelled error dismissal with saved content preserved", + "Change target during an in-flight request, deliver the old and current responses independently and inspect original ownership", + "Render desktop light/dark and WebKit 390px/320px, inspect wrapping and all 24 captures" + ], + "script": "frontend-modern/browser-tests/guest-discovery-completion.cjs", + "browsers": [ + { + "name": "desktop-light", + "browser": "141.0.7390.37", + "width": 1365, + "dark": false + }, + { + "name": "desktop-dark", + "browser": "141.0.7390.37", + "width": 1365, + "dark": true + }, + { + "name": "phone-dark", + "browser": "26.0", + "width": 390, + "dark": true + }, + { + "name": "narrow-light", + "browser": "26.0", + "width": 320, + "dark": false + } + ], + "limitations": [ + "Synthetic inputs and API responses, not native QGA, backup or automatic poller acceptance.", + "Vite-transformed production components. Exact production build is checked separately in the source-proof VM.", + "No original freeze cause, independent thaw, covered-filesystem writes, installed use, physical phone or containing release is established." + ] +} diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index 270d15e838..8578f16dd9 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -101,48 +101,114 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings → Infrastructure → Platform connections** flow is the simplest option. For an API client, -prepare the private Pulse header file as described in the -[API authentication guide](API.md). The Pulse API token authenticates to -Pulse; the separate TrueNAS API key belongs in the connection's JSON file. -Do not put either credential in shell arguments, URLs or a public thread. +Prefer **Settings → Infrastructure → Platform connections** for setup. These +API examples are for an authorised administrator using curl 7.76 or later, +not a way to diagnose stale polling. Testing makes a live system-information +request to TrueNAS; saving creates a connection and starts ordinary polling. +Do not use either during a backup, freeze/thaw or an unresponsive-host incident. -Create or protect the connection file before opening it in a trusted editor: +First prepare a private Pulse header file using the +[API token file procedure](API.md#api-token-recommended), with the admin access +needed for connection management. The Pulse token authenticates to Pulse; +the separate TrueNAS API key belongs in the connection's JSON file. Enter +credentials only in your local editor, never in arguments, URLs or a thread. + +On a trusted machine, as the account running curl, prepare the connection +file below. Stop if either file-preparation step fails. This preserves an +existing file, restricts the directory and refuses symlinked credential paths +or a non-regular connection file before opening the editor: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/truenas-connection.json" -chmod 600 "$HOME/.config/pulse/truenas-connection.json" -vi "$HOME/.config/pulse/truenas-connection.json" +( + set -eu + umask 077 + config_dir="$HOME/.config/pulse" + connection_file="$config_dir/truenas-connection.json" + if [ -L "$HOME/.config" ] || [ -L "$config_dir" ] || [ -L "$connection_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + if [ -e "$connection_file" ] && [ ! -f "$connection_file" ]; then + printf 'Connection file must be a regular file.\n' >&2 + exit 1 + fi + mkdir -p "$config_dir" + chmod 700 "$config_dir" + touch "$connection_file" + chmod 600 "$connection_file" + vi "$connection_file" +) ``` -In that file, enter a JSON object with `name`, `host` (the TrueNAS HTTPS URL), -`username` (the key owner) and `apiKey`. Keep both credential files private; -do not paste their contents into a terminal command or upload them. +In the editor, save a JSON object with `name`, `host` (the TrueNAS HTTPS URL), +`username` (the key owner) and `apiKey`. Keep both credential files private and +outside repositories and diagnostics. Do not upload them. -Test the connection before saving it: +The following examples run **on the Pulse host**, using its direct loopback +address. They ignore curl configuration, do not follow redirects or retry, +and retain each response in a new private local file. Responses and errors can +contain identifying details or echoed credentials; review them locally, not in +a public terminal recording or thread. Keep `--disable` first so local curl +configuration cannot enable tracing or change the request. + +Test once without saving: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections/test +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-test.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections/test) + printf 'HTTP %s\n' "$status" + [ "$status" = 200 ] +) ``` -After checking the test response, save the connection with: +Require exit 0, HTTP **200** and `success: true` in that private test response. +A redirect, HTTP error or `000` is not a successful test. Test success is not +inventory, metric or guest-thaw acceptance. + +Only after reviewing the test response, create the connection once: ```bash -curl --fail-with-body --silent --show-error \ - --header "@$HOME/.config/pulse/api-header" \ - --header 'Content-Type: application/json' \ - --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ - http://127.0.0.1:7655/api/truenas/connections +( + set -eu + umask 077 + result_file=$(mktemp "$HOME/.config/pulse/truenas-save.XXXXXX") + printf 'Private response file: %s\n' "$result_file" + status=$(curl --disable --fail-with-body --silent --show-error \ + --connect-timeout 5 --max-time 20 --proto '=http' --noproxy 127.0.0.1 \ + --header "@$HOME/.config/pulse/api-header" \ + --header 'Content-Type: application/json' \ + --data-binary "@$HOME/.config/pulse/truenas-connection.json" \ + --output "$result_file" --write-out '%{http_code}' \ + http://127.0.0.1:7655/api/truenas/connections) + printf 'HTTP %s\n' "$status" + [ "$status" = 201 ] +) ``` -These Pulse URLs are loopback-only examples. For a remote Pulse server, use -its trusted HTTPS origin without disabling certificate verification. +A successful save returns HTTP **201** and the new connection's `id`, not +proof of a complete poll. If saving times out or loses its response, it may +already have created the connection. Check **Platform connections** or +`/api/truenas/connections` in your existing signed-in Pulse browser before +trying again; do not blindly repeat POST and create duplicates. Continue with +the [ordinary polling checks](#stale-truenas-data) for collection acceptance. + +For remote Pulse access, replace the loopback origin in the relevant command +with your trusted **HTTPS** origin and change `--proto '=http'` to +`--proto '=https'`. Keep certificate verification enabled. For a private CA, +add `--cacert /path/to/trusted-ca.pem` using a CA obtained independently from +your administrator, not from the failing connection. Do not add `--insecure`, +`--location`, verbose/trace flags or automatic retries. Share only the relevant +HTTP status and a manually redacted error, never the full response or headers. ## Troubleshooting diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 6de5668272..a95395eed0 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -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 = (props) => { @@ -75,6 +78,7 @@ export const DiscoveryTab: Component = (props) => { liveElapsedSeconds, notesText, mutateDiscovery, + runBlockReason, saveError, scanError, scanProgress, @@ -90,6 +94,7 @@ export const DiscoveryTab: Component = (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 = (props) => { + + {(reason) => ( + + {reason()} + + )} + +
@@ -236,6 +254,7 @@ export const DiscoveryTab: Component = (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" > = (props) => { {/* Scan Error */} -
+