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-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/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/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." + ] +} 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, 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) => {