mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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
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