Keep open guest drawers truthful about completed backups

Age the existing completed-backup readout and policy tone on the shared passive clock. Show elapsed age and the validated UTC completion time without clipping; keep activity, absence and invalid times separate. Cover default and custom policy boundaries, clock changes, tab round trips and retained completion with connected controls and desktop/phone browser proof.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-12 17:50:55 +01:00
parent 0d8ade2b3b
commit 174a32ca3d
9 changed files with 356 additions and 25 deletions
@@ -9416,3 +9416,21 @@ provider precedence, stopping/recovery and zero. The local
Workloads snapshot adapter/drawer, History and CSS on desktop and phone, with
synthetic GET responses. These controls do not establish native restart, thaw,
covered-filesystem writes, installed recovery or containing software.
### Guest backup completion readout (12 October 2026)
The existing guest Overview's Last completed backup row shows elapsed age
(hours/minutes for recent points, days for older ones), never a rolling-day
Today/Yesterday claim. A validated completion also has visible UTC date/time
in a semantic `time` element with its original ISO timestamp. Invalid, future
or absent times produce no completion element. The row still distinguishes
running activity from completed protection and preserves the policy's tone;
the passive clock may rejudge a previously future timestamp when it becomes
valid, but cannot supply new completion evidence.
`GuestDrawer.backup-clock.test.tsx` and the connected drawer/model controls
cover these states and update on time alone. Desktop Chromium and touch-phone
WebKit exercise the real policy producer, consuming drawer, shared clock and
existing table evidence. No route, guest check, backup, threshold, collector or
permission changes. This presentation result does not establish thaw, covered
filesystem writes, native restart recovery or containing released software.
@@ -4116,3 +4116,21 @@ notification is introduced. Current snapshot/delta fast paths retain their
existing scope; only availability complete-record replacement changes, never
other partial provider facets or independent metric updates. This is truthful
presentation and scoped invalidation, not a measured whole-fleet CPU saving.
### Open guest drawers age completed backups passively (12 October 2026)
`useGuestDrawerState` derives completed-backup age and policy tone from the
existing shared 30-second clock, not just a new guest snapshot. This adds a
clock subscription for the mounted drawer, not a timer per row, network read,
refresh, guest probe or alert evaluation. Disposal releases the subscription.
The supplied completed timestamp is immutable under ticks and backup activity;
only replacement completion evidence changes it. Configured fresh/stale hours
retain their existing inclusive boundaries and unknown/absent distinctions.
`GuestDrawer.backup-clock.test.tsx` covers passive default/custom policy
crossings, unchanged completion time, tab round trips, later completion,
future/backwards-clock uncertainty and timer disposal. Connected
`GuestDrawer.test.tsx` and `guestDrawerModel.branchcov2.test.ts` retain existing
backup-activity, configured policy and caution/absence controls. Browser proof
uses the real read-only AlertsAPI/store and existing drawer/table badge with
synthetic timestamps, not native backup safety, guest recovery or availability.
@@ -0,0 +1,79 @@
{
"version": 1,
"result": "passed",
"base_sha": "0d8ade2b3b8cf73a60eb9efe07534e45f1d7072a",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "801aa50aec4c0ec7b801f4f14da60ef9c9a56a886f6df3f509f3fc844ecf4dca",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "66ada43fb44fcdbdbbde5640f21ed92d84115f27011a4d49bdcc8b711e0dc088",
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f3a446f40d39212d00dcbef049918297f211fdf5b8ee3b96094bf6438cbe17a9"
},
"routes": [
"/tmp/backup-clock/index.html (isolated existing production guest drawer/table fixture, no product route added)"
],
"states": [
"unchanged VM/LXC completion across a saved one-hour freshness boundary",
"default 24-hour policy covered by connected source controls",
"running/ended activity separate from prior completion",
"new completion then passive minute ageing",
"absent/invalid/future timestamps",
"previous-calendar-day completion"
],
"interactions": [
"read synthetic saved policy through actual AlertsAPI/store",
"advance passive browser clock without API requests; both drawer and table reclassify",
"Manage/Overview round trip retains original completed time",
"replace completion evidence without remounting the workload",
"keyboard and touch open existing table evidence; Escape closes and restores focus",
"inspect six final PNG captures with native view_image at original detail"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"verified_at": "2026-10-12T16:49:58.639554Z",
"proof_scope": "Read-only synthetic alert policy through real AlertsAPI/store, guest drawer state/model/Overview, existing backup table badge and production CSS. Passive Playwright clocks, no native backup/recovery or availability claim.",
"browser_proof": {
"checks": 32,
"loaded_production_hashes": 115,
"captures": 6,
"client_exit": 0,
"playwright": "1.56.1",
"browsers": [
{
"name": "desktop-light",
"version": "141.0.7390.37",
"viewport": {
"width": 1440,
"height": 900
}
},
{
"name": "phone-dark",
"version": "26.0",
"viewport": {
"width": 390,
"height": 844
}
}
],
"cleanup": {
"desktop-light-context": "closed",
"desktop-light-browser": "closed",
"phone-dark-context": "closed",
"phone-dark-browser": "closed",
"server": "closed"
},
"dossier": "RECEIPTS.md / browser/result.json and native-image-review.json"
}
}
@@ -319,12 +319,12 @@ describe('GuestDrawer', () => {
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();
expect(details.queryByText('1 hour ago')).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.getByText('1 hour ago')).toBeVisible();
expect(details.queryByText(/unavailable/)).not.toBeInTheDocument();
setGuest({ ...guest(), lastBackup: 0 });
expect(details.getByText('No completed backup found')).toBeVisible();
@@ -1011,17 +1011,17 @@ describe('GuestDrawer', () => {
vi.setSystemTime(new Date('2026-03-02T12:00:00Z'));
});
it('shows "Today" for a backup from today', () => {
it('shows elapsed hours for a recent backup', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
expect(screen.getByText('Last completed backup')).toBeInTheDocument();
expect(technicalDetails().getByText('Today')).toBeInTheDocument();
expect(technicalDetails().getByText('2 hours ago')).toBeInTheDocument();
});
it('shows "Yesterday" for a 1-day-old backup', () => {
it('shows elapsed days for a 1-day-old backup', () => {
const yesterday = new Date('2026-03-01T12:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: yesterday })} onClose={vi.fn()} />);
expect(technicalDetails().getByText('Yesterday')).toBeInTheDocument();
expect(technicalDetails().getByText('1d ago')).toBeInTheDocument();
});
it('shows "Xd ago" for older backups', () => {
@@ -1054,7 +1054,7 @@ describe('GuestDrawer', () => {
it('applies green color for recent backups', () => {
const now = new Date('2026-03-02T10:00:00Z').getTime();
render(() => <GuestDrawer guest={makeGuest({ lastBackup: now })} onClose={vi.fn()} />);
const ageCell = technicalDetails().getByText('Today').closest('td');
const ageCell = technicalDetails().getByText('2 hours ago').closest('td');
expect(ageCell).toHaveClass('text-emerald-700');
});
@@ -1080,7 +1080,7 @@ describe('GuestDrawer', () => {
it.each([
['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'],
['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'],
['2026-03-02T10:00:00Z', '2 hours ago', 'text-emerald-700'],
])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => {
solidRender(() => (
<GuestDrawer
@@ -1108,7 +1108,9 @@ describe('GuestDrawer', () => {
expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument();
setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() });
expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument();
expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700');
expect(technicalDetails().getByText('2 hours ago').closest('td')).toHaveClass(
'text-emerald-700',
);
expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer);
});
@@ -26,6 +26,7 @@ import {
getGuestDrawerMemoryReading,
getGuestDrawerGuestReadPresentation,
isGuestDrawerVM,
type GuestDrawerBackupPresentation,
} from './guestDrawerModel';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import { WORKLOAD_ACTION_AGENT_LABEL } from './workloadAgentReadiness';
@@ -59,11 +60,7 @@ interface GuestDrawerOverviewProps {
networkInterfaces: NonNullable<GuestDrawerProps['guest']['networkInterfaces']>;
nestedWorkloadContext?: NestedWorkloadContext;
normalizedTags: string[];
backupPresentation: {
ageClass: string;
ageLabel: string;
dateLabel: string;
} | null;
backupPresentation: GuestDrawerBackupPresentation | null;
diskThresholds?: MetricDisplayThresholds | null;
workloadActionAgentTitle: string;
parentMemoryTotal?: GuestDrawerProps['parentMemoryTotal'];
@@ -143,7 +140,23 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
// backup. Keep its activity separate from the last completed age.
makeDetailRow('Last completed backup', protectionPresentation().label, {
tone: protectionPresentation().tone,
layout: 'stacked',
wrap: true,
valueContent: (
<>
<span class="block whitespace-normal wrap-break-word text-left leading-snug">
{protectionPresentation().label}
</span>
<Show when={props.backupPresentation?.completedAt}>
<time
class="mt-1 block whitespace-normal wrap-break-word text-left font-normal leading-snug text-muted"
dateTime={props.backupPresentation?.completedAt}
>
{props.backupPresentation?.dateLabel}
</time>
</Show>
</>
),
}),
makeDetailRow(
'Backup activity',
@@ -0,0 +1,187 @@
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkloadGuest } from '@/types/workloads';
import { RELATIVE_TIME_TICK_MS } from '@/utils/relativeTimeClock';
import { GuestDrawer } from '../GuestDrawer';
import { useGuestDrawerState } from '../useGuestDrawerState';
const policy = vi.hoisted(() => ({ freshHours: 24, staleHours: 72 }));
const calls = vi.hoisted(() => ({ history: vi.fn() }));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({
getBackupThresholds: () => policy,
getMetricThresholds: () => null,
}),
}));
vi.mock('@/api/charts', async () => {
const actual = await vi.importActual<typeof import('@/api/charts')>('@/api/charts');
return { ...actual, ChartsAPI: { ...actual.ChartsAPI, getMetricsHistory: calls.history } };
});
vi.mock('@/api/resourceOperatorState', async () => {
const actual = await vi.importActual<typeof import('@/api/resourceOperatorState')>(
'@/api/resourceOperatorState',
);
return { ...actual, getResourceOperatorState: vi.fn().mockResolvedValue(null) };
});
vi.mock('@/stores/license', () => ({
isRangeLocked: () => false,
loadRuntimeCapabilities: vi.fn(),
maxHistoryDays: () => 90,
}));
const start = Date.parse('2026-10-12T12:00:00Z');
const hour = 3600_000;
const guest = (type: 'qemu' | 'lxc', lastBackup: number | string): WorkloadGuest => ({
id: 'lab:pve-a:101',
name: 'backup-clock-fixture',
vmid: 101,
instance: 'lab',
node: 'pve-a',
type,
status: 'running',
cpu: 0.1,
cpus: 1,
memory: { total: 1024, used: 256, free: 768, usage: 25 },
disk: { total: 1024, used: 256, free: 768, usage: 25 },
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
tags: [],
lastBackup,
});
const details = () => within(screen.getByTestId('guest-technical-details'));
const backupRow = () => details().getByText('Last completed backup').closest('tr')!;
const completion = () => backupRow().querySelector('time');
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(start);
policy.freshHours = 24;
policy.staleHours = 72;
calls.history.mockClear();
});
afterEach(() => {
cleanup();
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
describe('mounted completed-backup evidence', () => {
it('invalidates the saved age policy on time alone, independently of new copy or markup', () => {
function StateProbe() {
const state = useGuestDrawerState({
guest: guest('qemu', start - 24 * hour + 5_000),
onClose: () => {},
});
return <span data-testid="age-policy">{state.backupPresentation().ageClass}</span>;
}
render(() => <StateProbe />);
expect(screen.getByTestId('age-policy')).toHaveTextContent('text-green-600');
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(screen.getByTestId('age-policy')).toHaveTextContent('text-yellow-600');
});
it.each(['qemu', 'lxc'] as const)(
'ages an unchanged %s backup across the default policy boundary',
(type) => {
const timestamp = start - 24 * hour + 5_000;
render(() => <GuestDrawer guest={guest(type, timestamp)} onClose={() => {}} />);
const drawer = screen.getByTestId('guest-technical-details');
const row = backupRow();
expect(within(row).getByText('23 hours ago').closest('td')).toHaveClass('text-emerald-700');
const originalTime = completion();
expect(originalTime).toHaveAttribute('datetime', new Date(timestamp).toISOString());
expect(originalTime).toHaveTextContent('2026-10-11 12:00:05 UTC');
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(within(backupRow()).getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
expect(screen.getByTestId('guest-technical-details')).toBe(drawer);
expect(calls.history).not.toHaveBeenCalled();
},
);
it.each(['qemu', 'lxc'] as const)(
'ages an unchanged %s backup under a one-hour policy',
(type) => {
policy.freshHours = 1;
policy.staleHours = 3;
render(() => <GuestDrawer guest={guest(type, start - hour + 5_000)} onClose={() => {}} />);
expect(details().getByText('59 mins ago').closest('td')).toHaveClass('text-emerald-700');
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(details().getByText('1 hour ago').closest('td')).toHaveClass('text-amber-700');
expect(calls.history).not.toHaveBeenCalled();
},
);
it('does not label a previous-calendar-day completion Today', () => {
vi.setSystemTime(Date.parse('2026-10-12T00:30:00Z'));
render(() => <GuestDrawer guest={guest('qemu', '2026-10-11T23:30:00Z')} onClose={() => {}} />);
expect(details().getByText('1 hour ago')).toBeVisible();
expect(completion()).toHaveTextContent('2026-10-11 23:30:00 UTC');
expect(details().queryByText('Today')).not.toBeInTheDocument();
});
it('keeps activity separate and ages while another drawer tab is open', () => {
const timestamp = start - 24 * hour + 5_000;
const [current, setCurrent] = createSignal({
...guest('qemu', timestamp),
backupInProgress: true,
});
render(() => <GuestDrawer guest={current()} onClose={() => {}} />);
expect(details().getByText('Running · not completed yet')).toBeVisible();
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
setCurrent({ ...current(), backupInProgress: false });
fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
expect(details().getByText('1d ago').closest('td')).toHaveClass('text-amber-700');
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
expect(details().queryByText('Running · not completed yet')).not.toBeInTheDocument();
setCurrent({ ...current(), lastBackup: start + RELATIVE_TIME_TICK_MS });
expect(details().getByText('0s ago').closest('td')).toHaveClass('text-emerald-700');
expect(completion()).toHaveAttribute('datetime', '2026-10-12T12:00:30.000Z');
vi.advanceTimersByTime(60_000);
expect(details().getByText('1 min ago')).toBeVisible();
expect(calls.history).not.toHaveBeenCalled();
});
it('rejudges future and backwards-clock times without manufacturing completion', () => {
const timestamp = start + 45_000;
render(() => <GuestDrawer guest={guest('qemu', timestamp)} onClose={() => {}} />);
expect(
details()
.getByText(/timestamp is in the future/)
.closest('td'),
).toHaveClass('text-amber-700');
expect(completion()).toBeNull();
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(completion()).toBeNull();
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(details().getByText('15s ago').closest('td')).toHaveClass('text-emerald-700');
expect(completion()).toHaveAttribute('datetime', new Date(timestamp).toISOString());
vi.setSystemTime(start - hour);
vi.advanceTimersByTime(RELATIVE_TIME_TICK_MS);
expect(
details()
.getByText(/timestamp is in the future/)
.closest('td'),
).toHaveClass('text-amber-700');
expect(completion()).toBeNull();
expect(details().queryByText('No completed backup found')).not.toBeInTheDocument();
expect(calls.history).not.toHaveBeenCalled();
});
it.each([0, NaN, 'invalid-time'])(
'does not turn absent or invalid evidence %s into a dated completion',
(timestamp) => {
render(() => <GuestDrawer guest={guest('lxc', timestamp)} onClose={() => {}} />);
const initial = backupRow().textContent;
vi.advanceTimersByTime(60_000);
expect(backupRow()).toHaveTextContent(initial!);
expect(completion()).toBeNull();
expect(calls.history).not.toHaveBeenCalled();
},
);
});
@@ -686,21 +686,25 @@ describe('guestDrawerModel (branch coverage)', () => {
const expectPresentation = (days: number) => {
const lastBackup = backupNDaysAgo(days);
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
expect(result.dateLabel).toBe(
new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
);
expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
return result;
};
it('labels day 0 as Today and uses the green (fresh) class', () => {
it('labels a just-completed backup with elapsed time and the green (fresh) class', () => {
expect(expectPresentation(0)).toStrictEqual({
ageClass: 'text-green-600 dark:text-green-400',
ageLabel: 'Today',
dateLabel: new Date(backupNDaysAgo(0)).toLocaleDateString(),
ageLabel: '0s ago',
dateLabel: now.toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
completedAt: now.toISOString(),
});
});
it('labels day 1 as Yesterday', () => {
it('labels day 1 as elapsed days', () => {
const result = expectPresentation(1);
expect(result.ageLabel).toBe('Yesterday');
expect(result.ageLabel).toBe('1d ago');
expect(result.ageClass).toBe('text-green-600 dark:text-green-400');
});
@@ -733,7 +737,10 @@ describe('guestDrawerModel (branch coverage)', () => {
const result = getGuestDrawerBackupPresentation(lastBackup, undefined, now);
expect(result.ageLabel).toBe('10d ago');
expect(result.ageClass).toBe('text-yellow-600 dark:text-yellow-400');
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
expect(result.dateLabel).toBe(
new Date(lastBackup).toISOString().replace('T', ' ').replace('.000Z', ' UTC'),
);
expect(result.completedAt).toBe(new Date(lastBackup).toISOString());
});
});
});
@@ -83,6 +83,7 @@ export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
dateLabel: string;
completedAt?: string;
}
export const isGuestDrawerVM = (guest: Guest): guest is VM => resolveWorkloadType(guest) === 'vm';
@@ -632,7 +633,7 @@ export const normalizeGuestDrawerTags = (tags: Guest['tags']): string[] => {
export const getGuestDrawerBackupPresentation = (
lastBackup: string | number | Date | null | undefined,
thresholds?: BackupThresholds,
now: Date = new Date(),
now: number | Date = Date.now(),
): GuestDrawerBackupPresentation => {
const timestamp = lastBackup instanceof Date ? lastBackup.getTime() : lastBackup;
const info = getBackupInfo(timestamp, thresholds, now);
@@ -644,12 +645,15 @@ export const getGuestDrawerBackupPresentation = (
dateLabel: 'Unknown',
};
}
const backupDate = new Date(timestamp!);
const completedAt = new Date(timestamp!).toISOString();
const daysSince = Math.floor(info.ageMs / (1000 * 60 * 60 * 24));
return {
ageClass: statusPresentation.color,
ageLabel: daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`,
dateLabel: backupDate.toLocaleDateString(),
// Elapsed age, not a rolling-day calendar claim: a 23-hour-old backup
// need not have completed today. Keep older ages in days, like the table.
ageLabel: daysSince === 0 ? info.ageFormatted : `${daysSince}d ago`,
dateLabel: completedAt.replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC'),
completedAt,
};
};
@@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal } from 'solid-js';
import type { HistoryTimeRange } from '@/api/charts';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import type { DisplayMetricType } from '@/utils/metricThresholds';
import {
getCanonicalWorkloadId,
@@ -36,6 +37,7 @@ import {
export function useGuestDrawerState(props: GuestDrawerProps) {
const alertsActivation = useAlertsActivation();
const now = useRelativeTimeNow();
const [activeTab, setActiveTab] = createSignal<GuestDrawerTab>('overview');
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
@@ -84,6 +86,7 @@ export function useGuestDrawerState(props: GuestDrawerProps) {
getGuestDrawerBackupPresentation(
props.guest.lastBackup,
alertsActivation.getBackupThresholds(),
now(),
),
);
const historyTarget = createMemo(() => getGuestDrawerHistoryTarget(props.guest));