From 06c7dcc9b2250d581bee4e42cb3ebfbb79b04795 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 26 Mar 2026 00:33:15 +0000 Subject: [PATCH] refactor(recovery): neutralize provider detail shell --- .../internal/subsystems/storage-recovery.md | 38 +++--- .../Recovery/RecoveryPointDetails.test.tsx | 109 ++++++++++++++++++ .../Recovery/RecoveryPointDetails.tsx | 39 +++++-- .../Recovery/__tests__/Recovery.test.tsx | 41 +++++++ 4 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 34a9ad610..7c3ca913b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -34,22 +34,23 @@ querying, and the operator-facing storage health presentation layer. 8. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` 9. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx` 10. `frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx` -11. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` -12. `frontend-modern/src/pages/Storage.tsx` -13. `frontend-modern/src/components/Storage/Storage.tsx` -14. `frontend-modern/src/features/storageBackups/storageModelCore.ts` -15. `frontend-modern/src/hooks/useRecoveryPoints.ts` -16. `frontend-modern/src/hooks/useRecoveryRollups.ts` -17. `frontend-modern/src/pages/RecoveryRoute.tsx` -18. `frontend-modern/src/routing/resourceLinks.ts` -19. `frontend-modern/src/pages/Dashboard.tsx` -20. `frontend-modern/src/features/dashboardOverview/dashboardWidgets.ts` -21. `frontend-modern/src/components/Recovery/DashboardRecoveryStatusPanel.tsx` -22. `frontend-modern/src/components/Storage/DashboardStoragePanel.tsx` -23. `frontend-modern/src/types/recovery.ts` -24. `frontend-modern/src/utils/recoverySummaryPresentation.ts` -25. `frontend-modern/src/utils/recoveryTablePresentation.ts` -26. `frontend-modern/src/utils/textPresentation.ts` +11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` +12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` +13. `frontend-modern/src/pages/Storage.tsx` +14. `frontend-modern/src/components/Storage/Storage.tsx` +15. `frontend-modern/src/features/storageBackups/storageModelCore.ts` +16. `frontend-modern/src/hooks/useRecoveryPoints.ts` +17. `frontend-modern/src/hooks/useRecoveryRollups.ts` +18. `frontend-modern/src/pages/RecoveryRoute.tsx` +19. `frontend-modern/src/routing/resourceLinks.ts` +20. `frontend-modern/src/pages/Dashboard.tsx` +21. `frontend-modern/src/features/dashboardOverview/dashboardWidgets.ts` +22. `frontend-modern/src/components/Recovery/DashboardRecoveryStatusPanel.tsx` +23. `frontend-modern/src/components/Storage/DashboardStoragePanel.tsx` +24. `frontend-modern/src/types/recovery.ts` +25. `frontend-modern/src/utils/recoverySummaryPresentation.ts` +26. `frontend-modern/src/utils/recoveryTablePresentation.ts` +27. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -325,6 +326,11 @@ label fallback for recovery rows and delegates its title-casing to the shared `frontend-modern/src/utils/textPresentation.ts` helper rather than keeping a local recovery-only formatter, so subject and outcome labels stay aligned with the shared frontend label contract. +That same recovery drill-in surface now also keeps provider-specific metadata +inside a provider-neutral detail shell through +`frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`, so PBS +repository and verification enrichments remain available without presenting the +event drawer itself as if PBS were the native recovery model. Those transport hooks are direct governed runtime surfaces, not just page implementation detail: `frontend-modern/src/hooks/useRecoveryPoints.ts`, `frontend-modern/src/hooks/useRecoveryPointsFacets.ts`, diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx new file mode 100644 index 000000000..15e9ca2e3 --- /dev/null +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, within } from '@solidjs/testing-library'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { RecoveryPointDetails } from './RecoveryPointDetails'; + +const wsState = vi.hoisted(() => ({ resources: [] as Resource[] })); + +vi.mock('@/App', () => ({ + useWebSocket: () => ({ + state: wsState, + }), +})); + +describe('RecoveryPointDetails', () => { + beforeEach(() => { + wsState.resources = []; + }); + + it('renders provider-neutral details framing while preserving PBS-specific metadata', () => { + wsState.resources = [ + { + id: 'pbs-resource-1', + type: 'pbs', + name: 'pbs-main', + displayName: 'pbs-main', + platformId: 'pbs-main', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: Date.parse('2026-03-10T10:00:00Z'), + platformData: { + pbs: { + instanceId: 'pbs-main', + datastores: [ + { + name: 'fast-store', + used: 500, + total: 1000, + usage: 50, + status: 'ok', + deduplicationFactor: 2.25, + }, + ], + }, + }, + } as Resource, + ]; + + render(() => ( + + )); + + expect(screen.getByText('Provider Details')).toBeInTheDocument(); + expect(screen.queryByText('PBS Details')).not.toBeInTheDocument(); + expect(screen.getByText('Provider-specific recovery metadata, verification state, and repository health.')).toBeInTheDocument(); + expect(screen.getByText('Repository Health')).toBeInTheDocument(); + expect(screen.getByText('Verification')).toBeInTheDocument(); + + const providerCard = screen.getByText('Provider').parentElement?.parentElement; + expect(providerCard).not.toBeNull(); + expect(within(providerCard as HTMLDivElement).getByText('PBS')).toBeInTheDocument(); + }); + + it('uses canonical provider labels without forcing provider detail panels for other platforms', () => { + render(() => ( + + )); + + expect(screen.queryByText('Provider Details')).not.toBeInTheDocument(); + expect(screen.queryByText('PBS Details')).not.toBeInTheDocument(); + + const providerCard = screen.getByText('Provider').parentElement?.parentElement; + expect(providerCard).not.toBeNull(); + expect(within(providerCard as HTMLDivElement).getByText('TrueNAS')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx index b22e6dd46..43dcde940 100644 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx @@ -1,10 +1,15 @@ import type { Component } from 'solid-js'; import { For, Show, createMemo, createSignal } from 'solid-js'; import { useWebSocket } from '@/App'; +import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; import type { PBSDatastore } from '@/types/api'; import type { RecoveryExternalRef, RecoveryPoint } from '@/types/recovery'; import { formatAbsoluteTime, formatBytes, formatUptime } from '@/utils/format'; import { pbsInstanceFromResource } from '@/utils/resourceStateAdapters'; +import { + getSourcePlatformLabel, + normalizeSourcePlatformQueryValue, +} from '@/utils/sourcePlatforms'; interface RecoveryPointDetailsProps { point: RecoveryPoint; @@ -59,11 +64,13 @@ const usageBarColorClass = (usagePercent: number): string => { export const RecoveryPointDetails: Component = (props) => { const { state } = useWebSocket(); const point = () => props.point; + const providerKey = createMemo(() => + normalizeSourcePlatformQueryValue(String(point().provider || '').trim()), + ); + const providerLabel = createMemo(() => getSourcePlatformLabel(providerKey() || point().provider)); + const providerBadge = createMemo(() => getSourcePlatformBadge(providerKey() || point().provider)); const isPbsProvider = createMemo( - () => - String(point().provider || '') - .trim() - .toLowerCase() === 'proxmox-pbs', + () => providerKey() === 'proxmox-pbs', ); const pbsComment = createMemo(() => { @@ -109,7 +116,7 @@ export const RecoveryPointDetails: Component = (props }, ); - const hasPbsDetails = createMemo( + const hasProviderDetails = createMemo( () => isPbsProvider() && (pbsComment().length > 0 || @@ -141,7 +148,7 @@ export const RecoveryPointDetails: Component = (props const pairs: { k: string; v: string }[] = []; pairs.push({ k: 'ID', v: p.id }); - pairs.push({ k: 'Provider', v: String(p.provider || 'n/a') }); + pairs.push({ k: 'Provider', v: providerLabel() || 'n/a' }); pairs.push({ k: 'Kind', v: String(p.kind || 'n/a') }); pairs.push({ k: 'Mode', v: String(p.mode || 'n/a') }); pairs.push({ k: 'Outcome', v: String(p.outcome || 'unknown') }); @@ -251,10 +258,24 @@ export const RecoveryPointDetails: Component = (props - +
-
- PBS Details +
+
+
+ Provider Details +
+
+ Provider-specific recovery metadata, verification state, and repository health. +
+
+ + {(badge) => ( + + {badge().label} + + )} +
diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 212085099..2bf277b0c 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -8,6 +8,7 @@ let mockLocationPath = '/recovery'; const navigateSpy = vi.hoisted(() => vi.fn()); const apiFetchMock = vi.hoisted(() => vi.fn()); +const wsState = vi.hoisted(() => ({ resources: [] as any[] })); vi.mock('@solidjs/router', async () => { const actual = await vi.importActual('@solidjs/router'); @@ -73,6 +74,12 @@ vi.mock('@/utils/apiClient', () => ({ apiFetchJSON: apiFetchMock, })); +vi.mock('@/App', () => ({ + useWebSocket: () => ({ + state: wsState, + }), +})); + vi.mock('@/hooks/useUnifiedResources', () => ({ useStorageRecoveryResources: () => ({ resources: () => [{ id: 'vm-123', name: 'VM 123' }], @@ -90,6 +97,34 @@ describe('Recovery', () => { apiFetchMock.mockClear(); mockLocationSearch = ''; mockLocationPath = '/recovery'; + wsState.resources = [ + { + id: 'pbs-resource-1', + type: 'pbs', + name: 'pbs-main', + displayName: 'pbs-main', + platformId: 'pbs-main', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: Date.parse('2026-03-10T10:00:00Z'), + platformData: { + pbs: { + instanceId: 'pbs-main', + datastores: [ + { + name: 'fast-store', + used: 500, + total: 1000, + usage: 50, + status: 'ok', + deduplicationFactor: 2.25, + }, + ], + }, + }, + }, + ]; facetsPayload = { clusters: [], @@ -238,6 +273,12 @@ describe('Recovery', () => { } }); + it('surfaces platform coverage in the unified recovery summary', async () => { + render(() => ); + + expect(await screen.findByText('2 platforms')).toBeInTheDocument(); + }); + it('keeps recovery history width aligned with canonical column specs', async () => { render(() => );