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 5eaa84950b..8e1892536b 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -3737,3 +3737,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/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 @@
+
+
+
+
+
+ Backup evidence accessibility verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/backup-details-access.tsx b/frontend-modern/browser-tests/backup-details-access.tsx
new file mode 100644
index 0000000000..1f7c7030c9
--- /dev/null
+++ b/frontend-modern/browser-tests/backup-details-access.tsx
@@ -0,0 +1,110 @@
+// Production Workloads rows/shared dialog, with explicit guest observations.
+// Mapping/collection are unchanged. No backup, guest, thaw or credential operation.
+import { createSignal, Show } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { GuestRow } from '../src/components/Workloads/GuestRow';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import type { WorkloadGuest } from '../src/types/workloads';
+import '../src/index.css';
+
+type Observation = { timestamp: string; running: boolean };
+const guest = (vmid: number, value: Observation): WorkloadGuest => ({
+ id: `fixture-pve1-${vmid}`,
+ vmid,
+ name: vmid === 101 ? 'backup-vm' : 'backup-ct',
+ node: 'pve1',
+ instance: 'fixture',
+ status: 'running',
+ type: vmid === 101 ? 'qemu' : 'lxc',
+ workloadType: vmid === 101 ? 'vm' : 'system-container',
+ platformType: 'proxmox-pve',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: 1024 ** 3, used: 1024 ** 3 / 4, free: (3 * 1024 ** 3) / 4, usage: 25 },
+ disk: { total: 10 * 1024 ** 3, used: 0, free: 10 * 1024 ** 3, usage: 0 },
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: value.timestamp === '' ? 0 : Date.parse(value.timestamp),
+ backupInProgress: value.running,
+ tags: null,
+ lock: value.running ? 'backup' : '',
+ lastSeen: '2026-10-05T08:00:00Z',
+});
+const initial: Observation = { timestamp: '2026-10-05T06:00:00Z', running: false };
+function Fixture() {
+ const [vm, setVM] = createSignal(guest(101, initial));
+ const ct = guest(102, { timestamp: '', running: false });
+ const [vmPresent, setVMPresent] = createSignal(true);
+ const [actions, setActions] = createSignal(0);
+ const [showBackup, setShowBackup] = createSignal(true);
+ (window as any).__backupDetails = {
+ update: (value: Observation) => setVM(guest(101, value)),
+ remove: () => setVMPresent(false),
+ identity: () => vm().id,
+ };
+ return (
+
+ Backup evidence accessibility
+
+ 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-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/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.'}
+
+
+
+ );
+ const focusClass =
+ 'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
+
return (
<>
- {
+ event.stopPropagation();
+ tip.onMouseLeave();
+ setDetailsOpen(true);
+ }}
aria-label={ariaLabel()}
+ title={props.indicator ? ariaLabel() : undefined}
+ aria-haspopup="dialog"
+ aria-expanded={detailsOpen()}
>
-
+
{badgeLabel()}
-
+
-
-
- Backup Status
+
+
+ Backup status
-
- Backup running now…
-
-
-
-
- {props.backupRunning ? 'Last completed backup' : 'Last backup'}
-
-
- {new Date(props.lastBackup!).toLocaleDateString(undefined, {
- weekday: 'short',
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- })}
-
-
{new Date(props.lastBackup!).toLocaleTimeString()}
-
-
-
- {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.'}
-
-
+
+
+
+
>
);
}
diff --git a/frontend-modern/src/components/Workloads/__tests__/BackupStatusAccess.test.tsx b/frontend-modern/src/components/Workloads/__tests__/BackupStatusAccess.test.tsx
new file mode 100644
index 0000000000..e0986f7944
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/BackupStatusAccess.test.tsx
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createSignal } from 'solid-js';
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { BackupIndicator, BackupStatusCell } from '../GuestRowCells';
+
+vi.mock('@/stores/alertsActivation', () => ({
+ useAlertsActivation: () => ({ getBackupThresholds: () => undefined }),
+}));
+
+const completed = Date.parse('2026-10-05T06:00:00Z');
+const now = Date.parse('2026-10-05T08:00:00Z');
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+describe('backup evidence access', () => {
+ it.each(['badge', 'indicator'] as const)(
+ 'regression: %s exposes completed-backup details through a native button',
+ async (variant) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+ render(() =>
+ variant === 'badge' ? (
+
+ ) : (
+
+ ),
+ );
+ const trigger = screen.getByRole('button', {
+ name: variant === 'badge' ? /^Backup status:/ : /^Last backup:/,
+ });
+ expect(trigger).toHaveAttribute('type', 'button');
+ expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(trigger);
+ const dialog = screen.getByRole('dialog', { name: 'Backup status details' });
+ expect(dialog).toHaveTextContent('Last completed backup');
+ expect(dialog).toHaveTextContent(
+ new Date(completed).toLocaleDateString(undefined, {
+ weekday: 'short',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }),
+ );
+ expect(dialog).toHaveTextContent('2 hours ago');
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ await Promise.resolve();
+ expect(screen.getByRole('button', { name: 'Close backup details' })).toHaveFocus();
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(trigger).toHaveFocus();
+ },
+ );
+
+ it('keeps open details reactive without interpreting running or uncertainty as completion', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+ const [value, setValue] = createSignal
(completed);
+ const [running, setRunning] = createSignal(false);
+ render(() => );
+ fireEvent.click(screen.getByRole('button', { name: /^Backup status:/ }));
+ const dialog = screen.getByRole('dialog');
+ setRunning(true);
+ expect(dialog).toHaveTextContent('Backup running now');
+ expect(dialog).toHaveTextContent('Last completed backup');
+ setValue('not-a-date');
+ expect(dialog).toHaveTextContent('invalid timestamp');
+ expect(dialog).not.toHaveTextContent('Last completed backup');
+ expect(dialog).not.toHaveTextContent('No backup has ever');
+ setValue(now + 60000);
+ expect(dialog).toHaveTextContent('timestamp is in the future');
+ expect(dialog).not.toHaveTextContent('Invalid Date');
+ setValue(0);
+ expect(dialog).toHaveTextContent('No completed backup yet');
+ setRunning(false);
+ expect(dialog).toHaveTextContent('No backup has ever been recorded');
+ expect(dialog).not.toHaveTextContent('Backup running now');
+ setValue(completed);
+ expect(dialog).toHaveTextContent('2 hours ago');
+ expect(dialog).not.toHaveTextContent('unavailable');
+ });
+
+ it('uses separate disclosures for adjacent guests and omits template indicators', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+ render(() => (
+ <>
+
+
+
+ >
+ ));
+ const triggers = screen.getAllByRole('button', { name: /^Backup status:/ });
+ expect(triggers).toHaveLength(2);
+ fireEvent.click(triggers[0]);
+ fireEvent.click(screen.getByRole('button', { name: 'Close backup details' }));
+ fireEvent.click(triggers[1]);
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
+ expect(screen.getByRole('dialog')).toHaveTextContent('No backup has ever been recorded');
+ expect(screen.getByRole('dialog')).not.toHaveTextContent('2 hours ago');
+ expect(screen.queryByRole('button', { name: /^Last backup:/ })).toBeNull();
+ });
+});
diff --git a/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx b/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx
index 028de384f7..c4ad178d24 100644
--- a/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/BackupStatusCell.test.tsx
@@ -9,7 +9,7 @@ const renderCell = (lastBackup: string | number | null, backupRunning?: boolean)
const { container } = render(() => (
));
- const badge = container.querySelector('span[aria-label^="Backup status"]');
+ const badge = container.querySelector('button[aria-label^="Backup status"]');
if (!badge) throw new Error('backup badge not rendered');
return badge as HTMLElement;
};
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
index 657ddd04ad..079934ef2c 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
@@ -2314,3 +2314,28 @@ describe('app-container update button visibility', () => {
expect(screen.queryByTestId('update-button')).toBeNull();
});
});
+
+describe('GuestRow backup evidence disclosure', () => {
+ it.each([{ columns: ['name', 'backup'] }, { columns: ['name'] }])(
+ 'opens read-only evidence without triggering the enclosing row for columns %j',
+ ({ columns }) => {
+ const onClick = vi.fn();
+ renderGuestRow({
+ guest: makeGuest({ lastBackup: Date.now() - 3600000 }),
+ visibleColumnIds: columns,
+ onClick,
+ });
+ const trigger = screen.getByRole('button', {
+ name: columns.includes('backup') ? /^Backup status:/ : /^Last backup:/,
+ });
+ fireEvent.click(trigger);
+ expect(
+ screen.getByRole('dialog', { name: 'Backup status details for test-vm' }),
+ ).toHaveTextContent('Last completed backup');
+ expect(onClick).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Close backup details' }));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(onClick).not.toHaveBeenCalled();
+ },
+ );
+});