mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Polish recovery drawer secondary details
This commit is contained in:
parent
247dc3f389
commit
5fdf80cfd9
4 changed files with 148 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue