Clarify Proxmox backup table columns

This commit is contained in:
rcourtman
2026-06-01 11:59:48 +01:00
parent b4e1957c47
commit 3e0c7be81c
9 changed files with 74 additions and 28 deletions
@@ -32,6 +32,7 @@ import { getProxmoxBackupSourcePresentation } from './proxmoxBackupSourcePresent
import {
ArtifactSourceBadge,
ArtifactStateBadge,
PROXMOX_BACKUP_COLUMN_LABELS,
ProxmoxBackupWorkloadTypeBadge,
SortableHead,
artifactStateLabel,
@@ -149,7 +150,9 @@ export function ProxmoxCoverageTable(props: {
headClass={getPlatformTableHeadClassForKind('name')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Type</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>ID</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
{PROXMOX_BACKUP_COLUMN_LABELS.targetId}
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
<SortableHead
label="Posture"
@@ -404,10 +407,14 @@ export function ProxmoxCoverageTable(props: {
<tr class="bg-surface-alt text-muted">
<th class="px-2 py-0.5 text-left font-medium">Source</th>
<th class="px-2 py-0.5 text-left font-medium">Location</th>
<th class="px-2 py-0.5 text-right font-medium">Created</th>
<th class="px-2 py-0.5 text-right font-medium">
{PROXMOX_BACKUP_COLUMN_LABELS.created}
</th>
<th class="px-2 py-0.5 text-right font-medium">Size</th>
<th class="px-2 py-0.5 text-left font-medium">State</th>
<th class="px-2 py-0.5 text-left font-medium">Details</th>
<th class="px-2 py-0.5 text-left font-medium">
{PROXMOX_BACKUP_COLUMN_LABELS.details}
</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
@@ -29,6 +29,7 @@ import type { RecoverableSortKey } from './proxmoxBackupsTableModel';
import {
ArtifactSourceBadge,
ArtifactStateBadge,
PROXMOX_BACKUP_COLUMN_LABELS,
ProxmoxBackupWorkloadTypeBadge,
RowMetricBar,
SortableHead,
@@ -187,7 +188,9 @@ export function ProxmoxRecoverableTable(props: {
headClass={getPlatformTableHeadClassForKind('name')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Type</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>ID</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
{PROXMOX_BACKUP_COLUMN_LABELS.targetId}
</TableHead>
<SortableHead
label="Source"
sortKey="source"
@@ -207,7 +210,7 @@ export function ProxmoxRecoverableTable(props: {
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Created"
label={PROXMOX_BACKUP_COLUMN_LABELS.created}
sortKey="created"
currentSort={props.sortKey}
direction={props.sortDirection}
@@ -233,7 +236,9 @@ export function ProxmoxRecoverableTable(props: {
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Details</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
{PROXMOX_BACKUP_COLUMN_LABELS.details}
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@@ -250,8 +255,7 @@ export function ProxmoxRecoverableTable(props: {
colspan={COLUMN_COUNT}
class="border-t border-border bg-surface-alt px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-base-content"
>
{group.label}
{' '}
{group.label}{' '}
<span class="ml-2 normal-case tracking-normal text-muted">
{group.items.length} {group.items.length === 1 ? 'backup' : 'backups'}
</span>
@@ -143,8 +143,10 @@ describe('ProxmoxBackupsTable', () => {
expect(screen.getByRole('columnheader', { name: /location/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /source/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /type/i })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /target id/i })).toBeInTheDocument();
expect(screen.getAllByText('LXC').length).toBeGreaterThan(0);
expect(screen.getAllByText('PBS').length).toBeGreaterThan(0);
expect(screen.getByText('2 PBS files')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /pbs snapshots/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /pve backup files/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /guest snapshots/i })).toBeInTheDocument();
@@ -55,12 +55,12 @@ describe('ProxmoxCoverageTable column visibility', () => {
// Always-on columns.
expect(headers).toContain('Workload');
expect(headers).toContain('Type');
expect(headers).toContain('ID');
expect(headers).toContain('Target ID');
expect(headers).toContain('Node');
// Source columns gate on their flags.
expect(headers).toContain('PBS');
expect(headers).toContain('Snapshots');
expect(headers).not.toContain('PVE files');
expect(headers).toContain('Latest PBS');
expect(headers).toContain('Latest snapshot');
expect(headers).not.toContain('Latest PVE file');
expect(headers).not.toContain('Latest task');
});
});
@@ -108,11 +108,28 @@ describe('proxmoxBackupRecoveryModel', () => {
'PVE file',
'Snapshot',
]);
expect(model.recoverableArtifacts[0].detail).toBe('2 PBS files');
expect(coverageRowMatchesSearch(row, 'pbs-docker')).toBe(true);
expect(coverageRowMatchesSearch(row, 'PVE backup file')).toBe(true);
expect(recoverableArtifactMatchesSearch(model.recoverableArtifacts[0], 'main')).toBe(true);
});
it('does not describe PBS backups with omitted file manifests as zero-file backups', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
pbsBackups: [pbsBackup({ files: [] })],
archives: [],
snapshots: [],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
expect(model.recoverableArtifacts[0].detail).toBe('PBS files not listed');
expect(recoverableArtifactMatchesSearch(model.recoverableArtifacts[0], 'not listed')).toBe(
true,
);
});
it('surfaces a failed latest backup task as workload attention', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [workload({})],
@@ -12,16 +12,19 @@ describe('proxmoxBackupSourcePresentation', () => {
expect(getProxmoxBackupSourcePresentation('pbs')).toMatchObject({
badgeLabel: 'PBS',
coverageColumnLabel: 'Latest PBS',
filterLabel: 'PBS snapshots',
timelineLabel: 'PBS snapshots',
});
expect(getProxmoxBackupSourcePresentation('archive')).toMatchObject({
badgeLabel: 'PVE file',
coverageColumnLabel: 'Latest PVE file',
filterLabel: 'PVE backup files',
timelineLabel: 'PVE backup files',
});
expect(getProxmoxBackupSourcePresentation('snapshot')).toMatchObject({
badgeLabel: 'Snapshot',
coverageColumnLabel: 'Latest snapshot',
filterLabel: 'Guest snapshots',
timelineLabel: 'Guest snapshots',
});
@@ -116,6 +116,12 @@ function parseTimestampMs(value: string | undefined): number | undefined {
return Number.isFinite(ms) ? ms : undefined;
}
function pbsBackupFileDetailLabel(fileCount: number): string {
if (fileCount <= 0) return 'PBS files not listed';
if (fileCount === 1) return '1 PBS file';
return `${fileCount} PBS files`;
}
function normalizeKey(value: string | number | undefined | null): string {
return String(value ?? '')
.trim()
@@ -463,7 +469,7 @@ export function buildProxmoxBackupRecoveryModel(
createdMs,
size: backup.size,
location: `${backup.datastore || '—'} / ${backup.namespace?.trim() || '(root)'}`,
detail: backup.files.length === 1 ? '1 file' : `${backup.files.length} files`,
detail: pbsBackupFileDetailLabel(backup.files.length),
protected: backup.protected,
verified: backup.verified,
fileCount: backup.files.length,
@@ -24,7 +24,7 @@ const SOURCE_PRESENTATION: Record<ProxmoxBackupSourceKind, ProxmoxBackupSourcePr
badgeClassName: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-200',
badgeLabel: 'PBS',
compactFilterLabel: 'PBS',
coverageColumnLabel: 'PBS',
coverageColumnLabel: 'Latest PBS',
coverageFallbackLabel: 'No PBS snapshot',
detailFallbackLabel: 'PBS snapshot',
filterAriaLabel: 'PBS snapshots from Proxmox Backup Server',
@@ -40,7 +40,7 @@ const SOURCE_PRESENTATION: Record<ProxmoxBackupSourceKind, ProxmoxBackupSourcePr
badgeClassName: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
badgeLabel: 'PVE file',
compactFilterLabel: 'PVE files',
coverageColumnLabel: 'PVE files',
coverageColumnLabel: 'Latest PVE file',
coverageFallbackLabel: 'No PVE backup file',
detailFallbackLabel: 'PVE backup file',
filterAriaLabel: 'PVE backup files found on Proxmox VE storage',
@@ -56,7 +56,7 @@ const SOURCE_PRESENTATION: Record<ProxmoxBackupSourceKind, ProxmoxBackupSourcePr
badgeClassName: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200',
badgeLabel: 'Snapshot',
compactFilterLabel: 'Snapshots',
coverageColumnLabel: 'Snapshots',
coverageColumnLabel: 'Latest snapshot',
coverageFallbackLabel: 'No guest snapshot',
detailFallbackLabel: 'Guest snapshot',
filterAriaLabel: 'Guest snapshots from Proxmox VE',
@@ -26,18 +26,25 @@ import type {
const statusDot = (className: string) => <span class={`h-2 w-2 rounded-full ${className}`} />;
export const ARCHIVE_STATUS_FILTERS: FilterOption<'all' | 'protected' | 'verified' | 'unverified'>[] =
[
{ value: 'all', label: 'All' },
{ value: 'protected', label: 'Protected', tone: 'info', leading: statusDot('bg-blue-500') },
{ value: 'verified', label: 'Verified', tone: 'success', leading: statusDot('bg-emerald-500') },
{
value: 'unverified',
label: 'Unverified',
tone: 'warning',
leading: statusDot('bg-amber-500'),
},
];
export const PROXMOX_BACKUP_COLUMN_LABELS = {
targetId: 'Target ID',
created: 'Created',
details: 'Details',
} as const;
export const ARCHIVE_STATUS_FILTERS: FilterOption<
'all' | 'protected' | 'verified' | 'unverified'
>[] = [
{ value: 'all', label: 'All' },
{ value: 'protected', label: 'Protected', tone: 'info', leading: statusDot('bg-blue-500') },
{ value: 'verified', label: 'Verified', tone: 'success', leading: statusDot('bg-emerald-500') },
{
value: 'unverified',
label: 'Unverified',
tone: 'warning',
leading: statusDot('bg-amber-500'),
},
];
export const PBS_STATUS_FILTERS: FilterOption<'all' | 'protected' | 'verified' | 'unverified'>[] = [
{ value: 'all', label: 'All' },