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/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 520e96d735..1a49ebcb1e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1118,6 +1118,18 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. renders only for a saved URL and adds no fetch, polling or per-row work. `GuestDrawer.test.tsx` verifies its target, the unsafe-URL warning, and that opening it does not collapse the drawer. + The workloads table header owns the bulk web-link editor. The trigger in + `WorkloadWebLinksAction.tsx` holds only the open flag and unsaved drafts + and lazy-loads `WorkloadWebLinksDialog.tsx`, whose state + (`useWorkloadWebLinksState.ts`) and derivations + (`workloadWebLinksModel.ts`) stay out of the WorkloadsSurface chunk and + run only while the panel is mounted. It edits the guests in the current + filtered view through the same guest metadata API and metadata-changed + event as the drawer field. `workloadGuestMetadataRecord.ts` is the single + lookup for a row's saved link in both the table and the editor. Panel rows + keep unchanged row objects across inventory ticks so an open editor never + remounts the input being typed in. + `WorkloadsSurface.performance.contract.test.tsx` pins that wiring. Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. A current metric may remain visible in a chart legend only when labelled `current`; it must never be expanded into synthetic timestamps or a flat @@ -1678,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 @@ -3659,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/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ccc9bf860a..890572cc5c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -171,6 +171,16 @@ backup files, never guest-local snapshots. Guest snapshots remain listed as restore evidence but cannot make an old independent backup look current. Archive detail uses its format (or filename fallback), preserving the full provider volid for hover and search in both Coverage and By date. +Coverage rows carry that one counted backup age at every width; per-source +PBS, PVE-file and guest-snapshot age columns are not row columns, because a +fresh snapshot beside a stale backup read as protection that posture does not +grant. The row expansion keeps the newest point and the newest completed point +of every source inside its bounded evidence list, so neither recent snapshots +nor a running or failed job hide the latest usable backup. The Backup health +strip states what its attention, unprotected and unknown counts mean from the +server posture policy (`freshnessWindowSeconds`) using the causes +`internal/recovery/posture.go` evaluates, and renders no rule until that policy +has loaded rather than a guessed window. Phone pool/disk rows preserve health and identity while using weighted widths, consistent gutters and compact capacity/health labels. Full disk risk reasons diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5b32979bee..d706acae89 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1293,6 +1293,14 @@ names remain inert identity text, while the separate accessible launch target owns new-tab behavior and event containment. Proxmox host table columns are governed by `proxmoxHostTableModel.ts`; that model must not reintroduce a separate `Web` column, a linked row name, or a page-local launch implementation. +When every node shares one cluster and none is standalone, the model drops +the `cluster` column (it would repeat one value on every row) and +`ProxmoxNodesTable` names that cluster in the table header instead, visible +during search and independent of the inventory-count preference, so the +cluster identity is never lost. Workload status buckets read the canonical +health too: a running workload whose unified health names an open +`warning_alert` or `critical_alert` reason counts under Attention, while +`backup_stale` and other non-alert reasons do not. The same presentation contract applies to Docker/Podman hosts and containers, standalone agents, PBS/PMG instances, Kubernetes clusters/nodes/workloads, and card or mobile variants. An absent URL is honestly absent, while an invalid or @@ -3291,7 +3299,9 @@ metadata. ### Protection posture identity consumer `ProxmoxCoverageTable` remains a unified-resource identity consumer while -storage/recovery owns protection truth. Live VM/LXC rows carry the exact +storage/recovery owns protection truth. Dropping the per-source age columns +from its rows changes no identity input: rows still key posture by the exact +canonical `Resource.id`. Live VM/LXC rows carry the exact canonical `Resource.id` into one bounded posture batch; the table must not parse its presentation key, VMID, name, node, or instance to mint a replacement resource identity. Host and orphaned backup artifacts have no canonical live 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-tests/operator-tables-consent.tsx b/frontend-modern/browser-tests/operator-tables-consent.tsx index 17b99932c1..e156be1fe7 100644 --- a/frontend-modern/browser-tests/operator-tables-consent.tsx +++ b/frontend-modern/browser-tests/operator-tables-consent.tsx @@ -319,9 +319,6 @@ function Fixture() { : new Set([...current, key]), ) } - showPbsColumn - showArchiveColumn - showSnapshotColumn showTaskColumn={false} />

By date

diff --git a/frontend-modern/browser-verification/04ae6f445fa38e25.json b/frontend-modern/browser-verification/04ae6f445fa38e25.json new file mode 100644 index 0000000000..6e40bd6347 --- /dev/null +++ b/frontend-modern/browser-verification/04ae6f445fa38e25.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "base_sha": "967e51701e215cba3522dbafa4b1b6ac8620d333", + "verified_at": "2026-10-05T00:14:02Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", + "frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts", + "frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "72db08228690ba571a285771ef81b2ef9be308794da2ff1cd09be612efbbd29b", + "frontend-modern/src/components/Workloads/WorkloadWebLinksAction.tsx": "9b57f62cf0de2196918f8faf9791e227fabaf960ad2ec3aa4302325379955e30", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "e779b8210c85326fda8bc37f7213384136766f93c80f06b6ac43eb8d0340f93c", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "42169c016a13fceb359fbd72b3284101769c27519aa749deb9c7a9fc72c12221", + "frontend-modern/src/components/Workloads/useWorkloadWebLinksState.ts": "8affd5f0798fa9c4af05cf1c763d1c3176200756a95e54294553f3b09969ac27", + "frontend-modern/src/components/Workloads/workloadGuestMetadataRecord.ts": "532af19b043255ebcc116faa3b2247ca828c552f2f00ad544e06a62524761099", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "bab44fddeb88d842c60eddcbef834dcddc602e37a9e92af2a773ff3b3cca071a" + }, + "routes": [ + "/proxmox" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Follow-up to the bulk web-link editor: the CI bundle budget failed with WorkloadsSurface at 71.44 kB gzip against a 70.23 kB max (main was 68.80 kB). After moving the panel, its state and row model into a lazily loaded chunk, a local production build measured WorkloadsSurface at 69.13 kB and the new WorkloadWebLinksDialog chunk at 3.9 kB gzip; check-bundle-size reported no violations.", + "Private mock stack from this tree (backend :7701, Vite :5221, 31 Proxmox guests): Edit links opened the lazily loaded panel with all 31 rows and '7 of 31 in this view have a link', focus on the close button.", + "A draft typed into grafana-agent-01 survived Escape closing the panel; focus returned to the trigger, which showed (unsaved); reopening restored the draft; editing checkout-web-01 away and back to its saved value did not count as a change (footer stayed at 1 unsaved change).", + "Submitting saved grafana-agent-01 through PUT /api/guests/metadata, closed the panel, cleared the (unsaved) marker and the table row gained its link without reload.", + "Phone 375x812: full-width sheet, 44px inputs, no horizontal overflow; an invalid entry ('redis admin') showed the shared validator's message under its row with aria-invalid and focus. This is not physical-device, vSphere-estate or release acceptance." + ], + "interactions": [ + "Clicked Edit links at 1280 and 375 widths; typed into inputs across live inventory ticks; pressed Escape; reopened; submitted the form.", + "Submitted an invalid link on phone width; the shared validator's error appeared under that row, aria-invalid was set and focus moved to the field." + ] +} 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/162c35dc6e7baed5.json b/frontend-modern/browser-verification/162c35dc6e7baed5.json new file mode 100644 index 0000000000..509e8e1f59 --- /dev/null +++ b/frontend-modern/browser-verification/162c35dc6e7baed5.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded", + "verified_at": "2026-10-05T00:16:51Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/workloadSelectors.ts", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "d1d60dfc1fca8e2cf94c0870ae348ad005e527656e57bac84b304151a87a808b", + "frontend-modern/src/components/Workloads/workloadSelectors.ts": "cc59933a4f75515ce1ab075e737fdd26d2523d526455dbd7765d85ce8d8b3fd0", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "9a111deb348d54c2c778595d770d6bf25752067214cec6e4d0cc363cdbd0d321", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "0088db54f683bfb8c5603480f240c05a930248442cb08c6a344db9363d62c08d", + "frontend-modern/src/hooks/useWorkloads.ts": "b662ce635aad385ab8e18334874fcc16f324566295d2d2eedfdfb8684c367d5f", + "frontend-modern/src/types/workloads.ts": "924235c100c8d78a9546cd566e2d1cf306bf92415f649aa29e0364832d445ca5" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/overview?status=degraded", + "/docker/overview", + "/vmware/overview", + "/kubernetes/workloads" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Mock estate with the guest memory threshold lowered to 60% on the private stack so seven running guests carried warning alerts. Before (changes reverted in place): chips read Running 60 / Attention 0 / Stopped 5. After: Running 53 / Attention 7; ?status=degraded lists exactly the seven alerting guests.", + "Nodes table: the Cluster column that repeated Production West on every row is gone and the header reads Cluster Production West; it stays visible while searching (Nodes 2 of 5).", + "VM disk Unavailable label renders muted slate with the reason as a title tooltip for vm-stopped; permission-denied and agent-error stay amber (component test).", + "Other platform chips checked after the change: Docker Healthy 27 / Degraded 10 / Offline 2, vSphere Attention 0, Kubernetes workloads Degraded 2, TrueNAS VMs Attention 0, Machines Degraded 0." + ], + "interactions": [ + "Clicked the Attention chip and read the filtered rows; typed a search into the guest search box and confirmed the cluster label; read the disk status title attribute and computed colour." + ] +} 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/743cc04a20d534ac.json b/frontend-modern/browser-verification/743cc04a20d534ac.json new file mode 100644 index 0000000000..76e0f3baaf --- /dev/null +++ b/frontend-modern/browser-verification/743cc04a20d534ac.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded", + "verified_at": "2026-10-04T23:36:36Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "f46e070a2fad03d7fe30103a8f5a9a6412604a810b3b4b65c56071baeaf6198c", + "frontend-modern/src/components/Workloads/WorkloadWebLinksDialog.tsx": "85af2231815206c30c34671af4b358cb9e4243df4a1fa54e7f9968491cc668fa", + "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "ea5b51e677ccb2a8deaeb51e18068ef7c4dd4a956f0004b4afb373e67708985a", + "frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "d7d54e85014320bf46a3a19fd0ac47d12de3341580bd368643a9a0edc56232bc", + "frontend-modern/src/components/Workloads/workloadWebLinksModel.ts": "cec219e47e3bf46487fccdac46535cb6ae0301b5e3e165970d0bf3946f9179e8" + }, + "routes": [ + "/proxmox", + "/proxmox?agent=Production+West-pve2" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 1024, + "height": 768 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack built from this tree (backend :7701, Vite :5221, 3 nodes, 31 Proxmox guests) with five guests already carrying saved web-interface URLs.", + "Guests table header shows an 'Edit links' action beside the guest count at 1280, 1024 and 375 widths without horizontal overflow; it opens a right-hand panel listing all 31 guests in the filtered view, name-sorted, each with type, ID and node, the saved URL or an IP-based placeholder, and a label tied to its input.", + "Node-scoped view (Show guests on West Production B) limits the editor to that node's 8 guests; the 'Without a link' filter shows the 25 guests with no saved link, including one whose link was just removed.", + "Phone 375x812: full-height sheet, name above a 44px input, footer pinned, no horizontal overflow (dark theme). Light theme checked at 1280.", + "Invalid entry 'auth14 admin page' blocked the whole save with the shared validator's message on that row and aria-invalid; native browser URL validation is disabled so Pulse's message shows. GET /api/guests/metadata confirmed nothing was written.", + "Valid save of one addition, one correction and one removal wrote all three through PUT /api/guests/metadata, showed 'Saved 3 links.', closed the panel, returned focus to the trigger, and the table link icons updated without reload. This is not physical-device, vSphere-estate or release acceptance.", + "Re-checked after renaming the row-reuse helper (identifier only): the editor opened with 31 rows, a draft survived three seconds of live ticks, and submitting saved docs-wiki-01 through PUT /api/guests/metadata and closed the panel." + ], + "interactions": [ + "Clicked Edit links; typed into three inputs over several live inventory ticks without losing focus or values; footer counted 3 unsaved changes and the trigger showed (unsaved).", + "Pressed Enter to submit with one invalid value, corrected it, pressed Enter again; toggled All / Without a link; closed via the header button and reopened.", + "Selected a node's guests from the nodes table, then reopened the editor to confirm scope follows the table filter." + ] +} diff --git a/frontend-modern/browser-verification/b7a057ac9a6f4dd8.json b/frontend-modern/browser-verification/b7a057ac9a6f4dd8.json new file mode 100644 index 0000000000..e2ec2c423c --- /dev/null +++ b/frontend-modern/browser-verification/b7a057ac9a6f4dd8.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "base_sha": "53e2d27ab420c505f54694b0ccbb7d843ff6eded", + "verified_at": "2026-10-05T00:02:30Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx": "b13db44a0fea2de8605bb79a1d1fd011f5023e9c8cba44f465c305eeac4f00b5" + }, + "routes": [ + "/proxmox/overview", + "/docker/overview", + "/kubernetes/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 700 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Mock estate with five Proxmox hosts, three Docker hosts and three Kubernetes nodes on an older agent. Before: a three-line amber warning listing three names plus \"and 2 more\", a Show all button and an action row above every platform tab.", + "After at 1440: one info-tone line on Proxmox (5 hosts run an older Pulse agent. Update them to v4.26.0 for the latest agent-contributed Proxmox node detail and command support. Which hosts? Open agent upgrade commands) with the node table directly below.", + "Phone 390x844 Kubernetes: the missing-data sentence, Which nodes? toggle and upgrade link wrap within the notice above the clusters table." + ], + "interactions": [ + "Loaded each route signed in and inspected screenshots; the component test exercises the Which hosts? toggle expanding and collapsing the affected names and checks the inline link href." + ] +} 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/browser-verification/dbe3b19ac8e27fa0.json b/frontend-modern/browser-verification/dbe3b19ac8e27fa0.json new file mode 100644 index 0000000000..7f3b64628b --- /dev/null +++ b/frontend-modern/browser-verification/dbe3b19ac8e27fa0.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "4c266391c3753eaa1e6bea1cfd8339c5d40c01de", + "verified_at": "2026-10-05T01:10:06Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx": "97d3f7a23a959406dd8cb9e92a8921fd38118194d5f757e31b6e0e2eec74f27f", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "2cd4cfb0c470ba51efcdb0beafef0b630acac34373b92dd9b77de03f99b16743", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "2568f261531b4b601df7da9e1b8c1826bacc70e8a28420bbe909f2eb3524c6eb", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableModel.ts": "8f7f8a80bb994404c21cce7b75c621bdf844f80b38a9def79d6dc61fb42b8dd6", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "55dd4787cd47e9ccbf056ba67809fd45638b1e3f21889c6a31ce192ce87b8e67" + }, + "routes": [ + "/proxmox/backups" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Coverage workspace, mock fleet of 68 targets: Backup health strip footnote explains attention, unprotected and unknown with the 7-day window from the server policy", + "Coverage table at 1440: Workload / Type / Target ID / Node / Posture / Last backup / Task, no PBS snapshot, PVE file or Guest snapshot columns", + "Coverage table at 390: Workload / Posture / Age / Job, footnote wraps under the legend without a reserved column", + "Row expansion for a Needs attention guest with a failed newer job: posture explanation, Provider evidence and Restore evidence listing PVE file and Snapshot sources", + "No console errors on either viewport" + ], + "interactions": [ + "Expanded and collapsed coverage rows to inspect provider and restore evidence", + "Compared before/after screenshots at both viewports" + ] +} 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 */} -
+