Show backup age in workload rows

This commit is contained in:
rcourtman 2026-05-26 10:05:40 +01:00
parent d6f2ec202d
commit 6f4a9ca88b
5 changed files with 80 additions and 5 deletions

View file

@ -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

View file

@ -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

View file

@ -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 (
<>
<span
class={`flex-shrink-0 cursor-help ${config().color}`}
class={getBackupAgeBadgeClass(info().status)}
onMouseEnter={tip.onMouseEnter}
onMouseLeave={tip.onMouseLeave}
aria-label={`Backup status: ${info().status}`}
aria-label={ariaLabel()}
>
<svg
class="w-4 h-4"
class="h-3 w-3 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@ -306,6 +341,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine
<path d="M10 10l4 4M14 10l-4 4" />
</Show>
</svg>
<span>{badgeLabel()}</span>
</span>
<TooltipPortal when={tip.show()} x={tip.pos().x} y={tip.pos().y}>

View file

@ -187,6 +187,7 @@ function renderGuestRow(props: Parameters<typeof GuestRow>[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' }),

View file

@ -303,7 +303,8 @@ export const GUEST_COLUMNS: ColumnDef[] = [
/>
</svg>
),
width: '50px',
width: '72px',
minWidth: '68px',
toggleable: true,
kind: 'badge',
},