From 6f4a9ca88bbbeaaea98d4e78286f51163597afd0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 26 May 2026 10:05:40 +0100 Subject: [PATCH] Show backup age in workload rows --- .../subsystems/performance-and-scalability.md | 4 ++ .../internal/subsystems/storage-recovery.md | 6 +++ .../components/Workloads/GuestRowCells.tsx | 44 +++++++++++++++++-- .../Workloads/__tests__/GuestRow.test.tsx | 28 ++++++++++++ .../components/Workloads/guestRowModel.tsx | 3 +- 5 files changed, 80 insertions(+), 5 deletions(-) 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 23f425ce9..936f37438 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -299,6 +299,10 @@ regression protection. namespace reference in tooltip/detail metadata, and the image column must stay left-aligned as text; table rows should optimize for container/version scanning, not registry path inspection. + Workload backup cells may expose a compact visible freshness label derived + from the row's scalar `lastBackup` value, but they must remain + presentation-only: no per-row backup API calls, recovery evidence joins, or + provider-specific backup fetches belong in the shared workload hot path. 11. Extend workload drawer derivations and runtime wiring through `frontend-modern/src/components/Workloads/guestDrawerModel.ts` and `frontend-modern/src/components/Workloads/useGuestDrawerState.ts`, and extend drawer overview rendering through `frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx`, rather than rebuilding canonical guest identity, discovery routing, or drawer-local normalization inside `frontend-modern/src/components/Workloads/GuestDrawer.tsx` Drawer history charts belong to `frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx`. History cards must let the plot area stretch to the card height instead of diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a6a0fd051..f87c35817 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -103,6 +103,12 @@ into PVE archive or snapshot semantics. Workload coverage posture must be derived from real recovery evidence and recent task outcomes, including explicit uncovered and failed-latest-task states, rather than from source-detail row counts alone. +The Proxmox overview workload table's Backup column is the primary +glance-level protection signal for current guests. It must remain visible, +dense, and backed by the canonical guest `LastBackup` synchronization from PVE +storage and PBS evidence; the Proxmox Backups tab is the drilldown for +coverage, restore-point, and source-detail evidence, not the first place users +must visit to learn whether a guest is backed up. The platform page embedding point may pass read-only Proxmox guest inventory into that backup surface solely for workload identity correlation; the backup surface must still source restore evidence from the PVE/PBS backup APIs rather diff --git a/frontend-modern/src/components/Workloads/GuestRowCells.tsx b/frontend-modern/src/components/Workloads/GuestRowCells.tsx index 34b196c39..ab1c3b923 100644 --- a/frontend-modern/src/components/Workloads/GuestRowCells.tsx +++ b/frontend-modern/src/components/Workloads/GuestRowCells.tsx @@ -4,7 +4,7 @@ import { TooltipPortal } from '@/components/shared/TooltipPortal'; import { useTooltip } from '@/hooks/useTooltip'; import { useAlertsActivation } from '@/stores/alertsActivation'; import type { GuestNetworkInterface } from '@/types/api'; -import { formatBytes, getBackupInfo } from '@/utils/format'; +import { formatBytes, formatRelativeTime, getBackupInfo, type BackupInfo } from '@/utils/format'; import { getWorkloadsGuestBackupStatusPresentation, getWorkloadsGuestBackupTooltip, @@ -71,6 +71,35 @@ function BackupIndicator(props: { ); } +function getBackupAgeBadgeLabel( + lastBackup: string | number | null | undefined, + info: BackupInfo, +): string { + if (info.status === 'never') return 'None'; + const compact = formatRelativeTime(lastBackup ?? undefined, { + compact: true, + emptyText: 'Unknown', + }); + if (compact === 'just now') return 'Now'; + return compact.replace(/\s+ago$/, ''); +} + +function getBackupAgeBadgeClass(status: BackupInfo['status']): string { + const base = + 'inline-flex h-5 min-w-[3.25rem] items-center justify-center gap-1 rounded-full border px-1.5 text-[10px] font-semibold leading-none tabular-nums cursor-help'; + + switch (status) { + case 'fresh': + return `${base} border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/40 dark:text-green-300`; + case 'stale': + return `${base} border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900/70 dark:bg-yellow-950/40 dark:text-yellow-300`; + case 'critical': + return `${base} border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300`; + case 'never': + return `${base} border-border bg-surface-alt text-muted`; + } +} + function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: GuestNetworkInterface[]; @@ -276,17 +305,23 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine getBackupInfo(props.lastBackup, alertsActivation.getBackupThresholds()), ); const config = createMemo(() => getWorkloadsGuestBackupStatusPresentation(info().status)); + const badgeLabel = createMemo(() => getBackupAgeBadgeLabel(props.lastBackup, info())); + const ariaLabel = createMemo(() => { + const currentInfo = info(); + if (currentInfo.status === 'never') return 'Backup status: never'; + return `Backup status: ${currentInfo.status}, last backup ${currentInfo.ageFormatted}`; + }); return ( <> + {badgeLabel()} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 9a2bdd404..6cc1a1e1f 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -187,6 +187,7 @@ function renderGuestRow(props: Parameters[0]) { afterEach(() => { cleanup(); + vi.useRealTimers(); vi.clearAllMocks(); isMobileMock.mockReturnValue(false); }); @@ -1172,6 +1173,33 @@ describe('context column for PVE workloads', () => { }); describe('backup column', () => { + it('shows compact age text for supported guests with a backup', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-26T12:00:00Z')); + + const { container } = renderGuestRow({ + guest: makeGuest({ + type: 'qemu', + workloadType: 'vm', + lastBackup: Date.parse('2026-05-26T07:00:00Z'), + }), + visibleColumnIds: ['name', 'backup'], + }); + + expect(screen.getByText('5h')).toBeTruthy(); + expect( + container.querySelector('[aria-label="Backup status: fresh, last backup 5 hours ago"]'), + ).toBeTruthy(); + }); + + it('shows None for supported guests without a backup', () => { + renderGuestRow({ + guest: makeGuest({ type: 'qemu', workloadType: 'vm', lastBackup: 0 }), + visibleColumnIds: ['name', 'backup'], + }); + expect(screen.getByText('None')).toBeTruthy(); + }); + it('shows dash for app-container workloads (no backup support)', () => { renderGuestRow({ guest: makeGuest({ type: 'app-container', workloadType: 'app-container' }), diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 89b103bee..d080cba97 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -303,7 +303,8 @@ export const GUEST_COLUMNS: ColumnDef[] = [ /> ), - width: '50px', + width: '72px', + minWidth: '68px', toggleable: true, kind: 'badge', },