refactor(recovery): neutralize provider detail shell

This commit is contained in:
rcourtman
2026-03-26 00:33:15 +00:00
parent 8901a3f1d8
commit 06c7dcc9b2
4 changed files with 202 additions and 25 deletions
@@ -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`,
@@ -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(() => (
<RecoveryPointDetails
point={{
id: 'point-1',
provider: 'proxmox-pbs',
kind: 'backup',
mode: 'remote',
outcome: 'success',
startedAt: '2026-03-10T09:58:00Z',
completedAt: '2026-03-10T10:00:00Z',
verified: true,
immutable: true,
repositoryRef: {
type: 'pbs-datastore',
namespace: 'pbs-main',
name: 'fast-store',
},
details: {
comment: 'Nightly retention protected copy',
owner: 'root@pam',
files: ['vm/100/2026-03-10T10:00:00Z'],
verificationState: 'ok',
},
}}
/>
));
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(() => (
<RecoveryPointDetails
point={{
id: 'point-2',
provider: 'truenas',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'failed',
completedAt: '2026-03-10T10:00:00Z',
}}
/>
));
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();
});
});
@@ -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<RecoveryPointDetailsProps> = (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<RecoveryPointDetailsProps> = (props
},
);
const hasPbsDetails = createMemo(
const hasProviderDetails = createMemo(
() =>
isPbsProvider() &&
(pbsComment().length > 0 ||
@@ -141,7 +148,7 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (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<RecoveryPointDetailsProps> = (props
</div>
</div>
<Show when={hasPbsDetails()}>
<Show when={hasProviderDetails()}>
<div class="rounded border border-border bg-surface p-3">
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
PBS Details
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-[10px] font-semibold uppercase tracking-wide text-muted">
Provider Details
</div>
<div class="mt-1 text-xs text-muted">
Provider-specific recovery metadata, verification state, and repository health.
</div>
</div>
<Show when={providerBadge()}>
{(badge) => (
<span class={badge().classes} title={badge().title}>
{badge().label}
</span>
)}
</Show>
</div>
<div class="mt-2 space-y-2">
<Show when={pbsComment()}>
@@ -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<typeof import('@solidjs/router')>('@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(() => <Recovery />);
expect(await screen.findByText('2 platforms')).toBeInTheDocument();
});
it('keeps recovery history width aligned with canonical column specs', async () => {
render(() => <Recovery />);