diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a89cbb5c1..02866cafd 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -689,6 +689,13 @@ recovery filters such as the provider-neutral `itemType` selector must be derived, normalized, and fanned out to inventory, history, activity, facets, and series consumers from that shared state owner rather than being recreated as page-local toolbar state inside individual recovery sections. +That same shared recovery filter boundary also owns canonical recovery +item-type derivation through +`frontend-modern/src/utils/recoveryItemTypePresentation.ts`. Recovery shell +state, tables, summaries, and point-detail surfaces must resolve rollup and +point item types through the shared presenter helpers instead of repeating +`display.itemType` / `subjectType` / `subjectRef.type` fallback chains in +page-local consumers. That same shared recovery state owner now also keeps `platform` as the canonical route and transport filter name for operator-facing recovery links, while any accepted legacy `provider` aliases remain parser compatibility only. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7c72736e4..32fb898b0 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -185,6 +185,11 @@ That same runtime-helper contract should prefer `item` terminology in shared recovery presenters too. Helper exports that resolve labels or item-type badges should expose canonical item-facing names, while any retained `subject` aliases remain compatibility wrappers instead of the primary runtime boundary. +That same presenter boundary should also own canonical item-type derivation. +Recovery surfaces must resolve rollup and point item types through one shared +item-type helper instead of repeating `display.itemType` / `subjectType` / +`subjectRef.type` fallback chains across state, summary, details, and table +renderers. 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 diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 518f6ed5c..40235f96f 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -29,6 +29,7 @@ import { getRecoveryRollupItemLabel, } from '@/utils/recoveryRecordPresentation'; import { + getRecoveryRollupItemTypeKey, getRecoveryItemTypePresentation, normalizeRecoveryItemTypeQueryValue, } from '@/utils/recoveryItemTypePresentation'; @@ -118,9 +119,7 @@ const Recovery: Component = () => { .map((entry) => String(entry || '').trim()) .filter(Boolean); if (platform && !platforms.includes(platform)) return false; - const rollupItemType = normalizeRecoveryItemTypeQueryValue( - rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type, - ); + const rollupItemType = getRecoveryRollupItemTypeKey(rollup); if (itemType && rollupItemType !== itemType) return false; if (!query) return true; @@ -129,7 +128,7 @@ const Recovery: Component = () => { rollup.rollupId, rollup.subjectResourceId || '', label, - rollup.subjectRef?.type || '', + rollupItemType, rollup.subjectRef?.namespace || '', rollup.subjectRef?.name || '', platforms.join(' '), diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx index f062cf77e..df703959b 100644 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx @@ -5,7 +5,7 @@ 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 { getRecoveryItemTypeLabel } from '@/utils/recoveryItemTypePresentation'; +import { getRecoveryItemTypeLabel, getRecoveryPointItemTypeKey } from '@/utils/recoveryItemTypePresentation'; import { getRecoveryPointLocationEntries } from '@/utils/recoveryLocationPresentation'; import { getRecoveryPointKindLabel, @@ -156,9 +156,7 @@ export const RecoveryPointDetails: Component = (props const summaryPairs = createMemo(() => { const p = point(); const pairs: { k: string; v: string }[] = []; - const itemType = getRecoveryItemTypeLabel( - p.display?.itemType || p.display?.subjectType || p.subjectRef?.type, - ); + const itemType = getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(p)); pairs.push({ k: 'ID', v: p.id }); if (itemType && itemType !== 'Unknown') pairs.push({ k: 'Item Type', v: itemType }); diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx index be2028437..9380ada6e 100644 --- a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx @@ -25,6 +25,7 @@ import { } from '@/utils/recoveryEmptyStatePresentation'; import { getRecoveryItemTypePresentation, + getRecoveryRollupItemTypeKey, normalizeRecoveryItemTypeQueryValue, } from '@/utils/recoveryItemTypePresentation'; import { @@ -124,12 +125,8 @@ export const RecoveryProtectedInventorySection: Component< return multiplier * leftLabel.localeCompare(rightLabel); } case 'type': { - const leftType = getRecoveryItemTypePresentation( - left.display?.itemType || left.display?.subjectType || left.subjectRef?.type, - )?.label.toLowerCase(); - const rightType = getRecoveryItemTypePresentation( - right.display?.itemType || right.display?.subjectType || right.subjectRef?.type, - )?.label.toLowerCase(); + const leftType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(left))?.label.toLowerCase(); + const rightType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(right))?.label.toLowerCase(); return multiplier * (leftType || '').localeCompare(rightType || ''); } case 'platform': { @@ -376,11 +373,7 @@ export const RecoveryProtectedInventorySection: Component< getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)), ); const itemTypePresentation = - getRecoveryItemTypePresentation( - rollup.display?.itemType || - rollup.display?.subjectType || - rollup.subjectRef?.type, - ) || null; + getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null; const nowMs = Date.now(); const issueTone: RecoveryIssueTone = getRecoveryRollupIssueTone(rollup, nowMs); const issueRailClass = diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 1fabf9843..9eed2a43b 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -348,6 +348,31 @@ describe('Recovery', () => { } }); + it('derives canonical item types from shared fallback fields when itemType is absent', async () => { + const originalRollupDisplay = rollupsPayload[0].display; + const originalPointDisplay = pointsByRollupId['res:vm-123'][0].display; + rollupsPayload[0].display = { subjectType: 'proxmox-vm' }; + pointsByRollupId['res:vm-123'][0].display = { subjectType: 'proxmox-vm' }; + + try { + render(() => ); + + expect(await screen.findByText('VM 123')).toBeInTheDocument(); + const inventoryTable = (await screen.findAllByRole('table'))[0]; + expect(within(inventoryTable).getAllByText('VM').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByText('VM 123')); + await screen.findByText('Recovery Events'); + + const tables = await screen.findAllByRole('table'); + const historyTable = tables[tables.length - 1]; + expect(within(historyTable).getByText('VM')).toBeInTheDocument(); + } finally { + rollupsPayload[0].display = originalRollupDisplay; + pointsByRollupId['res:vm-123'][0].display = originalPointDisplay; + } + }); + it('keeps recovery history width aligned with canonical column specs', async () => { render(() => ); diff --git a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts index e4b325d29..0bf3f700d 100644 --- a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts +++ b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts @@ -19,6 +19,8 @@ import { normalizeRecoveryModeQueryValue, } from '@/utils/recoveryRecordPresentation'; import { + getRecoveryPointItemTypeKey, + getRecoveryRollupItemTypeKey, getRecoveryItemTypePresentation, normalizeRecoveryItemTypeQueryValue, } from '@/utils/recoveryItemTypePresentation'; @@ -352,16 +354,12 @@ export function useRecoverySurfaceState() { } for (const rollup of rollups()) { - const normalized = normalizeRecoveryItemTypeQueryValue( - rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type, - ); + const normalized = getRecoveryRollupItemTypeKey(rollup); if (normalized) values.add(normalized); } for (const point of recoveryPoints.points() || []) { - const normalized = normalizeRecoveryItemTypeQueryValue( - point.display?.itemType || point.display?.subjectType || point.subjectRef?.type, - ); + const normalized = getRecoveryPointItemTypeKey(point); if (normalized) values.add(normalized); } diff --git a/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts index da24b8214..419217d5a 100644 --- a/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + getRecoveryPointItemTypeKey, getRecoveryItemTypeBadgeClass, getRecoveryItemTypeLabel, getRecoveryItemTypePresentation, + getRecoveryRollupItemTypeKey, normalizeRecoveryItemTypeQueryValue, } from '@/utils/recoveryItemTypePresentation'; @@ -41,4 +43,22 @@ describe('recoveryItemTypePresentation', () => { expect(getRecoveryItemTypeLabel('custom-thing')).toBe('Custom Thing'); expect(getRecoveryItemTypeBadgeClass('custom-thing')).toBe('bg-surface-alt text-base-content'); }); + + it('derives canonical item type keys from recovery rollups and points', () => { + expect( + getRecoveryRollupItemTypeKey({ + display: { subjectType: 'proxmox-vm' }, + subjectRef: { type: 'proxmox-vm' }, + }), + ).toBe('vm'); + expect( + getRecoveryPointItemTypeKey({ + display: { itemType: 'dataset' }, + subjectRef: { type: 'truenas-dataset' }, + }), + ).toBe('dataset'); + expect(getRecoveryPointItemTypeKey({ display: {}, subjectRef: { type: 'custom-thing' } })).toBe( + 'custom-thing', + ); + }); }); diff --git a/frontend-modern/src/utils/recoveryItemTypePresentation.ts b/frontend-modern/src/utils/recoveryItemTypePresentation.ts index bab9fabe1..a1a3e1204 100644 --- a/frontend-modern/src/utils/recoveryItemTypePresentation.ts +++ b/frontend-modern/src/utils/recoveryItemTypePresentation.ts @@ -1,3 +1,4 @@ +import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery'; import { getResourceTypePresentation } from '@/utils/resourceTypePresentation'; import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation'; import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; @@ -10,6 +11,15 @@ export interface RecoveryItemTypePresentation { const DEFAULT_BADGE_CLASSES = 'bg-surface-alt text-base-content'; +const getRecoveryItemTypeValue = ( + value: + | Pick + | Pick + | null + | undefined, +): string => + String(value?.display?.itemType || value?.display?.subjectType || value?.subjectRef?.type || ''); + export const normalizeRecoveryItemTypeQueryValue = ( value: string | null | undefined, ): string => { @@ -128,3 +138,11 @@ export const getRecoveryItemTypeLabel = (value: string | null | undefined): stri export const getRecoveryItemTypeBadgeClass = (value: string | null | undefined): string => getRecoveryItemTypePresentation(value)?.badgeClasses || DEFAULT_BADGE_CLASSES; + +export const getRecoveryRollupItemTypeKey = ( + rollup: Pick | null | undefined, +): string => normalizeRecoveryItemTypeQueryValue(getRecoveryItemTypeValue(rollup)); + +export const getRecoveryPointItemTypeKey = ( + point: Pick | null | undefined, +): string => normalizeRecoveryItemTypeQueryValue(getRecoveryItemTypeValue(point)); diff --git a/frontend-modern/src/utils/recoverySummaryPresentation.ts b/frontend-modern/src/utils/recoverySummaryPresentation.ts index f6421293a..2c9fd4f62 100644 --- a/frontend-modern/src/utils/recoverySummaryPresentation.ts +++ b/frontend-modern/src/utils/recoverySummaryPresentation.ts @@ -13,6 +13,7 @@ import { } from '@/utils/sourcePlatforms'; import { getRecoveryItemTypePresentation, + getRecoveryRollupItemTypeKey, } from '@/utils/recoveryItemTypePresentation'; import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel'; @@ -431,11 +432,7 @@ export function buildRecoveryItemCoverage( for (const rollup of rollups) { const presentation = - getRecoverySummarySubjectTypePresentation( - String( - rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type || '', - ).trim(), - ) || { + getRecoverySummarySubjectTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || { key: 'unknown', label: 'Unknown', toneClass: 'bg-surface-alt text-base-content', diff --git a/frontend-modern/src/utils/recoveryTablePresentation.ts b/frontend-modern/src/utils/recoveryTablePresentation.ts index 2b90ba0e3..dbe77d385 100644 --- a/frontend-modern/src/utils/recoveryTablePresentation.ts +++ b/frontend-modern/src/utils/recoveryTablePresentation.ts @@ -2,6 +2,7 @@ import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/r import { getRecoveryItemTypeBadgeClass, getRecoveryItemTypeLabel, + getRecoveryPointItemTypeKey, } from '@/utils/recoveryItemTypePresentation'; import { getRecoveryLocationFacetLabel } from '@/utils/recoveryLocationPresentation'; import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation'; @@ -83,13 +84,11 @@ export function getRecoveryArtifactColumnLabel(id: string, fallback?: string): s } export function getRecoveryPointItemTypeBadgeClass(point: RecoveryPoint): string { - return getRecoveryItemTypeBadgeClass(point.display?.itemType || point.display?.subjectType || point.subjectRef?.type); + return getRecoveryItemTypeBadgeClass(getRecoveryPointItemTypeKey(point)); } export function getRecoveryPointItemTypeLabel(point: RecoveryPoint): string { - return getRecoveryItemTypeLabel( - point.display?.itemType || point.display?.subjectType || point.subjectRef?.type, - ); + return getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(point)); } export function getRecoverySubjectTypeBadgeClass(point: RecoveryPoint): string {