mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Collapse Proxmox backups surface to a guest-centric coverage view
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:
parent
af358ef365
commit
cfa9283c98
18 changed files with 548 additions and 4046 deletions
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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 7–30d "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 / 7–30d / >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 / 30–90d / >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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 7–30 days ago
|
||||
// `stale` captured 30–90 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: '7–30d',
|
||||
rowAccentClass: 'bg-sky-500',
|
||||
swatchClass: 'bg-sky-500',
|
||||
},
|
||||
stale: {
|
||||
label: '30–90d',
|
||||
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 (7–30d)', 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 (30–90d)', 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 7–30d (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 7–30d, 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue