mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user