refactor(recovery): align operator recovery vocabulary

This commit is contained in:
rcourtman
2026-03-26 00:46:07 +00:00
parent 88d0f73ec0
commit e123c1b2fd
10 changed files with 44 additions and 12 deletions
@@ -153,6 +153,12 @@ Operator-facing filter and detail labels should likewise prefer `platform`
wording over implementation-facing `provider` wording, so the recovery surface
describes the monitored platform families Pulse covers rather than exposing
backend transport vocabulary as the primary UI model.
That same operator-facing vocabulary should also prefer `item` over backend
`subject` wording, and `platform` over generic `source` wording, across the
primary recovery headers, tables, focus chips, and detail metadata labels.
The data model can keep its internal subject/provider fields, but the page
frame that operators read should present one consistent protected-item and
platform model from summary through drill-in.
That same shared presentation layer also owns the distinction between
aggregate recovery-method language and single-record recovery-method language.
Timeline legends and daily breakdowns must use aggregate labels such as
@@ -29,6 +29,7 @@ import {
getRecoveryRollupSubjectLabel,
} from '@/utils/recoveryRecordPresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryGroupNoTimestampLabel,
getRecoveryArtifactTableMinWidth,
STALE_ISSUE_THRESHOLD_MS,
@@ -279,12 +280,12 @@ const Recovery: Component = () => {
const artifactColumns: ColumnDef[] = [
{ id: 'time', label: 'Time' },
createHiddenCanonicalTypeColumn(),
{ id: 'subject', label: 'Subject' },
{ id: 'subject', label: getRecoveryArtifactColumnLabel('subject', 'Subject') },
{ id: 'entityId', label: 'ID', toggleable: true },
{ id: 'cluster', label: 'Cluster', toggleable: true },
{ id: 'nodeAgent', label: 'Node/Agent', toggleable: true },
{ id: 'namespace', label: 'Namespace', toggleable: true },
{ id: 'source', label: 'Source' },
{ id: 'source', label: getRecoveryArtifactColumnLabel('source', 'Source') },
{ id: 'verified', label: 'Verified', toggleable: true },
{ id: 'size', label: 'Size', toggleable: true },
{ id: 'method', label: 'Method' },
@@ -537,7 +538,7 @@ const Recovery: Component = () => {
when={workspaceView() === 'inventory'}
fallback="Cross-platform recovery artifacts grouped by day and filtered through one shared recovery model."
>
Platform-neutral protection rollups across every connected recovery platform in the selected window.
Platform-neutral protection inventory across every connected recovery platform in the selected window.
</Show>
</div>
</Card>
@@ -113,7 +113,7 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
<div class="flex flex-wrap items-center gap-2">
<Show when={props.selectedHistorySubjectLabel()}>
<div class="inline-flex items-center gap-2 rounded-md border border-border bg-surface-alt px-2.5 py-1.5 text-xs">
<span class="font-semibold uppercase tracking-wide text-muted">Focused</span>
<span class="font-semibold uppercase tracking-wide text-muted">Focused Item</span>
<span class="max-w-[18rem] truncate font-medium text-base-content">
{props.selectedHistorySubjectLabel()}
</span>
@@ -165,9 +165,9 @@ export const RecoveryPointDetails: Component<RecoveryPointDetailsProps> = (props
if (p.verified != null) pairs.push({ k: 'Verified', v: p.verified ? 'Verified' : 'Not Verified' });
if (p.encrypted != null) pairs.push({ k: 'Encrypted', v: p.encrypted ? 'Encrypted' : 'Not Encrypted' });
if (p.subjectResourceId) pairs.push({ k: 'Subject Resource', v: p.subjectResourceId });
if (p.subjectResourceId) pairs.push({ k: 'Item Resource', v: p.subjectResourceId });
if (p.repositoryResourceId) pairs.push({ k: 'Repository Resource', v: p.repositoryResourceId });
if (p.subjectRef) pairs.push({ k: 'Subject Ref', v: labelForRef(p.subjectRef) });
if (p.subjectRef) pairs.push({ k: 'Item Ref', v: labelForRef(p.subjectRef) });
if (p.repositoryRef) pairs.push({ k: 'Repository Ref', v: labelForRef(p.repositoryRef) });
const commonDetailKeys = [
@@ -24,6 +24,7 @@ import {
getRecoveryProtectedItemsLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryRollupAgeTextClass,
getRecoveryRollupIssueTone,
getRecoveryProtectedSearchPlaceholder,
@@ -151,7 +152,7 @@ export const RecoveryProtectedInventorySection: Component<
Protected Items
</div>
<div class="text-xs text-muted">
Unified subject rollups across backups, snapshots, and replication providers.
Unified protection inventory across workloads, datasets, and other protected items from every connected platform.
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
@@ -283,8 +284,8 @@ export const RecoveryProtectedInventorySection: Component<
<TableRow class="bg-surface-alt text-muted border-b border-border">
{(
[
['subject', 'Subject'],
['source', 'Source'],
['subject', getRecoveryArtifactColumnLabel('subject', 'Subject')],
['source', getRecoveryArtifactColumnLabel('source', 'Source')],
['lastBackup', 'Latest Point'],
['outcome', 'Outcome'],
] as const
@@ -57,6 +57,8 @@ describe('RecoverySummary', () => {
expect(screen.getByText('Recent History')).toBeInTheDocument();
expect(screen.getByText('Attention Queue')).toBeInTheDocument();
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
expect(screen.getByText('Multi-platform')).toBeInTheDocument();
expect(screen.getByText('Primary Platform')).toBeInTheDocument();
expect(screen.getByText('2 protected')).toBeInTheDocument();
expect(screen.getByText('2 platforms')).toBeInTheDocument();
expect(screen.getByText('1 healthy')).toBeInTheDocument();
@@ -142,13 +142,13 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Multi-source</div>
<div class="text-[10px] uppercase tracking-wide text-muted">Multi-platform</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{platformCoverage().mixedCount}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Primary</div>
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Platform</div>
<div class="mt-1 text-xl font-semibold text-base-content">
{platformCoverage().primaryLabel ?? 'n/a'}
</div>
@@ -190,6 +190,9 @@ describe('Recovery', () => {
await waitFor(() => {
expect(screen.getAllByRole('table')).toHaveLength(1);
});
const inventoryTable = screen.getAllByRole('table')[0];
expect(within(inventoryTable).getByText('Item')).toBeInTheDocument();
expect(within(inventoryTable).getByText('Platform')).toBeInTheDocument();
expect(screen.queryByText('Backups By Date')).not.toBeInTheDocument();
fireEvent.click(await screen.findByText('VM 123'));
@@ -199,6 +202,9 @@ describe('Recovery', () => {
await waitFor(() => {
expect(screen.getAllByRole('table')).toHaveLength(1);
});
const historyTable = screen.getAllByRole('table')[0];
expect(within(historyTable).getByText('Item')).toBeInTheDocument();
expect(within(historyTable).getByText('Platform')).toBeInTheDocument();
});
it('persists the selected recovery workspace view in the route when explicitly changed', async () => {
@@ -277,6 +283,7 @@ describe('Recovery', () => {
render(() => <Recovery />);
expect(await screen.findByText('2 platforms')).toBeInTheDocument();
expect(screen.getByText('Multi-platform')).toBeInTheDocument();
});
it('keeps recovery history width aligned with canonical column specs', async () => {
@@ -304,7 +311,7 @@ describe('Recovery', () => {
});
});
expect(await screen.findByText('Focused')).toBeInTheDocument();
expect(await screen.findByText('Focused Item')).toBeInTheDocument();
expect(screen.getAllByText('VM 123').length).toBeGreaterThan(0);
await screen.findByText(/Showing 1 - 1 of 1 recovery points/i);
const tables = await screen.findAllByRole('table');
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery';
import {
getRecoveryArtifactColumnLabel,
getRecoveryArtifactColumnHeaderClass,
getRecoveryArtifactRowClass,
getRecoveryArtifactTableMinWidth,
@@ -16,6 +17,7 @@ import {
isRecoveryRollupStale,
RECOVERY_ADVANCED_FILTER_FIELD_CLASS,
RECOVERY_ADVANCED_FILTER_LABEL_CLASS,
RECOVERY_ARTIFACT_COLUMN_LABELS,
RECOVERY_GROUP_HEADER_ROW_CLASS,
RECOVERY_GROUP_HEADER_TEXT_CLASS,
RECOVERY_GROUP_NO_TIMESTAMP_LABEL,
@@ -38,6 +40,11 @@ describe('recoveryTablePresentation', () => {
expect(getRecoveryProtectedSearchPlaceholder()).toBe('Search protected items...');
expect(getRecoveryHistorySearchPlaceholder()).toBe('Search recovery history...');
expect(getRecoverySearchHistoryEmptyMessage()).toBe('Recent searches appear here.');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.subject).toBe('Item');
expect(RECOVERY_ARTIFACT_COLUMN_LABELS.source).toBe('Platform');
expect(getRecoveryArtifactColumnLabel('subject', 'Subject')).toBe('Item');
expect(getRecoveryArtifactColumnLabel('source', 'Source')).toBe('Platform');
expect(getRecoveryArtifactColumnLabel('outcome', 'Outcome')).toBe('Outcome');
});
it('derives time text classes from event recency', () => {
@@ -21,6 +21,10 @@ export const RECOVERY_GROUP_NO_TIMESTAMP_LABEL = 'No Timestamp';
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> = {
subject: 'Item',
source: 'Platform',
};
const RECOVERY_ARTIFACT_COLUMN_SPECS: Record<string, { headerClass: string; minWidthPx: number }> = {
time: { headerClass: 'w-[76px] text-right', minWidthPx: 76 },
@@ -67,6 +71,10 @@ export function getRecoverySearchHistoryEmptyMessage(): string {
return RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE;
}
export function getRecoveryArtifactColumnLabel(id: string, fallback?: string): string {
return RECOVERY_ARTIFACT_COLUMN_LABELS[id] || fallback || id;
}
const normalizeRecoverySubjectTypeKey = (value: string): string =>
value
.replace(/^k8s-/, '')