Polish recovery drawer secondary details

This commit is contained in:
rcourtman 2026-05-14 22:47:41 +01:00
parent 247dc3f389
commit 5fdf80cfd9
4 changed files with 148 additions and 53 deletions

View file

@ -1985,10 +1985,14 @@ action or restore flow, not in the evidence drawer. Chain context must be
derived from the current recovery result set only when at least two concrete
stages are visible, so mixed PVE/PBS/TrueNAS history can explain adjacent local
snapshot, local copy, and remote copy stages without filling the drawer with
missing-only cards. Raw transport IDs, provider refs, and provider task IDs
belong behind `Technical details`; the primary drawer should keep human
metadata, verification provenance, target health, and collapsed file lists
without repeating the same verification fact in provider-specific sections.
missing-only cards. Raw transport IDs, provider refs, provider task IDs, and
raw JSON copy actions belong behind `Technical details`; the primary drawer
should keep human metadata, recorded verification provenance, target health,
and collapsed file lists without repeating the same verification fact in
provider-specific sections or rendering empty verifier/evidence placeholders
when no verification record exists. Container recovery points should present
container ids with operator vocabulary such as `CTID`, and duplicated placement
values should not be repeated under lower-priority location labels.
Provider-specific metadata must not recast the event drawer itself as if PBS
were the native recovery model. When target-specific technical labels are
surfaced, they should prefer neutral wording such as `Target Ref`, `Target

View file

@ -143,11 +143,51 @@ describe('RecoveryPointDetails', () => {
expect(screen.queryByText('Restore action path')).not.toBeInTheDocument();
expect(screen.getByText('Restore readiness')).toBeInTheDocument();
expect(screen.getByText('Available candidate')).toBeInTheDocument();
expect(screen.getByText('Verification provenance')).toBeInTheDocument();
expect(screen.getAllByText('Needs verification').length).toBeGreaterThan(0);
expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument();
expect(screen.queryByText('Needs verification')).not.toBeInTheDocument();
expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument();
});
it('keeps PBS container identifiers and empty verification details operator-safe', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-pbs-container',
platform: 'proxmox-pbs',
kind: 'backup',
mode: 'remote',
outcome: 'success',
completedAt: '2026-03-10T10:00:00Z',
verified: true,
display: {
clusterLabel: 'delly',
nodeHostLabel: 'minipc',
namespaceLabel: 'minipc',
itemType: 'lxc',
},
repositoryRef: {
type: 'pbs-datastore',
namespace: 'minipc',
name: 'main',
},
details: {
verificationState: 'ok',
vmid: '113',
datastore: 'main',
},
}}
/>
));
expect(screen.getByText('Verification provenance')).toBeInTheDocument();
expect(screen.getByText('PBS catalog verification')).toBeInTheDocument();
expect(screen.getByText('State: ok')).toBeInTheDocument();
expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument();
expect(screen.getByText('CTID')).toBeInTheDocument();
expect(screen.queryByText('VMID')).not.toBeInTheDocument();
expect(screen.queryByText('Namespace / Group')).not.toBeInTheDocument();
});
it('uses canonical platform labels without forcing provider detail panels for other platforms', () => {
render(() => (
<RecoveryPointDetails
@ -252,6 +292,7 @@ describe('RecoveryPointDetails', () => {
).toBeGreaterThan(0);
expect(screen.getByText('Not restorable')).toBeInTheDocument();
expect(screen.queryByText('Investigate source task')).not.toBeInTheDocument();
expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument();
expect(screen.queryByText('VMID')).not.toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});

View file

@ -57,6 +57,11 @@ const COMMON_DETAIL_LABELS: Record<string, string> = {
veleroName: 'Velero Backup',
};
const getRecoveryPointNumericIdLabel = (p: RecoveryPoint): string => {
const itemTypeKey = getRecoveryPointItemTypeKey(p);
return itemTypeKey === 'system-container' ? 'CTID' : 'VMID';
};
const formatDurationFromISO = (
startedAt: string | null | undefined,
completedAt: string | null | undefined,
@ -242,6 +247,33 @@ const getVerificationEvidenceLabel = (p: RecoveryPoint): string => {
return 'No verification evidence recorded';
};
const hasVerificationEvidence = (p: RecoveryPoint): boolean => {
const directEvidenceKeys = [
'verificationState',
'verificationStatus',
'verification',
'verificationResult',
'verifyResult',
'verificationUpid',
'verificationTaskId',
];
return directEvidenceKeys.some((key) => detailString(p, key)) || p.verified != null;
};
const hasVerificationMethod = (p: RecoveryPoint): boolean => {
const explicitMethodKeys = ['verificationMethod', 'verificationSource', 'verifier'];
if (explicitMethodKeys.some((key) => detailString(p, key))) return true;
const platform = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(p));
if (
platform === 'proxmox-pbs' &&
(detailString(p, 'verificationState') || detailString(p, 'verificationUpid'))
) {
return true;
}
return Boolean(detailString(p, 'verification') || p.verified != null);
};
const getVerificationMethodLabel = (p: RecoveryPoint): string => {
const explicit =
detailString(p, 'verificationMethod') ||
@ -538,6 +570,12 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
const verificationConfidence = createMemo(() =>
getVerificationConfidence(point(), normalizedOutcome()),
);
const hasVerificationProvenance = createMemo(
() =>
verificationTimestamp() > 0 ||
hasVerificationMethod(point()) ||
hasVerificationEvidence(point()),
);
const verificationProvenancePairs = createMemo(() => {
const pairs: RecoveryDetailPair[] = [
{
@ -547,13 +585,10 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
},
];
if (verificationTimestamp() > 0 || point().verified != null) {
if (verificationTimestamp() > 0) {
pairs.push({
k: 'Checked',
v:
verificationTimestamp() > 0
? formatAbsoluteTime(verificationTimestamp())
: 'No verification timestamp recorded',
v: formatAbsoluteTime(verificationTimestamp()),
valueClass: 'text-base-content',
});
}
@ -607,12 +642,20 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
};
const itemType = getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(p));
const locationEntries = getRecoveryPointLocationEntries(p);
const visibleLocationEntries: typeof locationEntries = [];
const locationValues = new Set<string>();
for (const entry of locationEntries) {
const normalizedValue = normalizeComparableText(entry.value);
if (entry.key === 'namespace' && locationValues.has(normalizedValue)) continue;
visibleLocationEntries.push(entry);
locationValues.add(normalizedValue);
}
const placementValues = new Set(
locationEntries.map((entry) => normalizeComparableText(entry.value)),
visibleLocationEntries.map((entry) => normalizeComparableText(entry.value)),
);
if (itemType && itemType !== 'Unknown') addPair('Item Type', itemType);
for (const entry of locationEntries) {
for (const entry of visibleLocationEntries) {
addPair(entry.label, entry.value);
}
if (typeof sizeBytes() === 'number') addPair('Size', formatBytes(sizeBytes()!));
@ -642,7 +685,10 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
if (!displayValue) continue;
if (k === 'vmid' && displayValue === '0') continue;
if (placementValues.has(normalizeComparableText(displayValue))) continue;
addPair(COMMON_DETAIL_LABELS[k] || k, displayValue);
addPair(
k === 'vmid' ? getRecoveryPointNumericIdLabel(p) : COMMON_DETAIL_LABELS[k] || k,
displayValue,
);
}
return pairs;
@ -675,18 +721,6 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
return (
<div class="space-y-3">
<div class="flex justify-end">
<button
type="button"
onClick={() => void copyJSON()}
class="rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium text-base-content hover:bg-surface-hover"
>
<Show when={copied()} fallback="Copy JSON">
Copied
</Show>
</button>
</div>
<div class="rounded-md border border-border bg-surface-alt/40 p-3">
<div class="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
@ -785,35 +819,37 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
</div>
</Show>
<div class="rounded border border-border bg-surface p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
Verification provenance
</div>
<div class="mt-1 text-xs text-muted">
Recorded verification evidence for this recovery point.
</div>
</div>
<span class={`text-xs font-semibold ${verificationConfidence().className}`}>
{verificationConfidence().label}
</span>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<For each={verificationProvenancePairs()}>
{(pair) => (
<div class="rounded border border-border bg-surface-alt/45 px-3 py-2 text-xs">
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
{pair.k}
</div>
<div class={`mt-0.5 text-[11px] leading-4 break-words ${pair.valueClass}`}>
{pair.v}
</div>
<Show when={hasVerificationProvenance()}>
<div class="rounded border border-border bg-surface p-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
Verification provenance
</div>
)}
</For>
<div class="mt-1 text-xs text-muted">
Recorded verification evidence for this recovery point.
</div>
</div>
<span class={`text-xs font-semibold ${verificationConfidence().className}`}>
{verificationConfidence().label}
</span>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<For each={verificationProvenancePairs()}>
{(pair) => (
<div class="rounded border border-border bg-surface-alt/45 px-3 py-2 text-xs">
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
{pair.k}
</div>
<div class={`mt-0.5 text-[11px] leading-4 break-words ${pair.valueClass}`}>
{pair.v}
</div>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
<Show when={hasPlatformDetails()}>
<div class="rounded border border-border bg-surface p-3">
@ -981,6 +1017,17 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
Technical details
</summary>
<div class="border-t border-border px-3 py-2">
<div class="mb-2 flex justify-end">
<button
type="button"
onClick={() => void copyJSON()}
class="rounded-md border border-border bg-surface px-2.5 py-1 text-xs font-medium normal-case tracking-normal text-base-content hover:bg-surface-hover"
>
<Show when={copied()} fallback="Copy JSON">
Copied
</Show>
</button>
</div>
<pre class="overflow-auto text-[11px] leading-relaxed text-base-content font-mono">
{prettyJSON()}
</pre>

View file

@ -974,6 +974,9 @@ describe('Recovery', () => {
'Succeeded; verification is not recorded.',
),
).toBeInTheDocument();
expect(
within(detailsPanel as HTMLTableCellElement).queryByText('Verification provenance'),
).not.toBeInTheDocument();
expect(within(detailsPanel as HTMLTableCellElement).getByText('Item Type')).toBeInTheDocument();
expect(within(detailsPanel as HTMLTableCellElement).getByText('VM')).toBeInTheDocument();
expect(