From 174a32ca3d7aaa89e1f1b7718543b1f79342bbce Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 17:50:55 +0100
Subject: [PATCH] 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
---
.../subsystems/frontend-primitives.md | 18 ++
.../subsystems/performance-and-scalability.md | 18 ++
.../2ebeb891380553fa.json | 79 ++++++++
.../components/Workloads/GuestDrawer.test.tsx | 20 +-
.../Workloads/GuestDrawerOverview.tsx | 23 ++-
.../GuestDrawer.backup-clock.test.tsx | 187 ++++++++++++++++++
.../guestDrawerModel.branchcov2.test.ts | 21 +-
.../components/Workloads/guestDrawerModel.ts | 12 +-
.../Workloads/useGuestDrawerState.ts | 3 +
9 files changed, 356 insertions(+), 25 deletions(-)
create mode 100644 frontend-modern/browser-verification/2ebeb891380553fa.json
create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 88ca0ec11e..76759c8ba5 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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.
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index bbda325c7f..836e0e1492 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -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.
diff --git a/frontend-modern/browser-verification/2ebeb891380553fa.json b/frontend-modern/browser-verification/2ebeb891380553fa.json
new file mode 100644
index 0000000000..c70030850d
--- /dev/null
+++ b/frontend-modern/browser-verification/2ebeb891380553fa.json
@@ -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"
+ }
+}
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
index a1f14123aa..85201a76e3 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
@@ -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(() => );
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(() => );
- 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(() => );
- 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(() => (
{
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);
});
diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
index 319748c473..fcb8d8675f 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx
@@ -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;
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: (
+ <>
+
+ {protectionPresentation().label}
+
+
+
+
+ >
+ ),
}),
makeDetailRow(
'Backup activity',
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx
new file mode 100644
index 0000000000..afcee678b4
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawer.backup-clock.test.tsx
@@ -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('@/api/charts');
+ return { ...actual, ChartsAPI: { ...actual.ChartsAPI, getMetricsHistory: calls.history } };
+});
+vi.mock('@/api/resourceOperatorState', async () => {
+ const actual = await vi.importActual(
+ '@/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 {state.backupPresentation().ageClass};
+ }
+ render(() => );
+ 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(() => {}} />);
+ 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(() => {}} />);
+ 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(() => {}} />);
+ 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(() => {}} />);
+ 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(() => {}} />);
+ 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(() => {}} />);
+ const initial = backupRow().textContent;
+ vi.advanceTimersByTime(60_000);
+ expect(backupRow()).toHaveTextContent(initial!);
+ expect(completion()).toBeNull();
+ expect(calls.history).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
index 2ffd5318e2..0e6cb0570a 100644
--- a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts
@@ -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());
});
});
});
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index cb06222121..d9b26fdb02 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -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,
};
};
diff --git a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
index 8d0a49be66..e2f262ba83 100644
--- a/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
+++ b/frontend-modern/src/components/Workloads/useGuestDrawerState.ts
@@ -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('overview');
const [historyRange, setHistoryRange] = createSignal(
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));