mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
Normalize Proxmox backup tables
This commit is contained in:
+15
-4
@@ -6,6 +6,7 @@ import kubernetesClustersTableSource from '@/features/kubernetes/KubernetesClust
|
||||
import kubernetesDeploymentsTableSource from '@/features/kubernetes/KubernetesDeploymentsTable.tsx?raw';
|
||||
import kubernetesNodesTableSource from '@/features/kubernetes/KubernetesNodesTable.tsx?raw';
|
||||
import kubernetesPageSurfaceSource from '@/features/kubernetes/KubernetesPageSurface.tsx?raw';
|
||||
import proxmoxBackupsTableSource from '@/features/proxmox/ProxmoxBackupsTable.tsx?raw';
|
||||
import proxmoxCephTableSource from '@/features/proxmox/ProxmoxCephTable.tsx?raw';
|
||||
import proxmoxMailGatewayTableSource from '@/features/proxmox/ProxmoxMailGatewayTable.tsx?raw';
|
||||
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
|
||||
@@ -52,6 +53,13 @@ const proxmoxDetailTableSources = [
|
||||
proxmoxReplicationTableSource,
|
||||
];
|
||||
|
||||
const proxmoxBespokeTableSources = [
|
||||
proxmoxBackupsTableSource,
|
||||
proxmoxCephTableSource,
|
||||
proxmoxMailGatewayTableSource,
|
||||
proxmoxReplicationTableSource,
|
||||
];
|
||||
|
||||
describe('platform overview layout guardrails', () => {
|
||||
it('keeps platform inventory tables on the shared dense table styling contract', () => {
|
||||
expect(sharedPlatformPageSource).toContain('PLATFORM_TABLE_CARD_CLASS');
|
||||
@@ -85,19 +93,22 @@ describe('platform overview layout guardrails', () => {
|
||||
});
|
||||
|
||||
it('keeps Proxmox detail tables on the shared platform table primitives', () => {
|
||||
for (const source of proxmoxDetailTableSources) {
|
||||
for (const source of proxmoxBespokeTableSources) {
|
||||
expect(source).toContain('TableCard');
|
||||
expect(source).toContain('PlatformTableToolbar');
|
||||
expect(source).toContain('PLATFORM_TABLE_CARD_CLASS');
|
||||
expect(source).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
|
||||
expect(source).toContain('PLATFORM_TABLE_BODY_CLASS');
|
||||
expect(source).toContain('getPlatformTableHeadClassForKind');
|
||||
expect(source).toContain('getPlatformTableCellClassForKind');
|
||||
expect(source).not.toContain("from '@/components/shared/SearchInput'");
|
||||
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
|
||||
expect(source).not.toContain('border-collapse text-xs');
|
||||
expect(source).not.toContain('bg-surface-alt text-muted border-b border-border');
|
||||
}
|
||||
|
||||
for (const source of proxmoxDetailTableSources) {
|
||||
expect(source).toContain('PlatformTableToolbar');
|
||||
expect(source).not.toContain("from '@/components/shared/SearchInput'");
|
||||
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps provider overview pages in the parent-table plus child-inventory stack', () => {
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import type { Accessor, Component } from 'solid-js';
|
||||
|
||||
import { TooltipPortal } from '@/components/shared/TooltipPortal';
|
||||
import {
|
||||
SUMMARY_CHART_PLOT_AREA_CLASS,
|
||||
SUMMARY_CHART_SLOT_COMPACT_CLASS,
|
||||
} from '@/components/shared/summaryChartLayout';
|
||||
import {
|
||||
getRecoveryCompactAxisLabel,
|
||||
getRecoveryPrettyDateLabel,
|
||||
} from '@/utils/recoveryDatePresentation';
|
||||
import {
|
||||
getRecoveryTimelineAxisLabelClass,
|
||||
getRecoveryTimelineAxisTicks,
|
||||
getRecoveryTimelineChartGapPx,
|
||||
getRecoveryTimelineChartMinWidthPx,
|
||||
RECOVERY_TIMELINE_LEGEND_ITEM_CLASS,
|
||||
} from '@/utils/recoveryTimelineChartPresentation';
|
||||
import {
|
||||
getRecoveryTimelineBarMarkerClass,
|
||||
getRecoveryTimelineColumnButtonClass,
|
||||
getRecoveryTimelineEmptyMarkerClass,
|
||||
} from '@/utils/recoveryTimelinePresentation';
|
||||
|
||||
import {
|
||||
BACKUP_ACTIVITY_RANGE_DAYS,
|
||||
getBackupActivityColumnAriaLabel,
|
||||
getBackupActivityDayFilterStateLabel,
|
||||
getBackupActivityPointTotalLabel,
|
||||
getBackupActivitySegmentPresentation,
|
||||
getBackupActivityTooltipRows,
|
||||
type BackupActivityPoint,
|
||||
type BackupActivityRangeDays,
|
||||
type BackupActivitySegmentKind,
|
||||
type BackupActivityTimeline,
|
||||
} from './proxmoxBackupActivityPresentation';
|
||||
|
||||
interface TooltipState {
|
||||
dateLabel: string;
|
||||
point: BackupActivityPoint;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface BackupActivityChartProps {
|
||||
title: string;
|
||||
noun: 'archive' | 'task';
|
||||
segmentKinds: readonly BackupActivitySegmentKind[];
|
||||
range: Accessor<BackupActivityRangeDays>;
|
||||
onRangeChange: (days: BackupActivityRangeDays) => void;
|
||||
timeline: Accessor<BackupActivityTimeline>;
|
||||
selectedDateKey: Accessor<string | null>;
|
||||
onToggleDay: (key: string) => void;
|
||||
}
|
||||
|
||||
export const BackupActivityChart: Component<BackupActivityChartProps> = (props) => {
|
||||
const [tooltip, setTooltip] = createSignal<TooltipState | null>(null);
|
||||
|
||||
const points = () => props.timeline().points;
|
||||
const axisMax = () => props.timeline().axisMax;
|
||||
const hasSelection = () => props.selectedDateKey() !== null;
|
||||
|
||||
const axisTicks = () =>
|
||||
getRecoveryTimelineAxisTicks(points().length, false, props.timeline().labelEvery);
|
||||
|
||||
const chartMinWidthStyle = () => {
|
||||
const minWidth = getRecoveryTimelineChartMinWidthPx(false, props.range(), points().length);
|
||||
return minWidth > 0 ? `${minWidth}px` : '100%';
|
||||
};
|
||||
|
||||
const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.range())}px`;
|
||||
|
||||
const showTooltip = (target: HTMLElement, point: BackupActivityPoint, dateLabel: string) => {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setTooltip({
|
||||
dateLabel,
|
||||
point,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
});
|
||||
};
|
||||
const hideTooltip = () => setTooltip(null);
|
||||
|
||||
return (
|
||||
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 px-2 py-2">
|
||||
<div class="mb-2 flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<div class="font-semibold uppercase tracking-[0.18em] text-muted">{props.title}</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Activity range"
|
||||
class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5"
|
||||
>
|
||||
<For each={BACKUP_ACTIVITY_RANGE_DAYS}>
|
||||
{(days) => {
|
||||
const selected = () => props.range() === days;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded px-2 py-1 font-medium transition-colors ${
|
||||
selected()
|
||||
? 'bg-surface-hover text-base-content'
|
||||
: 'text-muted hover:bg-surface-hover hover:text-base-content'
|
||||
}`}
|
||||
aria-pressed={selected() ? 'true' : 'false'}
|
||||
onClick={() => props.onRangeChange(days)}
|
||||
>
|
||||
{days === 365 ? '1y' : `${days}d`}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={points().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-24 items-center justify-center text-xs text-muted">
|
||||
No activity in the selected window.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-2">
|
||||
<div
|
||||
class={`flex ${SUMMARY_CHART_PLOT_AREA_CLASS} flex-col justify-between text-[10px] text-muted tabular-nums`}
|
||||
>
|
||||
<For each={[0, 1, 2]}>
|
||||
{(step) => {
|
||||
const value = () => Math.round((axisMax() * (2 - step)) / 2);
|
||||
return <span>{value()}</span>;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto overscroll-x-contain pb-1">
|
||||
<div
|
||||
class={`relative ${SUMMARY_CHART_SLOT_COMPACT_CLASS}`}
|
||||
style={{ 'min-width': chartMinWidthStyle() }}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch"
|
||||
style={{ gap: chartGapStyle() }}
|
||||
>
|
||||
<For each={points()}>
|
||||
{(point) => {
|
||||
const total = () => point.total;
|
||||
const heightPct = () =>
|
||||
axisMax() > 0 ? Math.min(100, (total() / axisMax()) * 100) : 0;
|
||||
const isSelected = () => props.selectedDateKey() === point.key;
|
||||
const dateLabel = getRecoveryPrettyDateLabel(point.key);
|
||||
|
||||
return (
|
||||
<div class="min-w-[3px] flex-1 self-stretch">
|
||||
<button
|
||||
type="button"
|
||||
class={`h-full w-full ${getRecoveryTimelineColumnButtonClass(
|
||||
isSelected(),
|
||||
hasSelection(),
|
||||
)}`}
|
||||
aria-label={getBackupActivityColumnAriaLabel(
|
||||
dateLabel,
|
||||
total(),
|
||||
isSelected(),
|
||||
props.noun,
|
||||
)}
|
||||
aria-pressed={isSelected() ? 'true' : 'false'}
|
||||
onClick={() => props.onToggleDay(point.key)}
|
||||
onMouseEnter={(event) =>
|
||||
showTooltip(event.currentTarget, point, dateLabel)
|
||||
}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocus={(event) => showTooltip(event.currentTarget, point, dateLabel)}
|
||||
onBlur={hideTooltip}
|
||||
>
|
||||
<div class="relative h-full w-full overflow-hidden rounded-sm">
|
||||
<Show
|
||||
when={total() > 0}
|
||||
fallback={
|
||||
<div
|
||||
class={getRecoveryTimelineEmptyMarkerClass(
|
||||
isSelected(),
|
||||
hasSelection(),
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={getRecoveryTimelineBarMarkerClass(
|
||||
isSelected(),
|
||||
hasSelection(),
|
||||
)}
|
||||
style={{ height: `${heightPct()}%` }}
|
||||
>
|
||||
<For each={props.segmentKinds}>
|
||||
{(kind) => {
|
||||
const count = () => point.counts[kind] ?? 0;
|
||||
const segmentPct = () =>
|
||||
total() > 0 ? (count() / total()) * 100 : 0;
|
||||
return (
|
||||
<Show when={segmentPct() > 0}>
|
||||
<div
|
||||
class={`w-full ${getBackupActivitySegmentPresentation(kind).segmentClassName}`}
|
||||
style={{ height: `${segmentPct()}%` }}
|
||||
/>
|
||||
</Show>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-4">
|
||||
<For each={axisTicks()}>
|
||||
{(tick) => {
|
||||
const point = points()[tick.index];
|
||||
const isSelected = () =>
|
||||
point ? props.selectedDateKey() === point.key : false;
|
||||
const alignmentClass =
|
||||
tick.align === 'start'
|
||||
? 'left-0 text-left'
|
||||
: tick.align === 'end'
|
||||
? 'left-full -translate-x-full text-right'
|
||||
: '-translate-x-1/2 text-center';
|
||||
return (
|
||||
<Show when={point}>
|
||||
<span
|
||||
class={`absolute bottom-0 whitespace-nowrap text-[9px] ${getRecoveryTimelineAxisLabelClass(isSelected())} ${alignmentClass}`}
|
||||
style={{ left: `${tick.positionPct}%` }}
|
||||
>
|
||||
{getRecoveryCompactAxisLabel(point!.key, props.range())}
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipPortal
|
||||
when={tooltip() !== null}
|
||||
x={tooltip()?.x ?? 0}
|
||||
y={tooltip()?.y ?? 0}
|
||||
align="center"
|
||||
direction="up"
|
||||
maxWidth={240}
|
||||
>
|
||||
<Show when={tooltip()}>
|
||||
{(t) => (
|
||||
<div class="min-w-[180px]">
|
||||
<div class="flex items-start justify-between gap-3 border-b border-border pb-1">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[11px] font-semibold text-base-content">
|
||||
{t().dateLabel}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[10px] text-muted">
|
||||
{getBackupActivityPointTotalLabel(t().point.total, props.noun)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[9px] font-medium text-muted">
|
||||
{getBackupActivityDayFilterStateLabel(
|
||||
props.selectedDateKey() === t().point.key,
|
||||
hasSelection(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="mt-1.5 space-y-1">
|
||||
<For each={getBackupActivityTooltipRows(t().point, props.segmentKinds)}>
|
||||
{(row) => (
|
||||
<li
|
||||
class={`flex items-center justify-between gap-4 ${
|
||||
row.muted ? 'text-muted/70' : 'text-base-content'
|
||||
}`}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span class={`h-2 w-2 shrink-0 rounded-sm ${row.segmentClassName}`} />
|
||||
<span class="truncate">{row.label}</span>
|
||||
</span>
|
||||
<span class="shrink-0 font-mono tabular-nums">{row.value}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TooltipPortal>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupActivityChart;
|
||||
@@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCard } from '@/components/shared/TableCard';
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
import {
|
||||
Table,
|
||||
@@ -26,6 +27,17 @@ import {
|
||||
} from '@/components/shared/Table';
|
||||
import { apiFetch } from '@/utils/apiClient';
|
||||
import { formatBytes, formatRelativeTime } from '@/utils/format';
|
||||
import {
|
||||
recoveryDateKeyFromTimestamp,
|
||||
getRecoveryFilterDateLabel,
|
||||
} from '@/utils/recoveryDatePresentation';
|
||||
import {
|
||||
PLATFORM_TABLE_BODY_CLASS,
|
||||
PLATFORM_TABLE_CARD_CLASS,
|
||||
PLATFORM_TABLE_HEADER_ROW_CLASS,
|
||||
getPlatformTableCellClassForKind,
|
||||
getPlatformTableHeadClassForKind,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import type {
|
||||
BackupTask,
|
||||
GuestSnapshot,
|
||||
@@ -33,6 +45,12 @@ import type {
|
||||
PVEBackupsResponse,
|
||||
StorageBackup,
|
||||
} from '@/types/api';
|
||||
import { BackupActivityChart } from './BackupActivityChart';
|
||||
import {
|
||||
buildBackupActivityTimeline,
|
||||
type BackupActivityRangeDays,
|
||||
type BackupActivitySegmentKind,
|
||||
} from './proxmoxBackupActivityPresentation';
|
||||
|
||||
// Proxmox VE backups split into three meaningfully different surfaces:
|
||||
// - Snapshots: qm/pct snapshots taken on the host (no external storage)
|
||||
@@ -149,13 +167,55 @@ export const ProxmoxBackupsTable: Component<{
|
||||
const [backups, { refetch }] = createResource<PVEBackupsPayload>(fetchPVEBackups);
|
||||
const [tab, setTab] = createSignal<BackupTabId>('snapshots');
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [archiveFilter, setArchiveFilter] = createSignal<'all' | 'protected' | 'verified' | 'unverified'>('all');
|
||||
const [archiveFilter, setArchiveFilter] = createSignal<
|
||||
'all' | 'protected' | 'verified' | 'unverified'
|
||||
>('all');
|
||||
const [taskFilter, setTaskFilter] = createSignal<'all' | 'ok' | 'failed' | 'running'>('all');
|
||||
const [chartRange, setChartRange] = createSignal<BackupActivityRangeDays>(30);
|
||||
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
|
||||
const toggleDay = (key: string) =>
|
||||
setSelectedDateKey((current) => (current === key ? null : key));
|
||||
|
||||
const snapshots = createMemo<GuestSnapshot[]>(() => backups()?.guestSnapshots ?? []);
|
||||
const archives = createMemo<StorageBackup[]>(() => backups()?.storageBackups ?? []);
|
||||
const tasks = createMemo<BackupTask[]>(() => backups()?.backupTasks ?? []);
|
||||
|
||||
const archiveTimestampMs = (arc: StorageBackup): number | undefined => {
|
||||
const ms = Date.parse(arc.time ?? '');
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
};
|
||||
const taskTimestampMs = (task: BackupTask): number | undefined => {
|
||||
const ms = Date.parse(task.startTime ?? '');
|
||||
return Number.isFinite(ms) ? ms : undefined;
|
||||
};
|
||||
const classifyTaskSegment = (task: BackupTask): BackupActivitySegmentKind | null => {
|
||||
const variant = classifyTaskStatus(task.status).variant;
|
||||
if (variant === 'success') return 'ok';
|
||||
if (variant === 'danger') return 'failed';
|
||||
if (variant === 'warning') return 'running';
|
||||
return null;
|
||||
};
|
||||
|
||||
const archiveTimeline = createMemo(() =>
|
||||
buildBackupActivityTimeline<StorageBackup>(
|
||||
chartRange(),
|
||||
archives(),
|
||||
archiveTimestampMs,
|
||||
() => 'archive',
|
||||
),
|
||||
);
|
||||
const taskTimeline = createMemo(() =>
|
||||
buildBackupActivityTimeline<BackupTask>(
|
||||
chartRange(),
|
||||
tasks(),
|
||||
taskTimestampMs,
|
||||
classifyTaskSegment,
|
||||
),
|
||||
);
|
||||
|
||||
const ARCHIVE_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['archive'];
|
||||
const TASK_SEGMENT_KINDS: readonly BackupActivitySegmentKind[] = ['ok', 'failed', 'running'];
|
||||
|
||||
const filteredSnapshots = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
return snapshots().filter((snap) => {
|
||||
@@ -171,10 +231,15 @@ export const ProxmoxBackupsTable: Component<{
|
||||
const filteredArchives = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const filter = archiveFilter();
|
||||
const dateKey = selectedDateKey();
|
||||
return archives().filter((arc) => {
|
||||
if (filter === 'protected' && !arc.protected) return false;
|
||||
if (filter === 'verified' && !arc.verified) return false;
|
||||
if (filter === 'unverified' && arc.verified) return false;
|
||||
if (dateKey) {
|
||||
const ms = archiveTimestampMs(arc);
|
||||
if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false;
|
||||
}
|
||||
if (!term) return true;
|
||||
return [arc.storage, arc.node, arc.instance, arc.volid, arc.notes, String(arc.vmid)]
|
||||
.filter(Boolean)
|
||||
@@ -187,11 +252,16 @@ export const ProxmoxBackupsTable: Component<{
|
||||
const filteredTasks = createMemo(() => {
|
||||
const term = search().trim().toLowerCase();
|
||||
const filter = taskFilter();
|
||||
const dateKey = selectedDateKey();
|
||||
return tasks().filter((task) => {
|
||||
const classify = classifyTaskStatus(task.status);
|
||||
if (filter === 'ok' && classify.variant !== 'success') return false;
|
||||
if (filter === 'failed' && classify.variant !== 'danger') return false;
|
||||
if (filter === 'running' && classify.variant !== 'warning') return false;
|
||||
if (dateKey) {
|
||||
const ms = taskTimestampMs(task);
|
||||
if (ms === undefined || recoveryDateKeyFromTimestamp(ms) !== dateKey) return false;
|
||||
}
|
||||
if (!term) return true;
|
||||
return [task.node, task.instance, task.status, task.error, String(task.vmid)]
|
||||
.filter(Boolean)
|
||||
@@ -289,6 +359,31 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={tab() === 'archives'}>
|
||||
<BackupActivityChart
|
||||
title="Backup files per day"
|
||||
noun="archive"
|
||||
segmentKinds={ARCHIVE_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
onRangeChange={setChartRange}
|
||||
timeline={archiveTimeline}
|
||||
selectedDateKey={selectedDateKey}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={tab() === 'tasks'}>
|
||||
<BackupActivityChart
|
||||
title="Backup tasks per day"
|
||||
noun="task"
|
||||
segmentKinds={TASK_SEGMENT_KINDS}
|
||||
range={chartRange}
|
||||
onRangeChange={setChartRange}
|
||||
timeline={taskTimeline}
|
||||
selectedDateKey={selectedDateKey}
|
||||
onToggleDay={toggleDay}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<SearchInput
|
||||
@@ -317,6 +412,22 @@ export const ProxmoxBackupsTable: Component<{
|
||||
onChange={setTaskFilter}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={selectedDateKey() !== null && tab() !== 'snapshots'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDateKey(null)}
|
||||
class="inline-flex items-center gap-1 rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 transition-colors hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-200 dark:hover:bg-blue-900/50"
|
||||
aria-label="Clear date filter"
|
||||
>
|
||||
<span class="uppercase tracking-wide text-[9px] text-blue-600 dark:text-blue-300">
|
||||
Date
|
||||
</span>
|
||||
<span class="font-mono tabular-nums">
|
||||
{getRecoveryFilterDateLabel(selectedDateKey()!)}
|
||||
</span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</Show>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show
|
||||
when={visibleForTab() !== totalForTab()}
|
||||
@@ -348,51 +459,74 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[900px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Name</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Parent</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Captured</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">RAM</TableHead>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<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('text')}>Node</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Parent</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Captured
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>RAM</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={filteredSnapshots()}>
|
||||
{(snap) => (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<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}>
|
||||
<div
|
||||
class="text-[11px] text-muted truncate max-w-[20rem]"
|
||||
title={snap.description}
|
||||
>
|
||||
{snap.description}
|
||||
</div>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{guestLabel(snap.type, snap.vmid)}</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<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="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
{snap.parent?.trim() || '—'}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatRelativeTime(snap.time, { compact: true })}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
<Show when={snap.sizeBytes && snap.sizeBytes > 0} fallback={<span class="text-muted">—</span>}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
<Show
|
||||
when={snap.sizeBytes && snap.sizeBytes > 0}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
{formatBytes(snap.sizeBytes ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<Show
|
||||
when={snap.vmstate}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
<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>
|
||||
@@ -403,7 +537,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TableCard>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -428,57 +562,84 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[1050px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Volume</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Storage</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Format</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Created</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Protection</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Verified</TableHead>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<Table class="min-w-[1050px] text-xs">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('name')}>Volume</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>
|
||||
Storage
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Format</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>
|
||||
Protection
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>
|
||||
Verified
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={filteredArchives()}>
|
||||
{(arc) => (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('name')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
<span class="inline-block max-w-[18rem] truncate" title={arc.volid}>
|
||||
{arc.volid}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
{guestLabel(arc.type, arc.vmid)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{arc.storage || '—'}</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
{arc.storage || '—'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
{arc.node || '—'}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content uppercase text-[10px]">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content uppercase text-[10px]`}
|
||||
>
|
||||
{arc.format || '—'}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatRelativeTime(arc.time, { compact: true })}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
{formatBytes(arc.size)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<Show
|
||||
when={arc.protected}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show when={arc.protected} fallback={<span class="text-muted">—</span>}>
|
||||
<span class="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
Protected
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={arc.verified}
|
||||
fallback={
|
||||
@@ -498,7 +659,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TableCard>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -510,9 +671,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={
|
||||
tasks().length === 0
|
||||
? TABS[2].emptyTitle
|
||||
: 'No tasks match current filters'
|
||||
tasks().length === 0 ? TABS[2].emptyTitle : 'No tasks match current filters'
|
||||
}
|
||||
description={
|
||||
tasks().length === 0
|
||||
@@ -523,49 +682,77 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[1000px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Status</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Guest</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Started</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Duration</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Size</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Error</TableHead>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<Table class="min-w-[1000px] text-xs">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Guest</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Node</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Started
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Duration
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Size
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
|
||||
<For each={filteredTasks()}>
|
||||
{(task) => {
|
||||
const classify = classifyTaskStatus(task.status);
|
||||
return (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2">
|
||||
<TableCell class={getPlatformTableCellClassForKind('text')}>
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusDot size="sm" variant={classify.variant} title={classify.label} ariaHidden />
|
||||
<span class="text-[11px] font-medium text-base-content">{classify.label}</span>
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={classify.variant}
|
||||
title={classify.label}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="text-[11px] font-medium text-base-content">
|
||||
{classify.label}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
{guestLabel(task.type, task.vmid)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
|
||||
>
|
||||
{task.node || '—'}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatRelativeTime(task.startTime, { compact: true })}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
{formatDuration(task.startTime, task.endTime)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
<Show when={task.size && task.size > 0} fallback={<span class="text-muted">—</span>}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
|
||||
>
|
||||
<Show
|
||||
when={task.size && task.size > 0}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
>
|
||||
{formatBytes(task.size ?? 0)}
|
||||
</Show>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
|
||||
>
|
||||
<Show
|
||||
when={!!task.error?.trim()}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
@@ -584,7 +771,7 @@ export const ProxmoxBackupsTable: Component<{
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</TableCard>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { recoveryDateKeyFromTimestamp } from '@/utils/recoveryDatePresentation';
|
||||
import {
|
||||
buildBackupActivityTimeline,
|
||||
getBackupActivityColumnAriaLabel,
|
||||
getBackupActivityTooltipRows,
|
||||
type BackupActivitySegmentKind,
|
||||
} from '../proxmoxBackupActivityPresentation';
|
||||
|
||||
describe('proxmoxBackupActivityPresentation', () => {
|
||||
it('buckets backup activity by local day and segment kind', () => {
|
||||
const now = new Date(2026, 4, 20, 12);
|
||||
const today = new Date(2026, 4, 20, 9).getTime();
|
||||
const yesterday = new Date(2026, 4, 19, 9).getTime();
|
||||
const outsideWindow = new Date(2026, 4, 10, 9).getTime();
|
||||
const future = new Date(2026, 4, 21, 9).getTime();
|
||||
|
||||
const timeline = buildBackupActivityTimeline(
|
||||
7,
|
||||
[
|
||||
{ ts: today, kind: 'ok' },
|
||||
{ ts: today, kind: 'failed' },
|
||||
{ ts: yesterday, kind: 'ok' },
|
||||
{ ts: outsideWindow, kind: 'ok' },
|
||||
{ ts: future, kind: 'running' },
|
||||
],
|
||||
(item) => item.ts,
|
||||
(item) => item.kind as BackupActivitySegmentKind,
|
||||
now,
|
||||
);
|
||||
|
||||
const todayPoint = timeline.points.find(
|
||||
(point) => point.key === recoveryDateKeyFromTimestamp(today),
|
||||
);
|
||||
const yesterdayPoint = timeline.points.find(
|
||||
(point) => point.key === recoveryDateKeyFromTimestamp(yesterday),
|
||||
);
|
||||
|
||||
expect(timeline.points).toHaveLength(7);
|
||||
expect(todayPoint?.total).toBe(2);
|
||||
expect(todayPoint?.counts.ok).toBe(1);
|
||||
expect(todayPoint?.counts.failed).toBe(1);
|
||||
expect(yesterdayPoint?.total).toBe(1);
|
||||
expect(timeline.points.reduce((sum, point) => sum + point.total, 0)).toBe(3);
|
||||
expect(timeline.axisMax).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('formats activity labels and tooltip rows from segment totals', () => {
|
||||
const point = {
|
||||
key: '2026-05-20',
|
||||
total: 4,
|
||||
counts: { archive: 0, ok: 3, failed: 1, running: 0 },
|
||||
};
|
||||
|
||||
expect(getBackupActivityColumnAriaLabel('Wed 20 May', point.total, true, 'task')).toBe(
|
||||
'Wed 20 May: 4 tasks, selected',
|
||||
);
|
||||
expect(getBackupActivityTooltipRows(point, ['ok', 'failed', 'running'])).toMatchObject([
|
||||
{ kind: 'ok', label: 'OK', value: '3 (75%)', muted: false },
|
||||
{ kind: 'failed', label: 'Failed', value: '1 (25%)', muted: false },
|
||||
{ kind: 'running', label: 'Running', value: '0', muted: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
getRecoveryNiceAxisMax,
|
||||
recoveryDateKeyFromTimestamp,
|
||||
} from '@/utils/recoveryDatePresentation';
|
||||
import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation';
|
||||
import { getRecoveryTimelineDayFilterStateLabel } from '@/utils/recoveryTimelinePresentation';
|
||||
|
||||
export type BackupActivityRangeDays = 7 | 30 | 90 | 365;
|
||||
|
||||
export const BACKUP_ACTIVITY_RANGE_DAYS = [
|
||||
7, 30, 90, 365,
|
||||
] as const satisfies readonly BackupActivityRangeDays[];
|
||||
|
||||
export type BackupActivitySegmentKind = 'archive' | 'ok' | 'failed' | 'running';
|
||||
|
||||
interface BackupActivitySegmentPresentation {
|
||||
label: string;
|
||||
segmentClassName: string;
|
||||
swatchClassName: string;
|
||||
}
|
||||
|
||||
const SEGMENT_PRESENTATION: Record<BackupActivitySegmentKind, BackupActivitySegmentPresentation> = {
|
||||
archive: {
|
||||
label: 'Archives',
|
||||
segmentClassName: 'bg-blue-500',
|
||||
swatchClassName: 'bg-blue-500',
|
||||
},
|
||||
ok: {
|
||||
label: 'OK',
|
||||
segmentClassName: 'bg-emerald-500',
|
||||
swatchClassName: 'bg-emerald-500',
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
segmentClassName: 'bg-red-500',
|
||||
swatchClassName: 'bg-red-500',
|
||||
},
|
||||
running: {
|
||||
label: 'Running',
|
||||
segmentClassName: 'bg-amber-500',
|
||||
swatchClassName: 'bg-amber-500',
|
||||
},
|
||||
};
|
||||
|
||||
export function getBackupActivitySegmentPresentation(
|
||||
kind: BackupActivitySegmentKind,
|
||||
): BackupActivitySegmentPresentation {
|
||||
return SEGMENT_PRESENTATION[kind];
|
||||
}
|
||||
|
||||
export interface BackupActivityPoint {
|
||||
key: string;
|
||||
total: number;
|
||||
counts: Record<BackupActivitySegmentKind, number>;
|
||||
}
|
||||
|
||||
export interface BackupActivityTimeline {
|
||||
points: BackupActivityPoint[];
|
||||
axisMax: number;
|
||||
labelEvery: number;
|
||||
}
|
||||
|
||||
function emptyCounts(): Record<BackupActivitySegmentKind, number> {
|
||||
return { archive: 0, ok: 0, failed: 0, running: 0 };
|
||||
}
|
||||
|
||||
function startOfLocalDayMs(date: Date): number {
|
||||
const copy = new Date(date);
|
||||
copy.setHours(0, 0, 0, 0);
|
||||
return copy.getTime();
|
||||
}
|
||||
|
||||
export function buildBackupActivityTimeline<T>(
|
||||
days: BackupActivityRangeDays,
|
||||
items: readonly T[],
|
||||
getTimestampMs: (item: T) => number | undefined,
|
||||
classify: (item: T) => BackupActivitySegmentKind | null,
|
||||
now: Date = new Date(),
|
||||
): BackupActivityTimeline {
|
||||
const todayStart = startOfLocalDayMs(now);
|
||||
const windowStart = todayStart - (days - 1) * 24 * 60 * 60 * 1000;
|
||||
|
||||
const buckets = new Map<string, BackupActivityPoint>();
|
||||
const orderedKeys: string[] = [];
|
||||
|
||||
for (let i = 0; i < days; i += 1) {
|
||||
const dayStart = windowStart + i * 24 * 60 * 60 * 1000;
|
||||
const key = recoveryDateKeyFromTimestamp(dayStart);
|
||||
orderedKeys.push(key);
|
||||
buckets.set(key, { key, total: 0, counts: emptyCounts() });
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestampMs(item);
|
||||
if (ts === undefined || !Number.isFinite(ts)) continue;
|
||||
if (ts < windowStart) continue;
|
||||
if (ts >= todayStart + 24 * 60 * 60 * 1000) continue;
|
||||
const kind = classify(item);
|
||||
if (!kind) continue;
|
||||
const key = recoveryDateKeyFromTimestamp(ts);
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.counts[kind] += 1;
|
||||
bucket.total += 1;
|
||||
}
|
||||
|
||||
const points = orderedKeys.map((key) => buckets.get(key)!);
|
||||
const rawMax = points.reduce((max, p) => (p.total > max ? p.total : max), 0);
|
||||
const axisMax = Math.max(2, getRecoveryNiceAxisMax(rawMax));
|
||||
const labelEvery = getRecoveryTimelineLabelEvery(points.length);
|
||||
|
||||
return { points, axisMax, labelEvery };
|
||||
}
|
||||
|
||||
export interface BackupActivityTooltipRow {
|
||||
kind: BackupActivitySegmentKind;
|
||||
label: string;
|
||||
count: number;
|
||||
value: string;
|
||||
segmentClassName: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export function getBackupActivityTooltipRows(
|
||||
point: BackupActivityPoint,
|
||||
kinds: readonly BackupActivitySegmentKind[],
|
||||
): 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;
|
||||
return {
|
||||
kind,
|
||||
label: presentation.label,
|
||||
count,
|
||||
value: percentage > 0 ? `${count} (${percentage}%)` : String(count),
|
||||
segmentClassName: presentation.segmentClassName,
|
||||
muted: count === 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getBackupActivityPointTotalLabel(total: number, noun: 'archive' | 'task'): string {
|
||||
const normalized = Math.max(0, total);
|
||||
if (normalized === 1) return `1 ${noun}`;
|
||||
return `${normalized} ${noun}s`;
|
||||
}
|
||||
|
||||
export function getBackupActivityColumnAriaLabel(
|
||||
dateLabel: string,
|
||||
total: number,
|
||||
selected: boolean,
|
||||
noun: 'archive' | 'task',
|
||||
): string {
|
||||
const countLabel = getBackupActivityPointTotalLabel(total, noun);
|
||||
return selected ? `${dateLabel}: ${countLabel}, selected` : `${dateLabel}: ${countLabel}`;
|
||||
}
|
||||
|
||||
export { getRecoveryTimelineDayFilterStateLabel as getBackupActivityDayFilterStateLabel };
|
||||
Reference in New Issue
Block a user