Preserve returned backup presentation and exact browser proof

Compose the existing Web completion without reparenting its source or own-parent receipts. Keep the guest guard, completed-backup truth, upstream disk hover and Unknown dot, and test-only exact Deferred assertions. Return the combined source for independent review and protected publication.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 20:49:38 +01:00
13 changed files with 692 additions and 40 deletions
@@ -22,7 +22,7 @@
### Guest disk deferral and retained observations
The existing workload row, disk breakdown tooltip/list and guest Overview use
The existing workload row, disk breakdown tooltip and guest Overview use
one presentation for the fixed guest-read reason vocabulary. Operation locks,
unverified lock clearance, an in-flight request, cooldown, incomplete response
and capacity deferrals are not evidence that QGA is missing. Invalid VM identity
@@ -38,12 +38,36 @@ updates values without remounting the row. Permission, disabled-agent and actual
unavailable cases retain their own explanations.
Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`,
`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions,
and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no
previous data and fresh resumption with production components and CSS on desktop
Chromium and touch WebKit. Synthetic observations do not establish the native
command-ID cause, guest thaw, fresh History collection or containing release.
The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
shipped caller. Its empty-state title is not evidence of readable touch guidance;
the shipped guest Overview supplies the readable pre-sample explanation.
### Completed guest backup evidence versus current activity
The guest Overview keeps the last completed backup age and its existing
threshold-derived tone visible independently of current backup activity.
Starting a backup must not hide absent or old completed evidence or turn it
green. A separate cautionary activity row reports a running backup as not yet
completed. Ending that activity without a new completed timestamp does not
manufacture protection. A new completed timestamp updates the existing drawer
without remounting it. Both values wrap at phone widths, rather than requiring
hover to distinguish them.
Verification: the mounted `GuestDrawer.test.tsx` backup regressions,
`workloadGuestPresentation.test.ts` and production full-drawer fixture
`browser-tests/guest-backup-protection.cjs` cover VM/CT missing, stale and fresh
completion evidence, concurrent activity, stopped activity with no completion,
and later completed observations. They preserve independent disk-lock/timeout
guidance and existing touch/keyboard collapse. Synthetic observations establish
presentation, not native task success, archive restorability, QGA/thaw or release
availability; those require their own containing native and delivery evidence.
### Composed operator tables and conservative rollback consent
@@ -25,6 +25,14 @@ and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and
sparkline modes with desktop and phone CSS. This is client behaviour proof, not
native backup coordination, new History samples or a fleet performance result.
Completed backup presentation follows the existing same-guest snapshot owner.
Running activity and completed protection are independent derived detail rows;
neither starts a command, adds a read/timer nor remounts the drawer. Stopping
activity cannot synthesize a completed timestamp. `GuestDrawer.test.tsx` and
`browser-tests/guest-backup-protection.cjs` verify in-place missing/old/new
completion transitions through the actual drawer. This is ownership/presentation
proof, not an allocation benchmark, native backup or fleet performance claim.
### Phone workload identity weights
The existing phone guest projection allocates 38 percent to the name cell,
@@ -0,0 +1,255 @@
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 baseline = false;
const root = '/workspace/frontend-modern';
const output = `/workspace/tmp/guest-backup-protection-${baseline ? 'parent' : 'final'}`;
const runtime = [
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
'frontend-modern/src/components/Workloads/useGuestDrawerState.ts',
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
];
const sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(runtime.map((p) => [p, sha256(path.join('/workspace', p))]));
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5296, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['desktop', chromium, 1365, false],
['phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
for (const kind of ['qemu', 'lxc']) {
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
writes = [],
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,
'http://127.0.0.1:5296',
'only guest-local fixture requests are allowed',
);
if (url.pathname.startsWith('/api/')) {
if (request.method() !== 'GET') writes.push(`${request.method()} ${url.pathname}`);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
});
}
return route.continue();
});
await page.goto(
`http://127.0.0.1:5296/browser-tests/guest-backup-protection.html?kind=${kind}`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(() => window.__backupProtection);
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
assert.equal(await page.evaluate(() => window.innerWidth), width, 'actual CSS viewport');
const details = page.getByTestId('guest-technical-details');
await details.waitFor({ state: 'visible' });
await page.evaluate(() => {
window.__originalGuestDetails = document.querySelector(
'[data-testid="guest-technical-details"]',
);
window.__originalGuestHeading = document.querySelector('h3');
});
const update = async (lastBackup, running, reason = '') => {
await page.evaluate((value) => window.__backupProtection.update(value), {
lastBackup,
running,
reason,
});
assert.equal(
await page.evaluate(
() =>
document.querySelector('[data-testid="guest-technical-details"]') ===
window.__originalGuestDetails,
),
true,
);
assert.equal(
await page.evaluate(() => window.__backupProtection.identity()),
'fixture-pve:pve-a:101',
);
};
const capture = async (suffix) => {
const p = path.join(output, `${name}-${kind}-${suffix}.png`);
await page.screenshot({ path: p, fullPage: true });
screenshots.push({ path: path.relative('/workspace', p), sha256: sha256(p) });
};
const oldBackup = new Date('2026-09-23T12:00:00Z').getTime();
if (baseline) {
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
const activity = details.getByText('Backup running', { exact: true });
await activity.waitFor({ state: 'visible' });
assert((await activity.locator('..').getAttribute('class')).includes('text-emerald-700'));
assert.equal(await details.getByText('No backup found', { exact: true }).count(), 0);
checks.push(
'parent: running backup hides missing completed protection and appears green',
);
if (kind === 'qemu') await capture('running-with-none');
await update(oldBackup, true);
assert.equal(await details.getByText('10d ago', { exact: true }).count(), 0);
await update(oldBackup, false);
await details.getByText('10d ago', { exact: true }).waitFor({ state: 'visible' });
checks.push('parent: running backup hides existing ten-day-old completed evidence');
} else {
const missing = details.getByText('No completed backup found', { exact: true });
const activity = details.getByText('Running · not completed yet', { exact: true });
await missing.waitFor({ state: 'visible' });
assert((await missing.locator('..').getAttribute('class')).includes('text-rose-700'));
assert.equal(await details.getByText('Backup activity', { exact: true }).count(), 0);
await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : '');
await activity.waitFor({ state: 'visible' });
await missing.waitFor({ state: 'visible' });
assert((await activity.locator('..').getAttribute('class')).includes('text-amber-700'));
assert.equal(await details.getByText('Backup running', { exact: true }).count(), 0);
if (kind === 'qemu')
await details
.getByText(/Using last known disk stats\. Guest reads paused/)
.waitFor({ state: 'visible' });
checks.push(
'absent completion stays missing/danger while separate activity is cautionary; VM lock notice survives',
);
if (kind === 'qemu') await capture('running-with-none');
await update(0, false);
await missing.waitFor({ state: 'visible' });
assert.equal(await activity.count(), 0);
checks.push(
'activity stopping without new completion evidence never manufactures protection',
);
await update(oldBackup, true);
const age = details.getByText('10d ago', { exact: true });
await age.waitFor({ state: 'visible' });
assert((await age.locator('..').getAttribute('class')).includes('text-amber-700'));
await activity.waitFor({ state: 'visible' });
checks.push('stale completed age and caution tone remain visible during a new backup');
if (kind === 'qemu') await capture('running-with-old');
await update(oldBackup, false, kind === 'qemu' ? 'prev-agent-timeout' : '');
await age.waitFor({ state: 'visible' });
assert.equal(await activity.count(), 0);
if (kind === 'qemu')
await details
.getByText(/Do not restart the guest agent during a backup/)
.waitFor({ state: 'visible' });
checks.push('ended activity preserves old completion and independent timeout safety');
const freshBackup = new Date('2026-10-03T10:00:00Z').getTime();
await update(freshBackup, false);
const today = details.getByText('Today', { exact: true });
await today.waitFor({ state: 'visible' });
assert((await today.locator('..').getAttribute('class')).includes('text-emerald-700'));
assert.equal(await missing.count(), 0);
await update(freshBackup, true);
await today.waitFor({ state: 'visible' });
await activity.waitFor({ state: 'visible' });
checks.push(
'only new completed timestamp updates protection; fresh age stays separate from further activity',
);
await update(freshBackup, false);
const close = page.getByRole('button', { name: 'Collapse backup-guest details' });
if (name === 'phone') await close.tap();
else {
await close.focus();
await page.keyboard.press('Enter');
}
assert.equal(await page.evaluate(() => window.__backupProtection.closeCount()), 1);
checks.push(
'existing collapse remains touch/keyboard usable and observations trigger no write',
);
assert.equal(
await details.getByText('Last completed backup', { exact: true }).count(),
1,
);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
if (kind === 'qemu') await capture('fresh-completion');
}
assert.deepEqual(writes, []);
assert.deepEqual(errors, []);
results.push({
name,
kind,
browser_version: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
checks,
page_errors: errors,
writes,
});
await page.close();
}
await browser.close();
browser = null;
}
const receipt = {
version: 1,
result: baseline ? 'defect-reproduced' : 'passed',
verified_at: new Date().toISOString(),
content_sha256: hashes,
playwright_version: playwrightVersion,
results,
screenshots,
limitation:
'Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.',
};
fs.writeFileSync(
path.join(output, 'browser-result.json'),
JSON.stringify(receipt, null, 2) + '\n',
);
process.stdout.write(JSON.stringify(receipt) + '\n');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((e) => {
process.stderr.write(e.stack + '\n');
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>Guest backup protection verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-backup-protection.tsx"></script>
</body>
</html>
@@ -0,0 +1,82 @@
// Synthetic observations through the actual GuestDrawer, its state model and
// production CSS. This fixture neither starts a backup nor establishes thaw.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { WorkloadGuest } from '../src/types/workloads';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
function Fixture() {
const [observation, setObservation] = createSignal({
lastBackup: 0,
running: false,
reason: '',
});
const [closeCount, setCloseCount] = createSignal(0);
const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
const guest = (): WorkloadGuest => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type,
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
disks: [
{ mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
],
diskStatusReason: observation().reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: observation().lastBackup,
backupInProgress: observation().running,
tags: [],
lock: observation().running ? 'backup' : '',
lastSeen: '2026-10-03T12:00:00Z',
});
(window as any).__backupProtection = {
update: setObservation,
closeCount,
identity: () => guest().id,
};
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest backup protection</h1>
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
<section aria-label="Guest details" class="mx-auto max-w-4xl">
<GuestDrawer guest={guest()} onClose={() => setCloseCount((count) => count + 1)} />
</section>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -117,9 +117,9 @@ const held = [
requests.push({ path: url.pathname, query: url.search, mode });
if (mode.startsWith('hold-'))
await new Promise((resolve) => releases.push({ isHeld, resolve }));
if (mode.endsWith('denied'))
if (mode.endsWith('denied') || mode.endsWith('unavailable'))
return route.fulfill({
status: isHeld ? 403 : 503,
status: isHeld && mode.endsWith('denied') ? 403 : 503,
json: { error: 'Synthetic unavailable evidence' },
});
return route.fulfill({
@@ -240,6 +240,30 @@ const held = [
assert.equal(await empty.count(), 0);
},
);
await check(
'The CI locator matches the 503 warning after the held row is withdrawn',
async () => {
heldMode = 'unavailable';
await refresh.focus();
await page.keyboard.press('Enter');
await heldWarning.waitFor();
const broadTexts = await page.getByText('Deferred').allTextContents();
const exactBadgeCount = await activity.getByText('Deferred', { exact: true }).count();
assert.deepEqual(broadTexts, [
'Pulse could not read held or deferred notifications. Refresh to try again.',
]);
assert.equal(exactBadgeCount, 0);
assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1);
checks.push({ label: 'Observed broad versus exact status match', broadTexts, exactBadgeCount });
},
);
await capture('held-503-ci-locator');
await check('A healthy refresh restores the held row before permission withdrawal', async () => {
heldMode = 'known';
await refresh.click();
await activity.getByText('Deferred', { exact: true }).waitFor();
await heldWarning.waitFor({ state: 'hidden' });
});
await check(
'Held-event 403 withdraws held rows and warns without hiding delivered evidence',
async () => {
@@ -0,0 +1,56 @@
{
"version": 1,
"base_sha": "7a5635b2c0b76e13f510cc7a27b7ca0cd55091ba",
"verified_at": "2026-10-03T18:34:44.680Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/utils/workloadGuestPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "3c19f8214611369807c48715a4429f4913c346b687ba3fe40b43c8ce094e6bd9",
"frontend-modern/src/utils/workloadGuestPresentation.ts": "8b648129348725216f14f6b06931222daa69439e4bee76add81324699b6266bf"
},
"routes": [
"/browser-tests/guest-backup-protection.html?kind=qemu",
"/browser-tests/guest-backup-protection.html?kind=lxc"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Missing completed backup, idle and running; missing protection stays danger, running activity is cautionary",
"Existing ten-day-old completed backup remains visible and cautionary during a new backup",
"Activity ends without a new completed timestamp: missing or stale protection is unchanged",
"Fresh completed timestamp alone updates protection; later concurrent activity remains separate",
"VM disk-lock and uncertain-timeout guidance remain independent of backup activity"
],
"interactions": [
"Reactive same-ID guest observations through actual GuestDrawer, state model and production CSS; no technical-details wrapper remount",
"Desktop keyboard focus and Enter collapse, phone touch collapse; no API writes or page errors",
"Measured 390px phone CSS viewport, no page overflow and six candidate screenshots visually inspected"
],
"playwright_version": "1.56.1",
"browser_versions": [
{
"engine": "chromium",
"version": "141.0.7390.37"
},
{
"engine": "webkit",
"version": "26.0"
}
],
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
"limitations": [
"Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.",
"Synthetic completion evidence does not prove the reported freeze cause or native thaw; no released-availability claim."
]
}
@@ -0,0 +1,66 @@
{
"version": 1,
"base_sha": "d9a2c78612b21dd2e46de0f75b71c0ddaddb9f86",
"verified_at": "2026-10-03T19:32:24.556252Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "39f4000617cb62621c0abf5749a46eb26e769bb437ded70730f6d9727e358a55",
"frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "41202d1ae6095d882b124a55aed4110c74f38047d51b83b07bf52a6e95ca6b01"
},
"routes": [
"/combined-disk.html",
"/browser-tests/backups.html",
"/proxmox/backups/coverage",
"/alerts/overview",
"/alerts/notifications"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 1440,
"height": 1000
}
],
"states": [
"Retained fullest-filesystem 90% label and equal-weight micro-bars, missing observations and fresh 40% resumption",
"Restored 30% desktop hover ring with retained-observation explanation",
"Restored Unknown coverage dot at 40% rendered opacity on desktop; intentional dot-free compact phone filter stays readable",
"Dated independent archive remains distinct from a fresh local snapshot, including snapshot-only scope and restored full scope",
"Exact committed alert qualification spec: pending retention, 503 and 403 withdrawal of held rows only, warning through pending retry and healthy recovery"
],
"interactions": [
"Desktop hover and inspected hovered/unhovered disk screenshots; phone label remains readable without hover",
"By date and Coverage navigation, route-backed search, location selection/removal, phone Filters disclosure and inspected screenshots",
"Keyboard Refresh with independently successful attempts and deliberately pending held responses; exact row badges and unavailable warnings are asserted separately in Chromium, mobile Chromium and phone WebKit"
],
"playwright_version": "1.56.1",
"browser_versions": [
{
"engine": "chromium",
"version": "141.0.7390.37"
},
{
"engine": "webkit",
"version": "26.0"
}
],
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
"evidence": [
"Current Web outcome RECEIPTS.md retains raw browser results, the three-project exact-spec report and inspected screenshots."
],
"limitations": [
"Production component/CSS and full application entry with synthetic observations and HTTP/WebSocket data only; not native appliance, backup/thaw, authentication, signed recovery or release acceptance.",
"Fresh proof covers the two runtime files changed by merge composition. Earlier valid own-parent receipts remain preserved; this is not a new whole-range browser run."
]
}
@@ -7,7 +7,7 @@ import {
waitFor,
within,
} from '@solidjs/testing-library';
import { Suspense } from 'solid-js';
import { createSignal, Suspense } from 'solid-js';
import type { WorkloadGuest } from '@/types/workloads';
import type { Memory, Disk, GuestNetworkInterface } from '@/types/api';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
@@ -922,7 +922,7 @@ describe('GuestDrawer', () => {
it('shows "Today" for a backup from today', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
expect(screen.getByText('Protection')).toBeInTheDocument();
expect(screen.getByText('Last completed backup')).toBeInTheDocument();
expect(technicalDetails().getByText('Today')).toBeInTheDocument();
});
@@ -966,9 +966,64 @@ describe('GuestDrawer', () => {
expect(ageCell).toHaveClass('text-emerald-700');
});
it('hides Backup card when lastBackup is 0 (falsy)', () => {
render(() => <GuestDrawer guest={makeGuest({ lastBackup: 0 })} onClose={vi.fn()} />);
expect(screen.queryByText('Backup')).not.toBeInTheDocument();
it.each(['qemu', 'lxc'] as const)(
'keeps absent completed protection visible while a %s backup runs',
(type) => {
solidRender(() => (
<GuestDrawer
guest={makeGuest({ type, lastBackup: 0, backupInProgress: true })}
onClose={vi.fn()}
/>
));
expect(technicalDetails().getByText('Last completed backup')).toBeInTheDocument();
expect(technicalDetails().getByText('No completed backup found').closest('td')).toHaveClass(
'text-rose-700',
);
expect(technicalDetails().getByText('Backup activity')).toBeInTheDocument();
expect(
technicalDetails().getByText('Running · not completed yet').closest('td'),
).toHaveClass('text-amber-700');
},
);
it.each([
['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'],
['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'],
])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => {
solidRender(() => (
<GuestDrawer
guest={makeGuest({ lastBackup: new Date(date).getTime(), backupInProgress: true })}
onClose={vi.fn()}
/>
));
expect(technicalDetails().getByText(age).closest('td')).toHaveClass(tone);
expect(technicalDetails().getByText('Running · not completed yet').closest('td')).toHaveClass(
'text-amber-700',
);
});
it('never treats activity stopping as a completed backup and updates only on completion evidence', () => {
const [guest, setGuest] = createSignal(makeGuest({ lastBackup: 0 }));
const { container } = solidRender(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
const drawer = container.querySelector('[data-testid="guest-technical-details"]');
expect(drawer).not.toBeNull();
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
setGuest({ ...guest(), backupInProgress: true });
expect(technicalDetails().getByText('Running · not completed yet')).toBeInTheDocument();
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
setGuest({ ...guest(), backupInProgress: false });
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() });
expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument();
expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700');
expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer);
});
it('reports missing completed protection rather than hiding it when lastBackup is 0', () => {
solidRender(() => <GuestDrawer guest={makeGuest({ lastBackup: 0 })} onClose={vi.fn()} />);
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
});
});
@@ -64,7 +64,6 @@ interface GuestDrawerOverviewProps {
export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
const protectionPresentation = () =>
getWorkloadsGuestProtectionPresentation({
backupInProgress: props.guest.backupInProgress,
ageLabel: props.backupPresentation?.ageLabel,
ageClass: props.backupPresentation?.ageClass,
});
@@ -88,9 +87,17 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
: undefined,
}),
makeDetailRow('Primary IP', props.ipAddresses[0]),
makeDetailRow('Protection', protectionPresentation().label, {
// Starting or ending an operation is not evidence of a completed
// backup. Keep its activity separate from the last completed age.
makeDetailRow('Last completed backup', protectionPresentation().label, {
tone: protectionPresentation().tone,
wrap: true,
}),
makeDetailRow(
'Backup activity',
props.guest.backupInProgress ? 'Running · not completed yet' : null,
{ tone: 'warning', wrap: true },
),
makeDetailRow(
'Identified service',
props.discoveryIdentifiedSummary
@@ -45,12 +45,8 @@ describe('workloadGuestPresentation', () => {
});
it('returns canonical compact protection context for object drawers', () => {
expect(getWorkloadsGuestProtectionPresentation({ backupInProgress: true })).toEqual({
label: 'Backup running',
tone: 'success',
});
expect(getWorkloadsGuestProtectionPresentation({})).toEqual({
label: 'No backup found',
label: 'No completed backup found',
tone: 'danger',
});
expect(
@@ -59,6 +55,12 @@ describe('workloadGuestPresentation', () => {
ageClass: 'text-yellow-600',
}),
).toEqual({ label: '3d ago', tone: 'warning' });
expect(
getWorkloadsGuestProtectionPresentation({
ageLabel: 'Today',
ageClass: 'text-green-600',
}),
).toEqual({ label: 'Today', tone: 'success' });
});
it('presents a running backup as its own state, keeping the completed age', () => {
@@ -70,15 +70,11 @@ export function getWorkloadsGuestBackupTooltip(
}
export function getWorkloadsGuestProtectionPresentation(options: {
backupInProgress?: boolean;
ageLabel?: string | null;
ageClass?: string | null;
}): WorkloadsGuestProtectionPresentation {
if (options.backupInProgress) {
return { label: 'Backup running', tone: 'success' };
}
if (!options.ageLabel) {
return { label: 'No backup found', tone: 'danger' };
return { label: 'No completed backup found', tone: 'danger' };
}
return {
label: options.ageLabel,
@@ -367,12 +367,16 @@ test.describe('Alert operator qualification', () => {
}),
}),
);
let heldEventsAvailable = true;
await page.route('**/api/alerts/events**', (route) =>
route.fulfill({
status: heldEventsAvailable ? 200 : 503,
let heldEventsStatus = 200;
let heldEventsBarrier: Promise<void> | null = null;
let releaseHeldEvents: (() => void) | undefined;
await page.route('**/api/alerts/events**', async (route) => {
const status = heldEventsStatus;
if (heldEventsBarrier) await heldEventsBarrier;
await route.fulfill({
status,
contentType: 'application/json',
body: heldEventsAvailable
body: status === 200
? JSON.stringify([
{
id: 'held-1',
@@ -386,8 +390,8 @@ test.describe('Alert operator qualification', () => {
},
])
: JSON.stringify({ error: 'event log unavailable' }),
}),
);
});
});
await page.goto('/alerts/overview', { waitUntil: 'domcontentloaded' });
await expect(page.getByText('Delivery Node').first()).toBeVisible();
@@ -407,21 +411,82 @@ test.describe('Alert operator qualification', () => {
{ exact: false },
),
).toBeVisible();
await expect(page.getByText('Deferred')).toBeVisible();
await expect(page.getByText('Quiet hours')).toBeVisible();
await expect(page.getByText('Delivered', { exact: true })).toBeVisible();
const activity = page.locator('#notification-delivery-activity');
const deferred = activity.getByText('Deferred', { exact: true });
const delivered = activity.getByText('Delivered', { exact: true });
const heldWarning = activity.getByRole('alert').filter({
hasText: 'Pulse could not read held or deferred notifications. Refresh to try again.',
});
const heldLoading = activity.getByText('Loading held and deferred notifications...', {
exact: true,
});
const refresh = activity.getByRole('button', { name: 'Refresh delivery status' });
await expect(deferred).toBeVisible();
await expect(activity.getByText('Quiet hours', { exact: true })).toBeVisible();
await expect(delivered).toBeVisible();
await expect(heldLoading).toHaveCount(0);
await expect(heldWarning).toHaveCount(0);
heldEventsAvailable = false;
const refresh = page
.getByRole('heading', { name: 'Recent delivery activity' })
.locator('..')
.locator('..')
.getByRole('button', { name: 'Refresh delivery status' });
// A pending refresh retains known evidence until its result arrives. The
// independently successful attempt read must keep Refresh usable.
heldEventsStatus = 503;
heldEventsBarrier = new Promise<void>((resolve) => { releaseHeldEvents = resolve; });
await refresh.focus();
await page.keyboard.press('Enter');
await expect(page.getByText('Deferred')).toHaveCount(0);
await expect(page.getByText('Delivered', { exact: true })).toBeVisible();
await expect(page.getByText(/Delivery history is unavailable/i)).toHaveCount(0);
await expect(heldLoading).toBeVisible();
await expect(deferred).toBeVisible();
await expect(delivered).toBeVisible();
await expect(refresh).toBeEnabled();
releaseHeldEvents!();
heldEventsBarrier = null;
await expect(heldWarning).toBeVisible();
await expect(heldLoading).toHaveCount(0);
// Substring text lookup is case-insensitive and also matches the warning's
// "deferred notifications". Assert the exact status badge, not useful copy.
await expect(deferred).toHaveCount(0);
await expect(activity.getByText('Delivery Node (cpu)', { exact: true })).toHaveCount(0);
await expect(delivered).toBeVisible();
await expect(activity.getByText(/could not read the delivery log/i)).toHaveCount(0);
await expect(activity.getByText(/No alert deliveries were attempted/)).toHaveCount(0);
heldEventsStatus = 200;
await refresh.focus();
await page.keyboard.press('Enter');
await expect(deferred).toBeVisible();
await expect(heldWarning).toHaveCount(0);
// Permission withdrawal removes previously readable held evidence, not
// independent delivery attempts.
heldEventsStatus = 403;
const denied = page.waitForResponse((response) =>
new URL(response.url()).pathname === '/api/alerts/events' && response.status() === 403,
);
await refresh.focus();
await page.keyboard.press('Enter');
await denied;
await expect(heldLoading).toHaveCount(0);
await expect(heldWarning).toBeVisible();
await expect(deferred).toHaveCount(0);
await expect(delivered).toBeVisible();
// A pending retry keeps the failure visible; only a healthy held read
// restores that source's rows and clears its warning.
heldEventsStatus = 200;
heldEventsBarrier = new Promise<void>((resolve) => { releaseHeldEvents = resolve; });
await refresh.focus();
await page.keyboard.press('Enter');
await expect(heldLoading).toBeVisible();
await expect(heldWarning).toBeVisible();
await expect(deferred).toHaveCount(0);
await expect(delivered).toBeVisible();
await expect(refresh).toBeEnabled();
releaseHeldEvents!();
heldEventsBarrier = null;
await expect(deferred).toBeVisible();
await expect(heldLoading).toHaveCount(0);
await expect(heldWarning).toHaveCount(0);
await expect(delivered).toBeVisible();
});
test('mounts a bounded runway and renders 10,000 History records within budget', async ({ page }, testInfo) => {