mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
refactor(recovery): improve scan-first recovery identity
This commit is contained in:
@@ -667,6 +667,10 @@ That same summary rule also applies within individual cards: recovery posture,
|
||||
freshness, attention, footprint, and history cards should favor compact rows
|
||||
and metric lists over stacked prose callouts so the summary strip reads like
|
||||
Pulse monitoring telemetry rather than a page-local narrative panel.
|
||||
That same card-level scan rule should prefer one dominant metric per card with
|
||||
short supporting readouts, the same quick-scan rhythm operators already get on
|
||||
infrastructure and workloads, instead of nested sub-cards that turn recovery
|
||||
summary into a denser bespoke dashboard than the rest of Pulse.
|
||||
That same inventory surface should also follow the established monitoring-table
|
||||
scan pattern in its first column. Protected-item rows should lead with a clear
|
||||
status cue, the primary item name, and compact badge-backed item/platform
|
||||
@@ -676,6 +680,11 @@ That same row contract should avoid duplicating context that already has a
|
||||
dedicated column. When `Item Type` and `Platform` columns are visible, the
|
||||
primary item cell should not restate those same badges on desktop; duplicate
|
||||
context belongs only as a small-screen fallback when those columns collapse.
|
||||
That same item-identity contract also applies to synthetic Proxmox task
|
||||
recovery points. When the persisted subject label is just a raw
|
||||
`pve-task:*`/`UPID:*` identifier or `vmid=0`, the canonical recovery index
|
||||
should derive a readable task label and `task` item type from point details so
|
||||
recovery tables scan by operator meaning instead of transport IDs.
|
||||
That same inventory surface should stay on the flat monitoring-table pattern
|
||||
already used elsewhere in Pulse. Protected items should surface posture through
|
||||
row-level status cues, outcome pills, and filters rather than inserting extra
|
||||
|
||||
@@ -57,16 +57,16 @@ describe('RecoverySummary', () => {
|
||||
expect(screen.getByText('Protected Footprint')).toBeInTheDocument();
|
||||
expect(screen.getByText('Freshness')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent History')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Stale').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Attention').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
|
||||
expect(screen.getByText(/recovery points/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Item Types').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Primary Item')).toBeInTheDocument();
|
||||
expect(screen.getByText('Primary Platform')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Mix')).toBeInTheDocument();
|
||||
expect(screen.getByText('Avg / Day')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 protected')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 attention')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Never succeeded').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/Never Succeeded/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('need attention')).toBeInTheDocument();
|
||||
expect(screen.getByText('stale items')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
buildRecoveryPlatformCoverage,
|
||||
buildRecoveryPostureSegments,
|
||||
buildRecoveryPostureSummary,
|
||||
getRecoveryAttentionDotClass,
|
||||
RECOVERY_SUMMARY_TIME_RANGES,
|
||||
RECOVERY_SUMMARY_TIME_RANGE_LABELS,
|
||||
type RecoverySummaryTimeRange,
|
||||
@@ -43,35 +42,35 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
const activity = createMemo(() => buildRecoveryActivitySummary(props.series()));
|
||||
const healthyCount = createMemo(() => postureSummary().healthy);
|
||||
const attentionCount = createMemo(() => postureSummary().attention);
|
||||
const primaryPostureMetric = createMemo(() => {
|
||||
if (attentionCount() > 0) {
|
||||
return {
|
||||
value: attentionCount(),
|
||||
label: 'need attention',
|
||||
valueClass: 'text-amber-600 dark:text-amber-400',
|
||||
};
|
||||
}
|
||||
if (postureSummary().running > 0) {
|
||||
return {
|
||||
value: postureSummary().running,
|
||||
label: 'currently running',
|
||||
valueClass: 'text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: healthyCount(),
|
||||
label: 'healthy items',
|
||||
valueClass: 'text-emerald-600 dark:text-emerald-400',
|
||||
};
|
||||
});
|
||||
const visiblePostureSegments = createMemo(() =>
|
||||
postureSegments().filter((segment) => segment.count > 0).slice(0, 4),
|
||||
);
|
||||
const recentWindowLabel = createMemo(() => {
|
||||
const activitySummary = activity();
|
||||
if (!activitySummary.startLabel || !activitySummary.endLabel) return null;
|
||||
return `${activitySummary.startLabel} to ${activitySummary.endLabel}`;
|
||||
});
|
||||
const attentionItems = createMemo(() =>
|
||||
[
|
||||
{
|
||||
label: 'Stale',
|
||||
count: summary().stale,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Never succeeded',
|
||||
count: summary().neverSucceeded,
|
||||
tone: 'rose',
|
||||
},
|
||||
{
|
||||
label: 'Attention',
|
||||
count: attentionCount(),
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
count: postureSummary().running,
|
||||
tone: 'blue',
|
||||
},
|
||||
].filter((item) => item.count > 0),
|
||||
);
|
||||
const handleTimeRangeChange = (range: string) =>
|
||||
props.onTimeRangeChange?.(range as RecoverySummaryTimeRange);
|
||||
|
||||
@@ -108,30 +107,26 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<SummaryMetricCard label="Recovery Posture" loaded={true} hasData={hasRollups()}>
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
<div class="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/25 px-2 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Healthy</div>
|
||||
<div class="mt-1 font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{healthyCount()}
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class={`text-2xl font-semibold tabular-nums ${primaryPostureMetric().valueClass}`}>
|
||||
{primaryPostureMetric().value}
|
||||
</div>
|
||||
<div class="text-xs text-muted">{primaryPostureMetric().label}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/25 px-2 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Attention</div>
|
||||
<div class="mt-1 font-semibold text-amber-600 dark:text-amber-400">
|
||||
{attentionCount()}
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-semibold tabular-nums text-base-content">
|
||||
{summary().total}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/25 px-2 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Protected</div>
|
||||
<div class="mt-1 font-semibold text-base-content">{summary().total}</div>
|
||||
<div class="text-[11px] text-muted">protected items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-surface-alt">
|
||||
<div class="flex h-full">
|
||||
<For each={postureSegments()}>
|
||||
<For each={visiblePostureSegments()}>
|
||||
{(segment) => (
|
||||
<div
|
||||
class={segment.color}
|
||||
@@ -143,7 +138,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
|
||||
<For each={postureSegments()}>
|
||||
<For each={visiblePostureSegments()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-base-content">
|
||||
@@ -162,121 +157,117 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</SummaryMetricCard>
|
||||
|
||||
<SummaryMetricCard label="Freshness" loaded={true} hasData={hasRollups()}>
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 border-b border-border-subtle pb-1.5">
|
||||
<span class="uppercase tracking-wide text-muted">Stale</span>
|
||||
<span class="font-semibold text-amber-600 dark:text-amber-400">
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{summary().stale}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted">stale items</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-border-subtle pb-1.5">
|
||||
<span class="uppercase tracking-wide text-muted">Never succeeded</span>
|
||||
<span class="font-semibold text-rose-600 dark:text-rose-400">
|
||||
{summary().neverSucceeded}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="uppercase tracking-wide text-muted">Running</span>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{postureSummary().running}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="uppercase tracking-wide text-muted">Attention</span>
|
||||
<span class="font-semibold text-amber-600 dark:text-amber-400">
|
||||
{attentionCount()}
|
||||
</span>
|
||||
<div class="space-y-1 text-right text-xs">
|
||||
<div>
|
||||
<span class="font-semibold text-rose-600 dark:text-rose-400">
|
||||
{summary().neverSucceeded}
|
||||
</span>{' '}
|
||||
<span class="text-muted">never succeeded</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{postureSummary().running}
|
||||
</span>{' '}
|
||||
<span class="text-muted">running</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 border-t border-border-subtle pt-2">
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs">
|
||||
<For each={freshnessBuckets()}>
|
||||
{(bucket) => (
|
||||
<div class="inline-flex items-center gap-1.5 rounded-md border border-border-subtle bg-surface-alt/35 px-2 py-1 text-[11px]">
|
||||
<span class={`h-2 w-2 rounded-full ${bucket.color}`} />
|
||||
<span class="text-base-content">{bucket.label}</span>
|
||||
<span class="tabular-nums text-base-content">{bucket.count}</span>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-base-content">
|
||||
<span class={`h-2 w-2 rounded-full ${bucket.color}`} />
|
||||
<span>{bucket.label}</span>
|
||||
</div>
|
||||
<span class="tabular-nums font-semibold text-base-content">{bucket.count}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={attentionItems()}>
|
||||
{(item) => (
|
||||
<Show when={item.label === 'Never succeeded' || item.label === 'Running'}>
|
||||
<div class="inline-flex items-center gap-1.5 rounded-md border border-border-subtle bg-surface-alt/35 px-2 py-1 text-[11px]">
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
|
||||
/>
|
||||
<span class="text-base-content">{item.label}</span>
|
||||
<span class="tabular-nums text-base-content">{item.count}</span>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 border-t border-border-subtle pt-2 text-[11px]">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="text-muted">Attention</div>
|
||||
<div class="mt-1 font-semibold text-base-content">{attentionCount()}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="text-muted">Fresh <24h</div>
|
||||
<div class="mt-1 font-semibold text-base-content">
|
||||
{freshnessBuckets()
|
||||
.filter((bucket) => bucket.key === 'under1h' || bucket.key === 'under24h')
|
||||
.reduce((total, bucket) => total + bucket.count, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SummaryMetricCard>
|
||||
|
||||
<SummaryMetricCard label="Protected Footprint" loaded={true} hasData={hasRollups()}>
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
<dl class="space-y-1.5 text-sm">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Item Types</dt>
|
||||
<dd class="font-semibold text-base-content">{itemCoverage().itemTypeCount}</dd>
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-2">
|
||||
<div class="text-muted">Item Types</div>
|
||||
<div class="mt-1 text-xl font-semibold tabular-nums text-base-content">
|
||||
{itemCoverage().itemTypeCount}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Primary Item</dt>
|
||||
<dd class="font-semibold text-base-content">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-2">
|
||||
<div class="text-muted">Platforms</div>
|
||||
<div class="mt-1 text-xl font-semibold tabular-nums text-base-content">
|
||||
{platformCoverage().platformCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-1.5">
|
||||
<dt class="text-muted">Primary Item</dt>
|
||||
<dd class="font-medium text-base-content">
|
||||
{itemCoverage().primaryItemLabel ?? 'n/a'}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Platforms</dt>
|
||||
<dd class="font-semibold text-base-content">{platformCoverage().platformCount}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Primary Platform</dt>
|
||||
<dd class="font-semibold text-base-content">
|
||||
<dt class="text-muted">Primary Platform</dt>
|
||||
<dd class="font-medium text-base-content">
|
||||
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Show when={itemCoverage().items.length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||||
<For each={itemCoverage().items.slice(0, 6)}>
|
||||
<div class="grid gap-2 border-t border-border-subtle pt-2">
|
||||
<Show when={itemCoverage().items.length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={itemCoverage().items.slice(0, 3)}>
|
||||
{(item) => (
|
||||
<div class="inline-flex items-center gap-2 text-[11px]">
|
||||
<div class="inline-flex items-center gap-1.5 text-[11px]">
|
||||
<span class={item.toneClass}>{item.label}</span>
|
||||
<span class="tabular-nums text-base-content">{item.count}</span>
|
||||
<span class="text-muted">{item.percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="border-t border-border-subtle pt-2">
|
||||
<div class="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
Platform Mix
|
||||
</div>
|
||||
<Show when={platformCoverage().multiPlatformCount > 0}>
|
||||
<div class="mb-1.5 text-xs text-muted">
|
||||
{platformCoverage().multiPlatformCount} multi-platform item
|
||||
{platformCoverage().multiPlatformCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={platformCoverage().items.slice(0, 6)}>
|
||||
<For each={platformCoverage().items.slice(0, 3)}>
|
||||
{(item) => {
|
||||
const badge = getSourcePlatformBadge(item.key);
|
||||
return (
|
||||
<div class="inline-flex items-center gap-2 text-[11px]">
|
||||
<div class="inline-flex items-center gap-1.5 text-[11px]">
|
||||
<span class={badge?.classes || 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-base-content'}>
|
||||
{badge?.label || item.label}
|
||||
</span>
|
||||
<span class="tabular-nums text-base-content">{item.count}</span>
|
||||
<span class="text-muted">{item.percent}%</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -292,47 +283,48 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
hasData={activity().hasData}
|
||||
emptyMessage={props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
|
||||
>
|
||||
<div class="flex h-full flex-col gap-2">
|
||||
<dl class="space-y-1.5 text-sm">
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-2xl font-semibold tabular-nums text-base-content">
|
||||
{activity().totalEvents}
|
||||
</div>
|
||||
<div class="text-xs text-muted">recovery points</div>
|
||||
</div>
|
||||
<Show when={recentWindowLabel()}>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Window</dt>
|
||||
<dd class="text-right text-xs text-base-content">{recentWindowLabel()}</dd>
|
||||
<div class="max-w-[9rem] text-right text-[11px] text-muted">
|
||||
{recentWindowLabel()}
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Recovery Points</dt>
|
||||
<dd class="font-semibold text-base-content">{activity().totalEvents}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Days Active</dt>
|
||||
<dd class="font-semibold text-base-content">{activity().activeDays}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Peak Day</dt>
|
||||
<dd class="font-semibold text-base-content">{activity().busiestLabel ?? 'n/a'}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-[11px] uppercase tracking-wide text-muted">Latest Activity</dt>
|
||||
<dd class="font-semibold text-base-content">{activity().latestLabel ?? 'n/a'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="grid grid-cols-3 gap-2 border-t border-border-subtle pt-2 text-[11px]">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Avg / Day</div>
|
||||
<div class="text-muted">Days Active</div>
|
||||
<div class="mt-1 font-semibold text-base-content">{activity().activeDays}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="text-muted">Avg / Day</div>
|
||||
<div class="mt-1 font-semibold text-base-content">
|
||||
{activity().averagePerDay.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Peak</div>
|
||||
<div class="text-muted">Peak</div>
|
||||
<div class="mt-1 font-semibold text-base-content">{activity().busiestCount}</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 py-1.5">
|
||||
<div class="uppercase tracking-wide text-muted">Latest</div>
|
||||
<div class="mt-1 font-semibold text-base-content">{activity().latestCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-1.5 border-t border-border-subtle pt-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-muted">Peak Day</dt>
|
||||
<dd class="font-medium text-base-content">{activity().busiestLabel ?? 'n/a'}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<dt class="text-muted">Latest Activity</dt>
|
||||
<dd class="font-medium text-base-content">{activity().latestLabel ?? 'n/a'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</SummaryMetricCard>
|
||||
</SummaryPanel>
|
||||
|
||||
@@ -331,10 +331,10 @@ describe('Recovery', () => {
|
||||
it('surfaces item-first recovery coverage in the unified summary', async () => {
|
||||
render(() => <Recovery />);
|
||||
|
||||
await screen.findByText('Platform Mix');
|
||||
await screen.findByText('Protected Footprint');
|
||||
expect(screen.getByText('Primary Item')).toBeInTheDocument();
|
||||
expect(screen.getByText('Primary Platform')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platform Mix')).toBeInTheDocument();
|
||||
expect(screen.getByText('Platforms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('normalizes legacy provider-shaped recovery payloads before rendering', async () => {
|
||||
|
||||
@@ -37,6 +37,10 @@ describe('recoveryItemTypePresentation', () => {
|
||||
key: 'dataset',
|
||||
label: 'Dataset',
|
||||
});
|
||||
expect(getRecoveryItemTypePresentation('task')).toMatchObject({
|
||||
key: 'task',
|
||||
label: 'Task',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back cleanly for unknown item types', () => {
|
||||
|
||||
@@ -91,6 +91,51 @@ func preferredProxmoxBackupCommentLabel(comment, entityID string) string {
|
||||
return comment
|
||||
}
|
||||
|
||||
func isOpaqueProxmoxTaskLabel(value string) bool {
|
||||
value = strings.TrimSpace(strings.ToLower(value))
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(value, "pve-task:") || strings.Contains(value, "upid:")
|
||||
}
|
||||
|
||||
func preferredProxmoxTaskLabelFromDetails(p RecoveryPoint, currentLabel string) string {
|
||||
if strings.TrimSpace(string(p.Provider)) != string(ProviderProxmoxPVE) {
|
||||
return ""
|
||||
}
|
||||
currentLabel = strings.TrimSpace(currentLabel)
|
||||
if currentLabel != "" && !isOpaqueProxmoxTaskLabel(currentLabel) {
|
||||
return ""
|
||||
}
|
||||
|
||||
taskType := strings.ToLower(strings.TrimSpace(detailsString(p, "type")))
|
||||
baseLabel := ""
|
||||
switch taskType {
|
||||
case "vzdump", "backup":
|
||||
baseLabel = "backup task"
|
||||
case "":
|
||||
baseLabel = "task"
|
||||
default:
|
||||
baseLabel = strings.ReplaceAll(taskType, "-", " ") + " task"
|
||||
}
|
||||
|
||||
entityID := entityIDLabel(p)
|
||||
node := nodeHostLabel(p)
|
||||
if entityID != "" {
|
||||
if node != "" {
|
||||
return fmt.Sprintf("%s guest %s %s", node, entityID, baseLabel)
|
||||
}
|
||||
return fmt.Sprintf("guest %s %s", entityID, baseLabel)
|
||||
}
|
||||
if node != "" {
|
||||
return node + " " + baseLabel
|
||||
}
|
||||
if cluster := clusterLabel(p); cluster != "" {
|
||||
return cluster + " " + baseLabel
|
||||
}
|
||||
return "proxmox " + baseLabel
|
||||
}
|
||||
|
||||
func preferredSubjectLabelFromDetails(p RecoveryPoint, currentLabel string) string {
|
||||
currentLabel = strings.TrimSpace(currentLabel)
|
||||
if !strings.HasPrefix(string(p.Provider), "proxmox-") {
|
||||
@@ -99,6 +144,9 @@ func preferredSubjectLabelFromDetails(p RecoveryPoint, currentLabel string) stri
|
||||
|
||||
entityID := entityIDLabel(p)
|
||||
if currentLabel != "" && currentLabel != entityID && !isNumericOnlyLabel(currentLabel) {
|
||||
if candidate := preferredProxmoxTaskLabelFromDetails(p, currentLabel); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -108,6 +156,10 @@ func preferredSubjectLabelFromDetails(p RecoveryPoint, currentLabel string) stri
|
||||
}
|
||||
}
|
||||
|
||||
if candidate := preferredProxmoxTaskLabelFromDetails(p, currentLabel); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -274,11 +326,17 @@ func namespaceLabel(p RecoveryPoint) string {
|
||||
func entityIDLabel(p RecoveryPoint) string {
|
||||
// Proxmox VMID (int or string depending on source).
|
||||
if v := detailsString(p, "vmid"); v != "" {
|
||||
if strings.TrimSpace(string(p.Provider)) == string(ProviderProxmoxPVE) && v == "0" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
if p.Details != nil {
|
||||
if raw, ok := p.Details["vmid"]; ok {
|
||||
if v := anyToString(raw); v != "" {
|
||||
if strings.TrimSpace(string(p.Provider)) == string(ProviderProxmoxPVE) && v == "0" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -401,6 +459,11 @@ func DeriveIndex(p RecoveryPoint) PointIndex {
|
||||
if p.SubjectRef != nil {
|
||||
subjectType = strings.TrimSpace(p.SubjectRef.Type)
|
||||
}
|
||||
subjectLabel := subjectLabel(p)
|
||||
itemType := NormalizeRecoveryItemType(subjectType)
|
||||
if itemType == "" && preferredProxmoxTaskLabelFromDetails(p, "") != "" {
|
||||
itemType = "task"
|
||||
}
|
||||
|
||||
isWorkload := isWorkloadSubjectType(subjectType)
|
||||
// If the point is linked to a unified resource, treat it as a protected subject (workload)
|
||||
@@ -410,9 +473,9 @@ func DeriveIndex(p RecoveryPoint) PointIndex {
|
||||
}
|
||||
|
||||
return PointIndex{
|
||||
SubjectLabel: subjectLabel(p),
|
||||
SubjectLabel: subjectLabel,
|
||||
SubjectType: subjectType,
|
||||
ItemType: NormalizeRecoveryItemType(subjectType),
|
||||
ItemType: itemType,
|
||||
IsWorkload: isWorkload,
|
||||
ClusterLabel: clusterLabel(p),
|
||||
NodeHostLabel: nodeHostLabel(p),
|
||||
|
||||
@@ -169,6 +169,35 @@ func TestDeriveIndex(t *testing.T) {
|
||||
DetailsSummary: "pulse-v4-prod, pi, 140",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PVE task falls back to node backup task label instead of raw task id",
|
||||
point: RecoveryPoint{
|
||||
ID: "pve-task:delly-UPID:minipc:0014B9F1:22DC4693:69C600C1:vzdump::root@pam:",
|
||||
Provider: ProviderProxmoxPVE,
|
||||
Kind: KindBackup,
|
||||
Mode: ModeLocal,
|
||||
Outcome: OutcomeSuccess,
|
||||
Details: map[string]any{
|
||||
"instance": "delly",
|
||||
"node": "minipc",
|
||||
"vmid": 0,
|
||||
"type": "vzdump",
|
||||
"taskID": "delly-UPID:minipc:0014B9F1:22DC4693:69C600C1:vzdump::root@pam:",
|
||||
},
|
||||
},
|
||||
expected: PointIndex{
|
||||
SubjectLabel: "minipc backup task",
|
||||
SubjectType: "",
|
||||
ItemType: "task",
|
||||
IsWorkload: false,
|
||||
ClusterLabel: "delly",
|
||||
NodeHostLabel: "minipc",
|
||||
NamespaceLabel: "",
|
||||
EntityIDLabel: "",
|
||||
RepositoryLabel: "",
|
||||
DetailsSummary: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TrueNAS with hostname",
|
||||
point: RecoveryPoint{
|
||||
|
||||
@@ -42,7 +42,9 @@ func (s *Store) BackfillIndex(ctx context.Context) error {
|
||||
subject_label IS NOT NULL AND TRIM(subject_label) <> '' AND
|
||||
entity_id_label IS NOT NULL AND TRIM(entity_id_label) <> '' AND
|
||||
TRIM(subject_label) = TRIM(entity_id_label) AND
|
||||
details_json IS NOT NULL AND TRIM(details_json) <> ''))
|
||||
details_json IS NOT NULL AND TRIM(details_json) <> '') OR
|
||||
(provider = 'proxmox-pve' AND
|
||||
subject_label IS NOT NULL AND TRIM(subject_label) LIKE 'pve-task:%'))
|
||||
LIMIT `+fmt.Sprint(maxBackfillRows)+`
|
||||
`)
|
||||
if err != nil {
|
||||
|
||||
@@ -287,6 +287,80 @@ func TestStore_OpenBackfillsLegacyNumericPBSSubjectLabels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_OpenBackfillsLegacyPVETaskSubjectLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "recovery.db")
|
||||
|
||||
store, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 3, 27, 4, 7, 9, 0, time.UTC)
|
||||
point := recovery.RecoveryPoint{
|
||||
ID: "pve-task:delly-UPID:minipc:0014B9F1:22DC4693:69C600C1:vzdump::root@pam:",
|
||||
Provider: recovery.ProviderProxmoxPVE,
|
||||
Kind: recovery.KindBackup,
|
||||
Mode: recovery.ModeLocal,
|
||||
Outcome: recovery.OutcomeSuccess,
|
||||
StartedAt: &now,
|
||||
CompletedAt: &now,
|
||||
Details: map[string]any{
|
||||
"instance": "delly",
|
||||
"node": "minipc",
|
||||
"status": "OK",
|
||||
"type": "vzdump",
|
||||
"taskID": "delly-UPID:minipc:0014B9F1:22DC4693:69C600C1:vzdump::root@pam:",
|
||||
"vmid": 0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.UpsertPoints(context.Background(), []recovery.RecoveryPoint{point}); err != nil {
|
||||
t.Fatalf("UpsertPoints() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.db.ExecContext(
|
||||
context.Background(),
|
||||
`UPDATE recovery_points
|
||||
SET subject_label = ?, entity_id_label = ?
|
||||
WHERE id = ?`,
|
||||
point.ID,
|
||||
"0",
|
||||
point.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("degrade legacy pve task label: %v", err)
|
||||
}
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
reopened, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
|
||||
points, total, err := reopened.ListPoints(context.Background(), recovery.ListPointsOptions{Page: 1, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPoints() error = %v", err)
|
||||
}
|
||||
if total != 1 || len(points) != 1 {
|
||||
t.Fatalf("ListPoints() total=%d len=%d, want 1/1", total, len(points))
|
||||
}
|
||||
if points[0].Display == nil || points[0].Display.SubjectLabel != "minipc backup task" {
|
||||
t.Fatalf("ListPoints() display = %#v, want backfilled subject label minipc backup task", points[0].Display)
|
||||
}
|
||||
if points[0].Display != nil && points[0].Display.EntityIDLabel != "" {
|
||||
t.Fatalf("ListPoints() display entity id = %#v, want empty for synthetic PVE task label", points[0].Display)
|
||||
}
|
||||
if points[0].Display == nil || points[0].Display.ItemType != "task" {
|
||||
t.Fatalf("ListPoints() display = %#v, want synthetic item type task", points[0].Display)
|
||||
}
|
||||
}
|
||||
|
||||
func createLegacyRecoveryDBWithoutItemType(t *testing.T, dbPath string, point recovery.RecoveryPoint) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user