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));