mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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.'
|
||||
|
||||
+114
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user