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()}`; }