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.'} -
-
+
+ + setDetailsOpen(false)} + returnFocus={() => trigger} + panelClass="max-w-sm" + > +
+

Backup status

+ +
+
+ +
+
+
); } 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(); + }, + ); +});