mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-12 06:19:11 +00:00
refactor(recovery): neutralize provider detail shell
This commit is contained in:
@@ -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 />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user