mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:22:17 +00:00
Align Proxmox backup tabs with sibling page conventions
Consistency pass after the initial backup-pages rework. Row-dot colours, coverage-strip segments, and filter-chip labels were encoding the same data through three different threshold schemes; all three now share the per-tab SLA buckets so a 14d archive shows amber in the row, sits in the amber stale (7–30d) segment of the strip, and lands under the "Stale" filter chip. Other tightening: hide the single-segment chart legend when there's nothing to disambiguate (Snapshots and Backup files); always render the Error column on Recent tasks (matching Replication, where it shows "—" rather than collapsing); reword the Backup-files strip tail from the ambiguous "guests covered" to "guests with archives"; strip the redundant inner-table border in the snapshot drilldown so it reads like Ceph's borderless sub-tables; terse "X guests" or "X of Y guests" counter on the Snapshots tab matching Replication's "8 jobs" style. Adds classifyArchiveRowAge / classifySnapshotRowAge in the summary module with unit coverage for the threshold breakpoints, and drops a dead archiveMetricMode() reference left over from the earlier patch.
This commit is contained in:
@@ -99,17 +99,19 @@ export const BackupActivityChart: Component<BackupActivityChartProps> = (props)
|
||||
<div class="mb-2 flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<div class="font-semibold uppercase tracking-[0.18em] text-muted">{props.title}</div>
|
||||
<For each={props.segmentKinds}>
|
||||
{(kind) => {
|
||||
const presentation = getBackupActivitySegmentPresentation(kind);
|
||||
return (
|
||||
<span class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
|
||||
<span class={`h-2.5 w-2.5 rounded ${presentation.swatchClassName}`} />
|
||||
{presentation.label}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.segmentKinds.length > 1}>
|
||||
<For each={props.segmentKinds}>
|
||||
{(kind) => {
|
||||
const presentation = getBackupActivitySegmentPresentation(kind);
|
||||
return (
|
||||
<span class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
|
||||
<span class={`h-2.5 w-2.5 rounded ${presentation.swatchClassName}`} />
|
||||
{presentation.label}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<Show when={props.metricToggle}>
|
||||
|
||||
@@ -57,12 +57,12 @@ import {
|
||||
buildArchiveCoverageSummary,
|
||||
buildSnapshotCoverageSummary,
|
||||
buildTaskOutcomeSummary,
|
||||
classifyBackupAge,
|
||||
classifyArchiveRowAge,
|
||||
classifySnapshotRowAge,
|
||||
computeMedianTaskDurationSeconds,
|
||||
getBackupAgeBucketPresentation,
|
||||
guestKey,
|
||||
taskDurationSeconds,
|
||||
type BackupAgeBucket,
|
||||
} from './proxmoxBackupSummaryPresentation';
|
||||
import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip';
|
||||
|
||||
@@ -183,7 +183,7 @@ const SNAPSHOT_FILTERS: FilterOption<SnapshotFilterValue>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{
|
||||
value: 'recent',
|
||||
label: 'Recent ≤7d',
|
||||
label: 'Recent ≤30d',
|
||||
tone: 'success',
|
||||
leading: statusDot('bg-emerald-500'),
|
||||
},
|
||||
@@ -237,9 +237,9 @@ function InlineProgressBar(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotAgeBucket(snap: GuestSnapshot, now: number): BackupAgeBucket {
|
||||
return classifyBackupAge(snap.time, now);
|
||||
}
|
||||
// SLA-aligned row swatch helpers exposed for inner-table rendering — both
|
||||
// the guest row's leftmost dot and the per-snapshot inner row dot use the
|
||||
// same `classifySnapshotRowAge` so colours match the coverage strip.
|
||||
|
||||
export const ProxmoxBackupsTable: Component<{
|
||||
emptyIcon: JSX.Element;
|
||||
@@ -363,7 +363,11 @@ export const ProxmoxBackupsTable: Component<{
|
||||
withRamCount: number;
|
||||
newestMs: number | undefined;
|
||||
totalBytes: number;
|
||||
bucket: BackupAgeBucket;
|
||||
// Filter discriminator aligned with the snapshot coverage strip: any
|
||||
// newest-snapshot age > 30d is treated as stale (covers strip's stale
|
||||
// 30–90d and ancient >90d segments). Row-dot colours are derived
|
||||
// independently from classifySnapshotRowAge in the JSX.
|
||||
isStale: boolean;
|
||||
}
|
||||
|
||||
const filteredSnapshotGuests = createMemo<SnapshotGuestRow[]>(() => {
|
||||
@@ -400,7 +404,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
withRamCount: 0,
|
||||
newestMs: undefined,
|
||||
totalBytes: 0,
|
||||
bucket: 'ancient',
|
||||
isStale: true,
|
||||
};
|
||||
byGuest.set(key, row);
|
||||
}
|
||||
@@ -452,15 +456,16 @@ export const ProxmoxBackupsTable: Component<{
|
||||
if (newest === undefined) return ms;
|
||||
return ms > newest ? ms : newest;
|
||||
}, undefined),
|
||||
bucket: classifyBackupAge(
|
||||
visibleSnapshots[0] ? snapshotTimestampMs(visibleSnapshots[0]) : undefined,
|
||||
now,
|
||||
),
|
||||
isStale: ((): boolean => {
|
||||
const newest = visibleSnapshots[0]
|
||||
? snapshotTimestampMs(visibleSnapshots[0])
|
||||
: undefined;
|
||||
if (newest === undefined) return true;
|
||||
return now - newest > 30 * 24 * 60 * 60 * 1000;
|
||||
})(),
|
||||
};
|
||||
if (filter === 'recent' && enriched.bucket !== 'recent') continue;
|
||||
if (filter === 'stale' && enriched.bucket !== 'stale' && enriched.bucket !== 'ancient') {
|
||||
continue;
|
||||
}
|
||||
if (filter === 'recent' && enriched.isStale) continue;
|
||||
if (filter === 'stale' && !enriched.isStale) continue;
|
||||
if (filter === 'with-ram' && enriched.withRamCount === 0) continue;
|
||||
rows.push(enriched);
|
||||
}
|
||||
@@ -555,9 +560,6 @@ export const ProxmoxBackupsTable: Component<{
|
||||
});
|
||||
|
||||
const visibleSnapshotGuestCount = createMemo(() => filteredSnapshotGuests().length);
|
||||
const visibleSnapshotItemCount = createMemo(() =>
|
||||
filteredSnapshotGuests().reduce((sum, row) => sum + row.count, 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -623,11 +625,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
|
||||
<Show when={tab() === 'snapshots' && snapshots().length > 0}>
|
||||
<BackupActivityChart
|
||||
title={
|
||||
archiveMetricMode() === 'volume'
|
||||
? 'Snapshots per day · GB captured'
|
||||
: 'Snapshots per day'
|
||||
}
|
||||
title="Snapshots per day"
|
||||
noun="snapshot"
|
||||
segmentKinds={SNAPSHOT_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
@@ -694,7 +692,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
title="Backup coverage"
|
||||
tail={
|
||||
<span>
|
||||
{archiveCoverage().totalGuests} guests covered ·{' '}
|
||||
{archiveCoverage().totalGuests} guests with archives ·{' '}
|
||||
{formatBytes(archiveCoverage().totalBytes)} stored
|
||||
</span>
|
||||
}
|
||||
@@ -839,8 +837,12 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{visibleSnapshotGuestCount()} guests · {visibleSnapshotItemCount()} of{' '}
|
||||
{snapshots().length} snapshots
|
||||
<Show
|
||||
when={visibleSnapshotGuestCount() !== snapshotCoverage().totalGuests}
|
||||
fallback={<>{snapshotCoverage().totalGuests} guests</>}
|
||||
>
|
||||
{visibleSnapshotGuestCount()} of {snapshotCoverage().totalGuests} guests
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
@@ -888,7 +890,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<For each={filteredSnapshotGuests()}>
|
||||
{(row) => {
|
||||
const isExpanded = () => expandedGuests().has(row.key);
|
||||
const bucketPresentation = getBackupAgeBucketPresentation(row.bucket);
|
||||
const rowAge = classifySnapshotRowAge(row.newestMs, nowMs());
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
@@ -906,9 +908,9 @@ export const ProxmoxBackupsTable: Component<{
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class={`h-2 w-2 shrink-0 rounded-full ${bucketPresentation.swatchClass}`}
|
||||
class={`h-2 w-2 shrink-0 rounded-full ${rowAge.swatchClass}`}
|
||||
aria-hidden="true"
|
||||
title={`Newest snapshot: ${bucketPresentation.label}`}
|
||||
title={`Newest snapshot: ${rowAge.label}`}
|
||||
/>
|
||||
<span class="font-semibold">
|
||||
{guestLabel(row.type, row.vmid)}
|
||||
@@ -961,7 +963,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<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">
|
||||
<div class="overflow-hidden">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-surface-alt text-muted">
|
||||
@@ -975,18 +977,18 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<tbody class="divide-y divide-border-subtle">
|
||||
<For each={row.snapshots}>
|
||||
{(snap) => {
|
||||
const bucket = snapshotAgeBucket(snap, nowMs());
|
||||
const presentation = getBackupAgeBucketPresentation(
|
||||
bucket,
|
||||
const age = classifySnapshotRowAge(
|
||||
snap.time,
|
||||
nowMs(),
|
||||
);
|
||||
return (
|
||||
<tr class="hover:bg-surface-hover">
|
||||
<td class="px-2 py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${presentation.swatchClass}`}
|
||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${age.swatchClass}`}
|
||||
aria-hidden="true"
|
||||
title={`Age: ${presentation.label}`}
|
||||
title={`Age: ${age.label}`}
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-base-content">
|
||||
@@ -1130,17 +1132,16 @@ export const ProxmoxBackupsTable: Component<{
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<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
|
||||
}`}
|
||||
/>
|
||||
{(() => {
|
||||
const age = classifyArchiveRowAge(arc.time, nowMs());
|
||||
return (
|
||||
<span
|
||||
class={`h-1.5 w-1.5 shrink-0 rounded-full ${age.swatchClass}`}
|
||||
aria-hidden="true"
|
||||
title={`Coverage: ${age.label}`}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span>{formatRelativeTime(arc.time, { compact: true })}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -1232,9 +1233,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
</TableHead>
|
||||
<Show when={taskOutcome().hasErrors}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
|
||||
</Show>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
@@ -1310,23 +1309,21 @@ export const ProxmoxBackupsTable: Component<{
|
||||
{formatBytes(task.size ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<Show when={taskOutcome().hasErrors}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={!!task.error?.trim()}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
<Show
|
||||
when={!!task.error?.trim()}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
<span
|
||||
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
|
||||
title={task.error}
|
||||
>
|
||||
<span
|
||||
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
|
||||
title={task.error}
|
||||
>
|
||||
{task.error}
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
</Show>
|
||||
{task.error}
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
|
||||
+35
@@ -5,7 +5,9 @@ import {
|
||||
buildArchiveCoverageSummary,
|
||||
buildSnapshotCoverageSummary,
|
||||
buildTaskOutcomeSummary,
|
||||
classifyArchiveRowAge,
|
||||
classifyBackupAge,
|
||||
classifySnapshotRowAge,
|
||||
computeMedianTaskDurationSeconds,
|
||||
guestKey,
|
||||
} from '../proxmoxBackupSummaryPresentation';
|
||||
@@ -205,6 +207,39 @@ describe('proxmoxBackupSummaryPresentation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyArchiveRowAge', () => {
|
||||
const now = Date.parse('2026-05-20T12:00:00Z');
|
||||
|
||||
it('aligns row swatch with the archive coverage strip thresholds (≤7d / 7–30d / >30d)', () => {
|
||||
expect(classifyArchiveRowAge(now - 1 * DAY_MS, now).swatchClass).toBe('bg-emerald-500');
|
||||
expect(classifyArchiveRowAge(now - 7 * DAY_MS + 1000, now).swatchClass).toBe('bg-emerald-500');
|
||||
expect(classifyArchiveRowAge(now - 14 * DAY_MS, now).swatchClass).toBe('bg-amber-500');
|
||||
expect(classifyArchiveRowAge(now - 30 * DAY_MS + 1000, now).swatchClass).toBe('bg-amber-500');
|
||||
expect(classifyArchiveRowAge(now - 40 * DAY_MS, now).swatchClass).toBe('bg-red-500');
|
||||
expect(classifyArchiveRowAge(now - 200 * DAY_MS, now).swatchClass).toBe('bg-red-500');
|
||||
});
|
||||
|
||||
it('treats undefined as the worst bucket so missing timestamps surface', () => {
|
||||
expect(classifyArchiveRowAge(undefined, now).swatchClass).toBe('bg-red-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifySnapshotRowAge', () => {
|
||||
const now = Date.parse('2026-05-20T12:00:00Z');
|
||||
|
||||
it('aligns row swatch with the snapshot coverage strip thresholds (≤30d / 30–90d / >90d)', () => {
|
||||
expect(classifySnapshotRowAge(now - 1 * DAY_MS, now).swatchClass).toBe('bg-emerald-500');
|
||||
expect(classifySnapshotRowAge(now - 30 * DAY_MS + 1000, now).swatchClass).toBe(
|
||||
'bg-emerald-500',
|
||||
);
|
||||
expect(classifySnapshotRowAge(now - 60 * DAY_MS, now).swatchClass).toBe('bg-amber-500');
|
||||
expect(classifySnapshotRowAge(now - 90 * DAY_MS + 1000, now).swatchClass).toBe(
|
||||
'bg-amber-500',
|
||||
);
|
||||
expect(classifySnapshotRowAge(now - 100 * DAY_MS, now).swatchClass).toBe('bg-red-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guestKey', () => {
|
||||
it('namespaces vmid by instance and type to avoid VM/CT collisions', () => {
|
||||
expect(guestKey({ type: 'qemu', instance: 'a', vmid: 100 })).toBe('a:qemu:100');
|
||||
|
||||
@@ -60,6 +60,46 @@ export function classifyBackupAge(
|
||||
return 'ancient';
|
||||
}
|
||||
|
||||
// Per-tab row dot semantics that match each coverage strip's bucketing.
|
||||
// The shared 4-bucket classifyBackupAge is too granular: a 14-day archive
|
||||
// would land in `normal` (sky) in the row but `stale` (amber) in the
|
||||
// archive coverage strip, since archives have a 7-day SLA. These helpers
|
||||
// keep the row dot, strip segment, and strip label all reading the same
|
||||
// colour for the same data.
|
||||
|
||||
export interface AgeSwatch {
|
||||
label: string;
|
||||
swatchClass: string;
|
||||
}
|
||||
|
||||
export function classifyArchiveRowAge(
|
||||
timestamp: string | number | undefined,
|
||||
now: number = Date.now(),
|
||||
): AgeSwatch {
|
||||
if (timestamp === undefined || timestamp === null)
|
||||
return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
|
||||
const ms = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
|
||||
if (!Number.isFinite(ms)) return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
|
||||
const ageDays = (now - ms) / DAY_MS;
|
||||
if (ageDays <= 7) return { label: 'current (≤7d)', swatchClass: 'bg-emerald-500' };
|
||||
if (ageDays <= 30) return { label: 'stale (7–30d)', swatchClass: 'bg-amber-500' };
|
||||
return { label: 'uncovered (>30d)', swatchClass: 'bg-red-500' };
|
||||
}
|
||||
|
||||
export function classifySnapshotRowAge(
|
||||
timestamp: string | number | undefined,
|
||||
now: number = Date.now(),
|
||||
): AgeSwatch {
|
||||
if (timestamp === undefined || timestamp === null)
|
||||
return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
|
||||
const ms = typeof timestamp === 'number' ? timestamp : Date.parse(timestamp);
|
||||
if (!Number.isFinite(ms)) return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
|
||||
const ageDays = (now - ms) / DAY_MS;
|
||||
if (ageDays <= 30) return { label: 'recent (≤30d)', swatchClass: 'bg-emerald-500' };
|
||||
if (ageDays <= 90) return { label: 'stale (30–90d)', swatchClass: 'bg-amber-500' };
|
||||
return { label: 'ancient (>90d)', swatchClass: 'bg-red-500' };
|
||||
}
|
||||
|
||||
export function guestKey(guest: { type?: string; instance?: string; vmid: number }): string {
|
||||
// instance disambiguates 100@cluster-a vs 100@cluster-b. type narrows
|
||||
// VM-100 vs CT-100, which can collide in the same instance.
|
||||
|
||||
Reference in New Issue
Block a user