From 02e6a90374416e0ffd05a363fd1178306d770506 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:41:55 +0100 Subject: [PATCH] Make completed-backup evidence accessible in Workloads Use native read-only disclosure buttons for both backup entry points, with the shared Dialog preserving named completion evidence, focus and guest row action ownership. Keep existing freshness and uncertainty semantics, collection and backup operations unchanged. Include connected regression tests, the subsystem contract and inspected desktop/phone browser verification. Change-source: pulse-maintainer --- .../subsystems/performance-and-scalability.md | 23 ++ .../browser-tests/backup-details-access.cjs | 359 ++++++++++++++++++ .../browser-tests/backup-details-access.html | 12 + .../browser-tests/backup-details-access.tsx | 110 ++++++ .../5dc87cd447b452f0.json | 37 ++ .../src/components/Workloads/GuestRow.tsx | 2 + .../components/Workloads/GuestRowCells.tsx | 171 ++++++--- .../__tests__/BackupStatusAccess.test.tsx | 106 ++++++ .../__tests__/BackupStatusCell.test.tsx | 2 +- .../Workloads/__tests__/GuestRow.test.tsx | 25 ++ 10 files changed, 787 insertions(+), 60 deletions(-) create mode 100644 frontend-modern/browser-tests/backup-details-access.cjs create mode 100644 frontend-modern/browser-tests/backup-details-access.html create mode 100644 frontend-modern/browser-tests/backup-details-access.tsx create mode 100644 frontend-modern/browser-verification/5dc87cd447b452f0.json create mode 100644 frontend-modern/src/components/Workloads/__tests__/BackupStatusAccess.test.tsx 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(); + }, + ); +});