From 9730460f8ab9a1f2e087fe651069ea4bd3dfd788 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 20 May 2026 17:37:46 +0100 Subject: [PATCH] Bring Proxmox backup sub-pages up to Storage/Ceph visual density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/proxmox/BackupActivityChart.tsx | 60 +- .../proxmox/ProxmoxBackupsCoverageStrip.tsx | 86 +++ .../features/proxmox/ProxmoxBackupsTable.tsx | 730 +++++++++++++++--- .../proxmoxBackupActivityPresentation.test.ts | 24 +- .../proxmoxBackupSummaryPresentation.test.ts | 217 ++++++ .../proxmoxBackupActivityPresentation.ts | 65 +- .../proxmoxBackupSummaryPresentation.ts | 268 +++++++ 7 files changed, 1347 insertions(+), 103 deletions(-) create mode 100644 frontend-modern/src/features/proxmox/ProxmoxBackupsCoverageStrip.tsx create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupSummaryPresentation.test.ts create mode 100644 frontend-modern/src/features/proxmox/proxmoxBackupSummaryPresentation.ts 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; +}