mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Show backup age in workload rows
This commit is contained in:
parent
d6f2ec202d
commit
6f4a9ca88b
5 changed files with 80 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -303,7 +303,8 @@ export const GUEST_COLUMNS: ColumnDef[] = [
|
|||
/>
|
||||
</svg>
|
||||
),
|
||||
width: '50px',
|
||||
width: '72px',
|
||||
minWidth: '68px',
|
||||
toggleable: true,
|
||||
kind: 'badge',
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue