Normalize Proxmox backup tables

This commit is contained in:
rcourtman
2026-05-20 12:37:55 +01:00
parent 92b3116047
commit 18e62d3ed6
5 changed files with 827 additions and 85 deletions
@@ -6,6 +6,7 @@ import kubernetesClustersTableSource from '@/features/kubernetes/KubernetesClust
import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDeploymentsTable.tsx?raw';
import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw';
import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.tsx?raw';
import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw';
import proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.tsx?raw';
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
@@ -52,6 +53,13 @@ const proxmoxDetailTableSources = [
proxmoxReplicationTableSource,
];
const proxmoxBespokeTableSources = [
proxmoxBackupsTableSource,
proxmoxCephTableSource,
proxmoxMailGatewayTableSource,
proxmoxReplicationTableSource,
];
describe('platform overview layout guardrails', () => {
it('keeps platform inventory tables on the shared dense table styling contract', () => {
expect(sharedPlatformPageSource).toContain('PLATFORM_TABLE_CARD_CLASS');
@@ -85,19 +93,22 @@ describe('platform overview layout guardrails', () => {
});
it('keeps Proxmox detail tables on the shared platform table primitives', () => {
for (const source of proxmoxDetailTableSources) {
for (const source of proxmoxBespokeTableSources) {
expect(source).toContain('TableCard');
expect(source).toContain('PlatformTableToolbar');
expect(source).toContain('PLATFORM_TABLE_CARD_CLASS');
expect(source).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(source).toContain('PLATFORM_TABLE_BODY_CLASS');
expect(source).toContain('getPlatformTableHeadClassForKind');
expect(source).toContain('getPlatformTableCellClassForKind');
expect(source).not.toContain("from '@/components/shared/SearchInput'");
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
expect(source).not.toContain('border-collapse text-xs');
expect(source).not.toContain('bg-surface-alt text-muted border-b border-border');
}
for (const source of proxmoxDetailTableSources) {
expect(source).toContain('PlatformTableToolbar');
expect(source).not.toContain("from '@/components/shared/SearchInput'");
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
}
});
it('keeps provider overview pages in the parent-table plus child-inventory stack', () => {
@@ -0,0 +1,319 @@
import { createSignal, For, Show } from 'solid-js';
import type { Accessor, Component } from 'solid-js';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
import {
SUMMARY_CHART_PLOT_AREA_CLASS,
SUMMARY_CHART_SLOT_COMPACT_CLASS,
} from '@/components/shared/summaryChartLayout';
import {
getRecoveryCompactAxisLabel,
getRecoveryPrettyDateLabel,
} from '@/utils/recoveryDatePresentation';
import {
getRecoveryTimelineAxisLabelClass,
getRecoveryTimelineAxisTicks,
getRecoveryTimelineChartGapPx,
getRecoveryTimelineChartMinWidthPx,
RECOVERY_TIMELINE_LEGEND_ITEM_CLASS,
} from '@/utils/recoveryTimelineChartPresentation';
import {
getRecoveryTimelineBarMarkerClass,
getRecoveryTimelineColumnButtonClass,
getRecoveryTimelineEmptyMarkerClass,
} from '@/utils/recoveryTimelinePresentation';
import {
BACKUP_ACTIVITY_RANGE_DAYS,
getBackupActivityColumnAriaLabel,
getBackupActivityDayFilterStateLabel,
getBackupActivityPointTotalLabel,
getBackupActivitySegmentPresentation,
getBackupActivityTooltipRows,
type BackupActivityPoint,
type BackupActivityRangeDays,
type BackupActivitySegmentKind,
type BackupActivityTimeline,
} from './proxmoxBackupActivityPresentation';
interface TooltipState {
dateLabel: string;
point: BackupActivityPoint;
x: number;
y: number;
}
interface BackupActivityChartProps {
title: string;
noun: 'archive' | 'task';
segmentKinds: readonly BackupActivitySegmentKind[];
range: Accessor<BackupActivityRangeDays>;
onRangeChange: (days: BackupActivityRangeDays) => void;
timeline: Accessor<BackupActivityTimeline>;
selectedDateKey: Accessor<string | null>;
onToggleDay: (key: string) => void;
}
export const BackupActivityChart: Component<BackupActivityChartProps> = (props) => {
const [tooltip, setTooltip] = createSignal<TooltipState | null>(null);
const points = () => props.timeline().points;
const axisMax = () => props.timeline().axisMax;
const hasSelection = () => props.selectedDateKey() !== null;
const axisTicks = () =>
getRecoveryTimelineAxisTicks(points().length, false, props.timeline().labelEvery);
const chartMinWidthStyle = () => {
const minWidth = getRecoveryTimelineChartMinWidthPx(false, props.range(), points().length);
return minWidth > 0 ? `${minWidth}px` : '100%';
};
const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.range())}px`;
const showTooltip = (target: HTMLElement, point: BackupActivityPoint, dateLabel: string) => {
const rect = target.getBoundingClientRect();
setTooltip({
dateLabel,
point,
x: rect.left + rect.width / 2,
y: rect.top,
});
};
const hideTooltip = () => setTooltip(null);
return (
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 px-2 py-2">
<div class="mb-2 flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<div class="font-semibold uppercase tracking-[0.18em] text-muted">{props.title}</div>
<For each={props.segmentKinds}>
{(kind) => {
const presentation = getBackupActivitySegmentPresentation(kind);
return (
<span class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${presentation.swatchClassName}`} />
{presentation.label}
</span>
);
}}
</For>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<div
role="group"
aria-label="Activity range"
class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5"
>
<For each={BACKUP_ACTIVITY_RANGE_DAYS}>
{(days) => {
const selected = () => props.range() === days;
return (
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${
selected()
? 'bg-surface-hover text-base-content'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`}
aria-pressed={selected() ? 'true' : 'false'}
onClick={() => props.onRangeChange(days)}
>
{days === 365 ? '1y' : `${days}d`}
</button>
);
}}
</For>
</div>
</div>
</div>
<Show
when={points().length > 0}
fallback={
<div class="flex h-24 items-center justify-center text-xs text-muted">
No activity in the selected window.
</div>
}
>
<div class="relative">
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-2">
<div
class={`flex ${SUMMARY_CHART_PLOT_AREA_CLASS} flex-col justify-between text-[10px] text-muted tabular-nums`}
>
<For each={[0, 1, 2]}>
{(step) => {
const value = () => Math.round((axisMax() * (2 - step)) / 2);
return <span>{value()}</span>;
}}
</For>
</div>
<div class="overflow-x-auto overscroll-x-contain pb-1">
<div
class={`relative ${SUMMARY_CHART_SLOT_COMPACT_CLASS}`}
style={{ 'min-width': chartMinWidthStyle() }}
>
<div
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch"
style={{ gap: chartGapStyle() }}
>
<For each={points()}>
{(point) => {
const total = () => point.total;
const heightPct = () =>
axisMax() > 0 ? Math.min(100, (total() / axisMax()) * 100) : 0;
const isSelected = () => props.selectedDateKey() === point.key;
const dateLabel = getRecoveryPrettyDateLabel(point.key);
return (
<div class="min-w-[3px] flex-1 self-stretch">
<button
type="button"
class={`h-full w-full ${getRecoveryTimelineColumnButtonClass(
isSelected(),
hasSelection(),
)}`}
aria-label={getBackupActivityColumnAriaLabel(
dateLabel,
total(),
isSelected(),
props.noun,
)}
aria-pressed={isSelected() ? 'true' : 'false'}
onClick={() => props.onToggleDay(point.key)}
onMouseEnter={(event) =>
showTooltip(event.currentTarget, point, dateLabel)
}
onMouseLeave={hideTooltip}
onFocus={(event) => showTooltip(event.currentTarget, point, dateLabel)}
onBlur={hideTooltip}
>
<div class="relative h-full w-full overflow-hidden rounded-sm">
<Show
when={total() > 0}
fallback={
<div
class={getRecoveryTimelineEmptyMarkerClass(
isSelected(),
hasSelection(),
)}
/>
}
>
<div
class={getRecoveryTimelineBarMarkerClass(
isSelected(),
hasSelection(),
)}
style={{ height: `${heightPct()}%` }}
>
<For each={props.segmentKinds}>
{(kind) => {
const count = () => point.counts[kind] ?? 0;
const segmentPct = () =>
total() > 0 ? (count() / total()) * 100 : 0;
return (
<Show when={segmentPct() > 0}>
<div
class={`w-full ${getBackupActivitySegmentPresentation(kind).segmentClassName}`}
style={{ height: `${segmentPct()}%` }}
/>
</Show>
);
}}
</For>
</div>
</Show>
</div>
</button>
</div>
);
}}
</For>
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-4">
<For each={axisTicks()}>
{(tick) => {
const point = points()[tick.index];
const isSelected = () =>
point ? props.selectedDateKey() === point.key : false;
const alignmentClass =
tick.align === 'start'
? 'left-0 text-left'
: tick.align === 'end'
? 'left-full -translate-x-full text-right'
: '-translate-x-1/2 text-center';
return (
<Show when={point}>
<span
class={`absolute bottom-0 whitespace-nowrap text-[9px] ${getRecoveryTimelineAxisLabelClass(isSelected())} ${alignmentClass}`}
style={{ left: `${tick.positionPct}%` }}
>
{getRecoveryCompactAxisLabel(point!.key, props.range())}
</span>
</Show>
);
}}
</For>
</div>
</div>
</div>
</div>
<TooltipPortal
when={tooltip() !== null}
x={tooltip()?.x ?? 0}
y={tooltip()?.y ?? 0}
align="center"
direction="up"
maxWidth={240}
>
<Show when={tooltip()}>
{(t) => (
<div class="min-w-[180px]">
<div class="flex items-start justify-between gap-3 border-b border-border pb-1">
<div class="min-w-0">
<div class="truncate text-[11px] font-semibold text-base-content">
{t().dateLabel}
</div>
<div class="mt-0.5 text-[10px] text-muted">
{getBackupActivityPointTotalLabel(t().point.total, props.noun)}
</div>
</div>
<div class="shrink-0 rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[9px] font-medium text-muted">
{getBackupActivityDayFilterStateLabel(
props.selectedDateKey() === t().point.key,
hasSelection(),
)}
</div>
</div>
<ul class="mt-1.5 space-y-1">
<For each={getBackupActivityTooltipRows(t().point, props.segmentKinds)}>
{(row) => (
<li
class={`flex items-center justify-between gap-4 ${
row.muted ? 'text-muted/70' : 'text-base-content'
}`}
>
<span class="flex min-w-0 items-center gap-1.5">
<span class={`h-2 w-2 shrink-0 rounded-sm ${row.segmentClassName}`} />
<span class="truncate">{row.label}</span>
</span>
<span class="shrink-0 font-mono tabular-nums">{row.value}</span>
</li>
)}
</For>
</ul>
</div>
)}
</Show>
</TooltipPortal>
</div>
</Show>
</div>
);
};
export default BackupActivityChart;
@@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
import type { StatusIndicatorVariant } from '@/utils/status';
import {
Table,
@@ -26,6 +27,17 @@ import {
} from '@/components/shared/Table';
import { apiFetch } from '@/utils/apiClient';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
recoveryDateKeyFromTimestamp,
getRecoveryFilterDateLabel,
} from '@/utils/recoveryDatePresentation';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type {
BackupTask,
GuestSnapshot,
@@ -33,6 +45,12 @@ import type {
PVEBackupsResponse,
StorageBackup,
} from '@/types/api';
import { BackupActivityChart } from './BackupActivityChart';
import {
buildBackupActivityTimeline,
type BackupActivityRangeDays,
type BackupActivitySegmentKind,
} from './proxmoxBackupActivityPresentation';
// Proxmox VE backups split into three meaningfully different surfaces:
// - Snapshots: qm/pct snapshots taken on the host (no external storage)
@@ -149,13 +167,55 @@ export const ProxmoxBackupsTable: Component<{
const [backups, { refetch }] = createResource<PVEBackupsPayload>(fetchPVEBackups);
const [tab, setTab] = createSignal<BackupTabId>('snapshots');
const [search, setSearch] = createSignal('');
const [archiveFilter, setArchiveFilter] = createSignal<'all' | 'protected' | 'verified' | 'unverified'>('all');
const [archiveFilter, setArchiveFilter] = createSignal<
'all' | 'protected' | 'verified' | 'unverified'
>('all');
const [taskFilter, setTaskFilter] = createSignal<'all' | 'ok' | 'failed' | 'running'>('all');
const [chartRange, setChartRange] = createSignal<BackupActivityRangeDays>(30);
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
const toggleDay = (key: string) =>
setSelectedDateKey((current) => (current === key ? null : key));
const snapshots = createMemo<GuestSnapshot[]>(() => backups()?.guestSnapshots ?? []);
const archives = createMemo<StorageBackup[]>(() => backups()?.storageBackups ?? []);
const tasks = createMemo<BackupTask[]>(() => backups()?.backupTasks ?? []);
const archiveTimestampMs = (arc: StorageBackup): number | undefined => {
const ms = Date.parse(arc.time ?? '');
return Number.isFinite(ms) ? ms : undefined;
};
const taskTimestampMs = (task: BackupTask): number | undefined => {
const ms = Date.parse(task.startTime ?? '');
return Number.isFinite(ms) ? ms : undefined;
};
const classifyTaskSegment = (task: BackupTask): BackupActivitySegmentKind | null => {
const variant = classifyTaskStatus(task.status).variant;
if (variant === 'success') return 'ok';
if (variant === 'danger') return 'failed';
if (variant === 'warning') return 'running';
return null;
};
const archiveTimeline = createMemo(() =>
buildBackupActivityTimeline<StorageBackup>(
chartRange(),
archives(),
archiveTimestampMs,
() => 'archive',
),
);
const taskTimeline = createMemo(() =>
buildBackupActivityTimeline<BackupTask>(
chartRange(),
tasks(),
taskTimestampMs,
classifyTaskSegment,
),
);
const ARCHIVE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['archive'];
const TASK_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['ok', 'failed', 'running'];
const filteredSnapshots = createMemo(() => {
const term = search().trim().toLowerCase();
return snapshots().filter((snap) => {
@@ -171,10 +231,15 @@ export const ProxmoxBackupsTable: Component<{
const filteredArchives = createMemo(() => {
const term = search().trim().toLowerCase();
const filter = archiveFilter();
const dateKey = selectedDateKey();
return archives().filter((arc) => {
if (filter === 'protected' && !arc.protected) return false;
if (filter === 'verified' && !arc.verified) return false;
if (filter === 'unverified' && arc.verified) return false;
if (dateKey) {
const ms = archiveTimestampMs(arc);
if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false;
}
if (!term) return true;
return [arc.storage, arc.node, arc.instance, arc.volid, arc.notes, String(arc.vmid)]
.filter(Boolean)
@@ -187,11 +252,16 @@ export const ProxmoxBackupsTable: Component<{
const filteredTasks = createMemo(() => {
const term = search().trim().toLowerCase();
const filter = taskFilter();
const dateKey = selectedDateKey();
return tasks().filter((task) => {
const classify = classifyTaskStatus(task.status);
if (filter === 'ok' && classify.variant !== 'success') return false;
if (filter === 'failed' && classify.variant !== 'danger') return false;
if (filter === 'running' && classify.variant !== 'warning') return false;
if (dateKey) {
const ms = taskTimestampMs(task);
if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false;
}
if (!term) return true;
return [task.node, task.instance, task.status, task.error, String(task.vmid)]
.filter(Boolean)
@@ -289,6 +359,31 @@ export const ProxmoxBackupsTable: Component<{
</For>
</div>
<Show when={tab() === 'archives'}>
<BackupActivityChart
title="Backup files per day"
noun="archive"
segmentKinds={ARCHIVE_SEGMENT_KINDS}
range={chartRange}
onRangeChange={setChartRange}
timeline={archiveTimeline}
selectedDateKey={selectedDateKey}
onToggleDay={toggleDay}
/>
</Show>
<Show when={tab() === 'tasks'}>
<BackupActivityChart
title="Backup tasks per day"
noun="task"
segmentKinds={TASK_SEGMENT_KINDS}
range={chartRange}
onRangeChange={setChartRange}
timeline={taskTimeline}
selectedDateKey={selectedDateKey}
onToggleDay={toggleDay}
/>
</Show>
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-[200px] flex-1 sm:max-w-xs">
<SearchInput
@@ -317,6 +412,22 @@ export const ProxmoxBackupsTable: Component<{
onChange={setTaskFilter}
/>
</Show>
<Show when={selectedDateKey() !== null && tab() !== 'snapshots'}>
<button
type="button"
onClick={() => setSelectedDateKey(null)}
class="inline-flex items-center gap-1 rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
aria-label="Clear date filter"
>
<span class="uppercase tracking-wide text-[9px] text-blue-600 dark:text-blue-300">
Date
</span>
<span class="font-mono tabular-nums">
{getRecoveryFilterDateLabel(selectedDateKey()!)}
</span>
<span aria-hidden="true">×</span>
</button>
</Show>
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
<Show
when={visibleForTab() !== totalForTab()}
@@ -348,51 +459,74 @@ export const ProxmoxBackupsTable: Component<{
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[900px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Name</TableHead>
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Parent</TableHead>
<TableHead class="px-3 py-2 font-medium">Captured</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
<TableHead class="px-3 py-2 font-medium">RAM</TableHead>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[900px] text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Name</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Parent</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Captured
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Size
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>RAM</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={filteredSnapshots()}>
{(snap) => (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
>
<div class="font-semibold">{snap.name || '—'}</div>
<Show when={!!snap.description?.trim()}>
<div class="text-[11px] text-muted truncate max-w-[20rem]" title={snap.description}>
<div
class="text-[11px] text-muted truncate max-w-[20rem]"
title={snap.description}
>
{snap.description}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">{guestLabel(snap.type, snap.vmid)}</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{guestLabel(snap.type, snap.vmid)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{snap.node || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{snap.parent?.trim() || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatRelativeTime(snap.time, { compact: true })}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
<Show when={snap.sizeBytes && snap.sizeBytes > 0} fallback={<span class="text-muted">—</span>}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
<Show
when={snap.sizeBytes && snap.sizeBytes > 0}
fallback={<span class="text-muted">—</span>}
>
{formatBytes(snap.sizeBytes ?? 0)}
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<Show
when={snap.vmstate}
fallback={<span class="text-muted">—</span>}
>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show when={snap.vmstate} fallback={<span class="text-muted">—</span>}>
<span class="inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
with RAM
</span>
@@ -403,7 +537,7 @@ export const ProxmoxBackupsTable: Component<{
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
</Show>
@@ -428,57 +562,84 @@ export const ProxmoxBackupsTable: Component<{
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1050px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Volume</TableHead>
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
<TableHead class="px-3 py-2 font-medium">Storage</TableHead>
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Format</TableHead>
<TableHead class="px-3 py-2 font-medium">Created</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
<TableHead class="px-3 py-2 font-medium">Protection</TableHead>
<TableHead class="px-3 py-2 font-medium">Verified</TableHead>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1050px] text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Volume</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Storage
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Format</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Created
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Size
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Protection
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Verified
</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={filteredArchives()}>
{(arc) => (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content font-mono text-[11px]`}
>
<span class="inline-block max-w-[18rem] truncate" title={arc.volid}>
{arc.volid}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{guestLabel(arc.type, arc.vmid)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">{arc.storage || '—'}</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{arc.storage || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{arc.node || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content uppercase text-[10px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content uppercase text-[10px]`}
>
{arc.format || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatRelativeTime(arc.time, { compact: true })}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{formatBytes(arc.size)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<Show
when={arc.protected}
fallback={<span class="text-muted">—</span>}
>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show when={arc.protected} fallback={<span class="text-muted">—</span>}>
<span class="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
Protected
</span>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={arc.verified}
fallback={
@@ -498,7 +659,7 @@ export const ProxmoxBackupsTable: Component<{
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
</Show>
@@ -510,9 +671,7 @@ export const ProxmoxBackupsTable: Component<{
<EmptyState
icon={props.emptyIcon}
title={
tasks().length === 0
? TABS[2].emptyTitle
: 'No tasks match current filters'
tasks().length === 0 ? TABS[2].emptyTitle : 'No tasks match current filters'
}
description={
tasks().length === 0
@@ -523,49 +682,77 @@ export const ProxmoxBackupsTable: Component<{
</Card>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1000px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Status</TableHead>
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Started</TableHead>
<TableHead class="px-3 py-2 font-medium">Duration</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
<TableHead class="px-3 py-2 font-medium">Error</TableHead>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1000px] text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Started
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Duration
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Size
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={filteredTasks()}>
{(task) => {
const classify = classifyTaskStatus(task.status);
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot size="sm" variant={classify.variant} title={classify.label} ariaHidden />
<span class="text-[11px] font-medium text-base-content">{classify.label}</span>
<StatusDot
size="sm"
variant={classify.variant}
title={classify.label}
ariaHidden
/>
<span class="text-[11px] font-medium text-base-content">
{classify.label}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{guestLabel(task.type, task.vmid)}
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{task.node || '—'}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatRelativeTime(task.startTime, { compact: true })}
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatDuration(task.startTime, task.endTime)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
<Show when={task.size && task.size > 0} fallback={<span class="text-muted">—</span>}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
<Show
when={task.size && task.size > 0}
fallback={<span class="text-muted">—</span>}
>
{formatBytes(task.size ?? 0)}
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={!!task.error?.trim()}
fallback={<span class="text-muted">—</span>}
@@ -584,7 +771,7 @@ export const ProxmoxBackupsTable: Component<{
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
</Show>
</div>
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { recoveryDateKeyFromTimestamp } from '@/utils/recoveryDatePresentation';
import {
buildBackupActivityTimeline,
getBackupActivityColumnAriaLabel,
getBackupActivityTooltipRows,
type BackupActivitySegmentKind,
} from '../proxmoxBackupActivityPresentation';
describe('proxmoxBackupActivityPresentation', () => {
it('buckets backup activity by local day and segment kind', () => {
const now = new Date(2026, 4, 20, 12);
const today = new Date(2026, 4, 20, 9).getTime();
const yesterday = new Date(2026, 4, 19, 9).getTime();
const outsideWindow = new Date(2026, 4, 10, 9).getTime();
const future = new Date(2026, 4, 21, 9).getTime();
const timeline = buildBackupActivityTimeline(
7,
[
{ ts: today, kind: 'ok' },
{ ts: today, kind: 'failed' },
{ ts: yesterday, kind: 'ok' },
{ ts: outsideWindow, kind: 'ok' },
{ ts: future, kind: 'running' },
],
(item) => item.ts,
(item) => item.kind as BackupActivitySegmentKind,
now,
);
const todayPoint = timeline.points.find(
(point) => point.key === recoveryDateKeyFromTimestamp(today),
);
const yesterdayPoint = timeline.points.find(
(point) => point.key === recoveryDateKeyFromTimestamp(yesterday),
);
expect(timeline.points).toHaveLength(7);
expect(todayPoint?.total).toBe(2);
expect(todayPoint?.counts.ok).toBe(1);
expect(todayPoint?.counts.failed).toBe(1);
expect(yesterdayPoint?.total).toBe(1);
expect(timeline.points.reduce((sum, point) => sum + point.total, 0)).toBe(3);
expect(timeline.axisMax).toBeGreaterThanOrEqual(2);
});
it('formats activity labels and tooltip rows from segment totals', () => {
const point = {
key: '2026-05-20',
total: 4,
counts: { archive: 0, ok: 3, failed: 1, running: 0 },
};
expect(getBackupActivityColumnAriaLabel('Wed 20 May', point.total, true, 'task')).toBe(
'Wed 20 May: 4 tasks, selected',
);
expect(getBackupActivityTooltipRows(point, ['ok', 'failed', 'running'])).toMatchObject([
{ kind: 'ok', label: 'OK', value: '3 (75%)', muted: false },
{ kind: 'failed', label: 'Failed', value: '1 (25%)', muted: false },
{ kind: 'running', label: 'Running', value: '0', muted: true },
]);
});
});
@@ -0,0 +1,160 @@
import {
getRecoveryNiceAxisMax,
recoveryDateKeyFromTimestamp,
} from '@/utils/recoveryDatePresentation';
import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation';
import { getRecoveryTimelineDayFilterStateLabel } from '@/utils/recoveryTimelinePresentation';
export type BackupActivityRangeDays = 7 | 30 | 90 | 365;
export const BACKUP_ACTIVITY_RANGE_DAYS = [
7, 30, 90, 365,
] as const satisfies readonly BackupActivityRangeDays[];
export type BackupActivitySegmentKind = 'archive' | 'ok' | 'failed' | 'running';
interface BackupActivitySegmentPresentation {
label: string;
segmentClassName: string;
swatchClassName: string;
}
const SEGMENT_PRESENTATION: Record<BackupActivitySegmentKind, BackupActivitySegmentPresentation> = {
archive: {
label: 'Archives',
segmentClassName: 'bg-blue-500',
swatchClassName: 'bg-blue-500',
},
ok: {
label: 'OK',
segmentClassName: 'bg-emerald-500',
swatchClassName: 'bg-emerald-500',
},
failed: {
label: 'Failed',
segmentClassName: 'bg-red-500',
swatchClassName: 'bg-red-500',
},
running: {
label: 'Running',
segmentClassName: 'bg-amber-500',
swatchClassName: 'bg-amber-500',
},
};
export function getBackupActivitySegmentPresentation(
kind: BackupActivitySegmentKind,
): BackupActivitySegmentPresentation {
return SEGMENT_PRESENTATION[kind];
}
export interface BackupActivityPoint {
key: string;
total: number;
counts: Record<BackupActivitySegmentKind, number>;
}
export interface BackupActivityTimeline {
points: BackupActivityPoint[];
axisMax: number;
labelEvery: number;
}
function emptyCounts(): Record<BackupActivitySegmentKind, number> {
return { archive: 0, ok: 0, failed: 0, running: 0 };
}
function startOfLocalDayMs(date: Date): number {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy.getTime();
}
export function buildBackupActivityTimeline<T>(
days: BackupActivityRangeDays,
items: readonly T[],
getTimestampMs: (item: T) => number | undefined,
classify: (item: T) => BackupActivitySegmentKind | null,
now: Date = new Date(),
): BackupActivityTimeline {
const todayStart = startOfLocalDayMs(now);
const windowStart = todayStart - (days - 1) * 24 * 60 * 60 * 1000;
const buckets = new Map<string, BackupActivityPoint>();
const orderedKeys: string[] = [];
for (let i = 0; i < days; i += 1) {
const dayStart = windowStart + i * 24 * 60 * 60 * 1000;
const key = recoveryDateKeyFromTimestamp(dayStart);
orderedKeys.push(key);
buckets.set(key, { key, total: 0, counts: emptyCounts() });
}
for (const item of items) {
const ts = getTimestampMs(item);
if (ts === undefined || !Number.isFinite(ts)) continue;
if (ts < windowStart) continue;
if (ts >= todayStart + 24 * 60 * 60 * 1000) continue;
const kind = classify(item);
if (!kind) continue;
const key = recoveryDateKeyFromTimestamp(ts);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.counts[kind] += 1;
bucket.total += 1;
}
const points = orderedKeys.map((key) => buckets.get(key)!);
const rawMax = points.reduce((max, p) => (p.total > max ? p.total : max), 0);
const axisMax = Math.max(2, getRecoveryNiceAxisMax(rawMax));
const labelEvery = getRecoveryTimelineLabelEvery(points.length);
return { points, axisMax, labelEvery };
}
export interface BackupActivityTooltipRow {
kind: BackupActivitySegmentKind;
label: string;
count: number;
value: string;
segmentClassName: string;
muted: boolean;
}
export function getBackupActivityTooltipRows(
point: BackupActivityPoint,
kinds: readonly BackupActivitySegmentKind[],
): BackupActivityTooltipRow[] {
const total = Math.max(0, point.total);
return kinds.map((kind) => {
const presentation = SEGMENT_PRESENTATION[kind];
const count = Math.max(0, point.counts[kind] ?? 0);
const percentage = total > 0 && count > 0 ? Math.round((count / total) * 100) : 0;
return {
kind,
label: presentation.label,
count,
value: percentage > 0 ? `${count} (${percentage}%)` : String(count),
segmentClassName: presentation.segmentClassName,
muted: count === 0,
};
});
}
export function getBackupActivityPointTotalLabel(total: number, noun: 'archive' | 'task'): string {
const normalized = Math.max(0, total);
if (normalized === 1) return `1 ${noun}`;
return `${normalized} ${noun}s`;
}
export function getBackupActivityColumnAriaLabel(
dateLabel: string,
total: number,
selected: boolean,
noun: 'archive' | 'task',
): string {
const countLabel = getBackupActivityPointTotalLabel(total, noun);
return selected ? `${dateLabel}: ${countLabel}, selected` : `${dateLabel}: ${countLabel}`;
}
export { getRecoveryTimelineDayFilterStateLabel as getBackupActivityDayFilterStateLabel };