refactor(recovery): improve scan-first recovery identity

This commit is contained in:
rcourtman
2026-03-28 00:45:17 +00:00
parent 522e974aec
commit 4782186bd6
9 changed files with 326 additions and 153 deletions
@@ -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 &lt;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', () => {
+65 -2
View File
@@ -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),
+29
View File
@@ -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 {
+74
View File
@@ -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()