mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:22:17 +00:00
Bring Proxmox backup sub-pages up to Storage/Ceph visual density
The three Backups tabs (Snapshots, Backup files, Recent tasks) enumerated artifacts in flat tables with almost no graphic encoding, while sibling Proxmox pages (Overview, Storage, Ceph) lead with coverage-shaped summaries and per-row visual signal. Aligns them. Snapshots gains a per-day chart, filter chips (All / Recent ≤7d / Stale >30d / With RAM), a coverage strip splitting guests by newest- snapshot bucket, and a guest-grouped table whose rows expand to show the per-guest snapshot history (same drilldown pattern as Ceph). Backup files gains a coverage strip (current / stale / uncovered guests against a 7-day SLA), a Count/Volume toggle so the chart can answer "how much disk did backups take" not just "how many files", an age dot on the Created column, and an inline size bar in the Size column (relative to the largest file in view). Recent tasks gains a coverage strip (OK / failed / running plus median duration), an inline duration bar tone-coded against the median, and the Error column is hidden when no task in view has an error. Shared row-level age presentation lives in the new proxmoxBackupSummaryPresentation module with unit coverage.
This commit is contained in:
@@ -25,11 +25,14 @@ import {
|
||||
|
||||
import {
|
||||
BACKUP_ACTIVITY_RANGE_DAYS,
|
||||
getBackupActivityAxisLabel,
|
||||
getBackupActivityColumnAriaLabel,
|
||||
getBackupActivityDayFilterStateLabel,
|
||||
getBackupActivityPointTotalLabel,
|
||||
getBackupActivitySegmentPresentation,
|
||||
getBackupActivityTooltipRows,
|
||||
type BackupActivityMetricMode,
|
||||
type BackupActivityNoun,
|
||||
type BackupActivityPoint,
|
||||
type BackupActivityRangeDays,
|
||||
type BackupActivitySegmentKind,
|
||||
@@ -43,15 +46,22 @@ interface TooltipState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface BackupActivityChartMetricToggle {
|
||||
mode: Accessor<BackupActivityMetricMode>;
|
||||
onChange: (mode: BackupActivityMetricMode) => void;
|
||||
}
|
||||
|
||||
interface BackupActivityChartProps {
|
||||
title: string;
|
||||
noun: 'archive' | 'task';
|
||||
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) => {
|
||||
@@ -60,6 +70,8 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
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);
|
||||
@@ -100,6 +112,33 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
</For>
|
||||
</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"
|
||||
@@ -143,8 +182,8 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
>
|
||||
<For each={[0, 1, 2]}>
|
||||
{(step) => {
|
||||
const value = () => Math.round((axisMax() * (2 - step)) / 2);
|
||||
return <span>{value()}</span>;
|
||||
const value = () => (axisMax() * (2 - step)) / 2;
|
||||
return <span>{getBackupActivityAxisLabel(value(), metricMode())}</span>;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
@@ -179,6 +218,7 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
total(),
|
||||
isSelected(),
|
||||
props.noun,
|
||||
metricMode(),
|
||||
)}
|
||||
aria-pressed={isSelected() ? 'true' : 'false'}
|
||||
onClick={() => props.onToggleDay(point.key)}
|
||||
@@ -279,7 +319,11 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
{t().dateLabel}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[10px] text-muted">
|
||||
{getBackupActivityPointTotalLabel(t().point.total, props.noun)}
|
||||
{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">
|
||||
@@ -290,7 +334,13 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
</div>
|
||||
</div>
|
||||
<ul class="mt-1.5 space-y-1">
|
||||
<For each={getBackupActivityTooltipRows(t().point, props.segmentKinds)}>
|
||||
<For
|
||||
each={getBackupActivityTooltipRows(
|
||||
t().point,
|
||||
props.segmentKinds,
|
||||
metricMode(),
|
||||
)}
|
||||
>
|
||||
{(row) => (
|
||||
<li
|
||||
class={`flex items-center justify-between gap-4 ${
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
|
||||
// A single horizontal bar broken into proportional segments, followed by
|
||||
// a one-line legend of labels + counts. Visually integrated with the page
|
||||
// chrome (no tile border per metric) so it reads as a status line, not a
|
||||
// stat-card grid. Used at the top of Snapshots and Backup files to answer
|
||||
// the "is my backup posture healthy?" question on sight.
|
||||
|
||||
export interface CoverageStripSegment {
|
||||
key: string;
|
||||
// Value used to compute the segment's proportional width.
|
||||
value: number;
|
||||
label: string;
|
||||
// Tailwind class applied to the segment bar (e.g. `bg-emerald-500`).
|
||||
toneClass: string;
|
||||
// Optional formatted display value; defaults to the raw `value`.
|
||||
display?: string;
|
||||
// When true the segment is rendered with a neutral tone for "absent".
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface CoverageStripProps {
|
||||
title: string;
|
||||
segments: readonly CoverageStripSegment[];
|
||||
// Compact context shown to the right of the title (e.g. "1.2 TB on disk").
|
||||
tail?: JSX.Element;
|
||||
}
|
||||
|
||||
export const ProxmoxBackupsCoverageStrip: Component<CoverageStripProps> = (props) => {
|
||||
const total = () =>
|
||||
props.segments.reduce((sum, seg) => sum + (Number.isFinite(seg.value) ? Math.max(0, seg.value) : 0), 0);
|
||||
|
||||
return (
|
||||
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 px-3 py-2">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
{props.title}
|
||||
</div>
|
||||
<Show when={props.tail}>
|
||||
<div class="ml-auto text-[11px] text-muted">{props.tail}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={total() > 0}
|
||||
fallback={
|
||||
<div class="mt-2 h-2 w-full rounded-full bg-surface" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
<div class="mt-2 flex h-2 w-full overflow-hidden rounded-full bg-surface">
|
||||
<For each={props.segments}>
|
||||
{(segment) => {
|
||||
const widthPct = () =>
|
||||
total() > 0 ? Math.max(0, (segment.value / total()) * 100) : 0;
|
||||
return (
|
||||
<Show when={widthPct() > 0}>
|
||||
<div
|
||||
class={segment.toneClass}
|
||||
style={{ width: `${widthPct()}%` }}
|
||||
role="presentation"
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<ul class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px]">
|
||||
<For each={props.segments}>
|
||||
{(segment) => (
|
||||
<li
|
||||
class={`inline-flex items-center gap-1.5 ${segment.muted ? 'text-muted/70' : 'text-base-content'}`}
|
||||
>
|
||||
<span class={`h-2 w-2 shrink-0 rounded-sm ${segment.toneClass}`} aria-hidden="true" />
|
||||
<span class="tabular-nums">
|
||||
{segment.display ?? String(Math.max(0, Math.round(segment.value)))}
|
||||
</span>
|
||||
<span class="text-muted">{segment.label}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxmoxBackupsCoverageStrip;
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import ArchiveIcon from 'lucide-solid/icons/archive';
|
||||
import CameraIcon from 'lucide-solid/icons/camera';
|
||||
import ActivityIcon from 'lucide-solid/icons/activity';
|
||||
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
@@ -48,9 +49,22 @@ import type {
|
||||
import { BackupActivityChart } from './BackupActivityChart';
|
||||
import {
|
||||
buildBackupActivityTimeline,
|
||||
type BackupActivityMetricMode,
|
||||
type BackupActivityRangeDays,
|
||||
type BackupActivitySegmentKind,
|
||||
} from './proxmoxBackupActivityPresentation';
|
||||
import {
|
||||
buildArchiveCoverageSummary,
|
||||
buildSnapshotCoverageSummary,
|
||||
buildTaskOutcomeSummary,
|
||||
classifyBackupAge,
|
||||
computeMedianTaskDurationSeconds,
|
||||
getBackupAgeBucketPresentation,
|
||||
guestKey,
|
||||
taskDurationSeconds,
|
||||
type BackupAgeBucket,
|
||||
} from './proxmoxBackupSummaryPresentation';
|
||||
import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip';
|
||||
|
||||
// Proxmox VE backups split into three meaningfully different surfaces:
|
||||
// - Snapshots: qm/pct snapshots taken on the host (no external storage)
|
||||
@@ -163,6 +177,70 @@ const TASK_STATUS_FILTERS: FilterOption<'all' | 'ok' | 'failed' | 'running'>[] =
|
||||
{ value: 'running', label: 'Running', tone: 'info', leading: statusDot('bg-blue-500') },
|
||||
];
|
||||
|
||||
type SnapshotFilterValue = 'all' | 'recent' | 'stale' | 'with-ram';
|
||||
|
||||
const SNAPSHOT_FILTERS: FilterOption<SnapshotFilterValue>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{
|
||||
value: 'recent',
|
||||
label: 'Recent ≤7d',
|
||||
tone: 'success',
|
||||
leading: statusDot('bg-emerald-500'),
|
||||
},
|
||||
{
|
||||
value: 'stale',
|
||||
label: 'Stale >30d',
|
||||
tone: 'warning',
|
||||
leading: statusDot('bg-amber-500'),
|
||||
},
|
||||
{
|
||||
value: 'with-ram',
|
||||
label: 'With RAM',
|
||||
tone: 'info',
|
||||
leading: statusDot('bg-violet-500'),
|
||||
},
|
||||
];
|
||||
|
||||
function formatDurationFromSeconds(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
||||
const rounded = Math.round(seconds);
|
||||
if (rounded < 60) return `${rounded}s`;
|
||||
if (rounded < 3600) return `${Math.floor(rounded / 60)}m ${rounded % 60}s`;
|
||||
const h = Math.floor(rounded / 3600);
|
||||
const m = Math.floor((rounded % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// Compact, table-row-sized horizontal usage bar. Used inline in the
|
||||
// Backup files Size column (proportional to the largest archive in view)
|
||||
// and in the Recent tasks Duration column (proportional to 2x the median
|
||||
// duration so a typical task fills ~50% and outliers fill the bar).
|
||||
function InlineProgressBar(props: {
|
||||
valuePct: number;
|
||||
toneClass: string;
|
||||
width?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const widthClass = props.width ?? 'w-16';
|
||||
return (
|
||||
<div
|
||||
class={`${widthClass} h-1.5 overflow-hidden rounded-full bg-surface`}
|
||||
role="img"
|
||||
aria-label={props.label}
|
||||
title={props.label}
|
||||
>
|
||||
<div
|
||||
class={`h-full ${props.toneClass}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, props.valuePct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotAgeBucket(snap: GuestSnapshot, now: number): BackupAgeBucket {
|
||||
return classifyBackupAge(snap.time, now);
|
||||
}
|
||||
|
||||
export const ProxmoxBackupsTable: Component<{
|
||||
emptyIcon: JSX.Element;
|
||||
}> = (props) => {
|
||||
@@ -173,14 +251,32 @@ export const ProxmoxBackupsTable: Component<{
|
||||
'all' | 'protected' | 'verified' | 'unverified'
|
||||
>('all');
|
||||
const [taskFilter, setTaskFilter] = createSignal<'all' | 'ok' | 'failed' | 'running'>('all');
|
||||
const [snapshotFilter, setSnapshotFilter] = createSignal<SnapshotFilterValue>('all');
|
||||
const [chartRange, setChartRange] = createSignal<BackupActivityRangeDays>(30);
|
||||
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
|
||||
const [archiveMetricMode, setArchiveMetricMode] = createSignal<BackupActivityMetricMode>('count');
|
||||
const [expandedGuests, setExpandedGuests] = createSignal<ReadonlySet<string>>(new Set<string>());
|
||||
const toggleDay = (key: string) =>
|
||||
setSelectedDateKey((current) => (current === key ? null : key));
|
||||
const toggleGuestExpansion = (key: string) =>
|
||||
setExpandedGuests((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const snapshots = createMemo<GuestSnapshot[]>(() => backups()?.guestSnapshots ?? []);
|
||||
const archives = createMemo<StorageBackup[]>(() => backups()?.storageBackups ?? []);
|
||||
const tasks = createMemo<BackupTask[]>(() => backups()?.backupTasks ?? []);
|
||||
// Render-time `now` used for age bucketing. We snapshot once per render
|
||||
// so all comparisons within a single render share a reference moment;
|
||||
// not reactive to ticking time (good enough for sysadmin grouping).
|
||||
const nowMs = createMemo(() => Date.now());
|
||||
|
||||
const snapshotCoverage = createMemo(() => buildSnapshotCoverageSummary(snapshots(), nowMs()));
|
||||
const archiveCoverage = createMemo(() => buildArchiveCoverageSummary(archives(), nowMs()));
|
||||
const taskOutcome = createMemo(() => buildTaskOutcomeSummary(tasks()));
|
||||
|
||||
const archiveTimestampMs = (arc: StorageBackup): number | undefined => {
|
||||
const ms = Date.parse(arc.time ?? '');
|
||||
@@ -190,6 +286,10 @@ export const ProxmoxBackupsTable: Component<{
|
||||
const ms = Date.parse(task.startTime ?? '');
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
};
|
||||
const snapshotTimestampMs = (snap: GuestSnapshot): number | undefined => {
|
||||
const ms = Date.parse(snap.time ?? '');
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
};
|
||||
const classifyTaskSegment = (task: BackupTask): BackupActivitySegmentKind | null => {
|
||||
const variant = classifyTaskStatus(task.status).variant;
|
||||
if (variant === 'success') return 'ok';
|
||||
@@ -204,6 +304,10 @@ export const ProxmoxBackupsTable: Component<{
|
||||
archives(),
|
||||
archiveTimestampMs,
|
||||
() => 'archive',
|
||||
{
|
||||
getValue:
|
||||
archiveMetricMode() === 'volume' ? (arc) => (arc.size > 0 ? arc.size : 0) : undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
const taskTimeline = createMemo(() =>
|
||||
@@ -214,20 +318,154 @@ export const ProxmoxBackupsTable: Component<{
|
||||
classifyTaskSegment,
|
||||
),
|
||||
);
|
||||
const snapshotTimeline = createMemo(() =>
|
||||
buildBackupActivityTimeline<GuestSnapshot>(
|
||||
chartRange(),
|
||||
snapshots(),
|
||||
snapshotTimestampMs,
|
||||
() => 'snapshot',
|
||||
),
|
||||
);
|
||||
|
||||
const ARCHIVE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['archive'];
|
||||
const TASK_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['ok', 'failed', 'running'];
|
||||
const SNAPSHOT_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['snapshot'];
|
||||
|
||||
const snapshotMatchesSearch = (snap: GuestSnapshot, term: string): boolean => {
|
||||
if (!term) return true;
|
||||
return [snap.name, snap.node, snap.instance, snap.description, String(snap.vmid)]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
};
|
||||
|
||||
const filteredSnapshots = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const dateKey = selectedDateKey();
|
||||
return snapshots().filter((snap) => {
|
||||
if (dateKey) {
|
||||
const ms = snapshotTimestampMs(snap);
|
||||
if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false;
|
||||
}
|
||||
return snapshotMatchesSearch(snap, term);
|
||||
});
|
||||
});
|
||||
|
||||
interface SnapshotGuestRow {
|
||||
key: string;
|
||||
type: string;
|
||||
vmid: number;
|
||||
instance: string;
|
||||
node: string;
|
||||
snapshots: GuestSnapshot[]; // newest first
|
||||
count: number;
|
||||
withRamCount: number;
|
||||
newestMs: number | undefined;
|
||||
totalBytes: number;
|
||||
bucket: BackupAgeBucket;
|
||||
}
|
||||
|
||||
const filteredSnapshotGuests = createMemo<SnapshotGuestRow[]>(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const dateKey = selectedDateKey();
|
||||
const filter = snapshotFilter();
|
||||
const now = nowMs();
|
||||
const byGuest = new Map<string, SnapshotGuestRow>();
|
||||
for (const snap of snapshots()) {
|
||||
const matchesSnapshot = snapshotMatchesSearch(snap, term);
|
||||
if (!matchesSnapshot && !term) {
|
||||
// no term means everything matches; fall through.
|
||||
} else if (!matchesSnapshot && term) {
|
||||
// guest stays out of the matched set unless its identifiers match;
|
||||
// we re-evaluate that below if no snapshot inside matched.
|
||||
}
|
||||
const passesDate = (() => {
|
||||
if (!dateKey) return true;
|
||||
const ms = snapshotTimestampMs(snap);
|
||||
return ms !== undefined && recoveryDateKeyFromTimestamp(ms) === dateKey;
|
||||
})();
|
||||
if (!passesDate) continue;
|
||||
const key = guestKey(snap);
|
||||
let row = byGuest.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
key,
|
||||
type: snap.type,
|
||||
vmid: snap.vmid,
|
||||
instance: snap.instance,
|
||||
node: snap.node,
|
||||
snapshots: [],
|
||||
count: 0,
|
||||
withRamCount: 0,
|
||||
newestMs: undefined,
|
||||
totalBytes: 0,
|
||||
bucket: 'ancient',
|
||||
};
|
||||
byGuest.set(key, row);
|
||||
}
|
||||
row.snapshots.push(snap);
|
||||
row.count += 1;
|
||||
if (snap.vmstate) row.withRamCount += 1;
|
||||
if (typeof snap.sizeBytes === 'number' && snap.sizeBytes > 0) row.totalBytes += snap.sizeBytes;
|
||||
const ms = snapshotTimestampMs(snap);
|
||||
if (ms !== undefined && (row.newestMs === undefined || ms > row.newestMs)) {
|
||||
row.newestMs = ms;
|
||||
}
|
||||
}
|
||||
// Search-on-guest-identity: if the search term matches the guest's
|
||||
// identifying fields (node/vmid/type), every snapshot under it is
|
||||
// considered relevant even if individual snapshot text did not match.
|
||||
const searchedByGuestIdentity = (row: SnapshotGuestRow): boolean => {
|
||||
if (!term) return true;
|
||||
return [snap.name, snap.node, snap.instance, snap.description, String(snap.vmid)]
|
||||
return [`${row.type} ${row.vmid}`, row.node, row.instance, String(row.vmid)]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(term);
|
||||
});
|
||||
};
|
||||
const rows: SnapshotGuestRow[] = [];
|
||||
for (const row of byGuest.values()) {
|
||||
const matchesByIdentity = searchedByGuestIdentity(row);
|
||||
const snapshotsMatching = term
|
||||
? row.snapshots.filter((snap) => snapshotMatchesSearch(snap, term))
|
||||
: row.snapshots;
|
||||
if (!matchesByIdentity && snapshotsMatching.length === 0) continue;
|
||||
const visibleSnapshots = matchesByIdentity ? row.snapshots : snapshotsMatching;
|
||||
visibleSnapshots.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 enriched: SnapshotGuestRow = {
|
||||
...row,
|
||||
snapshots: visibleSnapshots,
|
||||
count: visibleSnapshots.length,
|
||||
withRamCount: visibleSnapshots.reduce((sum, s) => sum + (s.vmstate ? 1 : 0), 0),
|
||||
totalBytes: visibleSnapshots.reduce(
|
||||
(sum, s) => sum + (typeof s.sizeBytes === 'number' && s.sizeBytes > 0 ? s.sizeBytes : 0),
|
||||
0,
|
||||
),
|
||||
newestMs: visibleSnapshots.reduce<number | undefined>((newest, s) => {
|
||||
const ms = snapshotTimestampMs(s);
|
||||
if (ms === undefined) return newest;
|
||||
if (newest === undefined) return ms;
|
||||
return ms > newest ? ms : newest;
|
||||
}, undefined),
|
||||
bucket: classifyBackupAge(
|
||||
visibleSnapshots[0] ? snapshotTimestampMs(visibleSnapshots[0]) : undefined,
|
||||
now,
|
||||
),
|
||||
};
|
||||
if (filter === 'recent' && enriched.bucket !== 'recent') continue;
|
||||
if (filter === 'stale' && enriched.bucket !== 'stale' && enriched.bucket !== 'ancient') {
|
||||
continue;
|
||||
}
|
||||
if (filter === 'with-ram' && enriched.withRamCount === 0) continue;
|
||||
rows.push(enriched);
|
||||
}
|
||||
rows.sort((a, b) => (b.newestMs ?? 0) - (a.newestMs ?? 0));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const filteredArchives = createMemo(() => {
|
||||
@@ -299,6 +537,28 @@ export const ProxmoxBackupsTable: Component<{
|
||||
}
|
||||
});
|
||||
|
||||
// Used to scale the inline duration bar on Recent tasks. A typical task
|
||||
// sits at ~50% of the bar, outliers extend toward the right edge. The
|
||||
// baseline is recomputed against the *filtered* set so the bar stays
|
||||
// useful when the user narrows the view to a single guest.
|
||||
const taskDurationBaselineSeconds = createMemo(() =>
|
||||
computeMedianTaskDurationSeconds(filteredTasks()),
|
||||
);
|
||||
|
||||
// The largest archive in the filtered set anchors the size bar.
|
||||
const archiveSizeMaxBytes = createMemo(() => {
|
||||
let max = 0;
|
||||
for (const arc of filteredArchives()) {
|
||||
if (arc.size > max) max = arc.size;
|
||||
}
|
||||
return max;
|
||||
});
|
||||
|
||||
const visibleSnapshotGuestCount = createMemo(() => filteredSnapshotGuests().length);
|
||||
const visibleSnapshotItemCount = createMemo(() =>
|
||||
filteredSnapshotGuests().reduce((sum, row) => sum + row.count, 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!backups.error}
|
||||
@@ -361,9 +621,65 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={tab() === 'snapshots' && snapshots().length > 0}>
|
||||
<BackupActivityChart
|
||||
title={
|
||||
archiveMetricMode() === 'volume'
|
||||
? 'Snapshots per day · GB captured'
|
||||
: 'Snapshots per day'
|
||||
}
|
||||
noun="snapshot"
|
||||
segmentKinds={SNAPSHOT_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
onRangeChange={setChartRange}
|
||||
timeline={snapshotTimeline}
|
||||
selectedDateKey={selectedDateKey}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
<ProxmoxBackupsCoverageStrip
|
||||
title="Snapshot coverage"
|
||||
tail={
|
||||
<span>
|
||||
{snapshotCoverage().totalGuests} guests · {snapshotCoverage().totalSnapshots}{' '}
|
||||
snapshots
|
||||
<Show when={snapshotCoverage().withRamGuests > 0}>
|
||||
{' · '}
|
||||
{snapshotCoverage().withRamGuests} with RAM
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
segments={[
|
||||
{
|
||||
key: 'recent',
|
||||
value:
|
||||
snapshotCoverage().totalGuests -
|
||||
snapshotCoverage().staleGuests -
|
||||
snapshotCoverage().ancientGuests,
|
||||
label: 'recent (≤30d)',
|
||||
toneClass: getBackupAgeBucketPresentation('recent').swatchClass,
|
||||
},
|
||||
{
|
||||
key: 'stale',
|
||||
value: snapshotCoverage().staleGuests,
|
||||
label: 'stale (30–90d)',
|
||||
toneClass: getBackupAgeBucketPresentation('stale').swatchClass,
|
||||
muted: snapshotCoverage().staleGuests === 0,
|
||||
},
|
||||
{
|
||||
key: 'ancient',
|
||||
value: snapshotCoverage().ancientGuests,
|
||||
label: 'ancient (>90d)',
|
||||
toneClass: getBackupAgeBucketPresentation('ancient').swatchClass,
|
||||
muted: snapshotCoverage().ancientGuests === 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={tab() === 'archives'}>
|
||||
<BackupActivityChart
|
||||
title="Backup files per day"
|
||||
title={
|
||||
archiveMetricMode() === 'volume' ? 'Backup volume per day' : 'Backup files per day'
|
||||
}
|
||||
noun="archive"
|
||||
segmentKinds={ARCHIVE_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
@@ -371,7 +687,41 @@ export const ProxmoxBackupsTable: Component<{
|
||||
timeline={archiveTimeline}
|
||||
selectedDateKey={selectedDateKey}
|
||||
onToggleDay={toggleDay}
|
||||
metricToggle={{ mode: archiveMetricMode, onChange: setArchiveMetricMode }}
|
||||
/>
|
||||
<Show when={archiveCoverage().totalGuests > 0}>
|
||||
<ProxmoxBackupsCoverageStrip
|
||||
title="Backup coverage"
|
||||
tail={
|
||||
<span>
|
||||
{archiveCoverage().totalGuests} guests covered ·{' '}
|
||||
{formatBytes(archiveCoverage().totalBytes)} stored
|
||||
</span>
|
||||
}
|
||||
segments={[
|
||||
{
|
||||
key: 'current',
|
||||
value: archiveCoverage().currentGuests,
|
||||
label: 'current (≤7d)',
|
||||
toneClass: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
key: 'stale',
|
||||
value: archiveCoverage().staleGuests,
|
||||
label: 'stale (7–30d)',
|
||||
toneClass: 'bg-amber-500',
|
||||
muted: archiveCoverage().staleGuests === 0,
|
||||
},
|
||||
{
|
||||
key: 'uncovered',
|
||||
value: archiveCoverage().uncoveredGuests,
|
||||
label: 'uncovered (>30d)',
|
||||
toneClass: 'bg-red-500',
|
||||
muted: archiveCoverage().uncoveredGuests === 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={tab() === 'tasks'}>
|
||||
<BackupActivityChart
|
||||
@@ -384,6 +734,43 @@ export const ProxmoxBackupsTable: Component<{
|
||||
selectedDateKey={selectedDateKey}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
<Show when={taskOutcome().total > 0}>
|
||||
<ProxmoxBackupsCoverageStrip
|
||||
title="Task outcomes"
|
||||
tail={
|
||||
<Show when={taskDurationBaselineSeconds() > 0}>
|
||||
<span>
|
||||
median duration{' '}
|
||||
<span class="font-mono tabular-nums text-base-content">
|
||||
{formatDurationFromSeconds(taskDurationBaselineSeconds())}
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
segments={[
|
||||
{
|
||||
key: 'ok',
|
||||
value: taskOutcome().ok,
|
||||
label: 'OK',
|
||||
toneClass: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
key: 'failed',
|
||||
value: taskOutcome().failed,
|
||||
label: 'failed',
|
||||
toneClass: 'bg-red-500',
|
||||
muted: taskOutcome().failed === 0,
|
||||
},
|
||||
{
|
||||
key: 'running',
|
||||
value: taskOutcome().running,
|
||||
label: 'running',
|
||||
toneClass: 'bg-amber-500',
|
||||
muted: taskOutcome().running === 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@@ -400,6 +787,14 @@ export const ProxmoxBackupsTable: Component<{
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Show when={tab() === 'snapshots'}>
|
||||
<FilterButtonGroup
|
||||
variant="compact"
|
||||
options={SNAPSHOT_FILTERS}
|
||||
value={snapshotFilter()}
|
||||
onChange={setSnapshotFilter}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={tab() === 'archives'}>
|
||||
<FilterButtonGroup
|
||||
variant="compact"
|
||||
@@ -416,7 +811,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
onChange={setTaskFilter}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={selectedDateKey() !== null && tab() !== 'snapshots'}>
|
||||
<Show when={selectedDateKey() !== null}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDateKey(null)}
|
||||
@@ -434,17 +829,25 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</Show>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show
|
||||
when={visibleForTab() !== totalForTab()}
|
||||
fallback={<>{totalForTab()} entries</>}
|
||||
when={tab() === 'snapshots'}
|
||||
fallback={
|
||||
<Show
|
||||
when={visibleForTab() !== totalForTab()}
|
||||
fallback={<>{totalForTab()} entries</>}
|
||||
>
|
||||
{visibleForTab()} of {totalForTab()} entries
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{visibleForTab()} of {totalForTab()} entries
|
||||
{visibleSnapshotGuestCount()} guests · {visibleSnapshotItemCount()} of{' '}
|
||||
{snapshots().length} snapshots
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={tab() === 'snapshots'}>
|
||||
<Show
|
||||
when={filteredSnapshots().length > 0}
|
||||
when={filteredSnapshotGuests().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
@@ -457,7 +860,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
description={
|
||||
snapshots().length === 0
|
||||
? TABS[0].emptyDescription
|
||||
: 'Adjust the search to see more snapshots.'
|
||||
: 'Adjust the search or filters to see more snapshots.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
@@ -467,77 +870,178 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<Table class="min-w-[900px] text-xs">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('name')}>Name</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('name')}>Guest</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Parent</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Captured
|
||||
Latest
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
Snapshots
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Total size
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>RAM</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={filteredSnapshots()}>
|
||||
{(snap) => (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
|
||||
>
|
||||
<div class="font-semibold">{snap.name || '—'}</div>
|
||||
<Show when={!!snap.description?.trim()}>
|
||||
<div
|
||||
class="text-[11px] text-muted truncate max-w-[20rem]"
|
||||
title={snap.description}
|
||||
>
|
||||
{snap.description}
|
||||
</div>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
{guestLabel(snap.type, snap.vmid)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
{snap.node || '—'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
{snap.parent?.trim() || '—'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatRelativeTime(snap.time, { compact: true })}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
<Show
|
||||
when={snap.sizeBytes && snap.sizeBytes > 0}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
<For each={filteredSnapshotGuests()}>
|
||||
{(row) => {
|
||||
const isExpanded = () => expandedGuests().has(row.key);
|
||||
const bucketPresentation = getBackupAgeBucketPresentation(row.bucket);
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
class="cursor-pointer hover:bg-surface-hover"
|
||||
onClick={() => toggleGuestExpansion(row.key)}
|
||||
>
|
||||
{formatBytes(snap.sizeBytes ?? 0)}
|
||||
<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={`h-2 w-2 shrink-0 rounded-full ${bucketPresentation.swatchClass}`}
|
||||
aria-hidden="true"
|
||||
title={`Newest snapshot: ${bucketPresentation.label}`}
|
||||
/>
|
||||
<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>}
|
||||
>
|
||||
{formatRelativeTime(row.newestMs, { compact: true })}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
{row.count}
|
||||
</TableCell>
|
||||
<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>
|
||||
<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>
|
||||
</TableRow>
|
||||
<Show when={isExpanded()}>
|
||||
<TableRow class="bg-surface-alt/40">
|
||||
<TableCell class="px-2 py-2" colspan={6}>
|
||||
<div class="overflow-hidden rounded-md border border-border-subtle">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-surface-alt text-muted">
|
||||
<th class="px-2 py-1 text-left font-medium">Name</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Parent</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Captured</th>
|
||||
<th class="px-2 py-1 text-right font-medium">Size</th>
|
||||
<th class="px-2 py-1 text-left font-medium">RAM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border-subtle">
|
||||
<For each={row.snapshots}>
|
||||
{(snap) => {
|
||||
const bucket = snapshotAgeBucket(snap, nowMs());
|
||||
const presentation = getBackupAgeBucketPresentation(
|
||||
bucket,
|
||||
);
|
||||
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 ${presentation.swatchClass}`}
|
||||
aria-hidden="true"
|
||||
title={`Age: ${presentation.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>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show when={snap.vmstate} fallback={<span class="text-muted">—</span>}>
|
||||
<span class="inline-flex items-center rounded-sm bg-blue-100 px-1.5 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
with RAM
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -625,12 +1129,38 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatRelativeTime(arc.time, { compact: true })}
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span
|
||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
getBackupAgeBucketPresentation(classifyBackupAge(arc.time, nowMs()))
|
||||
.swatchClass
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
title={`Age: ${
|
||||
getBackupAgeBucketPresentation(classifyBackupAge(arc.time, nowMs()))
|
||||
.label
|
||||
}`}
|
||||
/>
|
||||
<span>{formatRelativeTime(arc.time, { compact: true })}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
{formatBytes(arc.size)}
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Show when={archiveSizeMaxBytes() > 0 && arc.size > 0}>
|
||||
<InlineProgressBar
|
||||
valuePct={(arc.size / archiveSizeMaxBytes()) * 100}
|
||||
toneClass={
|
||||
arc.size / archiveSizeMaxBytes() >= 0.66
|
||||
? 'bg-blue-500'
|
||||
: 'bg-blue-400'
|
||||
}
|
||||
label={`${formatBytes(arc.size)} (relative to largest file in view)`}
|
||||
/>
|
||||
</Show>
|
||||
<span>{formatBytes(arc.size)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
@@ -702,13 +1232,30 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
|
||||
<Show when={taskOutcome().hasErrors}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
|
||||
</Show>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={filteredTasks()}>
|
||||
{(task) => {
|
||||
const classify = classifyTaskStatus(task.status);
|
||||
const durationSec = taskDurationSeconds(task);
|
||||
// Anchor the bar so the median sits at ~50%.
|
||||
const durationBarPct = () => {
|
||||
const baseline = taskDurationBaselineSeconds();
|
||||
if (!durationSec || baseline <= 0) return 0;
|
||||
return (durationSec / (baseline * 2)) * 100;
|
||||
};
|
||||
const durationToneClass = () => {
|
||||
const baseline = taskDurationBaselineSeconds();
|
||||
if (!durationSec || baseline <= 0) return 'bg-slate-400';
|
||||
const ratio = durationSec / baseline;
|
||||
if (ratio >= 2) return 'bg-amber-500';
|
||||
if (ratio >= 1.5) return 'bg-amber-400';
|
||||
return 'bg-emerald-500';
|
||||
};
|
||||
return (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class={getPlatformTableCellClassForKind('text')}>
|
||||
@@ -742,7 +1289,16 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatDuration(task.startTime, task.endTime)}
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Show when={taskDurationBaselineSeconds() > 0 && durationSec}>
|
||||
<InlineProgressBar
|
||||
valuePct={durationBarPct()}
|
||||
toneClass={durationToneClass()}
|
||||
label={`Duration ${formatDuration(task.startTime, task.endTime)} (median ${formatDurationFromSeconds(taskDurationBaselineSeconds())})`}
|
||||
/>
|
||||
</Show>
|
||||
<span>{formatDuration(task.startTime, task.endTime)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
@@ -754,21 +1310,23 @@ export const ProxmoxBackupsTable: Component<{
|
||||
{formatBytes(task.size ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={!!task.error?.trim()}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
<Show when={taskOutcome().hasErrors}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
|
||||
title={task.error}
|
||||
<Show
|
||||
when={!!task.error?.trim()}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
{task.error}
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
|
||||
+22
-2
@@ -27,7 +27,7 @@ describe('proxmoxBackupActivityPresentation', () => {
|
||||
],
|
||||
(item) => item.ts,
|
||||
(item) => item.kind as BackupActivitySegmentKind,
|
||||
now,
|
||||
{ now },
|
||||
);
|
||||
|
||||
const todayPoint = timeline.points.find(
|
||||
@@ -50,7 +50,7 @@ describe('proxmoxBackupActivityPresentation', () => {
|
||||
const point = {
|
||||
key: '2026-05-20',
|
||||
total: 4,
|
||||
counts: { archive: 0, ok: 3, failed: 1, running: 0 },
|
||||
counts: { archive: 0, ok: 3, failed: 1, running: 0, snapshot: 0 },
|
||||
};
|
||||
|
||||
expect(getBackupActivityColumnAriaLabel('Wed 20 May', point.total, true, 'task')).toBe(
|
||||
@@ -62,4 +62,24 @@ describe('proxmoxBackupActivityPresentation', () => {
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { BackupTask, GuestSnapshot, StorageBackup } from '@/types/api';
|
||||
import {
|
||||
buildArchiveCoverageSummary,
|
||||
buildSnapshotCoverageSummary,
|
||||
buildTaskOutcomeSummary,
|
||||
classifyBackupAge,
|
||||
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('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,3 +1,4 @@
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import {
|
||||
getRecoveryNiceAxisMax,
|
||||
recoveryDateKeyFromTimestamp,
|
||||
@@ -11,7 +12,12 @@ export const BACKUP_ACTIVITY_RANGE_DAYS = [
|
||||
7, 30, 90, 365,
|
||||
] as const satisfies readonly BackupActivityRangeDays[];
|
||||
|
||||
export type BackupActivitySegmentKind = 'archive' | 'ok' | 'failed' | 'running';
|
||||
// `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' | 'ok' | 'failed' | 'running' | 'snapshot';
|
||||
|
||||
interface BackupActivitySegmentPresentation {
|
||||
label: string;
|
||||
@@ -40,6 +46,11 @@ const SEGMENT_PRESENTATION: Record<BackupActivitySegmentKind, BackupActivitySegm
|
||||
segmentClassName: 'bg-amber-500',
|
||||
swatchClassName: 'bg-amber-500',
|
||||
},
|
||||
snapshot: {
|
||||
label: 'Snapshots',
|
||||
segmentClassName: 'bg-violet-500',
|
||||
swatchClassName: 'bg-violet-500',
|
||||
},
|
||||
};
|
||||
|
||||
export function getBackupActivitySegmentPresentation(
|
||||
@@ -61,7 +72,7 @@ export interface BackupActivityTimeline {
|
||||
}
|
||||
|
||||
function emptyCounts(): Record<BackupActivitySegmentKind, number> {
|
||||
return { archive: 0, ok: 0, failed: 0, running: 0 };
|
||||
return { archive: 0, ok: 0, failed: 0, running: 0, snapshot: 0 };
|
||||
}
|
||||
|
||||
function startOfLocalDayMs(date: Date): number {
|
||||
@@ -75,8 +86,15 @@ export function buildBackupActivityTimeline<T>(
|
||||
items: readonly T[],
|
||||
getTimestampMs: (item: T) => number | undefined,
|
||||
classify: (item: T) => BackupActivitySegmentKind | null,
|
||||
now: Date = new Date(),
|
||||
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;
|
||||
|
||||
@@ -97,11 +115,13 @@ export function buildBackupActivityTimeline<T>(
|
||||
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] += 1;
|
||||
bucket.total += 1;
|
||||
bucket.counts[kind] += value;
|
||||
bucket.total += value;
|
||||
}
|
||||
|
||||
const points = orderedKeys.map((key) => buckets.get(key)!);
|
||||
@@ -121,28 +141,44 @@ export interface BackupActivityTooltipRow {
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export type BackupActivityNoun = 'archive' | '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 ? `${count} (${percentage}%)` : String(count),
|
||||
value: percentage > 0 && kinds.length > 1 ? `${formatted} (${percentage}%)` : formatted,
|
||||
segmentClassName: presentation.segmentClassName,
|
||||
muted: count === 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getBackupActivityPointTotalLabel(total: number, noun: 'archive' | 'task'): string {
|
||||
const normalized = Math.max(0, total);
|
||||
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`;
|
||||
}
|
||||
@@ -151,10 +187,19 @@ export function getBackupActivityColumnAriaLabel(
|
||||
dateLabel: string,
|
||||
total: number,
|
||||
selected: boolean,
|
||||
noun: 'archive' | 'task',
|
||||
noun: BackupActivityNoun,
|
||||
mode: BackupActivityMetricMode = 'count',
|
||||
): string {
|
||||
const countLabel = getBackupActivityPointTotalLabel(total, noun);
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
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';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user