Collapse Proxmox backups surface to a guest-centric coverage view
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

The backups page was four navigation levels deep (Backups tab -> four
sub-tabs -> a three-way Source details split) fronted by per-day activity
bar charts and PBS-jargon summary strips. That buried the one question a
backup monitor exists to answer -- is every guest backed up, recently, and
did the last job run -- under a read-only re-render of the PBS/PVE console.

Collapse it to a single coverage table (one row per guest, posture dot,
latest restore point, per-source evidence in the row expansion) and:

- Add a Backup servers table: PBS reachability + datastore fill % (the
  headline backup risk) + dedup, from data already on the pbs resource
  (new ResourcePBSDatastore type). Filtered to type==='pbs' so a PBS
  datastore storage row can't render as a phantom server.
- Split orphaned backups (records whose guest no longer exists in
  inventory, keyed backup: not resource:) out of the main table into a
  collapsed section, so live named guests aren't pushed below nameless
  stale records. Health strip counts live guests only. Also surfaces
  orphaned-backup detection (refs #1286).
- Colour posture/task words on the exception only (amber/red for
  attention/danger, neutral when healthy), matching the Replication
  status-word and datastore-usage tone patterns.
- Remove the now-unused source-detail tables, activity chart, and their
  presentation helpers.

Verified on live PBS data: tsc clean, proxmox vitest green.
This commit is contained in:
rcourtman 2026-05-31 09:19:08 +01:00
parent af358ef365
commit cfa9283c98
18 changed files with 548 additions and 4046 deletions

View file

@ -1,371 +0,0 @@
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,
getBackupActivityAxisLabel,
getBackupActivityColumnAriaLabel,
getBackupActivityDayFilterStateLabel,
getBackupActivityPointTotalLabel,
getBackupActivitySegmentPresentation,
getBackupActivityTooltipRows,
type BackupActivityMetricMode,
type BackupActivityNoun,
type BackupActivityPoint,
type BackupActivityRangeDays,
type BackupActivitySegmentKind,
type BackupActivityTimeline,
} from './proxmoxBackupActivityPresentation';
interface TooltipState {
dateLabel: string;
point: BackupActivityPoint;
x: number;
y: number;
}
export interface BackupActivityChartMetricToggle {
mode: Accessor<BackupActivityMetricMode>;
onChange: (mode: BackupActivityMetricMode) => void;
}
interface BackupActivityChartProps {
title: string;
noun: BackupActivityNoun;
segmentKinds: readonly BackupActivitySegmentKind[];
range: Accessor<BackupActivityRangeDays>;
onRangeChange: (days: BackupActivityRangeDays) => void;
timeline: Accessor<BackupActivityTimeline>;
selectedDateKey: Accessor<string | null>;
onToggleDay: (key: string) => void;
metricMode?: Accessor<BackupActivityMetricMode>;
metricToggle?: BackupActivityChartMetricToggle;
}
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 metricMode = (): BackupActivityMetricMode =>
props.metricMode?.() ?? props.metricToggle?.mode() ?? 'count';
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>
<Show when={props.segmentKinds.length > 1}>
<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>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<Show when={props.metricToggle}>
<div
role="group"
aria-label="Activity metric"
class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5"
>
<For each={['count', 'volume'] as const}>
{(mode) => {
const selected = () => props.metricToggle?.mode() === mode;
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.metricToggle?.onChange(mode)}
>
{mode === 'count' ? 'Count' : 'Volume'}
</button>
);
}}
</For>
</div>
</Show>
<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 = () => (axisMax() * (2 - step)) / 2;
return <span>{getBackupActivityAxisLabel(value(), metricMode())}</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,
metricMode(),
)}
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,
metricMode(),
)}
</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,
metricMode(),
)}
>
{(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;

View file

@ -1,273 +0,0 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { StorageBackup } from '@/types/api';
import { classifyArchiveRowAge } from './proxmoxBackupSummaryPresentation';
import { guestLabel, type ArchiveSortKey } from './proxmoxBackupsTableModel';
import { RowMetricBar, SortableHead } from './proxmoxBackupsTableShared';
// "Source details > Archives" table: vzdump / storage backup files with
// volume, guest, storage, age swatch, size, and (when any archive carries PBS
// metadata) protection + verification columns. Presentational — the parent
// owns the filtered+sorted memo, shared filters, and the size/now scales.
// table-fixed + a weighted colgroup keeps the columns from ballooning on wide
// viewports, accounting for the conditional PBS columns.
export function ProxmoxArchivesTable(props: {
archives: StorageBackup[];
hasAnyArchives: boolean;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
sortKey: Accessor<ArchiveSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
onSort: (key: ArchiveSortKey) => void;
showPBSColumns: boolean;
sizeMaxBytes: number;
nowMs: number;
}) {
const visibleColumns = () => [
{ id: 'volume', weight: 18 },
{ id: 'guest', weight: 9 },
{ id: 'storage', weight: 11 },
{ id: 'node', weight: 10 },
{ id: 'format', weight: 8 },
{ id: 'created', weight: 12 },
{ id: 'size', weight: 14 },
...(props.showPBSColumns
? [
{ id: 'protected', weight: 9 },
{ id: 'verified', weight: 9 },
]
: []),
];
const totalColumnWeight = () =>
visibleColumns().reduce((sum, column) => sum + column.weight, 0);
return (
<Show
when={props.archives.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={!props.hasAnyArchives ? props.emptyTitle : 'No archives match current filters'}
description={
!props.hasAnyArchives
? props.emptyDescription
: 'Adjust the search or status filter to see more archives.'
}
/>
</Card>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1050px] table-fixed text-xs">
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col style={{ width: `${(column.weight / totalColumnWeight()) * 100}%` }} />
)}
</For>
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<SortableHead
label="Volume"
sortKey="volume"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('name')}
/>
<SortableHead
label="Guest"
sortKey="guest"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Storage"
sortKey="storage"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Node"
sortKey="node"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Format"
sortKey="format"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Created"
sortKey="created"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<SortableHead
label="Size"
sortKey="size"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="center"
headClass={getPlatformTableHeadClassForKind('metric-bar')}
/>
<Show when={props.showPBSColumns}>
<SortableHead
label="Protection"
sortKey="protected"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Verified"
sortKey="verified"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
</Show>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.archives}>
{(arc) => (
<TableRow class="hover:bg-surface-hover">
<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={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{guestLabel(arc.type, arc.vmid)}
</TableCell>
<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={`${getPlatformTableCellClassForKind('text')} text-base-content uppercase text-[10px]`}
>
{arc.format || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<div class="flex items-center justify-end gap-2">
{(() => {
const age = classifyArchiveRowAge(arc.time, props.nowMs);
return (
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full ${age.swatchClass}`}
aria-hidden="true"
title={`Coverage: ${age.label}`}
/>
);
})()}
<span>{formatRelativeTime(arc.time, { compact: true })}</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<RowMetricBar
valuePct={props.sizeMaxBytes > 0 ? (arc.size / props.sizeMaxBytes) * 100 : 0}
fillClass="bg-blue-500/40 dark:bg-blue-500/40"
label={formatBytes(arc.size)}
tooltip={`${formatBytes(arc.size)} (relative to largest file in view)`}
/>
</TableCell>
<Show when={props.showPBSColumns}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={arc.protected}
fallback={<span class="text-muted">Unprotected</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={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={arc.verified}
fallback={
<Show when={arc.isPBS} fallback={<span class="text-muted">n/a</span>}>
<span class="text-muted">Pending</span>
</Show>
}
>
<span class="inline-flex items-center rounded-sm bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200">
Verified
</span>
</Show>
</TableCell>
</Show>
</TableRow>
)}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
);
}

View file

@ -0,0 +1,214 @@
import { For, Show, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource, ResourcePBSDatastore } from '@/types/resource';
import type { StatusIndicatorVariant } from '@/utils/status';
// "Backup servers" answers the two questions the coverage table can't: is my
// PBS reachable, and is its datastore about to fill? Datastore fill is the
// headline backup risk — a full datastore silently fails every future backup —
// so it lives here on the Backups page, not buried on the platform Storage tab
// where the rows read as generic "PVE" storage. One row per datastore, labelled
// by its server; a server with no datastore data still gets a reachability row.
interface BackupServerRow {
key: string;
serverName: string;
online: boolean;
connectionLabel: string;
version?: string;
datastore?: ResourcePBSDatastore;
}
function serverIsOnline(resource: Resource): boolean {
const status = (resource.status ?? '').toLowerCase();
const conn = (resource.pbs?.connectionHealth ?? '').toLowerCase();
if (conn) return conn === 'healthy' || conn === 'ok';
return status === 'online' || status === 'running';
}
function connectionLabel(resource: Resource): string {
const conn = resource.pbs?.connectionHealth?.trim();
if (conn) return conn.charAt(0).toUpperCase() + conn.slice(1);
return serverIsOnline(resource) ? 'Online' : 'Offline';
}
function usagePercent(datastore: ResourcePBSDatastore): number | undefined {
if (typeof datastore.usagePercent === 'number') return datastore.usagePercent;
if (datastore.total > 0) return (datastore.used / datastore.total) * 100;
return undefined;
}
// >=90% is the silent-backup-failure danger zone; >=75% is the early warning.
function usageVariant(pct: number | undefined): StatusIndicatorVariant {
if (pct === undefined) return 'muted';
if (pct >= 90) return 'danger';
if (pct >= 75) return 'warning';
return 'success';
}
function usageToneClass(pct: number | undefined): string {
if (pct === undefined) return 'text-muted';
if (pct >= 90) return 'text-red-600 dark:text-red-300';
if (pct >= 75) return 'text-amber-600 dark:text-amber-300';
return 'text-base-content';
}
export function buildBackupServerRows(servers: readonly Resource[]): BackupServerRow[] {
const rows: BackupServerRow[] = [];
// `model().pbs` is scope-filtered (proxmox-pbs), which also catches PBS
// *datastore* storage resources (type 'storage', sources ['pbs']). This table
// is about the server, so keep only actual PBS server instances — otherwise a
// datastore renders as a phantom offline "server" row.
for (const server of servers.filter((resource) => resource.type === 'pbs')) {
const datastores = server.pbs?.datastores ?? [];
if (datastores.length === 0) {
rows.push({
key: server.id,
serverName: server.name,
online: serverIsOnline(server),
connectionLabel: connectionLabel(server),
version: server.pbs?.version,
});
continue;
}
for (const datastore of datastores) {
rows.push({
key: `${server.id}:${datastore.name}`,
serverName: server.name,
online: serverIsOnline(server),
connectionLabel: connectionLabel(server),
version: server.pbs?.version,
datastore,
});
}
}
return rows;
}
export function ProxmoxBackupServersTable(props: {
servers: readonly Resource[];
emptyIcon?: JSX.Element;
}) {
const rows = () => buildBackupServerRows(props.servers);
return (
<Show when={rows().length > 0}>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[760px] table-fixed text-xs">
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '22%' }} />
<col style={{ width: '10%' }} />
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Backup server</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Version</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Datastore</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>Used</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>Dedup</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={rows()}>
{(row) => {
const pct = () => (row.datastore ? usagePercent(row.datastore) : undefined);
return (
<TableRow class="hover:bg-surface-hover">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content truncate font-medium`}
>
{row.serverName}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={row.online ? 'success' : 'danger'}
title={row.connectionLabel}
ariaHidden
/>
<span class="truncate text-[11px] text-base-content">
{row.connectionLabel}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-muted truncate text-[11px]`}
>
{row.version || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content truncate font-mono text-[11px]`}
>
{row.datastore?.name ?? '—'}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('numeric-value')}>
<Show
when={row.datastore}
fallback={<span class="text-muted">No datastore data</span>}
>
{(datastore) => (
<div class="flex items-center justify-end gap-2">
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${Math.round(pct() ?? 0)}% used`}
ariaHidden
/>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<Show when={pct() !== undefined} fallback="—">
{Math.round(pct() ?? 0)}%
</Show>
</span>
<span class="text-[10px] text-muted tabular-nums">
{formatBytes(datastore().used)} / {formatBytes(datastore().total)}
</span>
</div>
)}
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums text-[11px]`}
>
<Show
when={row.datastore?.deduplicationFactor}
fallback={<span class="text-muted"></span>}
>
{(factor) => <>{factor().toFixed(1)}×</>}
</Show>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
);
}
export default ProxmoxBackupServersTable;

File diff suppressed because it is too large Load diff

View file

@ -43,6 +43,23 @@ const coveragePostureVariant = (
return 'warning';
};
// Colour marks the exception, not the baseline: healthy rows keep neutral text
// (the green dot already signals "fine"), while attention/danger words take an
// amber/red tone so a mixed fleet is scannable at a glance. Matches the
// datastore-usage tones on the Backup servers table and the status-word colour
// pattern in the Replication table.
const statusWordToneClass = (variant: StatusIndicatorVariant): string => {
if (variant === 'danger') return 'text-red-600 dark:text-red-300';
if (variant === 'warning') return 'text-amber-600 dark:text-amber-300';
return 'text-base-content';
};
const taskWordVariant = (label: string): StatusIndicatorVariant => {
if (label === 'Failed') return 'danger';
if (label === 'OK') return 'success';
return 'warning';
};
// "Workload coverage" table: one row per workload answering "does this have a
// backup?" across PBS / archive / snapshot, each expanding to its restore
// evidence. Presentational — the parent owns the filtered+sorted memo, shared
@ -240,7 +257,11 @@ export function ProxmoxCoverageTable(props: {
title={getWorkloadRecoveryPostureLabel(row.posture)}
ariaHidden
/>
<span class="truncate text-[11px] font-medium text-base-content">
<span
class={`truncate text-[11px] font-medium ${statusWordToneClass(
coveragePostureVariant(row.posture),
)}`}
>
{getWorkloadRecoveryPostureLabel(row.posture)}
</span>
</div>
@ -319,7 +340,13 @@ export function ProxmoxCoverageTable(props: {
title={task().label}
ariaHidden
/>
<span class="truncate">{task().label}</span>
<span
class={`truncate ${statusWordToneClass(
taskWordVariant(task().label),
)}`}
>
{task().label}
</span>
</div>
)}
</Show>

View file

@ -164,8 +164,8 @@ export function ProxmoxPageSurface() {
<Show when={activeTab() === 'backups'}>
<ProxmoxBackupsTable
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
hasPBS={model().pbs.length > 0}
workloads={model().guests}
servers={model().pbs}
/>
</Show>
<Show when={activeTab() === 'ceph'}>

View file

@ -1,262 +0,0 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { PBSBackup } from '@/types/api';
import { pbsRepositoryLabel, pbsWorkloadLabel, type PBSSortKey } from './proxmoxBackupsTableModel';
import { RowMetricBar, SortableHead } from './proxmoxBackupsTableShared';
const hasValidBackupTime = (backup: PBSBackup): boolean =>
!!backup.backupTime && Number.isFinite(Date.parse(backup.backupTime));
// "Source details > PBS artifacts" table: every Proxmox Backup Server snapshot
// with repository, age, size, verification, and protection. Presentational —
// the parent owns the filtered + sorted memo, shared filters, and the PBS
// resource; this component just renders its error / loading / empty / table
// states. table-fixed + colgroup keeps columns from ballooning on wide views.
export function ProxmoxPbsTable(props: {
backups: PBSBackup[];
hasAnyArtifacts: boolean;
errorMessage?: string;
isLoading: boolean;
onRefresh: () => void;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
sortKey: Accessor<PBSSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
onSort: (key: PBSSortKey) => void;
sizeMaxBytes: number;
}) {
return (
<Show
when={!props.errorMessage}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Could not load PBS artifacts"
description={props.errorMessage ?? 'Refresh to retry.'}
actions={
<button
type="button"
onClick={() => props.onRefresh()}
class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover"
>
Refresh
</button>
}
/>
</Card>
}
>
<Show
when={!props.isLoading}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title="Loading PBS artifacts"
description="Reading deduplicated backup snapshots from Proxmox Backup Server."
/>
</Card>
}
>
<Show
when={props.backups.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={
!props.hasAnyArtifacts ? props.emptyTitle : 'No PBS artifacts match current filters'
}
description={
!props.hasAnyArtifacts
? props.emptyDescription
: 'Adjust the search or status filter to see more PBS artifacts.'
}
/>
</Card>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1050px] table-fixed text-xs">
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '15%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '8%' }} />
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<SortableHead
label="Workload"
sortKey="workload"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('name')}
/>
<SortableHead
label="Repository"
sortKey="repository"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Instance</TableHead>
<SortableHead
label="Created"
sortKey="created"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<SortableHead
label="Size"
sortKey="size"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="center"
headClass={getPlatformTableHeadClassForKind('metric-bar')}
/>
<SortableHead
label="Verified"
sortKey="verified"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Protection"
sortKey="protected"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Files</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.backups}>
{(backup) => (
<TableRow class="hover:bg-surface-hover">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content truncate font-semibold`}
>
{pbsWorkloadLabel(backup)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content truncate font-mono text-[11px]`}
title={pbsRepositoryLabel(backup)}
>
{pbsRepositoryLabel(backup)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content truncate font-mono text-[11px]`}
title={backup.instance}
>
{backup.instance || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={hasValidBackupTime(backup)}
fallback={<span class="text-muted"></span>}
>
{formatRelativeTime(backup.backupTime, { compact: true })}
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<RowMetricBar
valuePct={
props.sizeMaxBytes > 0 ? (backup.size / props.sizeMaxBytes) * 100 : 0
}
fillClass="bg-blue-500/40 dark:bg-blue-500/40"
label={formatBytes(backup.size)}
tooltip={`${formatBytes(backup.size)} (relative to largest PBS artifact in view)`}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={backup.verified}
fallback={
<span class="text-amber-600 dark:text-amber-300">Unverified</span>
}
>
<span class="inline-flex items-center rounded-sm bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200">
Verified
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={backup.protected}
fallback={<span class="text-muted">Unprotected</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">
Protected
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[16rem] truncate"
title={(backup.files ?? []).join(', ')}
>
{(backup.files ?? []).length > 0 ? `${backup.files.length} files` : '—'}
</span>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</Show>
</Show>
);
}

View file

@ -1,218 +0,0 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { RecoverableArtifact } from './proxmoxBackupRecoveryModel';
import type { RecoverableSortKey } from './proxmoxBackupsTableModel';
import {
ArtifactSourceBadge,
ArtifactStateBadge,
RowMetricBar,
SortableHead,
artifactStateLabel,
} from './proxmoxBackupsTableShared';
// "Restore points" table: every recoverable artifact (PBS / archive / snapshot)
// across sources in one flat, sortable list. Presentational only — the parent
// owns the filtered + sorted memo and the shared search / day / source filters.
// Single-line rows: the workload identity (type/vmid) is already in the label.
export function ProxmoxRecoverableTable(props: {
artifacts: RecoverableArtifact[];
hasAnyArtifacts: boolean;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
sortKey: Accessor<RecoverableSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
onSort: (key: RecoverableSortKey) => void;
sizeMaxBytes: number;
}) {
return (
<Show
when={props.artifacts.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={
!props.hasAnyArtifacts
? props.emptyTitle
: 'No recoverable artifacts match current filters'
}
description={
!props.hasAnyArtifacts
? props.emptyDescription
: 'Adjust the search, source filter, or selected day to see more artifacts.'
}
/>
</Card>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1150px] table-fixed text-xs">
{/* table-fixed + colgroup so columns share the row width rather than
dumping all the slack into the trailing Details column on wide
viewports. */}
<colgroup>
<col style={{ width: '15%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '17%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '9%' }} />
<col style={{ width: '22%' }} />
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<SortableHead
label="Workload"
sortKey="workload"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('name')}
/>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>VMID</TableHead>
<SortableHead
label="Source"
sortKey="source"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Location"
sortKey="location"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Created"
sortKey="created"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<SortableHead
label="Size"
sortKey="size"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="center"
headClass={getPlatformTableHeadClassForKind('metric-bar')}
/>
<SortableHead
label="State"
sortKey="state"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.artifacts}>
{(artifact) => (
<TableRow class="hover:bg-surface-hover">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
>
<div class="min-w-0">
<div class="truncate font-semibold">
{artifact.workload.name || artifact.workload.label}
</div>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted font-mono text-[11px] tabular-nums`}
>
{artifact.workload.vmid || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<ArtifactSourceBadge artifact={artifact} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-[16rem] truncate" title={artifact.location}>
{artifact.location}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatRelativeTime(artifact.createdAt, { compact: true })}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<Show
when={artifact.size && artifact.size > 0}
fallback={<span class="text-muted">No size</span>}
>
<RowMetricBar
valuePct={
props.sizeMaxBytes > 0 && artifact.size
? (artifact.size / props.sizeMaxBytes) * 100
: 0
}
fillClass="bg-blue-500/40 dark:bg-blue-500/40"
label={formatBytes(artifact.size ?? 0)}
tooltip={`${formatBytes(artifact.size ?? 0)} (relative to largest artifact in view)`}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<ArtifactStateBadge artifact={artifact} label={artifactStateLabel(artifact)} />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-block max-w-[20rem] truncate" title={artifact.detail}>
{artifact.detail || '—'}
</span>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
);
}

View file

@ -1,318 +0,0 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import { classifySnapshotRowAge } from './proxmoxBackupSummaryPresentation';
import {
guestLabel,
type SnapshotGuestRow,
type SnapshotSortKey,
} from './proxmoxBackupsTableModel';
import { SortableHead } from './proxmoxBackupsTableShared';
// "Source details > Snapshots" table: one row per guest with its newest-snapshot
// age, count, total size, and RAM marker; each row expands to the guest's
// individual snapshots. Presentational — the parent owns the filtered+sorted
// memo, shared filters, and the expansion set. table-fixed + a weighted
// colgroup keeps the outer columns from ballooning (the inner per-snapshot
// table stays content-sized).
export function ProxmoxSnapshotsTable(props: {
guests: SnapshotGuestRow[];
hasAnySnapshots: boolean;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
sortKey: Accessor<SnapshotSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
onSort: (key: SnapshotSortKey) => void;
showSizeColumn: boolean;
showRAMColumn: boolean;
columnCount: number;
expandedKeys: ReadonlySet<string>;
onToggleExpand: (key: string) => void;
nowMs: number;
}) {
const visibleColumns = () => [
{ id: 'guest', weight: 24 },
{ id: 'node', weight: 16 },
{ id: 'latest', weight: 18 },
{ id: 'count', weight: 12 },
...(props.showSizeColumn ? [{ id: 'size', weight: 16 }] : []),
...(props.showRAMColumn ? [{ id: 'ram', weight: 14 }] : []),
];
const totalColumnWeight = () =>
visibleColumns().reduce((sum, column) => sum + column.weight, 0);
return (
<Show
when={props.guests.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={
!props.hasAnySnapshots ? props.emptyTitle : 'No snapshots match current filters'
}
description={
!props.hasAnySnapshots
? props.emptyDescription
: 'Adjust the search or filters to see more snapshots.'
}
/>
</Card>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[900px] table-fixed text-xs">
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col style={{ width: `${(column.weight / totalColumnWeight()) * 100}%` }} />
)}
</For>
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<SortableHead
label="Guest"
sortKey="guest"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('name')}
/>
<SortableHead
label="Node"
sortKey="node"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Latest"
sortKey="latest"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<SortableHead
label="Snapshots"
sortKey="count"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<Show when={props.showSizeColumn}>
<SortableHead
label="Total size"
sortKey="size"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
</Show>
<Show when={props.showRAMColumn}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>RAM</TableHead>
</Show>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.guests}>
{(row) => {
const isExpanded = () => props.expandedKeys.has(row.key);
const rowAge = classifySnapshotRowAge(row.newestMs, props.nowMs);
return (
<>
<TableRow
class="cursor-pointer hover:bg-surface-hover"
onClick={() => props.onToggleExpand(row.key)}
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
>
<div class="flex items-center gap-2">
<ChevronRightIcon
class={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
isExpanded() ? 'rotate-90' : ''
}`}
aria-hidden="true"
/>
<span class="font-semibold">{guestLabel(row.type, row.vmid)}</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{row.node || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={row.newestMs !== undefined}
fallback={<span class="text-muted"></span>}
>
<div class="flex items-center justify-end gap-2">
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full ${rowAge.swatchClass}`}
aria-hidden="true"
title={`Newest snapshot: ${rowAge.label}`}
/>
<span>{formatRelativeTime(row.newestMs, { compact: true })}</span>
</div>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{row.count}
</TableCell>
<Show when={props.showSizeColumn}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
<Show
when={row.totalBytes > 0}
fallback={<span class="text-muted"></span>}
>
{formatBytes(row.totalBytes)}
</Show>
</TableCell>
</Show>
<Show when={props.showRAMColumn}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={row.withRamCount > 0}
fallback={<span class="text-muted"></span>}
>
<span class="inline-flex items-center rounded-sm bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-200">
{row.withRamCount} with RAM
</span>
</Show>
</TableCell>
</Show>
</TableRow>
<Show when={isExpanded()}>
<TableRow class="bg-surface-alt/40">
<TableCell class="px-2 py-2" colspan={props.columnCount}>
<div class="overflow-hidden">
<table class="w-full text-[11px]">
<thead>
<tr class="bg-surface-alt text-muted">
<th class="px-2 py-0.5 text-left font-medium">Name</th>
<th class="px-2 py-0.5 text-left font-medium">Parent</th>
<th class="px-2 py-0.5 text-right font-medium">Captured</th>
<Show when={props.showSizeColumn}>
<th class="px-2 py-0.5 text-right font-medium">Size</th>
</Show>
<Show when={props.showRAMColumn}>
<th class="px-2 py-0.5 text-left font-medium">RAM</th>
</Show>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<For each={row.snapshots}>
{(snap) => {
const age = classifySnapshotRowAge(snap.time, props.nowMs);
return (
<tr class="hover:bg-surface-hover">
<td class="px-2 py-1">
<div class="flex items-center gap-2">
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full ${age.swatchClass}`}
aria-hidden="true"
title={`Age: ${age.label}`}
/>
<div class="min-w-0">
<div class="font-medium text-base-content">
{snap.name || '—'}
</div>
<Show when={!!snap.description?.trim()}>
<div
class="truncate max-w-[24rem] text-[10px] text-muted"
title={snap.description}
>
{snap.description}
</div>
</Show>
</div>
</div>
</td>
<td class="px-2 py-1 font-mono text-[10px] text-muted">
{snap.parent?.trim() || '—'}
</td>
<td class="px-2 py-1 text-right text-base-content">
{formatRelativeTime(snap.time, { compact: true })}
</td>
<Show when={props.showSizeColumn}>
<td class="px-2 py-1 text-right tabular-nums text-base-content">
<Show
when={snap.sizeBytes && snap.sizeBytes > 0}
fallback={<span class="text-muted"></span>}
>
{formatBytes(snap.sizeBytes ?? 0)}
</Show>
</td>
</Show>
<Show when={props.showRAMColumn}>
<td class="px-2 py-1">
<Show
when={snap.vmstate}
fallback={<span class="text-muted"></span>}
>
<span class="inline-flex items-center rounded-sm bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-700 dark:bg-violet-900/40 dark:text-violet-200">
with RAM
</span>
</Show>
</td>
</Show>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
);
}

View file

@ -1,264 +0,0 @@
import { For, Show, type Accessor, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { formatBytes, formatRelativeTime } from '@/utils/format';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import type { BackupTask } from '@/types/api';
import { taskDurationSeconds } from './proxmoxBackupSummaryPresentation';
import {
classifyTaskStatus,
formatDuration,
formatDurationFromSeconds,
guestLabel,
type TaskSortKey,
} from './proxmoxBackupsTableModel';
import { RowMetricBar, SortableHead } from './proxmoxBackupsTableShared';
// "Job history" table: one row per backup task with status, guest, node, start
// time, a median-anchored duration bar, and optional size / error columns.
// Presentational only — the parent owns the filtered + sorted memo, the shared
// search / status filters, and the duration baseline.
export function ProxmoxTasksTable(props: {
tasks: BackupTask[];
hasAnyTasks: boolean;
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
sortKey: Accessor<TaskSortKey>;
sortDirection: Accessor<'asc' | 'desc'>;
onSort: (key: TaskSortKey) => void;
showSizeColumn: boolean;
showErrorColumn: boolean;
durationBaselineSeconds: number;
}) {
// table-fixed + weighted colgroup so columns share the row width instead of
// dumping all the slack into the last column (the Error column ballooned to
// ~440px of blank when no rows had errors). Weights are relative; each
// visible column resolves to weight / total, matching the platform tables.
const visibleColumns = () => [
{ id: 'status', weight: 11 },
{ id: 'guest', weight: 13 },
{ id: 'node', weight: 12 },
{ id: 'started', weight: 13 },
{ id: 'duration', weight: 21 },
...(props.showSizeColumn ? [{ id: 'size', weight: 12 }] : []),
...(props.showErrorColumn ? [{ id: 'error', weight: 18 }] : []),
];
const totalColumnWeight = () =>
visibleColumns().reduce((sum, column) => sum + column.weight, 0);
return (
<Show
when={props.tasks.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={!props.hasAnyTasks ? props.emptyTitle : 'No tasks match current filters'}
description={
!props.hasAnyTasks
? props.emptyDescription
: 'Adjust the search or status filter to see more tasks.'
}
/>
</Card>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<Table class="min-w-[1000px] table-fixed text-xs">
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col style={{ width: `${(column.weight / totalColumnWeight()) * 100}%` }} />
)}
</For>
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<SortableHead
label="Status"
sortKey="status"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Guest"
sortKey="guest"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Node"
sortKey="node"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="left"
headClass={getPlatformTableHeadClassForKind('text')}
/>
<SortableHead
label="Started"
sortKey="started"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
<SortableHead
label="Duration"
sortKey="duration"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="center"
headClass={getPlatformTableHeadClassForKind('metric-bar')}
/>
<Show when={props.showSizeColumn}>
<SortableHead
label="Size"
sortKey="size"
currentSort={props.sortKey}
direction={props.sortDirection}
onSort={props.onSort}
align="right"
headClass={getPlatformTableHeadClassForKind('numeric-value')}
/>
</Show>
<Show when={props.showErrorColumn}>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
</Show>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.tasks}>
{(task) => {
const classify = classifyTaskStatus(task.status);
const durationSec = taskDurationSeconds(task);
// Anchor the bar so the median sits at ~50%.
const durationBarPct = () => {
const baseline = props.durationBaselineSeconds;
if (!durationSec || baseline <= 0) return 0;
return (durationSec / (baseline * 2)) * 100;
};
// Canonical Pulse metric tones (60% alpha) — same palette
// Storage and Ceph row bars use via getMetricColorClass.
// Cap at `warning` (soft yellow) rather than going to red
// for the worst case: a slow backup task is a perf
// outlier, not a failure. Failure is already conveyed by
// the Status column. Two tiers — normal / slow — keeps
// the column calm instead of screaming red on every
// long-running VM backup.
const durationToneClass = () => {
const baseline = props.durationBaselineSeconds;
if (!durationSec || baseline <= 0)
return 'bg-slate-500/30 dark:bg-slate-500/30';
const ratio = durationSec / baseline;
if (ratio >= 1.5) return 'bg-metric-warning-bg dark:bg-metric-warning-bg';
return 'bg-metric-normal-bg dark:bg-metric-normal-bg';
};
return (
<TableRow class="hover:bg-surface-hover">
<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 ${classify.toneClass}`}>
{classify.label}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{guestLabel(task.type, task.vmid)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{task.node || '—'}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{formatRelativeTime(task.startTime, { compact: true })}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<RowMetricBar
valuePct={
props.durationBaselineSeconds > 0 && durationSec ? durationBarPct() : 0
}
fillClass={durationToneClass()}
label={formatDuration(task.startTime, task.endTime)}
tooltip={`Duration ${formatDuration(task.startTime, task.endTime)} (median ${formatDurationFromSeconds(props.durationBaselineSeconds)})`}
/>
</TableCell>
<Show when={props.showSizeColumn}>
<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>
</Show>
<Show when={props.showErrorColumn}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={!!task.error?.trim()}
fallback={<span class="text-muted"></span>}
>
<span
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
title={task.error}
>
{task.error}
</span>
</Show>
</TableCell>
</Show>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
);
}

View file

@ -128,80 +128,43 @@ afterEach(() => {
});
describe('ProxmoxBackupsTable', () => {
it('shows PBS artifacts from the PBS backup endpoint when PBS is present', async () => {
it('renders one coverage row per workload with restore posture', async () => {
mockBackupAPIs();
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} hasPBS workloads={[workloadResource]} />
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
));
expect(await screen.findByText('pbs-docker')).toBeInTheDocument();
// The workload has a recent restore point, so its posture reads "Current".
expect(screen.getAllByText('Current').length).toBeGreaterThan(0);
await fireEvent.click(screen.getByRole('button', { name: /source details/i }));
await fireEvent.click(screen.getByRole('button', { name: /pbs artifacts/i }));
// The PBS repository (datastore / namespace) shows in the PBS artifacts
// table; the coverage tab keeps per-source location in the row expansion.
expect(screen.getByText('main / minipc')).toBeInTheDocument();
expect(screen.getByText('2 files')).toBeInTheDocument();
expect(screen.getAllByText('Verified').length).toBeGreaterThan(0);
expect(screen.getAllByText('Protected').length).toBeGreaterThan(0);
expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pbs');
expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pve');
});
it('omits PVE columns when the source cannot populate them', async () => {
mockBackupAPIs();
renderInRouter(() => <ProxmoxBackupsTable emptyIcon={<span />} hasPBS={false} />);
await fireEvent.click(await screen.findByRole('button', { name: /source details/i }));
await fireEvent.click(screen.getByRole('button', { name: 'Snapshots 1' }));
expect(await screen.findByText('CT 112')).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /total size/i })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /^ram$/i })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /backup files/i }));
expect(
screen.getByText('local:backup/vzdump-lxc-112-2026_05_25-02_00_00.tar.zst'),
).toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /protection/i })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /verified/i })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /job history/i }));
expect(screen.getAllByText('OK').length).toBeGreaterThan(0);
expect(screen.queryByRole('columnheader', { name: /^size$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('columnheader', { name: /error/i })).not.toBeInTheDocument();
});
it('keeps restore points searchable while moving raw sources behind source details', async () => {
it('collapses the surface to a single table — no inner source/job/restore tabs', async () => {
mockBackupAPIs();
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} hasPBS workloads={[workloadResource]} />
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
));
expect(await screen.findByText('pbs-docker')).toBeInTheDocument();
await screen.findByText('pbs-docker');
// The old four-tab + sub-tab tree is gone; the surface is the coverage
// table alone.
expect(screen.queryByRole('button', { name: /workload coverage/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /restore points/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /source details/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /job history/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /pbs artifacts/i })).not.toBeInTheDocument();
await fireEvent.click(await screen.findByRole('button', { name: /restore points/i }));
expect(screen.getByRole('columnheader', { name: /source/i })).toBeInTheDocument();
expect(screen.getAllByText('PBS').length).toBeGreaterThan(0);
expect(screen.getByText('PVE archive')).toBeInTheDocument();
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
expect(screen.getAllByText('pbs-docker').length).toBeGreaterThan(0);
await fireEvent.click(screen.getByRole('button', { name: /source details/i }));
await fireEvent.click(screen.getByRole('button', { name: 'Snapshots 1' }));
expect(screen.getByRole('columnheader', { name: /snapshots/i })).toBeInTheDocument();
});
it('expands coverage rows to show inline restore evidence', async () => {
it('keeps PBS / archive / snapshot evidence in the row expansion (demote, not delete)', async () => {
mockBackupAPIs();
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} hasPBS workloads={[workloadResource]} />
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
));
await screen.findByText('pbs-docker');
@ -210,8 +173,26 @@ describe('ProxmoxBackupsTable', () => {
screen.getByRole('button', { name: /show restore evidence for pbs-docker/i }),
);
// The per-source detail that used to live behind Source details is now one
// click down, inside the workload's row.
expect(screen.getByText('Restore evidence')).toBeInTheDocument();
expect(screen.getAllByText('PBS').length).toBeGreaterThan(0);
expect(screen.getAllByText('PVE archive').length).toBeGreaterThan(0);
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
});
it('filters coverage rows by search term', async () => {
mockBackupAPIs();
renderInRouter(() => (
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[workloadResource]} />
));
await screen.findByText('pbs-docker');
const searchInput = screen.getByPlaceholderText(/search backups by workload/i);
await fireEvent.input(searchInput, { target: { value: 'no-such-guest' } });
expect(screen.queryByText('pbs-docker')).not.toBeInTheDocument();
});
});

View file

@ -1,85 +0,0 @@
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, pbs: 0, ok: 3, failed: 1, running: 0, snapshot: 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 },
]);
});
it('accumulates bytes when a value accessor is supplied', () => {
const now = new Date(2026, 4, 20, 12);
const today = new Date(2026, 4, 20, 9).getTime();
const timeline = buildBackupActivityTimeline(
7,
[
{ ts: today, size: 1_000_000_000 },
{ ts: today, size: 500_000_000 },
],
(item) => item.ts,
() => 'archive' as BackupActivitySegmentKind,
{ now, getValue: (item) => item.size },
);
const todayPoint = timeline.points.find(
(point) => point.key === recoveryDateKeyFromTimestamp(today),
);
expect(todayPoint?.total).toBe(1_500_000_000);
expect(todayPoint?.counts.archive).toBe(1_500_000_000);
});
});

View file

@ -145,6 +145,34 @@ describe('proxmoxBackupRecoveryModel', () => {
expect(model.coverageSummary.uncovered).toBe(1);
});
it('flags backups whose guest is absent from inventory as orphaned, live guests as not', () => {
const model = buildProxmoxBackupRecoveryModel({
// Only VMID 112 exists in inventory; the PBS backup for 999 does not.
workloads: [workload({})],
pbsBackups: [
pbsBackup(),
pbsBackup({
id: 'pbs-main/main/minipc/ct/999/2026-05-25T01:34:25Z',
vmid: '999',
}),
],
archives: [],
snapshots: [],
tasks: [],
nowMs: Date.parse('2026-05-26T08:00:00Z'),
});
const live = model.coverageRows.find((row) => row.workload.vmid === '112');
const orphan = model.coverageRows.find((row) => row.workload.vmid === '999');
expect(live?.isOrphaned).toBe(false);
expect(live?.workload.name).toBe('pbs-docker');
expect(orphan?.isOrphaned).toBe(true);
// Orphans have no live guest to name them; label falls back to "CT <vmid>".
expect(orphan?.workload.name).toBeUndefined();
expect(orphan?.workload.label).toBe('CT 999');
});
it('does not create phantom workload rows from aggregate vzdump jobs', () => {
const model = buildProxmoxBackupRecoveryModel({
workloads: [],

View file

@ -1,252 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { BackupTask, GuestSnapshot, StorageBackup } from '@/types/api';
import {
buildArchiveCoverageSummary,
buildSnapshotCoverageSummary,
buildTaskOutcomeSummary,
classifyArchiveRowAge,
classifyBackupAge,
classifySnapshotRowAge,
computeMedianTaskDurationSeconds,
guestKey,
} from '../proxmoxBackupSummaryPresentation';
const DAY_MS = 24 * 60 * 60 * 1000;
function snapshot(overrides: Partial<GuestSnapshot> & { time: string }): GuestSnapshot {
return {
id: overrides.id ?? `${overrides.time}-${overrides.vmid ?? 0}`,
name: overrides.name ?? 'snap',
node: overrides.node ?? 'pve1',
instance: overrides.instance ?? 'cluster-a',
type: overrides.type ?? 'qemu',
vmid: overrides.vmid ?? 100,
time: overrides.time,
description: overrides.description,
parent: overrides.parent,
vmstate: overrides.vmstate ?? false,
sizeBytes: overrides.sizeBytes,
};
}
function archive(overrides: Partial<StorageBackup> & { time: string }): StorageBackup {
return {
id: overrides.id ?? `${overrides.time}-${overrides.vmid ?? 0}`,
storage: overrides.storage ?? 'backup-vault',
node: overrides.node ?? 'pve1',
instance: overrides.instance ?? 'cluster-a',
type: overrides.type ?? 'qemu',
vmid: overrides.vmid ?? 100,
time: overrides.time,
ctime: overrides.ctime ?? 0,
size: overrides.size ?? 0,
format: overrides.format ?? 'tar.zst',
notes: overrides.notes,
protected: overrides.protected ?? false,
volid: overrides.volid ?? 'vol',
isPBS: overrides.isPBS ?? false,
verified: overrides.verified ?? false,
verification: overrides.verification,
};
}
function task(overrides: Partial<BackupTask> & { startTime: string }): BackupTask {
return {
id: overrides.id ?? `${overrides.startTime}-${overrides.vmid ?? 0}`,
node: overrides.node ?? 'pve1',
instance: overrides.instance ?? 'cluster-a',
type: overrides.type ?? 'qemu',
vmid: overrides.vmid ?? 100,
status: overrides.status ?? 'ok',
startTime: overrides.startTime,
endTime: overrides.endTime,
size: overrides.size,
error: overrides.error,
};
}
describe('proxmoxBackupSummaryPresentation', () => {
describe('classifyBackupAge', () => {
const now = Date.parse('2026-05-20T12:00:00Z');
it('buckets by sysadmin-relevant age thresholds', () => {
expect(classifyBackupAge(now - 1 * DAY_MS, now)).toBe('recent');
expect(classifyBackupAge(now - 7 * DAY_MS + 1000, now)).toBe('recent');
expect(classifyBackupAge(now - 20 * DAY_MS, now)).toBe('normal');
expect(classifyBackupAge(now - 60 * DAY_MS, now)).toBe('stale');
expect(classifyBackupAge(now - 200 * DAY_MS, now)).toBe('ancient');
});
it('treats undefined and unparseable timestamps as ancient', () => {
expect(classifyBackupAge(undefined, now)).toBe('ancient');
expect(classifyBackupAge('not a date', now)).toBe('ancient');
});
});
describe('buildSnapshotCoverageSummary', () => {
const now = Date.parse('2026-05-20T12:00:00Z');
it('groups snapshots by guest and tallies stale/ancient counts', () => {
const summary = buildSnapshotCoverageSummary(
[
snapshot({ vmid: 100, time: new Date(now - 2 * DAY_MS).toISOString(), vmstate: true }),
snapshot({ vmid: 100, time: new Date(now - 50 * DAY_MS).toISOString() }),
snapshot({ vmid: 101, time: new Date(now - 100 * DAY_MS).toISOString() }),
snapshot({ vmid: 102, time: new Date(now - 40 * DAY_MS).toISOString() }),
],
now,
);
expect(summary.totalGuests).toBe(3);
expect(summary.totalSnapshots).toBe(4);
// vmid 100 newest is 2d (recent), vmid 102 newest is 40d (stale),
// vmid 101 newest is 100d (ancient).
expect(summary.staleGuests).toBe(1);
expect(summary.ancientGuests).toBe(1);
expect(summary.withRamGuests).toBe(1);
});
it('sorts guests by newest snapshot descending', () => {
const summary = buildSnapshotCoverageSummary(
[
snapshot({ vmid: 1, time: new Date(now - 30 * DAY_MS).toISOString() }),
snapshot({ vmid: 2, time: new Date(now - 5 * DAY_MS).toISOString() }),
snapshot({ vmid: 3, time: new Date(now - 15 * DAY_MS).toISOString() }),
],
now,
);
expect(summary.guests.map((g) => g.vmid)).toEqual([2, 3, 1]);
});
it('sorts each guest`s snapshots newest-first', () => {
const summary = buildSnapshotCoverageSummary(
[
snapshot({ vmid: 7, time: new Date(now - 20 * DAY_MS).toISOString(), name: 'old' }),
snapshot({ vmid: 7, time: new Date(now - 1 * DAY_MS).toISOString(), name: 'new' }),
snapshot({ vmid: 7, time: new Date(now - 10 * DAY_MS).toISOString(), name: 'mid' }),
],
now,
);
const guest = summary.guests[0];
expect(guest.snapshots.map((s) => s.name)).toEqual(['new', 'mid', 'old']);
});
});
describe('buildArchiveCoverageSummary', () => {
const now = Date.parse('2026-05-20T12:00:00Z');
it('splits guests into current/stale/uncovered by newest archive age', () => {
const summary = buildArchiveCoverageSummary(
[
archive({ vmid: 100, time: new Date(now - 1 * DAY_MS).toISOString(), size: 1000 }),
archive({ vmid: 100, time: new Date(now - 40 * DAY_MS).toISOString(), size: 2000 }),
archive({ vmid: 200, time: new Date(now - 14 * DAY_MS).toISOString(), size: 3000 }),
archive({ vmid: 300, time: new Date(now - 50 * DAY_MS).toISOString(), size: 4000 }),
archive({ vmid: 400, time: new Date(now - 120 * DAY_MS).toISOString(), size: 5000 }),
],
now,
);
expect(summary.totalGuests).toBe(4);
expect(summary.totalArchives).toBe(5);
expect(summary.totalBytes).toBe(15_000);
// 100 (newest 1d) → current; 200 (14d) → stale-window; 300 (50d) +
// 400 (120d) → uncovered. The summary collapses the 730d "normal"
// bucket into staleGuests since either way they are not "current".
expect(summary.currentGuests).toBe(1);
expect(summary.staleGuests).toBe(1);
expect(summary.uncoveredGuests).toBe(2);
});
});
describe('buildTaskOutcomeSummary', () => {
it('counts outcomes and flags whether any errors exist', () => {
const outcome = buildTaskOutcomeSummary([
task({ startTime: '2026-05-20T01:00:00Z', status: 'OK' }),
task({ startTime: '2026-05-20T02:00:00Z', status: 'failed', error: 'disk full' }),
task({ startTime: '2026-05-20T03:00:00Z', status: 'running' }),
task({ startTime: '2026-05-20T04:00:00Z', status: 'completed' }),
]);
expect(outcome.total).toBe(4);
expect(outcome.ok).toBe(2);
expect(outcome.failed).toBe(1);
expect(outcome.running).toBe(1);
expect(outcome.hasErrors).toBe(true);
});
it('reports hasErrors=false when no task carries an error message', () => {
const outcome = buildTaskOutcomeSummary([
task({ startTime: '2026-05-20T01:00:00Z', status: 'ok' }),
task({ startTime: '2026-05-20T02:00:00Z', status: 'ok' }),
]);
expect(outcome.hasErrors).toBe(false);
});
});
describe('computeMedianTaskDurationSeconds', () => {
it('returns 0 when no durations are present', () => {
expect(computeMedianTaskDurationSeconds([])).toBe(0);
expect(
computeMedianTaskDurationSeconds([
task({ startTime: '2026-05-20T01:00:00Z' }), // no endTime
]),
).toBe(0);
});
it('computes the median across finite durations', () => {
const median = computeMedianTaskDurationSeconds([
task({ startTime: '2026-05-20T01:00:00Z', endTime: '2026-05-20T01:00:10Z' }), // 10s
task({ startTime: '2026-05-20T01:00:00Z', endTime: '2026-05-20T01:01:00Z' }), // 60s
task({ startTime: '2026-05-20T01:00:00Z', endTime: '2026-05-20T01:02:00Z' }), // 120s
]);
expect(median).toBe(60);
});
});
describe('classifyArchiveRowAge', () => {
const now = Date.parse('2026-05-20T12:00:00Z');
it('aligns row swatch with the archive coverage strip thresholds (≤7d / 730d / >30d)', () => {
expect(classifyArchiveRowAge(now - 1 * DAY_MS, now).swatchClass).toBe('bg-emerald-500');
expect(classifyArchiveRowAge(now - 7 * DAY_MS + 1000, now).swatchClass).toBe('bg-emerald-500');
expect(classifyArchiveRowAge(now - 14 * DAY_MS, now).swatchClass).toBe('bg-amber-500');
expect(classifyArchiveRowAge(now - 30 * DAY_MS + 1000, now).swatchClass).toBe('bg-amber-500');
expect(classifyArchiveRowAge(now - 40 * DAY_MS, now).swatchClass).toBe('bg-red-500');
expect(classifyArchiveRowAge(now - 200 * DAY_MS, now).swatchClass).toBe('bg-red-500');
});
it('treats undefined as the worst bucket so missing timestamps surface', () => {
expect(classifyArchiveRowAge(undefined, now).swatchClass).toBe('bg-red-500');
});
});
describe('classifySnapshotRowAge', () => {
const now = Date.parse('2026-05-20T12:00:00Z');
it('aligns row swatch with the snapshot coverage strip thresholds (≤30d / 3090d / >90d)', () => {
expect(classifySnapshotRowAge(now - 1 * DAY_MS, now).swatchClass).toBe('bg-emerald-500');
expect(classifySnapshotRowAge(now - 30 * DAY_MS + 1000, now).swatchClass).toBe(
'bg-emerald-500',
);
expect(classifySnapshotRowAge(now - 60 * DAY_MS, now).swatchClass).toBe('bg-amber-500');
expect(classifySnapshotRowAge(now - 90 * DAY_MS + 1000, now).swatchClass).toBe(
'bg-amber-500',
);
expect(classifySnapshotRowAge(now - 100 * DAY_MS, now).swatchClass).toBe('bg-red-500');
});
});
describe('guestKey', () => {
it('namespaces vmid by instance and type to avoid VM/CT collisions', () => {
expect(guestKey({ type: 'qemu', instance: 'a', vmid: 100 })).toBe('a:qemu:100');
expect(guestKey({ type: 'lxc', instance: 'a', vmid: 100 })).toBe('a:lxc:100');
expect(guestKey({ type: 'qemu', instance: 'a', vmid: 100 })).not.toBe(
guestKey({ type: 'qemu', instance: 'b', vmid: 100 }),
);
});
});
});

View file

@ -1,213 +0,0 @@
import { formatBytes } from '@/utils/format';
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[];
// `count` accumulates 1 per item per day (files, snapshots, tasks). `volume`
// accumulates bytes per item per day (used by Backup files when the user
// switches the chart from "how many files" to "how much space".)
export type BackupActivityMetricMode = 'count' | 'volume';
export type BackupActivitySegmentKind =
| 'archive'
| 'pbs'
| 'ok'
| 'failed'
| 'running'
| 'snapshot';
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',
},
pbs: {
label: 'PBS artifacts',
segmentClassName: 'bg-cyan-500',
swatchClassName: 'bg-cyan-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',
},
snapshot: {
label: 'Snapshots',
segmentClassName: 'bg-violet-500',
swatchClassName: 'bg-violet-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, pbs: 0, ok: 0, failed: 0, running: 0, snapshot: 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,
options?: {
now?: Date;
// Per-item contribution to the bucket total. Defaults to 1 (count mode).
// For volume mode, pass `(item) => item.size ?? 0`.
getValue?: (item: T) => number;
},
): BackupActivityTimeline {
const now = options?.now ?? new Date();
const getValue = options?.getValue ?? (() => 1);
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 value = getValue(item);
if (!Number.isFinite(value) || value <= 0) continue;
const key = recoveryDateKeyFromTimestamp(ts);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.counts[kind] += value;
bucket.total += value;
}
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 type BackupActivityNoun = 'archive' | 'artifact' | 'task' | 'snapshot';
function formatActivityValue(value: number, mode: BackupActivityMetricMode): string {
if (mode === 'volume') return formatBytes(Math.max(0, value));
return String(Math.max(0, Math.round(value)));
}
export function getBackupActivityTooltipRows(
point: BackupActivityPoint,
kinds: readonly BackupActivitySegmentKind[],
mode: BackupActivityMetricMode = 'count',
): 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;
const formatted = formatActivityValue(count, mode);
return {
kind,
label: presentation.label,
count,
value: percentage > 0 && kinds.length > 1 ? `${formatted} (${percentage}%)` : formatted,
segmentClassName: presentation.segmentClassName,
muted: count === 0,
};
});
}
export function getBackupActivityPointTotalLabel(
total: number,
noun: BackupActivityNoun,
mode: BackupActivityMetricMode = 'count',
): string {
if (mode === 'volume') {
return formatBytes(Math.max(0, total));
}
const normalized = Math.max(0, Math.round(total));
if (normalized === 1) return `1 ${noun}`;
return `${normalized} ${noun}s`;
}
export function getBackupActivityColumnAriaLabel(
dateLabel: string,
total: number,
selected: boolean,
noun: BackupActivityNoun,
mode: BackupActivityMetricMode = 'count',
): string {
const countLabel = getBackupActivityPointTotalLabel(total, noun, mode);
return selected ? `${dateLabel}: ${countLabel}, selected` : `${dateLabel}: ${countLabel}`;
}
export function getBackupActivityAxisLabel(value: number, mode: BackupActivityMetricMode): string {
if (mode === 'volume') return formatBytes(Math.max(0, value));
return String(Math.max(0, Math.round(value)));
}
export { getRecoveryTimelineDayFilterStateLabel as getBackupActivityDayFilterStateLabel };

View file

@ -63,6 +63,11 @@ export interface WorkloadCoverageRow {
snapshotCount: number;
posture: WorkloadRecoveryPosture;
postureRank: number;
// True when this row exists only because a backup/task referenced a VMID with
// no matching live inventory guest — i.e. an orphaned backup for a guest that
// no longer exists. Live guests carry a `resource:` key; orphans carry a
// `backup:` key and have no real name (label falls back to "CT <vmid>").
isOrphaned: boolean;
}
export interface ProxmoxBackupRecoveryModel {
@ -93,7 +98,7 @@ interface WorkloadCandidate extends WorkloadReference {
instanceKey?: string;
}
type WorkloadRowDraft = Omit<WorkloadCoverageRow, 'posture' | 'postureRank'>;
type WorkloadRowDraft = Omit<WorkloadCoverageRow, 'posture' | 'postureRank' | 'isOrphaned'>;
const DAY_MS = 24 * 60 * 60 * 1000;
const CURRENT_RECOVERY_MS = 7 * DAY_MS;
@ -503,7 +508,12 @@ export function buildProxmoxBackupRecoveryModel(
const coverageRows = Array.from(rows.values()).map((row) => {
const posture = buildPosture(row, input.nowMs);
return { ...row, posture: posture.posture, postureRank: posture.rank };
return {
...row,
posture: posture.posture,
postureRank: posture.rank,
isOrphaned: !row.key.startsWith('resource:'),
};
});
coverageRows.sort((left, right) => {

View file

@ -1,308 +0,0 @@
import type { BackupTask, GuestSnapshot, StorageBackup } from '@/types/api';
// Sysadmin-oriented age buckets. These are how the page colour-codes
// freshness in row encodings and how the coverage strip splits guests.
//
// `recent` captured ≤ 7 days ago
// `normal` captured 730 days ago
// `stale` captured 3090 days ago
// `ancient` captured > 90 days ago, or no timestamp
export type BackupAgeBucket = 'recent' | 'normal' | 'stale' | 'ancient';
export interface BackupAgeBucketPresentation {
label: string;
rowAccentClass: string;
swatchClass: string;
}
const AGE_BUCKET_PRESENTATION: Record<BackupAgeBucket, BackupAgeBucketPresentation> = {
recent: {
label: '≤ 7d',
rowAccentClass: 'bg-emerald-500',
swatchClass: 'bg-emerald-500',
},
normal: {
label: '730d',
rowAccentClass: 'bg-sky-500',
swatchClass: 'bg-sky-500',
},
stale: {
label: '3090d',
rowAccentClass: 'bg-amber-500',
swatchClass: 'bg-amber-500',
},
ancient: {
label: '> 90d',
rowAccentClass: 'bg-red-500',
swatchClass: 'bg-red-500',
},
};
export function getBackupAgeBucketPresentation(
bucket: BackupAgeBucket,
): BackupAgeBucketPresentation {
return AGE_BUCKET_PRESENTATION[bucket];
}
const DAY_MS = 24 * 60 * 60 * 1000;
export function classifyBackupAge(
timestamp: string | number | undefined,
now: number = Date.now(),
): BackupAgeBucket {
if (timestamp === undefined || timestamp === null) return 'ancient';
const ms = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
if (!Number.isFinite(ms)) return 'ancient';
const ageDays = (now - ms) / DAY_MS;
if (ageDays <= 7) return 'recent';
if (ageDays <= 30) return 'normal';
if (ageDays <= 90) return 'stale';
return 'ancient';
}
// Per-tab row dot semantics that match each coverage strip's bucketing.
// The shared 4-bucket classifyBackupAge is too granular: a 14-day archive
// would land in `normal` (sky) in the row but `stale` (amber) in the
// archive coverage strip, since archives have a 7-day SLA. These helpers
// keep the row dot, strip segment, and strip label all reading the same
// colour for the same data.
export interface AgeSwatch {
label: string;
swatchClass: string;
}
export function classifyArchiveRowAge(
timestamp: string | number | undefined,
now: number = Date.now(),
): AgeSwatch {
if (timestamp === undefined || timestamp === null)
return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
const ms = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
if (!Number.isFinite(ms)) return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
const ageDays = (now - ms) / DAY_MS;
if (ageDays <= 7) return { label: 'current (≤7d)', swatchClass: 'bg-emerald-500' };
if (ageDays <= 30) return { label: 'stale (730d)', swatchClass: 'bg-amber-500' };
return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
}
export function classifySnapshotRowAge(
timestamp: string | number | undefined,
now: number = Date.now(),
): AgeSwatch {
if (timestamp === undefined || timestamp === null)
return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
const ms = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
if (!Number.isFinite(ms)) return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
const ageDays = (now - ms) / DAY_MS;
if (ageDays <= 30) return { label: 'recent (≤30d)', swatchClass: 'bg-emerald-500' };
if (ageDays <= 90) return { label: 'stale (3090d)', swatchClass: 'bg-amber-500' };
return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
}
export function guestKey(guest: { type?: string; instance?: string; vmid: number }): string {
// instance disambiguates 100@cluster-a vs 100@cluster-b. type narrows
// VM-100 vs CT-100, which can collide in the same instance.
const t = (guest.type ?? '').toLowerCase() || '?';
return `${guest.instance ?? ''}:${t}:${guest.vmid}`;
}
interface GuestSnapshotStats {
key: string;
type: string;
vmid: number;
instance: string;
node: string;
count: number;
withRamCount: number;
newestMs: number | undefined;
oldestMs: number | undefined;
totalBytes: number;
// Newest first.
snapshots: GuestSnapshot[];
}
export interface SnapshotCoverageSummary {
totalGuests: number;
staleGuests: number; // newest > 30d
ancientGuests: number; // newest > 90d
withRamGuests: number;
totalSnapshots: number;
// Sorted by newest snapshot descending (stalest at the end).
guests: GuestSnapshotStats[];
}
export function buildSnapshotCoverageSummary(
snapshots: readonly GuestSnapshot[],
now: number = Date.now(),
): SnapshotCoverageSummary {
const byGuest = new Map<string, GuestSnapshotStats>();
for (const snap of snapshots) {
const key = guestKey(snap);
let stats = byGuest.get(key);
if (!stats) {
stats = {
key,
type: snap.type,
vmid: snap.vmid,
instance: snap.instance,
node: snap.node,
count: 0,
withRamCount: 0,
newestMs: undefined,
oldestMs: undefined,
totalBytes: 0,
snapshots: [],
};
byGuest.set(key, stats);
}
stats.snapshots.push(snap);
stats.count += 1;
if (snap.vmstate) stats.withRamCount += 1;
if (typeof snap.sizeBytes === 'number' && snap.sizeBytes > 0) {
stats.totalBytes += snap.sizeBytes;
}
const ms = Date.parse(snap.time);
if (Number.isFinite(ms)) {
if (stats.newestMs === undefined || ms > stats.newestMs) stats.newestMs = ms;
if (stats.oldestMs === undefined || ms < stats.oldestMs) stats.oldestMs = ms;
}
}
for (const stats of byGuest.values()) {
stats.snapshots.sort((a, b) => {
const av = Date.parse(a.time);
const bv = Date.parse(b.time);
return (Number.isFinite(bv) ? bv : 0) - (Number.isFinite(av) ? av : 0);
});
}
const guests = Array.from(byGuest.values()).sort((a, b) => {
const av = a.newestMs ?? 0;
const bv = b.newestMs ?? 0;
return bv - av;
});
let staleGuests = 0;
let ancientGuests = 0;
let withRamGuests = 0;
for (const g of guests) {
const bucket = classifyBackupAge(g.newestMs, now);
if (bucket === 'stale') staleGuests += 1;
if (bucket === 'ancient') ancientGuests += 1;
if (g.withRamCount > 0) withRamGuests += 1;
}
return {
totalGuests: guests.length,
staleGuests,
ancientGuests,
withRamGuests,
totalSnapshots: snapshots.length,
guests,
};
}
export interface ArchiveCoverageSummary {
// Guests with at least one archive in the last 7d.
currentGuests: number;
// Last archive 730d (warning territory for daily-backup policies).
staleGuests: number;
// Last archive > 30d.
uncoveredGuests: number;
// Distinct guests appearing in the archive set.
totalGuests: number;
totalArchives: number;
totalBytes: number;
}
export function buildArchiveCoverageSummary(
archives: readonly StorageBackup[],
now: number = Date.now(),
): ArchiveCoverageSummary {
const newestByGuest = new Map<string, number>();
let totalBytes = 0;
for (const arc of archives) {
const key = guestKey(arc);
const ms = Date.parse(arc.time);
if (Number.isFinite(ms)) {
const existing = newestByGuest.get(key);
if (existing === undefined || ms > existing) newestByGuest.set(key, ms);
} else if (!newestByGuest.has(key)) {
newestByGuest.set(key, 0);
}
if (typeof arc.size === 'number' && arc.size > 0) totalBytes += arc.size;
}
// Backup coverage is judged on a 7-day SLA — any guest whose latest
// archive is older than a week is at least stale. The label semantics
// on the page (current ≤7d, stale 730d, uncovered >30d) reflect that
// tighter threshold; classifyBackupAge has a coarser bucketing used
// for row-level age dots, so we split it explicitly here.
let currentGuests = 0;
let staleGuests = 0;
let uncoveredGuests = 0;
for (const ms of newestByGuest.values()) {
const bucket = classifyBackupAge(ms, now);
if (bucket === 'recent') currentGuests += 1;
else if (bucket === 'normal') staleGuests += 1;
else uncoveredGuests += 1;
}
return {
currentGuests,
staleGuests,
uncoveredGuests,
totalGuests: newestByGuest.size,
totalArchives: archives.length,
totalBytes,
};
}
export interface TaskOutcomeSummary {
total: number;
ok: number;
failed: number;
running: number;
hasErrors: boolean;
}
export function buildTaskOutcomeSummary(tasks: readonly BackupTask[]): TaskOutcomeSummary {
let ok = 0;
let failed = 0;
let running = 0;
let hasErrors = false;
for (const task of tasks) {
const status = (task.status ?? '').toLowerCase();
if (status === 'ok' || status === 'success' || status === 'completed') ok += 1;
else if (status === 'failed' || status === 'error') failed += 1;
else if (status === 'running') running += 1;
if (task.error && task.error.trim().length > 0) hasErrors = true;
}
return { total: tasks.length, ok, failed, running, hasErrors };
}
// Median duration in seconds across the supplied set, used as the
// reference for the "duration vs typical" inline bar on Recent tasks.
// Returns 0 if no finite durations are present.
export function computeMedianTaskDurationSeconds(tasks: readonly BackupTask[]): number {
const durations: number[] = [];
for (const task of tasks) {
const start = Date.parse(task.startTime ?? '');
const end = Date.parse(task.endTime ?? '');
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
durations.push((end - start) / 1000);
}
if (durations.length === 0) return 0;
durations.sort((a, b) => a - b);
const mid = Math.floor(durations.length / 2);
if (durations.length % 2 === 1) return durations[mid];
return (durations[mid - 1] + durations[mid]) / 2;
}
export function taskDurationSeconds(task: BackupTask): number | undefined {
const start = Date.parse(task.startTime ?? '');
const end = Date.parse(task.endTime ?? '');
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
return (end - start) / 1000;
}

View file

@ -372,12 +372,26 @@ export interface ResourceStorageMeta {
numMissing?: number;
}
// A PBS datastore as reported on the live pbs resource payload. Distinct from
// api.ts `PBSDatastore` (backups-endpoint shape, uses `usage`/`free`): this is
// the resource-model shape the WebSocket sends, keyed `usagePercent`/`available`.
export interface ResourcePBSDatastore {
name: string;
total: number;
used: number;
available?: number;
usagePercent?: number;
status?: string;
deduplicationFactor?: number;
}
export interface ResourcePBSMeta {
instanceId?: string;
hostname?: string;
version?: string;
uptimeSeconds?: number;
datastoreCount?: number;
datastores?: ResourcePBSDatastore[];
backupJobCount?: number;
syncJobCount?: number;
verifyJobCount?: number;