refactor(recovery): neutralize placement vocabulary

This commit is contained in:
rcourtman 2026-03-26 09:03:27 +00:00
parent 2e49680bbc
commit a4e2f6f448
12 changed files with 186 additions and 16 deletions

View file

@ -360,6 +360,14 @@ when a recovery point includes canonical item-class metadata,
`RecoveryPointDetails.tsx` must surface it as `Item Type` in the summary grid
instead of jumping directly from item identity to platform and point-method
metadata.
That same shared presentation layer also owns recovery placement vocabulary.
Cluster, node, and namespace facets remain valid supporting filters for
Proxmox-heavy and Kubernetes-heavy operators, but the governed recovery
surface must present them through platform-neutral labels such as
`Cluster / Site`, `Host / Agent`, and `Namespace / Group` across advanced
filters, active chips, table headers, and point details so the page treats
placement as optional context inside a multi-platform recovery model rather
than a Proxmox-native spine.
The recovery table presentation helper now owns the canonical subject-type
label fallback for recovery rows and delegates its title-casing to the shared
`frontend-modern/src/utils/textPresentation.ts` helper rather than keeping a

View file

@ -22,6 +22,10 @@ import {
getRecoveryItemTypePresentation,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryLocationFacetAllLabel,
getRecoveryLocationFacetLabel,
} from '@/utils/recoveryLocationPresentation';
import { normalizeRecoveryModeQueryValue } from '@/utils/recoveryRecordPresentation';
import {
getRecoveryHistorySearchPlaceholder,
@ -178,7 +182,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
<div>
<div class={filterPanelTitleClass}>Filter results</div>
<div class={filterPanelDescriptionClass}>
Narrow by scope, method, verification, or location.
Narrow by scope, method, verification, or placement.
</div>
</div>
<Show when={props.activeAdvancedFilterCount() > 0}>
@ -261,7 +265,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
<Show when={props.showClusterFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Cluster</span>
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>
{getRecoveryLocationFacetLabel('cluster')}
</span>
<select
value={props.clusterFilter()}
onChange={(event) => {
@ -270,7 +276,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any cluster</option>
<option value="all">
{getRecoveryLocationFacetAllLabel('cluster')}
</option>
<For each={props.clusterOptions().filter((value) => value !== 'all')}>
{(cluster) => <option value={cluster}>{cluster}</option>}
</For>
@ -280,7 +288,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
<Show when={props.showNodeFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Node or agent</span>
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>
{getRecoveryLocationFacetLabel('node')}
</span>
<select
value={props.nodeFilter()}
onChange={(event) => {
@ -289,7 +299,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any node or agent</option>
<option value="all">
{getRecoveryLocationFacetAllLabel('node')}
</option>
<For each={props.nodeOptions().filter((value) => value !== 'all')}>
{(node) => <option value={node}>{node}</option>}
</For>
@ -299,7 +311,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
<Show when={props.showNamespaceFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Namespace</span>
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>
{getRecoveryLocationFacetLabel('namespace')}
</span>
<select
value={props.namespaceFilter()}
onChange={(event) => {
@ -308,7 +322,9 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any namespace</option>
<option value="all">
{getRecoveryLocationFacetAllLabel('namespace')}
</option>
<For each={props.namespaceOptions().filter((value) => value !== 'all')}>
{(namespace) => <option value={namespace}>{namespace}</option>}
</For>

View file

@ -59,6 +59,11 @@ describe('RecoveryPointDetails', () => {
completedAt: '2026-03-10T10:00:00Z',
verified: true,
immutable: true,
display: {
clusterLabel: 'Lab Cluster',
nodeHostLabel: 'pve-01',
namespaceLabel: 'Finance',
},
subjectRef: {
type: 'proxmox-vm',
name: '100',
@ -84,10 +89,16 @@ describe('RecoveryPointDetails', () => {
expect(screen.getByText('Repository Health')).toBeInTheDocument();
expect(screen.getByText('Verification')).toBeInTheDocument();
expect(screen.getByText('Item Type')).toBeInTheDocument();
expect(screen.getByText('Cluster / Site')).toBeInTheDocument();
expect(screen.getByText('Host / Agent')).toBeInTheDocument();
expect(screen.getByText('Namespace / Group')).toBeInTheDocument();
expect(screen.getByText('Point Type')).toBeInTheDocument();
expect(screen.getByText('Method')).toBeInTheDocument();
expect(screen.getByText('Outcome')).toBeInTheDocument();
expect(screen.getByText('VM')).toBeInTheDocument();
expect(screen.getByText('Lab Cluster')).toBeInTheDocument();
expect(screen.getByText('pve-01')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Backup')).toBeInTheDocument();
expect(screen.getByText('Remote Copy')).toBeInTheDocument();
expect(screen.getAllByText('Success').length).toBeGreaterThan(0);
@ -104,6 +115,9 @@ describe('RecoveryPointDetails', () => {
point={{
id: 'point-2',
provider: 'truenas',
display: {
nodeHostLabel: 'tn-scale-01',
},
subjectRef: {
type: 'truenas-dataset',
name: 'tank/apps',
@ -119,7 +133,9 @@ describe('RecoveryPointDetails', () => {
expect(screen.queryByText('Platform Details')).not.toBeInTheDocument();
expect(screen.queryByText('PBS Details')).not.toBeInTheDocument();
expect(screen.getByText('Item Type')).toBeInTheDocument();
expect(screen.getByText('Host / Agent')).toBeInTheDocument();
expect(screen.getByText('Dataset')).toBeInTheDocument();
expect(screen.getByText('tn-scale-01')).toBeInTheDocument();
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
const platformCard = screen.getByText('Platform').parentElement?.parentElement;

View file

@ -6,6 +6,7 @@ import type { PBSDatastore } from '@/types/api';
import type { RecoveryExternalRef, RecoveryPoint } from '@/types/recovery';
import { formatAbsoluteTime, formatBytes, formatUptime } from '@/utils/format';
import { getRecoveryItemTypeLabel } from '@/utils/recoveryItemTypePresentation';
import { getRecoveryPointLocationEntries } from '@/utils/recoveryLocationPresentation';
import {
getRecoveryPointKindLabel,
getRecoveryPointModeLabel,
@ -159,6 +160,9 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
pairs.push({ k: 'ID', v: p.id });
if (itemType && itemType !== 'Unknown') pairs.push({ k: 'Item Type', v: itemType });
pairs.push({ k: 'Platform', v: providerLabel() || 'n/a' });
for (const entry of getRecoveryPointLocationEntries(p)) {
pairs.push({ k: entry.label, v: entry.value });
}
pairs.push({ k: 'Point Type', v: getRecoveryPointKindLabel(p.kind) });
pairs.push({ k: 'Method', v: getRecoveryPointModeLabel(p.mode) });
pairs.push({ k: 'Outcome', v: getRecoveryPointOutcomeLabel(p.outcome) });

View file

@ -50,7 +50,16 @@ const pointsByRollupId: Record<string, any[]> = {
outcome: 'success',
completedAt: '2026-02-14T10:00:00.000Z',
sizeBytes: 1234,
display: { itemType: 'vm', subjectType: 'proxmox-vm' },
cluster: 'lab-cluster',
node: 'pve-01',
namespace: 'finance',
display: {
itemType: 'vm',
subjectType: 'proxmox-vm',
clusterLabel: 'Lab Cluster',
nodeHostLabel: 'pve-01',
namespaceLabel: 'Finance',
},
},
],
'ext:truenas-1': [
@ -337,6 +346,18 @@ describe('Recovery', () => {
expect(detailsPanel).not.toBeNull();
expect(within(detailsPanel as HTMLTableCellElement).getByText('Item Type')).toBeInTheDocument();
expect(within(detailsPanel as HTMLTableCellElement).getByText('VM')).toBeInTheDocument();
expect(
within(detailsPanel as HTMLTableCellElement).getByText('Cluster / Site'),
).toBeInTheDocument();
expect(
within(detailsPanel as HTMLTableCellElement).getByText('Host / Agent'),
).toBeInTheDocument();
expect(
within(detailsPanel as HTMLTableCellElement).getByText('Namespace / Group'),
).toBeInTheDocument();
expect(within(detailsPanel as HTMLTableCellElement).getByText('Lab Cluster')).toBeInTheDocument();
expect(within(detailsPanel as HTMLTableCellElement).getAllByText('pve-01').length).toBeGreaterThan(0);
expect(within(detailsPanel as HTMLTableCellElement).getByText('Finance')).toBeInTheDocument();
});
it('filters protected rollups by provider', async () => {
@ -523,7 +544,7 @@ describe('Recovery', () => {
fireEvent.click(await screen.findByRole('tab', { name: /recovery events/i }));
fireEvent.click(await screen.findByRole('button', { name: /^filter$/i }));
const clusterSelect = await screen.findByLabelText('Cluster');
const clusterSelect = await screen.findByLabelText('Cluster / Site');
fireEvent.change(clusterSelect, { target: { value: 'dev-cluster' } });
await waitFor(() => {
@ -602,10 +623,10 @@ describe('Recovery', () => {
fireEvent.click(await screen.findByRole('tab', { name: /recovery events/i }));
fireEvent.click(await screen.findByRole('button', { name: /^filter$/i }));
fireEvent.change(await screen.findByLabelText('Node or agent'), {
fireEvent.change(await screen.findByLabelText('Host / Agent'), {
target: { value: 'node-agent-1' },
});
fireEvent.change(await screen.findByLabelText('Namespace'), {
fireEvent.change(await screen.findByLabelText('Namespace / Group'), {
target: { value: 'tenant-a' },
});
@ -696,7 +717,7 @@ describe('Recovery', () => {
await screen.findByText(/Showing 1 - 1 of 1 recovery points/i);
fireEvent.click(screen.getByRole('button', { name: /^filter$/i }));
fireEvent.change(await screen.findByLabelText('Cluster'), {
fireEvent.change(await screen.findByLabelText('Cluster / Site'), {
target: { value: 'dev-cluster' },
});

View file

@ -25,6 +25,7 @@ export interface RecoveryPointDisplay {
itemType?: string;
isWorkload?: boolean;
clusterLabel?: string;
nodeHostLabel?: string;
nodeAgentLabel?: string;
namespaceLabel?: string;
entityIdLabel?: string;

View file

@ -15,7 +15,7 @@ describe('getRecoveryFilterChipPresentation', () => {
expect(getRecoveryFilterChipPresentation('namespace')).toMatchObject({
clearButtonClass:
'rounded px-1 py-0.5 text-[10px] hover:bg-violet-100 dark:hover:bg-violet-900',
label: 'Namespace',
label: 'Namespace / Group',
});
expect(getRecoveryFilterChipPresentation('namespace').className).toContain('border-violet-200');
});

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
getRecoveryLocationFacetAllLabel,
getRecoveryLocationFacetLabel,
getRecoveryPointLocationEntries,
} from '@/utils/recoveryLocationPresentation';
describe('recoveryLocationPresentation', () => {
it('returns platform-neutral placement labels for recovery facets', () => {
expect(getRecoveryLocationFacetLabel('cluster')).toBe('Cluster / Site');
expect(getRecoveryLocationFacetLabel('node')).toBe('Host / Agent');
expect(getRecoveryLocationFacetLabel('namespace')).toBe('Namespace / Group');
expect(getRecoveryLocationFacetAllLabel('cluster')).toBe('Any cluster or site');
expect(getRecoveryLocationFacetAllLabel('node')).toBe('Any host or agent');
expect(getRecoveryLocationFacetAllLabel('namespace')).toBe('Any namespace or group');
});
it('builds recovery point placement entries from the canonical display contract', () => {
expect(
getRecoveryPointLocationEntries({
id: 'p1',
provider: 'proxmox-pve',
kind: 'backup',
mode: 'local',
outcome: 'success',
cluster: 'cluster-a',
node: 'node-a',
namespace: 'tenant-a',
display: {
clusterLabel: 'Lab Cluster',
nodeHostLabel: 'pve-01',
namespaceLabel: 'Tenant A',
},
}),
).toEqual([
{ key: 'cluster', label: 'Cluster / Site', value: 'Lab Cluster' },
{ key: 'node', label: 'Host / Agent', value: 'pve-01' },
{ key: 'namespace', label: 'Namespace / Group', value: 'Tenant A' },
]);
});
});

View file

@ -43,9 +43,15 @@ describe('recoveryTablePresentation', () => {
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.type).toBe('Item Type');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.subject).toBe('Item');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.source).toBe('Platform');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.cluster).toBe('Cluster / Site');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.nodeAgent).toBe('Host / Agent');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.namespace).toBe('Namespace / Group');
expect(getRecoveryArtifactColumnLabel('type', 'Type')).toBe('Item Type');
expect(getRecoveryArtifactColumnLabel('subject', 'Subject')).toBe('Item');
expect(getRecoveryArtifactColumnLabel('source', 'Source')).toBe('Platform');
expect(getRecoveryArtifactColumnLabel('cluster', 'Cluster')).toBe('Cluster / Site');
expect(getRecoveryArtifactColumnLabel('nodeAgent', 'Node')).toBe('Host / Agent');
expect(getRecoveryArtifactColumnLabel('namespace', 'Namespace')).toBe('Namespace / Group');
expect(getRecoveryArtifactColumnLabel('outcome', 'Outcome')).toBe('Outcome');
});

View file

@ -1,3 +1,5 @@
import { getRecoveryLocationFacetLabel } from '@/utils/recoveryLocationPresentation';
export type RecoveryFilterChipKind = 'day' | 'cluster' | 'item-type' | 'node' | 'namespace';
type RecoveryFilterChipPresentation = {
@ -13,7 +15,7 @@ const CHIP_PRESENTATION: Record<RecoveryFilterChipKind, RecoveryFilterChipPresen
cluster: {
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-cyan-100 dark:hover:bg-cyan-900`,
className: `${CHIP_BASE_CLASS} border-cyan-200 bg-cyan-50 text-cyan-700 dark:border-cyan-700 dark:bg-cyan-900 dark:text-cyan-200`,
label: 'Cluster',
label: getRecoveryLocationFacetLabel('cluster'),
},
day: {
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-blue-100 dark:hover:bg-blue-900`,
@ -28,12 +30,12 @@ const CHIP_PRESENTATION: Record<RecoveryFilterChipKind, RecoveryFilterChipPresen
namespace: {
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-violet-100 dark:hover:bg-violet-900`,
className: `${CHIP_BASE_CLASS} border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700 dark:bg-violet-900 dark:text-violet-200`,
label: 'Namespace',
label: getRecoveryLocationFacetLabel('namespace'),
},
node: {
clearButtonClass: `${CLEAR_BUTTON_BASE_CLASS} hover:bg-emerald-100 dark:hover:bg-emerald-900`,
className: `${CHIP_BASE_CLASS} border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-900 dark:text-emerald-200`,
label: 'Node/Agent',
label: getRecoveryLocationFacetLabel('node'),
},
};

View file

@ -0,0 +1,50 @@
import type { RecoveryPoint } from '@/types/recovery';
export type RecoveryLocationFacetKind = 'cluster' | 'node' | 'namespace';
interface RecoveryLocationFacetPresentation {
allLabel: string;
label: string;
}
const RECOVERY_LOCATION_FACET_PRESENTATION: Record<
RecoveryLocationFacetKind,
RecoveryLocationFacetPresentation
> = {
cluster: {
allLabel: 'Any cluster or site',
label: 'Cluster / Site',
},
node: {
allLabel: 'Any host or agent',
label: 'Host / Agent',
},
namespace: {
allLabel: 'Any namespace or group',
label: 'Namespace / Group',
},
};
export function getRecoveryLocationFacetLabel(kind: RecoveryLocationFacetKind): string {
return RECOVERY_LOCATION_FACET_PRESENTATION[kind].label;
}
export function getRecoveryLocationFacetAllLabel(kind: RecoveryLocationFacetKind): string {
return RECOVERY_LOCATION_FACET_PRESENTATION[kind].allLabel;
}
export function getRecoveryPointLocationEntries(
point: RecoveryPoint,
): Array<{ key: RecoveryLocationFacetKind; label: string; value: string }> {
const cluster = String(point.display?.clusterLabel || point.cluster || '').trim();
const node = String(
point.display?.nodeHostLabel || point.display?.nodeAgentLabel || point.node || '',
).trim();
const namespace = String(point.display?.namespaceLabel || point.namespace || '').trim();
return [
{ key: 'cluster', label: getRecoveryLocationFacetLabel('cluster'), value: cluster },
{ key: 'node', label: getRecoveryLocationFacetLabel('node'), value: node },
{ key: 'namespace', label: getRecoveryLocationFacetLabel('namespace'), value: namespace },
].filter((entry) => entry.value !== '');
}

View file

@ -3,6 +3,7 @@ import {
getRecoveryItemTypeBadgeClass,
getRecoveryItemTypeLabel,
} from '@/utils/recoveryItemTypePresentation';
import { getRecoveryLocationFacetLabel } from '@/utils/recoveryLocationPresentation';
import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation';
import type { RecoveryIssueTone } from '@/utils/recoveryIssuePresentation';
@ -20,6 +21,9 @@ export const RECOVERY_PROTECTED_SEARCH_PLACEHOLDER = 'Search protected items...'
export const RECOVERY_HISTORY_SEARCH_PLACEHOLDER = 'Search recovery history...';
export const RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE = 'Recent searches appear here.';
export const RECOVERY_ARTIFACT_COLUMN_LABELS: Record<string, string> = {
cluster: getRecoveryLocationFacetLabel('cluster'),
nodeAgent: getRecoveryLocationFacetLabel('node'),
namespace: getRecoveryLocationFacetLabel('namespace'),
type: 'Item Type',
subject: 'Item',
source: 'Platform',