diff --git a/frontend-modern/src/features/proxmox/BackupActivityChart.tsx b/frontend-modern/src/features/proxmox/BackupActivityChart.tsx index 270279a54b..d1bb69105a 100644 --- a/frontend-modern/src/features/proxmox/BackupActivityChart.tsx +++ b/frontend-modern/src/features/proxmox/BackupActivityChart.tsx @@ -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; + onChange: (mode: BackupActivityMetricMode) => void; +} + interface BackupActivityChartProps { title: string; - noun: 'archive' | 'task'; + noun: BackupActivityNoun; segmentKinds: readonly BackupActivitySegmentKind[]; range: Accessor; onRangeChange: (days: BackupActivityRangeDays) => void; timeline: Accessor; selectedDateKey: Accessor; onToggleDay: (key: string) => void; + metricMode?: Accessor; + metricToggle?: BackupActivityChartMetricToggle; } export const BackupActivityChart: Component = (props) => { @@ -60,6 +70,8 @@ export const BackupActivityChart: Component = (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 = (props)
+ +
+ + {(mode) => { + const selected = () => props.metricToggle?.mode() === mode; + return ( + + ); + }} + +
+
= (props) > {(step) => { - const value = () => Math.round((axisMax() * (2 - step)) / 2); - return {value()}; + const value = () => (axisMax() * (2 - step)) / 2; + return {getBackupActivityAxisLabel(value(), metricMode())}; }}
@@ -179,6 +218,7 @@ export const BackupActivityChart: Component = (props) total(), isSelected(), props.noun, + metricMode(), )} aria-pressed={isSelected() ? 'true' : 'false'} onClick={() => props.onToggleDay(point.key)} @@ -279,7 +319,11 @@ export const BackupActivityChart: Component = (props) {t().dateLabel}
- {getBackupActivityPointTotalLabel(t().point.total, props.noun)} + {getBackupActivityPointTotalLabel( + t().point.total, + props.noun, + metricMode(), + )}
@@ -290,7 +334,13 @@ export const BackupActivityChart: Component = (props)
    - + {(row) => (
  • = (props) => { + const total = () => + props.segments.reduce((sum, seg) => sum + (Number.isFinite(seg.value) ? Math.max(0, seg.value) : 0), 0); + + return ( +
    +
    +
    + {props.title} +
    + +
    {props.tail}
    +
    +
    + 0} + fallback={ + 0} + when={filteredSnapshotGuests().length > 0} fallback={ @@ -467,77 +870,178 @@ export const ProxmoxBackupsTable: Component<{ - Name - Guest + Guest Node - Parent - Captured + Latest - Size + Snapshots + + + Total size RAM - - {(snap) => ( - - -
    {snap.name || '—'}
    - -
    - {snap.description} -
    -
    -
    - - {guestLabel(snap.type, snap.vmid)} - - - {snap.node || '—'} - - - {snap.parent?.trim() || '—'} - - - {formatRelativeTime(snap.time, { compact: true })} - - - 0} - fallback={—} + + {(row) => { + const isExpanded = () => expandedGuests().has(row.key); + const bucketPresentation = getBackupAgeBucketPresentation(row.bucket); + return ( + <> + toggleGuestExpansion(row.key)} > - {formatBytes(snap.sizeBytes ?? 0)} + +
    +
    +
    + + {row.node || '—'} + + + —} + > + {formatRelativeTime(row.newestMs, { compact: true })} + + + + {row.count} + + + 0} + fallback={—} + > + {formatBytes(row.totalBytes)} + + + + 0} + fallback={—} + > + + {row.withRamCount} with RAM + + + +
    + + + +
    +
    + + + + + + + + + + + + {(snap) => { + const bucket = snapshotAgeBucket(snap, nowMs()); + const presentation = getBackupAgeBucketPresentation( + bucket, + ); + return ( + + + + + + + + ); + }} + + +
    NameParentCapturedSizeRAM
    +
    +
    +
    + {snap.parent?.trim() || '—'} + + {formatRelativeTime(snap.time, { + compact: true, + })} + + 0} + fallback={—} + > + {formatBytes(snap.sizeBytes ?? 0)} + + + —} + > + + with RAM + + +
    +
    + + - - - —}> - - with RAM - - - - - )} + + ); + }} @@ -625,12 +1129,38 @@ export const ProxmoxBackupsTable: Component<{ - {formatRelativeTime(arc.time, { compact: true })} +
    +
    - {formatBytes(arc.size)} +
    + 0 && arc.size > 0}> + = 0.66 + ? 'bg-blue-500' + : 'bg-blue-400' + } + label={`${formatBytes(arc.size)} (relative to largest file in view)`} + /> + + {formatBytes(arc.size)} +
    Size - Error + + Error + {(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 ( @@ -742,7 +1289,16 @@ export const ProxmoxBackupsTable: Component<{ - {formatDuration(task.startTime, task.endTime)} +
    + 0 && durationSec}> + + + {formatDuration(task.startTime, task.endTime)} +
    - - —} + + - —} > - {task.error} - - - + + {task.error} + + +
    +
    ); }} diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts index babd9dc166..77c59e0376 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupActivityPresentation.test.ts @@ -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); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupSummaryPresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupSummaryPresentation.test.ts new file mode 100644 index 0000000000..03cf048d88 --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupSummaryPresentation.test.ts @@ -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 & { 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 & { 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 & { 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 }), + ); + }); + }); +}); diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts index babede268a..3822354b39 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupActivityPresentation.ts @@ -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 { - 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( 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( 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 }; diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupSummaryPresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupSummaryPresentation.ts new file mode 100644 index 0000000000..90e6b38f88 --- /dev/null +++ b/frontend-modern/src/features/proxmox/proxmoxBackupSummaryPresentation.ts @@ -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 = { + 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(); + 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(); + 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; +}