diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b2a7732c9..4b62eb8c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx index 9e8c84037..002ec81e2 100644 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx @@ -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(() => ( + + )); + + 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(() => ( { ).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(); }); diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx index 2b900edd3..ec099fe2c 100644 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx @@ -57,6 +57,11 @@ const COMMON_DETAIL_LABELS: Record = { 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 = (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 = (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 = (props }; const itemType = getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(p)); const locationEntries = getRecoveryPointLocationEntries(p); + const visibleLocationEntries: typeof locationEntries = []; + const locationValues = new Set(); + 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 = (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 = (props return (
-
- -
-
@@ -785,35 +819,37 @@ export const RecoveryPointDetails: Component = (props
-
-
-
-
- Verification provenance -
-
- Recorded verification evidence for this recovery point. -
-
- - {verificationConfidence().label} - -
-
- - {(pair) => ( -
-
- {pair.k} -
-
- {pair.v} -
+ +
+
+
+
+ Verification provenance
- )} - +
+ Recorded verification evidence for this recovery point. +
+
+ + {verificationConfidence().label} + +
+
+ + {(pair) => ( +
+
+ {pair.k} +
+
+ {pair.v} +
+
+ )} +
+
-
+
@@ -981,6 +1017,17 @@ export const RecoveryPointDetails: Component = (props Technical details
+
+ +
             {prettyJSON()}
           
diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 101f4b482..ba982878d 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -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(