Integrate reviewed backup timestamp uncertainty

Preserve the exact Web candidate and its connected source and production-component browser proof. Unusable completion times stay cautionary in existing rows and drawers; running activity and genuine absence remain distinct.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 09:28:07 +01:00
17 changed files with 773 additions and 27 deletions
@@ -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
@@ -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
@@ -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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup time evidence verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./backup-time-evidence.tsx"></script>
</body>
</html>
@@ -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<Resource[]>(
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 (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Backup time evidence</h1>
<p class="text-xs text-muted">Synthetic {transport} timestamps; no backup command is sent.</p>
<Show when={Boolean(guest())}>
<section aria-label="Backup age row">
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'backup']}
workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'}
/>
</tbody>
</table>
</section>
<section aria-label="Backup indicator row">
<table class="w-full table-fixed">
<tbody>
<GuestRow guest={guest()} visibleColumnIds={['name']} />
</tbody>
</table>
</section>
<section aria-label="Guest details">
<GuestDrawer guest={guest()} onClose={() => setClosed((n) => n + 1)} />
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -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."
}
@@ -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(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
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',
@@ -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: {
</span>
</div>
</Show>
<Show when={!hasCompletedBackup()}>
<Show when={info().status === 'unknown'}>
<div class="py-0.5 text-amber-400">{info().ageFormatted}</div>
</Show>
<Show when={info().status === 'never'}>
<div class="py-0.5 text-red-400">
{props.backupRunning
? 'No completed backup yet - the first backup is running now.'
@@ -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<string | number>(future);
render(() => (
<div>
<BackupStatusCell lastBackup={timestamp()} backupRunning={running} />
<BackupIndicator lastBackup={timestamp()} backupRunning={running} isTemplate={false} />
</div>
));
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/);
},
);
});
@@ -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,
@@ -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),
@@ -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<UseWorkloadsModule['useWorkloads']> | 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([
{
+5 -4
View File
@@ -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
@@ -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',
@@ -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',
+29 -4
View File
@@ -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
@@ -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()}`;
}