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