Integrate reviewed keyboard and touch access to Workloads backup evidence

Preserve the exact Web candidate and its own-parent browser receipt. Retained connected source checks and desktop/touch evidence cover the existing read-only disclosures without changing collection or backup operations.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 12:04:21 +01:00
10 changed files with 787 additions and 60 deletions
@@ -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.
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup evidence accessibility verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./backup-details-access.tsx"></script>
</body>
</html>
@@ -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 (
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Backup evidence accessibility</h1>
<p class="text-xs text-muted">
Synthetic guest observations, no backup commands or collector reads.
</p>
<button
class="rounded-sm border border-border p-2 text-xs focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => setShowBackup(!showBackup())}
>
{showBackup() ? 'Hide Backup column' : 'Show Backup column'}
</button>
<Show when={vmPresent()}>
<section aria-label="backup-vm row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={vm()}
visibleColumnIds={showBackup() ? ['name', 'backup'] : ['name']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
onClick={() => setActions((n) => n + 1)}
/>
</tbody>
</table>
</section>
</Show>
<section aria-label="backup-ct row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={ct}
visibleColumnIds={['name']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
onClick={() => setActions((n) => n + 1)}
/>
</tbody>
</table>
</section>
<p role="status" aria-label="Row action count">
{actions()}
</p>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -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."
]
}
@@ -323,6 +323,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup') && supportsBackup()}>
<BackupIndicator
guestName={props.guest.name}
lastBackup={props.guest.lastBackup}
isTemplate={props.guest.template}
backupRunning={props.guest.backupInProgress}
@@ -845,6 +846,7 @@ export function GuestRow(props: GuestRowProps) {
>
<Show when={!props.guest.template}>
<BackupStatusCell
guestName={props.guest.name}
lastBackup={props.guest.lastBackup}
backupRunning={props.guest.backupInProgress}
/>
@@ -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<WorkloadsGuestBackupDisplayStatus>(() =>
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 (
<span class={`shrink-0 ${config().color}`} title={tooltipText()} aria-label={tooltipText()}>
<BackupShieldIcon icon={config().icon} pulse={displayStatus() === 'running'} />
</span>
<BackupStatusCell
lastBackup={props.lastBackup}
backupRunning={props.backupRunning}
guestName={props.guestName}
indicator
/>
);
}
@@ -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 = () => (
<div class="space-y-2 text-xs">
<Show when={props.guestName}>
<p class="font-medium text-base-content wrap-break-word">{props.guestName}</p>
</Show>
<Show when={props.backupRunning}>
<p class="text-blue-600 dark:text-blue-400">Backup running now…</p>
</Show>
<Show when={hasCompletedBackup()}>
<div class="space-y-1">
<div class="text-muted">Last completed backup</div>
<time
class="block font-medium text-base-content"
dateTime={new Date(props.lastBackup!).toISOString()}
>
{new Date(props.lastBackup!).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{' · '}
{new Date(props.lastBackup!).toLocaleTimeString()}
</time>
<div class="text-base-content">{info().ageFormatted}</div>
</div>
</Show>
<Show when={info().status === 'unknown'}>
<p class="text-amber-700 dark:text-amber-300">{info().ageFormatted}</p>
</Show>
<Show when={info().status === 'never'}>
<p class="text-red-600 dark:text-red-400">
{props.backupRunning
? 'No completed backup yet - the first backup is running now.'
: 'No backup has ever been recorded for this guest.'}
</p>
</Show>
</div>
);
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 (
<>
<span
class={getBackupAgeBadgeClass(displayStatus())}
<button
ref={trigger}
type="button"
class={
props.indicator
? `inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-sm sm:min-h-0 sm:min-w-0 ${config().color} ${focusClass}`
: `${getBackupAgeBadgeClass(displayStatus())} min-h-11 min-w-11 sm:min-h-0 ${focusClass}`
}
onMouseEnter={tip.onMouseEnter}
onMouseLeave={tip.onMouseLeave}
onClick={(event) => {
event.stopPropagation();
tip.onMouseLeave();
setDetailsOpen(true);
}}
aria-label={ariaLabel()}
title={props.indicator ? ariaLabel() : undefined}
aria-haspopup="dialog"
aria-expanded={detailsOpen()}
>
<BackupShieldIcon icon={config().icon} pulse={displayStatus() === 'running'} />
<Show when={displayStatus() !== 'fresh'}>
<Show when={!props.indicator && displayStatus() !== 'fresh'}>
<span>{badgeLabel()}</span>
</Show>
</span>
</button>
<TooltipPortal when={tip.show()} x={tip.pos().x} y={tip.pos().y}>
<div class="min-w-[140px]">
<div class="font-medium mb-1 text-slate-300 border-b border-border pb-1">
Backup Status
<div class="min-w-[140px] max-w-[280px]">
<div class="mb-1 border-b border-border pb-1 font-medium text-base-content">
Backup status
</div>
<Show when={props.backupRunning}>
<div class="py-0.5 text-blue-400">Backup running now…</div>
</Show>
<Show when={hasCompletedBackup()}>
<div class="py-0.5">
<div class="text-slate-400">
{props.backupRunning ? 'Last completed backup' : 'Last backup'}
</div>
<div class="text-base-content font-medium">
{new Date(props.lastBackup!).toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</div>
<div class="text-slate-300">{new Date(props.lastBackup!).toLocaleTimeString()}</div>
</div>
<div class="pt-1 mt-1 border-t border-border">
<span class={getWorkloadsGuestBackupStatusPresentation(info().status).color}>
{info().ageFormatted}
</span>
</div>
</Show>
<Show when={info().status === 'unknown'}>
<div class="py-0.5 text-amber-400">{info().ageFormatted}</div>
</Show>
<Show when={info().status === 'never'}>
<div class="py-0.5 text-red-400">
{props.backupRunning
? 'No completed backup yet - the first backup is running now.'
: 'No backup has ever been recorded for this guest.'}
</div>
</Show>
<Evidence />
</div>
</TooltipPortal>
<Show when={detailsOpen()}>
<Dialog
isOpen
ariaLabel={`Backup status details${props.guestName ? ` for ${props.guestName}` : ''}`}
onClose={() => setDetailsOpen(false)}
returnFocus={() => trigger}
panelClass="max-w-sm"
>
<div class="flex items-center justify-between gap-3 border-b border-border px-4 py-2">
<h2 class="text-sm font-semibold text-base-content">Backup status</h2>
<button
type="button"
aria-label="Close backup details"
class="min-h-11 rounded-sm px-3 text-xs text-muted hover:bg-surface-hover focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => setDetailsOpen(false)}
>
Close
</button>
</div>
<div class="overflow-y-auto p-4">
<Evidence />
</div>
</Dialog>
</Show>
</>
);
}
@@ -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' ? (
<BackupStatusCell lastBackup={completed} />
) : (
<BackupIndicator lastBackup={completed} isTemplate={false} />
),
);
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<string | number>(completed);
const [running, setRunning] = createSignal(false);
render(() => <BackupStatusCell lastBackup={value()} backupRunning={running()} />);
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(() => (
<>
<BackupStatusCell lastBackup={completed} />
<BackupStatusCell lastBackup={0} />
<BackupIndicator lastBackup={completed} isTemplate={true} />
</>
));
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();
});
});
@@ -9,7 +9,7 @@ const renderCell = (lastBackup: string | number | null, backupRunning?: boolean)
const { container } = render(() => (
<BackupStatusCell lastBackup={lastBackup} backupRunning={backupRunning} />
));
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;
};
@@ -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();
},
);
});