From a0b3c09e07bd700374a587db7908fa780d6d7631 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:10:50 +0100 Subject: [PATCH] Keep uncertain backup timestamps out of healthy protection Preserve malformed timestamp evidence through resource projection and label future, invalid or unusable dates as unknown in existing row indicators, tooltips and drawer protection. Keep explicit absence, configured age thresholds and running activity distinct. Validate real API and canonical snapshot replacements with parent controls and production-component browser fixtures; no native backup or thaw claim. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 19 ++ .../subsystems/performance-and-scalability.md | 17 + .../browser-tests/backup-time-evidence.cjs | 315 ++++++++++++++++++ .../browser-tests/backup-time-evidence.html | 12 + .../browser-tests/backup-time-evidence.tsx | 119 +++++++ .../9a842b8ced818bae.json | 55 +++ .../components/Workloads/GuestDrawer.test.tsx | 17 + .../components/Workloads/GuestRowCells.tsx | 12 +- .../backupTimeEvidence.contract.test.tsx | 114 +++++++ .../components/Workloads/guestDrawerModel.ts | 19 +- .../Workloads/useGuestDrawerState.ts | 10 +- .../src/hooks/__tests__/useWorkloads.test.ts | 19 ++ frontend-modern/src/hooks/useWorkloads.ts | 9 +- .../src/utils/__tests__/format.test.ts | 6 +- .../workloadGuestPresentation.test.ts | 13 + frontend-modern/src/utils/format.ts | 33 +- .../src/utils/workloadGuestPresentation.ts | 11 +- 17 files changed, 773 insertions(+), 27 deletions(-) create mode 100644 frontend-modern/browser-tests/backup-time-evidence.cjs create mode 100644 frontend-modern/browser-tests/backup-time-evidence.html create mode 100644 frontend-modern/browser-tests/backup-time-evidence.tsx create mode 100644 frontend-modern/browser-verification/9a842b8ced818bae.json create mode 100644 frontend-modern/src/components/Workloads/__tests__/backupTimeEvidence.contract.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ca63ff380d..8f582d0b22 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,25 @@ ## Purpose +### Backup age requires a usable completed timestamp + +Existing workload indicators, age badges and drawer protection context distinguish +an absent completed-backup timestamp from a present but unusable one. Finite, +Date-representable positive timestamps no later than the current time may receive +the configured freshness verdict. Future, malformed and out-of-range times are +**Unknown**, cautionary rather than healthy or missing. The fixed explanation +never renders the invalid raw value; future times direct the operator to check +the Proxmox and browser clocks. An invalid current clock cannot confer freshness. +Zero, empty and absent values retain the existing missing-backup interpretation. + +Running activity remains separate: it neither validates the timestamp nor creates +a completed backup. The badge, its tooltip and accessible name retain uncertainty +while running; valid same-guest replacement restores the ordinary age verdict. +`backupTimeEvidence.contract.test.tsx`, `format.test.ts` and +`workloadGuestPresentation.test.ts` cover this admission and presentation. The +real row/drawer browser fixture is `browser-tests/backup-time-evidence.cjs`. +No backup execution, thaw, restored workload or installed acceptance is implied. + ### Filesystem capacity is not measured usage The existing filesystem list and guest Overview require finite positive capacity 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 97fbaf51bd..ea2c7509ac 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -15,6 +15,23 @@ ## Purpose +### Workload backup projection preserves timestamp uncertainty + +Both resource-API and owning-canonical-snapshot paths preserve a present malformed +backup time for the existing shared age admission, rather than replacing it with +the numeric zero absence sentinel. Only missing/empty values and the explicit Go +zero-time value (including zero fractional seconds) map to absence. The drawer +does not gate its backup presentation on timestamp truthiness: NaN must reach the +unknown verdict, not disappear as a missing completed backup. Future dates cannot +be clamped to Today. This adds only constant-time validation to the current mapper +and presentation memos; it introduces no requests, timers or retained caches. + +`useWorkloads.test.ts`, `GuestDrawer.test.tsx` and the focused backup time evidence +controls pin the boundary. `browser-tests/backup-time-evidence.cjs` exercises real +resource projection, rows, indicators and drawer replacement with synthetic API +and canonical full/delta observations; those observations are not native backup +or release proof. + ### Guest discovery evidence and UI state have exact owners The guest drawer's passive discovery lookup changes only when its explicit diff --git a/frontend-modern/browser-tests/backup-time-evidence.cjs b/frontend-modern/browser-tests/backup-time-evidence.cjs new file mode 100644 index 0000000000..4f2a1f85b7 --- /dev/null +++ b/frontend-modern/browser-tests/backup-time-evidence.cjs @@ -0,0 +1,315 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/backup-time/browser'; +const origin = 'http://127.0.0.1:5297'; +const now = '2026-10-04T08:00:00Z'; +const fresh = '2026-10-04T06:00:00Z'; +const future = '2026-10-04T08:01:00Z'; +const futureMessage = + 'Backup time unavailable: timestamp is in the future. Check the Proxmox and browser clocks.'; +const invalidMessage = 'Backup time unavailable: invalid timestamp.'; +const runtime = [ + 'frontend-modern/src/utils/format.ts', + 'frontend-modern/src/utils/workloadGuestPresentation.ts', + 'frontend-modern/src/hooks/useWorkloads.ts', + 'frontend-modern/src/components/Workloads/GuestRowCells.tsx', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', + 'frontend-modern/src/components/Workloads/useGuestDrawerState.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: 5297, strictPort: true }, + }); + const results = []; + const screenshots = []; + let browser; + let phase = 'server'; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop', chromium, 1365, false], + ['webkit-phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const transport of ['api', 'canonical-full', 'canonical-delta']) { + for (const kind of ['qemu', 'lxc']) { + phase = `${name}/${transport}/${kind}`; + 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(now)); + let observation = { timestamp: fresh, running: false }; + let resourceReads = 0; + const errors = []; + const writes = []; + const checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + assert.equal(url.origin, origin, 'no off-origin requests'); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') writes.push(`${request.method()} ${url.pathname}`); + let body = { success: true, data: [], alerts: [], anomalies: [], config: null }; + if (url.pathname === '/api/resources') { + resourceReads++; + body = { + data: [ + { + id: 'fixture-a-pve1-101', + type: kind === 'lxc' ? 'system-container' : 'vm', + name: 'backup-guest', + status: 'running', + sources: ['proxmox'], + metrics: { cpu: { percent: 10 }, memory: { percent: 25 } }, + proxmox: { + instance: 'fixture-a', + nodeName: 'pve1', + vmid: 101, + runtimeStatus: 'running', + lastBackup: observation.timestamp, + backupInProgress: observation.running, + lock: observation.running ? 'backup' : '', + disks: [], + }, + }, + ], + meta: { totalPages: 1 }, + }; + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + }); + if (dark) + await page.addInitScript(() => + document.addEventListener('DOMContentLoaded', () => + document.documentElement.classList.add('dark'), + ), + ); + await page.goto( + `${origin}/browser-tests/backup-time-evidence.html?transport=${transport}&kind=${kind}`, + { waitUntil: 'domcontentloaded' }, + ); + const row = page.getByRole('region', { name: 'Backup age row' }); + const badge = row.locator('[aria-label^="Backup status:"]'); + const indicatorRow = page.getByRole('region', { name: 'Backup indicator row' }); + const indicator = indicatorRow.locator( + '[aria-label^="Last backup:"], [aria-label^="No backup"], [aria-label^="Backup running now"], [aria-label^="Backup time unavailable:"]', + ); + const details = page.getByTestId('guest-technical-details'); + await details.waitFor(); + await badge.waitFor(); + const identity = await page.evaluate(() => window.__backupTimeEvidence.identity()); + await page.evaluate(() => { + window.__originalBackupRow = document.querySelector('[aria-label="Backup age row"] tr'); + window.__originalBackupDetails = document.querySelector( + '[data-testid="guest-technical-details"]', + ); + }); + const observe = async (timestamp, running = false) => { + observation = { timestamp, running }; + await page.evaluate( + async (value) => window.__backupTimeEvidence.update(value), + observation, + ); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + assert.equal( + await page.evaluate(() => window.__backupTimeEvidence.identity()), + identity, + ); + assert( + await page.evaluate( + () => + window.__originalBackupRow === + document.querySelector('[aria-label="Backup age row"] tr'), + ), + ); + assert( + await page.evaluate( + () => + window.__originalBackupDetails === + document.querySelector('[data-testid="guest-technical-details"]'), + ), + ); + }; + 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 checkUnknown = async (message, running) => { + assert.equal((await badge.innerText()).trim(), running ? 'Running' : 'Unknown'); + assert((await badge.getAttribute('aria-label')).includes(message)); + assert( + (await indicator.getAttribute('aria-label')) + .toLowerCase() + .includes(message.toLowerCase()), + ); + assert(!(await badge.getAttribute('class')).includes('green')); + assert(!(await indicator.getAttribute('class')).includes('green')); + const reason = details.getByText(message, { exact: true }); + await reason.waitFor({ state: 'visible' }); + assert((await reason.locator('..').getAttribute('class')).includes('amber')); + assert.equal( + await details.getByText('No completed backup found', { exact: true }).count(), + 0, + ); + assert.equal(await details.getByText('Today', { exact: true }).count(), 0); + assert.equal( + await details.getByText('Running · not completed yet', { exact: true }).count(), + running ? 1 : 0, + ); + assert( + !(await details.innerText()).includes('NaN') && + !(await details.innerText()).includes('Invalid Date'), + ); + checks.push(`${message} running=${running}: row, indicator, drawer and identity`); + }; + + assert((await badge.getAttribute('class')).includes('green')); + await details.getByText('Today', { exact: true }).waitFor(); + checks.push('valid initial completion retains fresh policy'); + await observe(future); + await checkUnknown(futureMessage, false); + if (transport === 'api' && kind === 'qemu') await capture('future'); + await observe(future, true); + await checkUnknown(futureMessage, true); + await observe('not-a-date', true); + await checkUnknown(invalidMessage, true); + if (name === 'chromium-desktop') { + await badge.hover(); + const tooltip = page.locator('[data-tooltip-portal="true"]'); + await tooltip.waitFor({ state: 'visible' }); + assert((await tooltip.innerText()).includes(invalidMessage)); + assert( + !(await tooltip.innerText()).includes('No backup has ever') && + !(await tooltip.innerText()).includes('Invalid Date'), + ); + await page.locator('h1').hover(); + checks.push( + 'hover tooltip preserves timestamp uncertainty without invalid date or absence claim', + ); + } + if (transport === 'api' && kind === 'qemu') await capture('invalid-running'); + await observe('not-a-date'); + await checkUnknown(invalidMessage, false); + await observe('0002-01-01T00:00:00Z'); + await checkUnknown(invalidMessage, false); + for (const timestamp of [ + undefined, + '', + '0001-01-01T00:00:00Z', + '0001-01-01T00:00:00.000000000Z', + ]) { + await observe(timestamp); + assert.equal((await badge.innerText()).trim(), 'None'); + assert((await badge.getAttribute('class')).includes('red')); + await details.getByText('No completed backup found', { exact: true }).waitFor(); + checks.push(`explicit absence ${timestamp}: missing, not unknown`); + } + if (transport === 'api' && kind === 'lxc') await capture('missing'); + await observe('2026-09-24T08:00:00Z', true); + await details.getByText('10d ago', { exact: true }).waitFor(); + assert.equal((await badge.innerText()).trim(), 'Running'); + assert((await badge.getAttribute('aria-label')).includes('last completed backup')); + checks.push('valid overdue completion retained independently of running activity'); + await observe(fresh); + assert((await badge.getAttribute('class')).includes('green')); + assert((await indicator.getAttribute('class')).includes('green')); + await details.getByText('Today', { exact: true }).waitFor(); + assert(!(await details.innerText()).includes('unavailable')); + const close = page.getByRole('button', { name: 'Collapse backup-guest details' }); + if (width === 390) await close.tap(); + else { + await close.focus(); + await page.keyboard.press('Enter'); + } + assert.equal(await page.evaluate(() => window.__backupTimeEvidence.closed()), 1); + assert( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ); + checks.push( + 'valid recovery, same mounted surfaces, keyboard/touch collapse and no page overflow', + ); + if (transport === 'api' && kind === 'qemu') await capture('recovered'); + assert.deepEqual(errors, []); + assert.deepEqual(writes, []); + assert.equal( + resourceReads > 0, + transport === 'api', + 'canonical path makes no parallel workload request', + ); + results.push({ + name, + browserVersion: browser.version(), + transport, + kind, + identity, + resourceReads, + checks, + errors, + writes, + }); + await page.close(); + } + } + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { result: 'passed', playwrightVersion, runtime: hashes, results, screenshots }, + null, + 2, + ), + ); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failure.json'), + JSON.stringify({ phase, error: error.message, results, screenshots }, null, 2), + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + process.stderr.write(`${error.stack}\n`); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/backup-time-evidence.html b/frontend-modern/browser-tests/backup-time-evidence.html new file mode 100644 index 0000000000..c5aaa6005d --- /dev/null +++ b/frontend-modern/browser-tests/backup-time-evidence.html @@ -0,0 +1,12 @@ + + + + + + Backup time evidence verification + + +
+ + + diff --git a/frontend-modern/browser-tests/backup-time-evidence.tsx b/frontend-modern/browser-tests/backup-time-evidence.tsx new file mode 100644 index 0000000000..bbf14476e9 --- /dev/null +++ b/frontend-modern/browser-tests/backup-time-evidence.tsx @@ -0,0 +1,119 @@ +// Production resource projection, rows and drawer; synthetic timestamps only. +// This fixture performs no backup, guest-agent, restore or thaw operation. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { + mergeCanonicalResourceSnapshot, + mergeCanonicalResourceDeltaSnapshot, +} from '../src/utils/resourceStateAdapters'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); +type Observation = { timestamp?: string; running?: boolean }; +const initial: Observation = { timestamp: '2026-10-04T06:00:00Z', running: false }; +const params = new URLSearchParams(window.location.search); +const kind = params.get('kind') === 'lxc' ? 'system-container' : 'vm'; +const nativeResource = (value: Observation): Resource => ({ + id: 'fixture-a-pve1-101', + type: kind, + name: 'backup-guest', + displayName: 'backup-guest', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'online', + sources: ['proxmox'], + lastSeen: Date.UTC(2026, 9, 4, 8), + cpu: { current: 10 }, + memory: { current: 25, used: 1024 ** 3 / 4, total: 1024 ** 3 }, + disk: { current: 50, used: 5 * 1024 ** 3, total: 10 * 1024 ** 3 }, + proxmox: { + instance: 'fixture-a', + nodeName: 'pve1', + vmid: 101, + runtimeStatus: 'running', + template: false, + lastBackup: value.timestamp, + backupInProgress: value.running ?? false, + lock: value.running ? 'backup' : '', + disks: [], + }, +}); + +function Fixture() { + const transport = params.get('transport') || 'api'; + const [snapshot, setSnapshot] = createSignal( + mergeCanonicalResourceSnapshot([nativeResource(initial)], []), + ); + const state = useWorkloads(() => true, transport === 'api' ? {} : { resourceSnapshot: snapshot }); + const guest = () => state.workloads()[0]!; + const [closed, setClosed] = createSignal(0); + (window as any).__backupTimeEvidence = { + update: async (value: Observation) => { + if (transport === 'api') { + await state.refetch(); + } else { + const incoming = nativeResource(value); + setSnapshot((previous) => + transport === 'canonical-full' + ? mergeCanonicalResourceSnapshot([incoming], previous) + : mergeCanonicalResourceDeltaSnapshot([incoming], previous, new Set([incoming.id])), + ); + } + }, + identity: () => guest().id, + closed, + }; + return ( +
+

Backup time evidence

+

Synthetic {transport} timestamps; no backup command is sent.

+ +
+ + + + +
+
+
+ + + + +
+
+
+ setClosed((n) => n + 1)} /> +
+
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/9a842b8ced818bae.json b/frontend-modern/browser-verification/9a842b8ced818bae.json new file mode 100644 index 0000000000..2a4fa0a0ba --- /dev/null +++ b/frontend-modern/browser-verification/9a842b8ced818bae.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "fe42207eb4bfb04461cc780f3f32d3d7bcbe4d4b", + "content_sha256": { + "frontend-modern/src/utils/format.ts": "52db9924828b2d41af130ce836b9dd41e0b697600b2f1b4afed73c218046b9ea", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "d9a8dfb35b6bbdbb9fde65c724a2b8b1182e1fff3b199dfb55121fdf968b8865", + "frontend-modern/src/hooks/useWorkloads.ts": "eb7f649920e134a9e9759324fec319c050f30baceb4c8a662640397f6489256f", + "frontend-modern/src/components/Workloads/GuestRowCells.tsx": "43c2c631d279719c6f311ec23591c7d62792ca57bf33d3bd847bfad17f69cfd8", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "aaf8b1b31d228461988bead4ad3949aafac8cb51043429b69ae0908539024ffc", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "63e46feb843079e27eafb84c65f6985674f576b4b5fbf331f58d77337fe24dc1" + }, + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRowCells.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/components/Workloads/useGuestDrawerState.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/utils/format.ts", + "frontend-modern/src/utils/workloadGuestPresentation.ts" + ], + "routes": [ + "Existing Proxmox VM/LXC workload row, hidden-column backup indicator and guest Overview through production-component fixture" + ], + "states": [ + "Valid fresh/overdue completions", + "Future/malformed/non-positive timestamps: unknown, not fresh or missing", + "Running activity with unknown completion age", + "Absent, empty and explicit Go zero-time timestamps", + "Same-guest valid recovery without remount" + ], + "interactions": [ + "Resource API replacement", + "Canonical full/delta replacement", + "Desktop hover tooltip", + "Keyboard Enter collapse", + "Touch collapse" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "verified_at": "2026-10-04T08:10:06.767899Z", + "browsers": [ + "Chromium desktop", + "WebKit phone emulation; not native iOS" + ], + "command": "pulse-worker-browser frontend-modern/browser-tests/backup-time-evidence.cjs", + "proof": "12 production-component fixture paths, 150 state/identity/accessibility assertions with synthetic timestamps. No native backup, appliance, thaw, restore or shipped acceptance." +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index b54ded0491..4aa35bd42c 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -220,6 +220,23 @@ afterEach(() => { // ── Tests ────────────────────────────────────────────────────────────── describe('GuestDrawer', () => { + it('keeps malformed and future backup times unknown through same-guest replacement', () => { + const [guest, setGuest] = createSignal(makeGuest({ lastBackup: NaN })); + render(() => ); + const details = technicalDetails(); + expect(details.getByText('Backup time unavailable: invalid timestamp.')).toBeVisible(); + expect(details.queryByText('No completed backup found')).not.toBeInTheDocument(); + expect(details.queryByText('Today')).not.toBeInTheDocument(); + setGuest({ ...guest(), lastBackup: Date.now() + 86_400_000, backupInProgress: true }); + expect(details.getByText(/timestamp is in the future/)).toBeVisible(); + expect(details.getByText('Running · not completed yet')).toBeVisible(); + setGuest({ ...guest(), lastBackup: Date.now() - 3600_000, backupInProgress: false }); + expect(details.getByText('Today')).toBeVisible(); + expect(details.queryByText(/unavailable/)).not.toBeInTheDocument(); + setGuest({ ...guest(), lastBackup: 0 }); + expect(details.getByText('No completed backup found')).toBeVisible(); + }); + it('keeps deferred filesystem fallback last-known through History and fresh resumption', async () => { chartsApiMocks.getMetricsHistory.mockResolvedValue({ resourceType: 'vm', diff --git a/frontend-modern/src/components/Workloads/GuestRowCells.tsx b/frontend-modern/src/components/Workloads/GuestRowCells.tsx index 9bcc470f3e..2ace665fbe 100644 --- a/frontend-modern/src/components/Workloads/GuestRowCells.tsx +++ b/frontend-modern/src/components/Workloads/GuestRowCells.tsx @@ -76,6 +76,7 @@ function getBackupAgeBadgeLabel( info: BackupInfo, ): string { if (info.status === 'never') return 'None'; + if (info.status === 'unknown') return 'Unknown'; const compact = formatRelativeTime(lastBackup ?? undefined, { compact: true, emptyText: 'Unknown', @@ -99,6 +100,7 @@ function getBackupAgeBadgeClass(status: WorkloadsGuestBackupDisplayStatus): stri case 'stale': return `${pill} border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/70 dark:bg-yellow-950/40 dark:text-yellow-300`; case 'overdue': + case 'unknown': return `${pill} border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/70 dark:bg-yellow-950/40 dark:text-yellow-300`; case 'never': return `${pill} border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300`; @@ -332,9 +334,12 @@ function BackupStatusCell(props: { const badgeLabel = createMemo(() => displayStatus() === 'running' ? 'Running' : getBackupAgeBadgeLabel(props.lastBackup, info()), ); - const hasCompletedBackup = createMemo(() => info().status !== 'never'); + const hasCompletedBackup = createMemo(() => info().ageMs !== null); const ariaLabel = createMemo(() => { const currentInfo = info(); + if (currentInfo.status === 'unknown') { + return `Backup status: ${props.backupRunning ? 'backup running now, ' : ''}${currentInfo.ageFormatted}`; + } if (props.backupRunning) { return hasCompletedBackup() ? `Backup status: backup running now, last completed backup ${currentInfo.ageFormatted}` @@ -387,7 +392,10 @@ function BackupStatusCell(props: { - + +
{info().ageFormatted}
+
+
{props.backupRunning ? 'No completed backup yet - the first backup is running now.' diff --git a/frontend-modern/src/components/Workloads/__tests__/backupTimeEvidence.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/backupTimeEvidence.contract.test.tsx new file mode 100644 index 0000000000..3979f230de --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/backupTimeEvidence.contract.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSignal } from 'solid-js'; +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { getBackupInfo } from '@/utils/format'; +import { BackupIndicator, BackupStatusCell } from '../GuestRowCells'; +import { getGuestDrawerBackupPresentation } from '../guestDrawerModel'; + +vi.mock('@/stores/alertsActivation', () => ({ + useAlertsActivation: () => ({ getBackupThresholds: () => undefined }), +})); + +const now = new Date('2026-10-04T08:00:00Z'); +const future = now.getTime() + 60_000; +const invalidMessage = 'Backup time unavailable: invalid timestamp.'; +const futureMessage = + 'Backup time unavailable: timestamp is in the future. Check the Proxmox and browser clocks.'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('backup time evidence', () => { + it.each([ + ['malformed', 'not-a-date', invalidMessage], + ['NaN', NaN, invalidMessage], + ['infinity', Infinity, invalidMessage], + ['negative', -1, invalidMessage], + ['outside Date range', 8.64e15 + 1, invalidMessage], + ['future numeric', future, futureMessage], + ['future ISO', new Date(future).toISOString(), futureMessage], + ] as const)( + 'regression: %s is uncertainty, never freshness or absence', + (_name, value, message) => { + const info = getBackupInfo(value, undefined, now); + expect(info).toEqual({ status: 'unknown', ageMs: null, ageFormatted: message }); + const drawer = getGuestDrawerBackupPresentation(value, undefined, now); + expect(drawer.ageLabel).toBe(message); + expect(drawer.ageClass).not.toContain('green'); + expect(drawer.ageClass).not.toContain('red'); + expect(drawer.dateLabel).toBe('Unknown'); + }, + ); + + it.each([NaN, Infinity, new Date(NaN), 8.64e15 + 1])( + 'regression: an unusable current time %s cannot confer freshness', + (clock) => { + expect(getBackupInfo(now.getTime() - 3600_000, undefined, clock)).toEqual({ + status: 'unknown', + ageMs: null, + ageFormatted: 'Backup age unavailable: invalid current time.', + }); + }, + ); + + it.each([null, undefined, '', 0])('control: absence sentinel %s stays missing', (value) => { + expect(getBackupInfo(value, undefined, now)).toEqual({ + status: 'never', + ageMs: null, + ageFormatted: 'Never', + }); + }); + + it.each([ + [0, 'fresh'], + [24 * 3600_000, 'fresh'], + [24 * 3600_000 + 1, 'stale'], + [72 * 3600_000, 'stale'], + [72 * 3600_000 + 1, 'overdue'], + ] as const)('control: a valid age of %s retains %s', (age, status) => { + const value = now.getTime() - age; + expect(getBackupInfo(value, undefined, now).status).toBe(status); + expect(getBackupInfo(new Date(value).toISOString(), undefined, now).status).toBe(status); + }); + + it('control: valid policy-relative thresholds stay in effect', () => { + expect( + getBackupInfo(now.getTime() - 15 * 24 * 3600_000, { freshHours: 16 * 24 }, now).status, + ).toBe('fresh'); + }); + + it.each([false, true])( + 'regression: row and indicator keep uncertainty while running=%s', + (running) => { + vi.useFakeTimers(); + vi.setSystemTime(now); + const [timestamp, setTimestamp] = createSignal(future); + render(() => ( +
+ + +
+ )); + const badge = screen.getByLabelText(/^Backup status:/); + expect(badge).toHaveTextContent(running ? 'Running' : 'Unknown'); + expect(badge).toHaveAccessibleName(new RegExp('timestamp is in the future')); + expect(badge.className).not.toContain('green'); + const indicator = screen.getByLabelText( + running ? /^Backup running now · backup time unavailable/ : /^Backup time unavailable/, + ); + expect(indicator.className).not.toContain('green'); + + setTimestamp('invalid'); + expect(badge).toHaveAccessibleName(new RegExp('invalid timestamp')); + expect(badge).not.toHaveAccessibleName(/no completed backup/); + setTimestamp(now.getTime() - 3600_000); + expect(badge).not.toHaveAccessibleName(/unavailable/); + expect(badge.className).toContain(running ? 'blue' : 'green'); + setTimestamp(0); + expect(badge).toHaveTextContent(running ? 'Running' : 'None'); + expect(badge).toHaveAccessibleName(/no (completed )?backup/); + }, + ); +}); diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 47ba99773e..127f8dd28b 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -430,17 +430,22 @@ export const normalizeGuestDrawerTags = (tags: Guest['tags']): string[] => { }; export const getGuestDrawerBackupPresentation = ( - lastBackup: string | number | Date, + lastBackup: string | number | Date | null | undefined, thresholds?: BackupThresholds, now: Date = new Date(), ): GuestDrawerBackupPresentation => { - const backupDate = new Date(lastBackup); - const daysSince = Math.max( - 0, - Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24)), - ); - const info = getBackupInfo(backupDate.getTime(), thresholds, now); + const timestamp = lastBackup instanceof Date ? lastBackup.getTime() : lastBackup; + const info = getBackupInfo(timestamp, thresholds, now); const statusPresentation = getWorkloadsGuestBackupStatusPresentation(info.status); + if (info.ageMs === null) { + return { + ageClass: statusPresentation.color, + ageLabel: info.status === 'never' ? 'No completed backup found' : info.ageFormatted, + dateLabel: 'Unknown', + }; + } + const backupDate = new Date(timestamp!); + const daysSince = Math.floor(info.ageMs / (1000 * 60 * 60 * 24)); return { ageClass: statusPresentation.color, diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts index ef40729969..9763e5b949 100644 --- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts +++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts @@ -99,12 +99,10 @@ export function useGuestDrawerState(props: GuestDrawerProps) { const hasNetworkInterfaces = createMemo(() => networkInterfaces().length > 0); const normalizedTags = createMemo(() => normalizeGuestDrawerTags(props.guest.tags)); const backupPresentation = createMemo(() => - props.guest.lastBackup - ? getGuestDrawerBackupPresentation( - props.guest.lastBackup, - alertsActivation.getBackupThresholds(), - ) - : null, + getGuestDrawerBackupPresentation( + props.guest.lastBackup, + alertsActivation.getBackupThresholds(), + ), ); const hasDiscoverySupport = createMemo( () => discoveryFeatureEnabled() && hasDiscoverySupportForWorkload(props.guest), diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 57f864710f..48bdb54cc2 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -80,6 +80,25 @@ describe('useWorkloads', () => { resetWorkloadsCacheForTests(); }); + it.each(['invalid', '0002-01-01T00:00:00Z'])( + 'does not turn a present unusable backup time %s into absence', + async (lastBackup) => { + const [snapshot] = createSignal([{ ...sampleResource, proxmox: { lastBackup } }] as any); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + try { + await flushAsync(); + expect(result!.workloads()[0]?.lastBackup).not.toBe(0); + } finally { + dispose(); + } + }, + ); + it('preserves linked-agent RAID arrays from the VM resource for guest detail', async () => { const [snapshot] = createSignal([ { diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 56ca84945c..b60f46937d 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -601,10 +601,11 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { uptime: uptime ?? 0, template: resource.proxmox?.template ?? false, lastBackup: (() => { - if (!resource.proxmox?.lastBackup) return 0; - const parsed = Date.parse(resource.proxmox.lastBackup); - // Go zero time "0001-01-01T00:00:00Z" parses to a large negative number - return parsed > 0 ? parsed : 0; + const timestamp = resource.proxmox?.lastBackup?.trim(); + // Go zero time is an explicit absence sentinel. Keep other invalid + // readings (NaN included) distinct so the UI can report uncertainty. + if (!timestamp || /^0001-01-01T00:00:00(?:\.0+)?Z$/.test(timestamp)) return 0; + return Date.parse(timestamp); })(), backupInProgress: resource.proxmox?.backupInProgress ?? false, // vSphere's flat `resource.tags` is a mixed keyword set: the adapter keeps diff --git a/frontend-modern/src/utils/__tests__/format.test.ts b/frontend-modern/src/utils/__tests__/format.test.ts index bf337a3b87..30522ac44b 100644 --- a/frontend-modern/src/utils/__tests__/format.test.ts +++ b/frontend-modern/src/utils/__tests__/format.test.ts @@ -242,11 +242,11 @@ describe('getBackupInfo', () => { }); }); - it('returns never status for invalid timestamp', () => { + it('keeps invalid timestamps distinct from an absent backup', () => { expect(getBackupInfo('invalid')).toEqual({ - status: 'never', + status: 'unknown', ageMs: null, - ageFormatted: 'Never', + ageFormatted: 'Backup time unavailable: invalid timestamp.', }); expect(getBackupInfo(0)).toEqual({ status: 'never', diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts index 28dd0c08ed..7edf413296 100644 --- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts @@ -44,6 +44,19 @@ describe('workloadGuestPresentation', () => { expect(getWorkloadsGuestBackupTooltip('stale', '3d')).toBe('Last backup: 3d'); }); + it('keeps an unknown backup age cautionary and distinct from no backup', () => { + expect(getWorkloadsGuestBackupStatusPresentation('unknown')).toEqual({ + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/25', + icon: 'warning', + }); + const reason = 'Backup time unavailable: invalid timestamp.'; + expect(getWorkloadsGuestBackupTooltip('unknown', reason)).toBe(reason); + expect(getWorkloadsGuestBackupTooltip('unknown', reason, true)).toBe( + `Backup running now · ${reason.toLowerCase()}`, + ); + }); + it('returns canonical compact protection context for object drawers', () => { expect(getWorkloadsGuestProtectionPresentation({})).toEqual({ label: 'No completed backup found', diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 7d959f9d89..49c35bbe2f 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -198,7 +198,7 @@ function formatTimeDiff(diffMs: number, compact?: boolean): string { } } -export type BackupStatus = 'fresh' | 'stale' | 'overdue' | 'never'; +export type BackupStatus = 'fresh' | 'stale' | 'overdue' | 'never' | 'unknown'; export interface BackupInfo { status: BackupStatus; @@ -227,7 +227,9 @@ export function getBackupInfo( thresholds?: BackupThresholds, now: number | Date = Date.now(), ): BackupInfo { - if (!lastBackup) { + // Only the explicit absence sentinels mean no completed backup was found. + // A present but unusable timestamp is uncertainty, not absence or freshness. + if (lastBackup === null || lastBackup === undefined || lastBackup === '' || lastBackup === 0) { return { status: 'never', ageMs: null, ageFormatted: 'Never' }; } @@ -238,11 +240,34 @@ export function getBackupInfo( timestamp = lastBackup; } - if (isNaN(timestamp) || timestamp <= 0) { - return { status: 'never', ageMs: null, ageFormatted: 'Never' }; + if ( + !Number.isFinite(timestamp) || + timestamp <= 0 || + !Number.isFinite(new Date(timestamp).getTime()) + ) { + return { + status: 'unknown', + ageMs: null, + ageFormatted: 'Backup time unavailable: invalid timestamp.', + }; } const nowMs = typeof now === 'number' ? now : now.getTime(); + if (!Number.isFinite(nowMs) || !Number.isFinite(new Date(nowMs).getTime())) { + return { + status: 'unknown', + ageMs: null, + ageFormatted: 'Backup age unavailable: invalid current time.', + }; + } + if (timestamp > nowMs) { + return { + status: 'unknown', + ageMs: null, + ageFormatted: + 'Backup time unavailable: timestamp is in the future. Check the Proxmox and browser clocks.', + }; + } const ageMs = nowMs - timestamp; // Use provided thresholds or fall back to defaults diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts index 41a08077c5..36fbe7d28f 100644 --- a/frontend-modern/src/utils/workloadGuestPresentation.ts +++ b/frontend-modern/src/utils/workloadGuestPresentation.ts @@ -40,6 +40,11 @@ const BACKUP_STATUS_PRESENTATION: Record< bgColor: 'bg-red-100 dark:bg-red-900/25', icon: 'x', }, + unknown: { + color: 'text-yellow-600 dark:text-yellow-400', + bgColor: 'bg-yellow-100 dark:bg-yellow-900/25', + icon: 'warning', + }, running: { color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/25', @@ -59,7 +64,11 @@ export function getWorkloadsGuestBackupTooltip( backupRunning?: boolean, ): string { const base = - status === 'never' ? 'No completed backup found' : `Last backup: ${ageFormatted || 'Unknown'}`; + status === 'never' + ? 'No completed backup found' + : status === 'unknown' + ? ageFormatted || 'Backup age unavailable' + : `Last backup: ${ageFormatted || 'Unknown'}`; if (backupRunning) { return `Backup running now · ${base.toLowerCase()}`; }