From bbf839a26194204fbe025415f67630f7b0afeece Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 19:36:44 +0100
Subject: [PATCH 1/3] Keep completed guest backup evidence visible during
activity
The real guest drawer replaced missing or old completed protection with a green running state. Preserve the completed age and tone, and show current activity separately as not completed yet. Ending activity without a new timestamp must not imply protection. Mounted and full-drawer VM/CT browser regressions preserve that distinction and the independent disk-lock guidance.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 28 +-
.../subsystems/performance-and-scalability.md | 8 +
.../browser-tests/guest-backup-protection.cjs | 255 ++++++++++++++++++
.../guest-backup-protection.html | 12 +
.../browser-tests/guest-backup-protection.tsx | 82 ++++++
.../98ed2bca9322b776.json | 56 ++++
.../components/Workloads/GuestDrawer.test.tsx | 65 ++++-
.../Workloads/GuestDrawerOverview.tsx | 11 +-
.../workloadGuestPresentation.test.ts | 12 +-
.../src/utils/workloadGuestPresentation.ts | 6 +-
10 files changed, 516 insertions(+), 19 deletions(-)
create mode 100644 frontend-modern/browser-tests/guest-backup-protection.cjs
create mode 100644 frontend-modern/browser-tests/guest-backup-protection.html
create mode 100644 frontend-modern/browser-tests/guest-backup-protection.tsx
create mode 100644 frontend-modern/browser-verification/98ed2bca9322b776.json
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 23f91dd104..9f64f9576c 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 9d9e0731fd..931c3191b5 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -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,
diff --git a/frontend-modern/browser-tests/guest-backup-protection.cjs b/frontend-modern/browser-tests/guest-backup-protection.cjs
new file mode 100644
index 0000000000..e220a85cd1
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-backup-protection.cjs
@@ -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;
+});
diff --git a/frontend-modern/browser-tests/guest-backup-protection.html b/frontend-modern/browser-tests/guest-backup-protection.html
new file mode 100644
index 0000000000..ff871fcf3c
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-backup-protection.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Guest backup protection verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/guest-backup-protection.tsx b/frontend-modern/browser-tests/guest-backup-protection.tsx
new file mode 100644
index 0000000000..37e9887ae7
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-backup-protection.tsx
@@ -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 (
+
+ Guest backup protection
+ Synthetic observations; no backup command is sent.
+
+ setCloseCount((count) => count + 1)} />
+
+
+ );
+}
+
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/98ed2bca9322b776.json b/frontend-modern/browser-verification/98ed2bca9322b776.json
new file mode 100644
index 0000000000..e3bc81dd66
--- /dev/null
+++ b/frontend-modern/browser-verification/98ed2bca9322b776.json
@@ -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."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
index adb3254ccb..47d2960bba 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
@@ -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(() => );
- 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(() => );
- expect(screen.queryByText('Backup')).not.toBeInTheDocument();
+ it.each(['qemu', 'lxc'] as const)(
+ 'keeps absent completed protection visible while a %s backup runs',
+ (type) => {
+ solidRender(() => (
+
+ ));
+ 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(() => (
+
+ ));
+ 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(() => );
+ 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(() => );
+ expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
+ expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument();
});
});
diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
index 2d843f3a35..fd5d76f240 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
@@ -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
diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
index e1194c4f5e..28dd0c08ed 100644
--- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
+++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts
@@ -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', () => {
diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts
index 00d1fe68cf..41a08077c5 100644
--- a/frontend-modern/src/utils/workloadGuestPresentation.ts
+++ b/frontend-modern/src/utils/workloadGuestPresentation.ts
@@ -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,
From d9a2c78612b21dd2e46de0f75b71c0ddaddb9f86 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 20:04:41 +0100
Subject: [PATCH 2/3] Assert held delivery rows separately from unreadable
evidence
The case-insensitive Deferred substring also matches the held-read warning after a 503. Scope the exact badge and source warning to delivery activity, prove pending retention and independent attempts, withdraw visible rows on 503 and 403, and recover only after a successful held read. Production runtime semantics are unchanged.
Contract-Neutral: Verification-only correction for existing alert evidence semantics; no runtime, alert policy or subsystem contract delta.
Change-source: pulse-maintainer
---
.../browser-tests/notification-evidence.cjs | 28 ++++-
.../93-alert-operator-qualification.spec.ts | 103 ++++++++++++++----
2 files changed, 110 insertions(+), 21 deletions(-)
diff --git a/frontend-modern/browser-tests/notification-evidence.cjs b/frontend-modern/browser-tests/notification-evidence.cjs
index 8a6fef7319..ad14362270 100644
--- a/frontend-modern/browser-tests/notification-evidence.cjs
+++ b/frontend-modern/browser-tests/notification-evidence.cjs
@@ -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 () => {
diff --git a/tests/integration/tests/93-alert-operator-qualification.spec.ts b/tests/integration/tests/93-alert-operator-qualification.spec.ts
index 9268223c13..e8d2d7bfe8 100644
--- a/tests/integration/tests/93-alert-operator-qualification.spec.ts
+++ b/tests/integration/tests/93-alert-operator-qualification.spec.ts
@@ -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 | 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((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((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) => {
From 5355a41c082363e45b34234e6a7b869e5be3b528 Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Sat, 3 Oct 2026 20:32:25 +0100
Subject: [PATCH 3/3] Record final browser proof for composed disk and backup
presentation
Fresh desktop Chromium and phone WebKit preserve the retained maximum-label structure with the restored hover ring and Unknown coverage dot. Read-only backup scope controls and the exact revised alert qualification spec pass. Record only exercised viewports and retain earlier valid receipts rather than rebinding them.
Contract-Neutral: Browser verification of existing composed frontend contracts and test-only alert qualification; no runtime or subsystem contract delta.
Change-source: pulse-maintainer
---
.../a562ef365082becb.json | 66 +++++++++++++++++++
1 file changed, 66 insertions(+)
create mode 100644 frontend-modern/browser-verification/a562ef365082becb.json
diff --git a/frontend-modern/browser-verification/a562ef365082becb.json b/frontend-modern/browser-verification/a562ef365082becb.json
new file mode 100644
index 0000000000..28ebd92908
--- /dev/null
+++ b/frontend-modern/browser-verification/a562ef365082becb.json
@@ -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."
+ ]
+}