diff --git a/.github/workflows/unified-agent-native.yml b/.github/workflows/unified-agent-native.yml
index ca020f2c39..ec5fa82dc2 100644
--- a/.github/workflows/unified-agent-native.yml
+++ b/.github/workflows/unified-agent-native.yml
@@ -84,6 +84,26 @@ jobs:
go-version-file: go.mod
cache: true
+ - name: Supply GNU timeout for macOS guidance checks
+ if: runner.os == 'macOS'
+ shell: bash
+ run: |
+ set -euo pipefail
+ brew install coreutils
+ gnu_timeout="$(brew --prefix coreutils)/bin/gtimeout"
+ test -x "$gnu_timeout"
+ "$gnu_timeout" --version | grep -q 'GNU coreutils'
+ timeout_bin="$(mktemp -d "$RUNNER_TEMP/pulse-guidance-tools.XXXXXX")"
+ ln -s "$gnu_timeout" "$timeout_bin/timeout"
+ printf '%s\n' "$timeout_bin" >> "$GITHUB_PATH"
+
+ - name: Verify native Unix timeout prerequisite
+ if: matrix.unix
+ shell: bash
+ run: |
+ set -euo pipefail
+ timeout --version | grep -q 'GNU coreutils'
+
- name: Test native agent runtime
run: >-
go test
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index ac46ce3f9e..e35f9f2cf1 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -123,6 +123,57 @@ services:
---
+## Container CPU readings
+
+Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
+capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
+normally use **100% for one logical CPU**, so their readings can exceed 100%.
+For that scale, divide the stats percentage by the CPU count reported for the
+runtime host to compare it with Pulse.
+
+If the engine runs inside a VM, use that VM's logical CPUs, not the physical
+Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
+own CPU quota or cpuset. If the reported CPU count differs from your configured
+VM size, retain both in the comparison rather than guessing a divisor.
+
+| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
+| --- | --- | --- |
+| 4 | 240% | 60% |
+| 6 | 16.52% | About 2.75% |
+| 6 | 1.04% | About 0.17% |
+
+These are examples, not an assumption about your host. A smaller Pulse value
+alone does not establish a collection fault. Whole-percent table labels,
+including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
+as 0%. Use **CPU History** rather than the rounded label; display rounding does
+not change stored readings or the values used by CPU alert evaluation. A dash
+or missing History is unavailable data, not a measured zero.
+
+For a useful comparison:
+
+1. Match the same container on the same runtime host. Compare a similar time
+ interval in CPU History with `docker stats` or `podman stats`; separate
+ sampling intervals can legitimately differ.
+2. Apply the reported host CPU count only to a stats reading on the per-CPU
+ scale. Do not divide Pulse History again, or compare with an average since
+ container start as though it were a recent interval.
+3. Check that observation times and History points advance during ordinary
+ monitoring. A connection marked Active does not prove fresh CPU samples.
+4. If the normalised readings still disagree, or History stops advancing,
+ report the server and agent versions, reported CPU count, sample times and
+ bounded readings. Do not restart workloads, create CPU load, lower alert
+ thresholds or export diagnostics just to make this comparison.
+
+CPU alerts use the same host-capacity scale, with the configured thresholds,
+duration and suppression rules. Non-zero History proves a collected reading,
+not that an alert should fire or that its notification was delivered. If a
+normal workload already demonstrates an alert problem, retain the reading,
+threshold, duration and observed result; no forced alert test is needed. See
+[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
+when measuring the Pulse server itself rather than a monitored container.
+
+---
+
## 🔄 Updates
These steps update the Pulse server, not the containers it monitors. Record the
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index dd9e0a54ef..213b11b17d 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
setup change during a maintenance window.
+#### Container CPU differs from Docker or Podman stats
+
+Pulse uses the runtime host's total CPU capacity; `docker stats` and
+`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
+by the **reported runtime host CPU count**, not the physical Proxmox host's
+count when the engine runs inside a VM. Compare the same container and time
+interval in **CPU History**, not just a rounded table label. A dash or missing
+History is unavailable, not zero; an Active connection alone is not fresh CPU
+evidence.
+
+See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
+examples, rounding and alert limits, and a passive comparison. Do not restart
+workloads, create CPU load or lower alert thresholds to test this.
+
#### Docker hosts appearing/disappearing
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
index 469e7d2737..0e9db6c03d 100644
--- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
+++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md
@@ -7520,13 +7520,26 @@ coarse activity class (`list`, `export`, `verify`, `summary`); the handler runs
unchanged whether or not recording succeeds, and a router without persistence
serves the request rather than failing it
(`TestWithAuditReadActivity_NilPersistenceIsSafe`).
-### Monitor shutdown drains queued guest metadata writes
+### Monitor shutdown seals guest metadata admission and retains incomplete teardown
+
+`Monitor.Stop` seals guest identity/metadata mutations and waits under its
+unchanged two-second metadata budget. One store-owned snapshot writer coalesces
+changes admitted during filesystem I/O; an older queued identity cannot overwrite
+an operator edit or reverse a newer accepted identity. `Close` reports both an
+incomplete drain and a final persistence error, while the public void Stop logs
+failure rather than claiming a successful stop.
+
+Tenant removal must first observe its monitoring loop exit and then a successful
+metadata close. Failure retains the monitor, cancellation/done handles and
+lifecycle guard: lazy recreation cannot start a second writer in the same
+directory. `BeginTenantDeletion` returns that failure to the organisation
+handler, which keeps persistence and offboarding callbacks untouched. Only
+successful shutdown allows directory deletion. A later explicit shutdown attempt
+may persist retained metadata after a completed write failure is repaired;
+admission stays sealed and no in-flight attempt is replayed. Other tenants keep their existing
+independent runtime. Store drain does not establish independent guest thaw,
+workload liveness or containment in installed software.
-`Monitor.Stop` waits for in-flight `GuestMetadataStore` writes before closing
-the metrics store, so a tenant monitor that has been stopped is guaranteed not
-to write into its data directory afterwards. Tenant offboarding and any caller
-that removes a tenant directory can rely on `Stop` having quiesced disk writes,
-rather than racing a detached goroutine.
### Per-tenant resource stores are released on offboarding and shutdown
`ResourceHandlers.getStore` opens a SQLite handle per org and caches it for the
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 4cc867961f..1b1ee43136 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -20,6 +20,20 @@
## Purpose
+### Organisation deletion retains data after incomplete monitoring shutdown
+
+Authenticated organisation-owner deletion waits for tenant-loop exit and sealed
+guest metadata persistence before removing the directory or calling offboarding.
+An incomplete drain or final write failure returns HTTP 503 with the existing
+error envelope and `tenant_shutdown_incomplete`; the message says the data was
+retained and exposes no filesystem path or underlying error. The runtime owner
+and recreation guard stay retained. Default-organisation immutability, session
+and membership checks, successful HTTP 204 and other tenants' access are
+unchanged. `TestOrgDeleteRetainsDataWhenGuestMetadataPersistenceFails` exercises
+a real confined rename failure through the ordinary handler and metadata store;
+existing successful deletion and authorization controls remain required.
+
+
### Optional guest-memory observation reaches existing frontend consumers
Frontend Memory and ResourceMetric model the existing optional server-owned
@@ -11207,3 +11221,20 @@ Pinning the Patrol attention detail header on phones
layout-only change. It reads no new attention fields, sends no new request,
and leaves the attention projection, lifecycle, and queue ordering contracts
above unchanged.
+
+### Router-owned worker shutdown
+
+Each Router owns its five route-specific limiters, eight endpoint-category
+limiters and, in hosted mode, its tenant limiter. `ShutdownBackgroundWorkers`
+seals lifecycle-worker admission, cancels and joins the constructor-owned
+workers exactly once; the server's existing `ShutdownAIIntelligence` calls
+that same lifecycle. HTTP admission must stop before shutdown. Resource and
+RBAC stores retain their separate shutdown owners. Integration fixtures use
+this lifecycle before stopping the monitor and removing temporary data.
+
+`RateLimiter.Stop` is concurrent-safe and joins its cleanup worker. Stopping
+cleanup does not clear request history, bypass rate enforcement or stop another
+Router's or the package-global limiters. Limits, windows, tenant selection,
+revocation and response payloads are unchanged. The connected lifetime and
+ownership controls are in `router_limiter_lifecycle_test.go`; the integration
+fixture's cleanup also runs on a failed listener/request assertion.
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 48bbc86ac5..0e47f9e058 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -17,6 +17,36 @@
## Purpose
+### Fresh broadcast projection owns one resource/target capture
+
+Canonical live, fallback and standalone-host continuity broadcast reads prefer
+the adapter's detached bulk resource/metrics-target capture. After host
+coalescing selects presentation identity, missing history targets are attached
+from that same capture, including authoritative absence. Stores without this
+optional capability retain the previous point-resolver fallback. Only the owned
+outer coalesced slice is decorated in place; nested source payloads stay read-only.
+
+Every read still observes complete current resource data and derives live alert
+health, metadata URL edits/clears and connected/ignored infrastructure. No
+timestamp-only cache, telemetry omission, policy change or new wire field is
+introduced. All existing catalogs, discovery/history coordinates, original
+observation times, LastSeen, identity and tenant/revocation boundaries remain.
+
+`broadcast_metrics_snapshot_test.go` compares the complete served JSON with an
+independent earlier projection across the metadata-heavy nine-provider demo,
+typed-view and same-time dirty cases; it also covers captured target replacement
+and continuity/metadata/live-alert boundaries. `monitor_host_agents_test.go`
+retains its one-materialization and live-change regression. Benchmarks include
+the exact parent's complete conversion and final JSON encoding, with cold,
+list-only, typed-clean and dirty inputs; they do not establish installed CPU/RSS
+relief, native identity refresh throughput or completion of #2199.
+
+Complete-content comparisons use one health-evaluation instant through a
+private clock seam. Production still samples real UTC at the same post-read
+boundary. The minute-boundary control requires age labels to advance without
+renewing LastSeen; comparisons retain every health field instead of stripping
+the reproduced `1m`/`2m` clock difference from JSON.
+
### Report identity lookups do not rebuild unrelated Kubernetes metadata
The producer's two temporary host/Docker-host admission registries use a
@@ -4581,27 +4611,45 @@ host dataset evidence is copied onto the matching provider-owned ZFS pool;
provider health, scan, device, and error fields remain authoritative. When the
provider cannot return pool detail, monitoring may synthesize only a minimal
`UNKNOWN` pool so valid dataset evidence is still inspectable.
-### Guest metadata writes are owned by the store and drained on shutdown
+### Guest identity admission coalesces owned JSON snapshots
-`persistGuestIdentity` no longer detaches its own goroutine per changed guest.
-It calls `GuestMetadataStore.SetAsync`, which tracks the write on a WaitGroup
-so `GuestMetadataStore.WaitForPendingWrites` can drain it. `Monitor.Stop` drains
-before closing the metrics store, under a bounded timeout matching
-`tenantMonitorShutdownTimeout` so a wedged store cannot hold up tenant teardown.
+`persistGuestIdentity` calls `GuestMetadataStore.RememberIdentity`, which merges
+only the last-known name/type under the store lock, preserves operator URLs,
+descriptions, tags and notes, and never downgrades OCI. Admission is visible
+before return. `SetAsync` deep-copies its full replacement at admission as well;
+caller mutation and goroutine scheduling cannot reverse accepted update order.
-Untracked writes were observable, not theoretical: a queued write could land
-after the monitor stopped and after a tenant directory was being removed,
-leaving a stray `guest_metadata.json.tmp` from the interrupted atomic write.
-That is what made `TestHostedTenantAgentInstallTokenCannotReportToOtherTenant`
-fail its `t.TempDir` cleanup with "directory not empty".
-`TestGuestMetadataStore_WaitForPendingWritesDrainsQueuedWrites` and
-`TestGuestMetadataStore_DataDirIsRemovableAfterDrain` pin the drain and fail if
-`SetAsync` stops tracking its goroutine.
+One lazy store-owned writer snapshots the complete metadata map. Filesystem I/O
+does not hold the memory lock, so changes admitted during that I/O are coalesced
+into the next complete snapshot. A separate write mutex serializes it with
+synchronous Set/Delete/ReplaceAll/UpdateAll and legacy migration; an older
+snapshot cannot resurrect a deleted or migrated entry or overwrite an operator
+edit. The existing tenant-local JSON shape, private file permissions and atomic
+temporary-file/rename path stay unchanged. A failed write is reported and not
+spin-retried; a later ordinary identity poll can retry the unpersisted state.
-Known and deliberately unchanged: each changed guest still triggers a full-file
-save, so one poll cycle over N changed guests performs N marshals and N atomic
-writes that serialize on the store mutex. Coalescing them is a behavioural
-change beyond the shutdown defect.
+`WaitForPendingWrites` reports quiescence, not durable success. `Close` seals
+mutation admission and observes the background writer and any admitted
+synchronous write within the unchanged caller budget. Timeout or final
+persistence failure remains an error. Monitor Stop logs that failure instead of
+claiming a clean stop; tenant removal retains the runtime owner and lifecycle
+guard, and organisation deletion refuses to remove its directory. An unfinished
+monitoring loop likewise prevents deletion. Shutdown does not cancel an admitted
+filesystem write or silently discard metadata to obtain a timely result. A later
+explicit close may retry a completed failed snapshot after filesystem repair;
+admission stays sealed, successful close is idempotent, and no close retries
+itself or overlaps a still-running attempt.
+
+`internal/config/guest_writer_control_test.go` exercises blocked snapshots at
+25/100 updates and caller-copy ownership. `guest_metadata_writer_test.go` covers
+ordered replacement, synchronous mutations, field-scoped identity, failure and
+explicit recovery, bounded close with blocked synchronous/asynchronous I/O,
+closed admission, real filesystem permissions and tenant separation. Existing
+ten-second drain and cleanup controls remain unchanged. Monitoring shutdown
+controls retain blocked writer/loop ownership; the real organisation-delete
+handler returns its safe failure before offboarding or directory removal.
+These are source controls, not an attribution of an earlier drain timeout,
+native CPU/RSS relief or all-path guest-agent recovery.
### Monitoring projects canonical resource policy into alert evaluation
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 e2c9654529..8610516f3c 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -3744,3 +3744,26 @@ 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.
+
+
+### Accessible Workloads backup evidence
+
+Both the Backup column badge and its hidden-column name indicator are native
+read-only disclosure buttons. Keyboard activation and an ordinary first touch
+open the same named-guest completion evidence that fine-pointer hover shows.
+Exact time and age use readable theme text rather than colour alone. The shared
+Dialog owns focus, Escape/backdrop dismissal and background isolation, and
+returns focus to the originating disclosure. Nested evidence activation must
+not trigger the enclosing guest row. Touch targets are at least 44px; compact
+desktop shields and the existing status colours remain.
+
+The evidence content is reactive to the same guest identity: running is not
+completion, malformed/future times are unknown, and only the existing absence
+sentinels mean no backup recorded. A removed row unmounts its disclosure and
+restores dialog isolation. Opening details adds no API/history read, collector,
+permission, backup/guest operation or polling timer. Dialog machinery and
+content are mounted only when opened; existing freshness thresholds and source
+projection are unchanged. `GuestRow.test.tsx` covers both nested row paths and
+`BackupStatusAccess.test.tsx` covers semantics, focus and observation changes.
+Browser proof exercises production rows with synthetic data, not native backup
+or guest-recovery acceptance.
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index f16a46ca9c..15c3f338e7 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -23,6 +23,29 @@ and sort the complete canonical change table while startup and ingestion wait.
## Purpose
+### Broadcast bulk reads capture resources and history coordinates together
+
+`ListWithMetricsTargets` and the monitor adapter's matching optional read return
+detached resources and a detached target map from one locked registry generation.
+The existing source-mapping inversion runs once for this read when typed views
+are dirty; it does not build all typed views or add a retained generation cache.
+Clean typed views may supply their already-current inverse mapping. Legacy List,
+GetAll and individual target reads preserve their shapes and semantics.
+
+The target map stays separate from resources until presentation host coalescing
+selects the row identity. Missing coordinates remain missing in that capture;
+they never fall through to a later live registry. Mapping changes with unchanged
+LastSeen take effect on the next read. Returned resources, policy/identity blocks
+and target values remain caller-owned across mutations and adapter replacement.
+Projection cannot pair an earlier resource row with a later history identity.
+
+`registry_test.go`, `registry_projection_test.go` and
+`monitor_adapter_read_state_test.go` exercise full resource/point-target parity,
+same-time edits, concurrent mapping writes, detachments, empty/nil reads and
+tenant separation. Connected monitoring proof covers all nine demo providers,
+continuity overlays and complete served JSON. Paired cold/clean/dirty benchmarks
+measure whole materialization, not a new point-lookup envelope or native relief.
+
Canonical resource History honours selected memory observation provenance and Proxmox VM filesystem deferrals instead of treating numeric carriers as fresh measurements.
### Canonical resource History cannot promote retained guest readings
diff --git a/frontend-modern/browser-tests/backup-details-access.cjs b/frontend-modern/browser-tests/backup-details-access.cjs
new file mode 100644
index 0000000000..0fef9ecc26
--- /dev/null
+++ b/frontend-modern/browser-tests/backup-details-access.cjs
@@ -0,0 +1,359 @@
+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';
+const frontend = path.join(root, 'frontend-modern');
+const output = path.join(root, 'tmp/backup-details-access/browser');
+const origin = 'http://127.0.0.1:5298';
+const runtime = [
+ 'frontend-modern/src/components/Workloads/GuestRowCells.tsx',
+ 'frontend-modern/src/components/Workloads/GuestRow.tsx',
+];
+const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const version = require('playwright/package.json').version;
+ assert.equal(
+ version,
+ JSON.parse(fs.readFileSync(path.join(root, 'tests/integration/package-lock.json'))).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const hashes = Object.fromEntries(
+ runtime.map((source) => [source, hash(path.join(root, source))]),
+ );
+ process.chdir(frontend);
+ const { createServer } = await import(
+ path.join(frontend, 'node_modules/vite/dist/node/index.js')
+ );
+ const server = await createServer({
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5298, strictPort: true },
+ });
+ const results = [];
+ const captures = [];
+ const phases = [];
+ const started = Date.now();
+ const mark = (stage) => {
+ const fact = {
+ stage,
+ elapsedMs: Date.now() - started,
+ memory: process.memoryUsage(),
+ resources: process.resourceUsage(),
+ };
+ phases.push(fact);
+ console.log(JSON.stringify({ browserPhase: fact }));
+ };
+ let browser;
+ let phase = 'server';
+ try {
+ mark('listen-start');
+ await server.listen();
+ mark('listen-complete');
+ // Supported Vite transform warmup, not a production build or app execution.
+ // Keep page navigation and the tool deadline unchanged; collect phases.
+ await server.warmupRequest('/browser-tests/backup-details-access.tsx');
+ await server.warmupRequest('/src/index.css');
+ mark('entry-css-warmup-complete');
+ for (const [name, engine, phone, dark] of [
+ ['desktop-light', chromium, false, false],
+ ['desktop-dark', chromium, false, true],
+ ['phone-light', webkit, true, false],
+ ['phone-dark', webkit, true, true],
+ ]) {
+ phase = name;
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const page = await browser.newPage({
+ viewport: { width: phone ? 390 : 1365, height: phone ? 844 : 900 },
+ isMobile: phone,
+ hasTouch: phone,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ page.setDefaultTimeout(12000);
+ page.setDefaultNavigationTimeout(45000);
+ await page.clock.setFixedTime(new Date('2026-10-05T08:00:00Z'));
+ const errors = [];
+ const operations = [];
+ const reads = [];
+ const checks = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.route('**/*', (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ assert.equal(url.origin, origin, 'no off-origin requests');
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ if (request.method() !== 'GET') operations.push(`${request.method()} ${url.pathname}`);
+ reads.push(url.pathname);
+ return route.fulfill({
+ json: { success: true, data: [], alerts: [], config: null, anomalies: [] },
+ });
+ });
+ if (dark)
+ await page.addInitScript(() =>
+ document.addEventListener('DOMContentLoaded', () =>
+ document.documentElement.classList.add('dark'),
+ ),
+ );
+ mark(`${name}:navigate-start`);
+ await page.goto(`${origin}/browser-tests/backup-details-access.html`, {
+ waitUntil: 'domcontentloaded',
+ });
+ mark(`${name}:domcontentloaded`);
+ const row = page.getByRole('region', { name: 'backup-vm row' });
+ const badge = row.getByRole('button', { name: /^Backup status:/ });
+ await badge.waitFor();
+ const identity = await page.evaluate(() => window.__backupDetails.identity());
+ const rowActionCount = async () =>
+ Number(await page.getByRole('status', { name: 'Row action count' }).innerText());
+ const activate = async (el) => (phone ? el.tap() : el.click());
+ const capture = async (suffix) => {
+ const file = path.join(output, `${name}-${suffix}.png`);
+ await page.screenshot({ path: file, fullPage: true });
+ captures.push({
+ path: path.relative(root, file),
+ bytes: fs.statSync(file).size,
+ sha256: hash(file),
+ });
+ };
+ const dialog = page.getByRole('dialog', { name: /^Backup status details for / });
+ const close = page.getByRole('button', { name: 'Close backup details' });
+ const checkOpen = async (trigger) => {
+ await dialog.waitFor({ state: 'visible' });
+ assert.equal(await trigger.getAttribute('aria-expanded'), 'true');
+ assert.equal(await rowActionCount(), 0, 'backup action must not open the guest row');
+ await close.waitFor();
+ await page.waitForFunction(
+ () => document.activeElement?.getAttribute('aria-label') === 'Close backup details',
+ );
+ // DOM visibility can precede the CSS entrance animation. Verify and
+ // capture the final user-visible panel, not its initial off-screen frame.
+ await dialog.evaluate(async (el) => {
+ await Promise.all(
+ el
+ .getAnimations({ subtree: true })
+ .filter((a) => Number.isFinite(a.effect?.getTiming().iterations))
+ .map((a) => a.finished.catch(() => {})),
+ );
+ });
+ const rect = await dialog.boundingBox();
+ assert(
+ rect.x >= 0 && rect.x + rect.width <= (phone ? 390 : 1365),
+ 'dialog stays within viewport',
+ );
+ assert(
+ rect.y >= 0 && rect.y + rect.height <= (phone ? 844 : 900),
+ 'settled panel stays vertically in view',
+ );
+ assert(
+ await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
+ 'background isolated',
+ );
+ };
+ const checkClosed = async (trigger) => {
+ await dialog.waitFor({ state: 'hidden' });
+ assert.equal(await trigger.getAttribute('aria-expanded'), 'false');
+ assert(
+ await trigger.evaluate((el) => document.activeElement === el),
+ 'focus returns to source evidence',
+ );
+ assert.equal(
+ await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
+ false,
+ );
+ };
+ assert.equal(await badge.getAttribute('aria-haspopup'), 'dialog');
+ assert.equal((await badge.innerText()).trim(), '', 'fresh shield remains compact');
+ if (!phone) {
+ await badge.hover();
+ const tooltip = page.locator('[data-tooltip-portal="true"]');
+ await tooltip.waitFor({ state: 'visible' });
+ assert((await tooltip.innerText()).includes('Last completed backup'));
+ await page.getByRole('heading').hover();
+ // Genuine sequential keyboard traversal, not synthetic click/focus.
+ await page.keyboard.press('Tab'); // column control
+ await page.keyboard.press('Tab'); // guest disclosure
+ await page.keyboard.press('Tab'); // backup disclosure
+ assert(await badge.evaluate((el) => document.activeElement === el));
+ await page.keyboard.press('Enter');
+ checks.push('desktop hover preserved and sequential Tab/Enter opens actual badge');
+ } else {
+ const rect = await badge.boundingBox();
+ assert(rect.width >= 44 && rect.height >= 44, 'touch disclosure is at least 44px');
+ await activate(badge);
+ assert.equal(
+ await page.locator('[data-tooltip-portal="true"]').count(),
+ 0,
+ 'no synthetic hover overlay',
+ );
+ checks.push('ordinary first tap opens 44px target, without hover UI');
+ }
+ await checkOpen(badge);
+ assert((await dialog.innerText()).includes('backup-vm'));
+ assert((await dialog.innerText()).includes('Mon, 5 Oct 2026'));
+ assert((await dialog.innerText()).includes('06:00:00'));
+ assert((await dialog.innerText()).includes('2 hours ago'));
+ await capture('fresh-completed');
+ await page.keyboard.press('Tab');
+ assert(
+ await close.evaluate((el) => document.activeElement === el),
+ 'one-action dialog retains focus',
+ );
+ await page.evaluate(() =>
+ window.__backupDetails.update({ timestamp: '2026-10-05T06:00:00Z', running: true }),
+ );
+ assert((await dialog.innerText()).includes('Backup running now'));
+ assert((await dialog.innerText()).includes('Last completed backup'));
+ assert.equal(await page.evaluate(() => window.__backupDetails.identity()), identity);
+ checks.push('same identity updates running while preserving prior completed observation');
+ await capture('running-completed');
+ await page.evaluate(() =>
+ window.__backupDetails.update({ timestamp: '2026-10-05T08:01:00Z', running: true }),
+ );
+ await dialog.getByText(/timestamp is in the future/).waitFor();
+ assert(!(await dialog.innerText()).includes('Last completed backup'));
+ assert(!(await dialog.innerText()).includes('No backup has ever'));
+ await capture('future-running');
+ await page.keyboard.press('Escape');
+ await checkClosed(badge);
+ if (!phone) {
+ await page.keyboard.press('Space');
+ await checkOpen(badge);
+ assert((await dialog.innerText()).includes('timestamp is in the future'));
+ await page.keyboard.press('Escape');
+ await checkClosed(badge);
+ checks.push('native Space reopens current evidence and Escape restores focus');
+ }
+ await page.getByRole('button', { name: 'Hide Backup column' }).click();
+ const indicator = row.getByRole('button', { name: /^Backup running now/ });
+ await activate(indicator);
+ await checkOpen(indicator);
+ assert((await dialog.innerText()).includes('timestamp is in the future'));
+ checks.push('hidden-column indicator exposes exactly the same uncertainty');
+ await page.evaluate(() =>
+ window.__backupDetails.update({ timestamp: 'bad-time', running: false }),
+ );
+ await dialog.getByText(/invalid timestamp/).waitFor();
+ await page.evaluate(() => window.__backupDetails.update({ timestamp: '', running: false }));
+ await dialog
+ .getByText('No backup has ever been recorded for this guest.', { exact: true })
+ .waitFor();
+ assert(!(await dialog.innerText()).includes('Invalid Date'));
+ assert(!(await dialog.innerText()).includes('NaN'));
+ checks.push('malformed then absence retain distinct truthful states');
+ await activate(close);
+ await checkClosed(row.getByRole('button', { name: 'No backup found', exact: true }));
+ const ct = page
+ .getByRole('region', { name: 'backup-ct row' })
+ .getByRole('button', { name: 'No backup found', exact: true });
+ await activate(ct);
+ await checkOpen(ct);
+ assert((await dialog.innerText()).includes('backup-ct'));
+ assert(
+ !(await dialog.innerText()).includes('2 hours ago'),
+ 'adjacent guest never inherits another observation',
+ );
+ await capture('ct-missing');
+ await page.locator('[data-dialog-backdrop]').click({ position: { x: 3, y: 3 } });
+ await checkClosed(ct);
+ await page.evaluate(() =>
+ window.__backupDetails.update({ timestamp: '2026-10-05T06:00:00Z', running: false }),
+ );
+ const recovered = row.getByRole('button', { name: /^Last backup:/ });
+ await activate(recovered);
+ await checkOpen(recovered);
+ assert((await dialog.innerText()).includes('2 hours ago'));
+ await page.evaluate(() => window.__backupDetails.remove());
+ await dialog.waitFor({ state: 'hidden' });
+ assert.equal(await page.locator('[data-dialog-layer]').count(), 0);
+ assert.equal(
+ await page.locator('main').evaluate((el) => el.closest('body > *').hasAttribute('inert')),
+ false,
+ );
+ checks.push(
+ 'recovery and actual row removal release dialog/inertness without extra operations',
+ );
+ assert.equal(await rowActionCount(), 0);
+ await activate(
+ page.getByRole('region', { name: 'backup-ct row' }).getByText('backup-ct', { exact: true }),
+ );
+ assert.equal(await rowActionCount(), 1, 'ordinary row action still works');
+ assert.deepEqual(errors, []);
+ assert.deepEqual(operations, []);
+ assert(
+ !reads.some((p) => /backup|metrics|guest|resources/.test(p)),
+ 'opening evidence adds no collector/history read',
+ );
+ results.push({
+ name,
+ browser: browser.version(),
+ checks,
+ apiReads: reads,
+ operations,
+ errors,
+ finalRowActions: await rowActionCount(),
+ });
+ mark(`${name}:checks-complete`);
+ await page.close();
+ await browser.close();
+ browser = undefined;
+ }
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ result: 'passed',
+ playwrightVersion: version,
+ runtime: hashes,
+ phases,
+ results,
+ captures,
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ console.log(
+ JSON.stringify({
+ result: 'passed',
+ cases: results.length,
+ checks: results.reduce((n, r) => n + r.checks.length, 0),
+ captures: captures.length,
+ hashes,
+ playwrightVersion: version,
+ }),
+ );
+ } catch (e) {
+ fs.writeFileSync(
+ path.join(output, 'failure.json'),
+ JSON.stringify(
+ {
+ result: 'failed',
+ phase,
+ error: String(e),
+ runtime: hashes,
+ phases,
+ playwrightVersion: version,
+ captures,
+ results,
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ throw e;
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ mark('browser-server-closed');
+ }
+})().catch((e) => {
+ console.error(e);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/backup-details-access.html b/frontend-modern/browser-tests/backup-details-access.html
new file mode 100644
index 0000000000..488f6afbdc
--- /dev/null
+++ b/frontend-modern/browser-tests/backup-details-access.html
@@ -0,0 +1,12 @@
+
+
+
+ Synthetic guest observations, no backup commands or collector reads.
+
+
+
+
+
+
+ setActions((n) => n + 1)}
+ />
+
+
+
+
+
+
+
+ setActions((n) => n + 1)}
+ />
+
+
+
+
+ {actions()}
+
+
+ );
+}
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-tests/container-cpu-help.cjs b/frontend-modern/browser-tests/container-cpu-help.cjs
new file mode 100644
index 0000000000..dbaa300a2d
--- /dev/null
+++ b/frontend-modern/browser-tests/container-cpu-help.cjs
@@ -0,0 +1,117 @@
+// The real Docs renderer, router and shipped help. No runtime socket, workload,
+// credentials, alert test or diagnostics collection is exercised.
+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');
+
+(async () => {
+ const root = path.resolve('frontend-modern');
+ const artifacts = path.join(root, 'node_modules', 'container-cpu-help-browser');
+ fs.mkdirSync(artifacts, { recursive: true });
+ 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(root, 'node_modules', '.vite-container-cpu-help'),
+ server: { host: '127.0.0.1', port: 5265, strictPort: true },
+ optimizeDeps: { noDiscovery: true, include: [] },
+ });
+ const results = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [engine, width, tone] of [
+ ['chromium', 1280, 'light'], ['chromium', 1280, 'dark'],
+ ['webkit', 390, 'light'], ['webkit', 390, 'dark'],
+ ]) {
+ browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true });
+ const page = await browser.newPage({ viewport: { width, height: 900 },
+ ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
+ const errors = [];
+ const operations = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ page.on('request', (request) => {
+ if (request.url().includes('/api/') || request.method() !== 'GET')
+ operations.push({ method: request.method(), url: request.url() });
+ });
+ await page.goto('http://127.0.0.1:5265/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting',
+ { waitUntil: 'domcontentloaded', timeout: 60_000 });
+ if (tone === 'dark') await page.evaluate(async () => {
+ document.documentElement.classList.add('dark');
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ });
+ const entry = page.getByRole('heading', {
+ name: 'Container CPU differs from Docker or Podman stats', exact: true,
+ });
+ await entry.waitFor();
+ await entry.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
+ await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-entry.png`) });
+ const guideLink = page.getByRole('link', { name: 'container CPU readings', exact: true });
+ if (engine === 'webkit') await guideLink.tap();
+ else {
+ await guideLink.focus();
+ await page.keyboard.press('Enter');
+ }
+ const heading = page.getByRole('heading', { name: 'Container CPU readings', exact: true });
+ await heading.waitFor();
+ await page.waitForFunction(() => {
+ const h = document.getElementById('container-cpu-readings');
+ return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
+ });
+ assert.ok(page.url().endsWith('/docs/DOCKER#container-cpu-readings'));
+ const article = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
+ for (const phrase of ["Docker/Podman host's total CPU capacity", '100% for one logical CPU',
+ 'not an assumption about your host', 'v6.5.0-rc.1', 'unavailable data, not a measured zero',
+ 'Do not divide Pulse History again', 'Active does not prove fresh CPU samples',
+ 'not that an alert should fire or that its notification was delivered',
+ 'Do not restart workloads, create CPU load, lower alert thresholds'])
+ assert.ok(article.includes(phrase), phrase);
+ const table = page.locator('article table').filter({ hasText: 'Example host logical CPUs' });
+ assert.equal(await table.getByRole('row').count(), 4);
+ assert.equal(await table.getByRole('columnheader').count(), 3);
+ const rows = await table.getByRole('row').allTextContents();
+ assert.ok(rows[1].includes('240%') && rows[1].includes('60%'));
+ assert.ok(rows[2].includes('16.52%') && rows[2].includes('2.75%'));
+ assert.ok(rows[3].includes('1.04%') && rows[3].includes('0.17%'));
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
+ await table.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: path.join(artifacts, `${engine}-${tone}-examples.png`) });
+ const performance = page.getByRole('link', { name: 'safe performance measurements', exact: true });
+ if (engine === 'webkit') await performance.tap();
+ else {
+ await performance.focus();
+ await page.keyboard.press('Enter');
+ }
+ await page.getByRole('heading', { name: 'Excessive CPU, writes or database growth', exact: true }).waitFor();
+ await page.waitForFunction(() => {
+ const h = document.getElementById('excessive-cpu-writes-or-database-growth');
+ return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight;
+ });
+ assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#excessive-cpu-writes-or-database-growth'));
+ assert.deepEqual(errors, []);
+ assert.deepEqual(operations, []);
+ results.push({ engine, version: browser.version(), width, tone, exampleRows: rows,
+ entryLink: engine === 'webkit' ? 'touch' : 'keyboard', performanceLink: true,
+ noDocumentOverflow: true, errors, operations });
+ await browser.close();
+ browser = undefined;
+ }
+ const docs = ['DOCKER.md', 'TROUBLESHOOTING.md'].map((name) => ({ name,
+ sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'public', 'docs', name))).digest('hex'),
+ }));
+ const result = { result: 'passed', playwright: require('playwright/package.json').version,
+ scope: 'shipped CPU help rendering and navigation, not collector accuracy or alert delivery', docs, results };
+ fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
+ console.log(JSON.stringify(result));
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => { console.error(error); process.exitCode = 1; });
diff --git a/frontend-modern/browser-verification/5dc87cd447b452f0.json b/frontend-modern/browser-verification/5dc87cd447b452f0.json
new file mode 100644
index 0000000000..7e21a7e09a
--- /dev/null
+++ b/frontend-modern/browser-verification/5dc87cd447b452f0.json
@@ -0,0 +1,37 @@
+{
+ "version": 1,
+ "base_sha": "7387703d23ca855539e67855389712d780d2a868",
+ "verified_at": "2026-10-05T10:40:50.589686Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestRow.tsx",
+ "frontend-modern/src/components/Workloads/GuestRowCells.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestRowCells.tsx": "90cc2bac8ab59556148aac49026aef6d18615a4d85ef39891f8d51ddac804681",
+ "frontend-modern/src/components/Workloads/GuestRow.tsx": "6668d1f0f6aba4d79e006adc80ac2b323445e624f3e462de4373cdd4a9d5c6e3"
+ },
+ "routes": ["/browser-tests/backup-details-access.html"],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production Workloads GuestRow, both backup disclosures, shared Dialog and CSS with explicit synthetic VM/CT observations. Desktop Chromium and touch WebKit, each light/dark. Playwright 1.56.1 matches the integration lock.",
+ "Completed date/time and age remain distinct from a running task. Same-ID running, future/malformed uncertainty, absence and recovery update open evidence without inventing completion.",
+ "Sixteen final screenshots opened: named VM/CT evidence and Close controls remain readable and within desktop/phone bounds after the finite entrance animation.",
+ "Final fixture does not exercise canonical projection or native backup correctness, PBS/source agreement, guest recovery, physical devices or published availability. Earlier timeouts and superseded source/capture failures are retained in the lane outcome."
+ ],
+ "interactions": [
+ "Existing hover is preserved. Sequential Tab/Enter and native Space open the desktop badge; ordinary first touch opens a minimum-44px phone target without a hover tooltip.",
+ "Close focus, background inertness, focus containment and Escape/Close/backdrop dismissal return focus to the originating disclosure. Nested activation does not open the enclosing guest row.",
+ "Hidden-column indicator presents the same uncertainty and named guest as the Backup column. Adjacent CT has no VM observation. Recovery and actual row removal release dialog isolation; an ordinary CT row activation still works.",
+ "Four cases, 22 grouped checks, no page errors or operation requests. Only the existing anomaly fixture GET occurs. No collector/history/resource read is added. Browser/server closure completed under unchanged deadlines."
+ ]
+}
diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md
index ac46ce3f9e..e35f9f2cf1 100644
--- a/frontend-modern/public/docs/DOCKER.md
+++ b/frontend-modern/public/docs/DOCKER.md
@@ -123,6 +123,57 @@ services:
---
+## Container CPU readings
+
+Pulse shows container CPU as a share of the **Docker/Podman host's total CPU
+capacity**: 100% means all its logical CPUs. `docker stats` and `podman stats`
+normally use **100% for one logical CPU**, so their readings can exceed 100%.
+For that scale, divide the stats percentage by the CPU count reported for the
+runtime host to compare it with Pulse.
+
+If the engine runs inside a VM, use that VM's logical CPUs, not the physical
+Proxmox host's CPUs. Pulse's percentage is not utilisation of the container's
+own CPU quota or cpuset. If the reported CPU count differs from your configured
+VM size, retain both in the comparison rather than guessing a divisor.
+
+| Example host logical CPUs | Stats CPU | Pulse CPU before display rounding |
+| --- | --- | --- |
+| 4 | 240% | 60% |
+| 6 | 16.52% | About 2.75% |
+| 6 | 1.04% | About 0.17% |
+
+These are examples, not an assumption about your host. A smaller Pulse value
+alone does not establish a collection fault. Whole-percent table labels,
+including those in **v6.5.0-rc.1**, can display a fractional reading below 0.5%
+as 0%. Use **CPU History** rather than the rounded label; display rounding does
+not change stored readings or the values used by CPU alert evaluation. A dash
+or missing History is unavailable data, not a measured zero.
+
+For a useful comparison:
+
+1. Match the same container on the same runtime host. Compare a similar time
+ interval in CPU History with `docker stats` or `podman stats`; separate
+ sampling intervals can legitimately differ.
+2. Apply the reported host CPU count only to a stats reading on the per-CPU
+ scale. Do not divide Pulse History again, or compare with an average since
+ container start as though it were a recent interval.
+3. Check that observation times and History points advance during ordinary
+ monitoring. A connection marked Active does not prove fresh CPU samples.
+4. If the normalised readings still disagree, or History stops advancing,
+ report the server and agent versions, reported CPU count, sample times and
+ bounded readings. Do not restart workloads, create CPU load, lower alert
+ thresholds or export diagnostics just to make this comparison.
+
+CPU alerts use the same host-capacity scale, with the configured thresholds,
+duration and suppression rules. Non-zero History proves a collected reading,
+not that an alert should fire or that its notification was delivered. If a
+normal workload already demonstrates an alert problem, retain the reading,
+threshold, duration and observed result; no forced alert test is needed. See
+[safe performance measurements](TROUBLESHOOTING.md#excessive-cpu-writes-or-database-growth)
+when measuring the Pulse server itself rather than a monitored container.
+
+---
+
## 🔄 Updates
These steps update the Pulse server, not the containers it monitors. Record the
diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md
index dd9e0a54ef..213b11b17d 100644
--- a/frontend-modern/public/docs/TROUBLESHOOTING.md
+++ b/frontend-modern/public/docs/TROUBLESHOOTING.md
@@ -251,6 +251,20 @@ repair an older generated unit rather than adding a JSON-parsing wrapper.
follow the [platform-specific guide](TEMPERATURE_MONITORING.md) for any needed
setup change during a maintenance window.
+#### Container CPU differs from Docker or Podman stats
+
+Pulse uses the runtime host's total CPU capacity; `docker stats` and
+`podman stats` normally use 100% per logical CPU. Divide a per-CPU stats reading
+by the **reported runtime host CPU count**, not the physical Proxmox host's
+count when the engine runs inside a VM. Compare the same container and time
+interval in **CPU History**, not just a rounded table label. A dash or missing
+History is unavailable, not zero; an Active connection alone is not fresh CPU
+evidence.
+
+See [container CPU readings](DOCKER.md#container-cpu-readings) for worked
+examples, rounding and alert limits, and a passive comparison. Do not restart
+workloads, create CPU load or lower alert thresholds to test this.
+
#### Docker hosts appearing/disappearing
Cloned hosts can share a **saved Pulse agent ID**, not just an OS machine ID.
diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx
index e9e573c69b..45b126ebd6 100644
--- a/frontend-modern/src/components/Workloads/GuestRow.tsx
+++ b/frontend-modern/src/components/Workloads/GuestRow.tsx
@@ -323,6 +323,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Show backup indicator in name cell only if backup column is hidden */}
diff --git a/frontend-modern/src/components/Workloads/GuestRowCells.tsx b/frontend-modern/src/components/Workloads/GuestRowCells.tsx
index 2ace665fbe..5a8f8f23a7 100644
--- a/frontend-modern/src/components/Workloads/GuestRowCells.tsx
+++ b/frontend-modern/src/components/Workloads/GuestRowCells.tsx
@@ -1,4 +1,6 @@
-import { createMemo, For, Show } from 'solid-js';
+import { createMemo, createSignal, For, Show } from 'solid-js';
+
+import { Dialog } from '@/components/shared/Dialog';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
import { useTooltip } from '@/hooks/useTooltip';
@@ -46,28 +48,18 @@ function BackupShieldIcon(props: { icon: 'check' | 'warning' | 'x' | 'running';
function BackupIndicator(props: {
lastBackup: string | number | null | undefined;
isTemplate: boolean;
+ guestName?: string;
backupRunning?: boolean;
}) {
if (props.isTemplate) return null;
- const alertsActivation = useAlertsActivation();
- const backupInfo = createMemo(() =>
- getBackupInfo(props.lastBackup, alertsActivation.getBackupThresholds()),
- );
- const displayStatus = createMemo(() =>
- props.backupRunning ? 'running' : backupInfo().status,
- );
- const config = createMemo(() => getWorkloadsGuestBackupStatusPresentation(displayStatus()));
-
- const tooltipText = createMemo(() => {
- const info = backupInfo();
- return getWorkloadsGuestBackupTooltip(info.status, info.ageFormatted, props.backupRunning);
- });
-
return (
-
-
-
+
);
}
@@ -316,8 +308,12 @@ function OSInfoCell(props: { osName: string; osVersion: string; agentVersion: st
function BackupStatusCell(props: {
lastBackup: string | number | null | undefined;
backupRunning?: boolean;
+ indicator?: boolean;
+ guestName?: string;
}) {
const tip = useTooltip();
+ const [detailsOpen, setDetailsOpen] = createSignal(false);
+ let trigger: HTMLButtonElement | undefined;
const alertsActivation = useAlertsActivation();
const info = createMemo(() =>
@@ -337,6 +333,13 @@ function BackupStatusCell(props: {
const hasCompletedBackup = createMemo(() => info().ageMs !== null);
const ariaLabel = createMemo(() => {
const currentInfo = info();
+ if (props.indicator) {
+ return getWorkloadsGuestBackupTooltip(
+ currentInfo.status,
+ currentInfo.ageFormatted,
+ props.backupRunning,
+ );
+ }
if (currentInfo.status === 'unknown') {
return `Backup status: ${props.backupRunning ? 'backup running now, ' : ''}${currentInfo.ageFormatted}`;
}
@@ -349,61 +352,111 @@ function BackupStatusCell(props: {
return `Backup status: ${currentInfo.status}, last backup ${currentInfo.ageFormatted}`;
});
+ // Both entry points reveal the same retained completion evidence. Hover
+ // stays a shortcut on a fine pointer; the native button opens a read-only
+ // dialog for keyboard/touch without stealing the enclosing row action.
+ const Evidence = () => (
+
+
+
{props.guestName}
+
+
+
Backup running now…
+
+
+
+
Last completed backup
+
+
{info().ageFormatted}
+
+
+
+
{info().ageFormatted}
+
+
+
+ {props.backupRunning
+ ? 'No completed backup yet - the first backup is running now.'
+ : 'No backup has ever been recorded for this guest.'}
+