diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 572e6779d..0dfd7ff7e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -690,6 +690,14 @@ Shared `internal/api/` recovery transport helpers now also preserve normalized filter coherence across rollup, point-history, series, and facet views so agent-adjacent protected-resource drill-downs do not fork between protected items and history slices under the same active recovery filter set. +That same shared `internal/api/` recovery boundary must also preserve the +canonical provider-neutral `itemType` filter and display contract. When +agent-adjacent recovery data originates from Proxmox, Kubernetes, TrueNAS, or +other platform-native subjects, the shared transport layer must normalize +those source-specific labels onto the governed recovery item vocabulary before +the UI route/filter state sees them, so lifecycle-adjacent drill-downs remain +coherent across platforms instead of reintroducing Proxmox-native subject +types as the de facto recovery model. The updater/runtime surfaces must preserve the one-shot `updated_from` continuity handoff and the non-TLS continuity path for supported self-hosted diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fedfa60bd..326c05d6c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -71,6 +71,7 @@ Own canonical runtime payload shapes between backend and frontend. 48. `internal/cloudcp/portal/frontend/src/styles.css` 49. `internal/cloudcp/portal/frontend/tsconfig.json` 50. `internal/cloudcp/portal/frontend_sync_test.go` +51. `internal/api/recovery_handlers.go` ## Shared Boundaries @@ -196,6 +197,7 @@ Own canonical runtime payload shapes between backend and frontend. 10. Treat Patrol recency as a singular transport-driven fact: once header metadata, verification copy, or the findings footer already present the governed Patrol timing context, frontend summary consumers must not derive an extra timing pill from the same payloads inside the primary summary card 11. Treat Patrol findings counts as a singular supporting surface as well: when the summary shell already exposes count cards for active findings, warnings, criticals, and fixes, the primary assessment card must not repeat those same payload-derived counts as secondary badges 12. Treat Patrol schedule and recency as header-owned metadata on the main Patrol page: findings empty-state consumers should not receive or restate `next_patrol_at`, `last_patrol_at`, or interval timing once those transport fields are already presented by the primary header and verification shell +13. Keep recovery payload filters canonical across `/api/recovery/rollups`, `/api/recovery/points`, `/api/recovery/series`, and `/api/recovery/facets`: when `internal/api/recovery_handlers.go` adds a governed recovery filter or display field such as provider-neutral `itemType`, the same normalized transport must land across all four endpoints and the contract tests must pin both outbound payload shape and accepted query aliases in the same slice ## Current State @@ -1341,6 +1343,18 @@ filter contract as `/api/recovery/points`, `/api/recovery/series`, and verification, and free-text query filters must remain coherent across all four recovery endpoints so the recovery UI cannot render mismatched protected-item and history views for the same active filter set. +That same recovery API contract now also includes canonical provider-neutral +`itemType` transport. `internal/api/recovery_handlers.go` must normalize +provider-native aliases such as `proxmox-vm` onto the shared recovery item +type vocabulary before filters reach rollups, points, series, or facets, and +those same handlers must preserve that normalized shape back out through +`display.itemType` and facet option payloads instead of forcing frontend +surfaces to re-derive cross-platform recovery categories from raw +`subjectType`. +`internal/api/contract_test.go` is the canonical proof owner for that +boundary, so route and query compatibility like `itemType` and accepted alias +inputs such as `type` must be pinned there whenever the shared recovery +transport shape changes. The same rule now also covers optional nested node cluster endpoint collections so `frontend-modern/src/api/nodes.ts` does not own its own `Array.isArray(node.clusterEndpoints)` response-shape branch. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3c4b3d5ed..cc2c50315 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -660,6 +660,13 @@ protected-items versus recovery-events workspace switch. The recovery lane may own the active view and route-state semantics, but the top-level tab framing must stay on the canonical shared subtabs control instead of reviving a recovery-local switcher pattern. +That same recovery shell boundary now also owns one canonical top-level filter +controller in +`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`. Route-backed +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. `frontend-modern/src/utils/problemResourcePresentation.ts` now also belongs to that same dashboard overview boundary so the problem-resource severity contract stays shared with `ProblemResourcesTable.tsx` instead of floating as an diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7559561b6..8a4410780 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -88,8 +88,8 @@ querying, and the operator-facing storage health presentation layer. 6. Letting whitespace-padded storage route params hydrate non-canonical page state; shared storage URLs must trim and normalize `tab`, `source`, `status`, `node`, `group`, `sort`, `order`, `query`, and deep-link `resource` before the page model consumes them so pasted or hand-edited links resolve to the same canonical state as UI-authored routes without dropping adjacent unmanaged params 7. Letting storage `source` aliases or case drift survive in canonical route state; shared storage URLs must rewrite pasted values like `PVE`, `pbs`, or `ALL` to the owned source option values (for example `proxmox-pve`) or the canonical unset state so copied links match the same source filter values the storage toolbar presents 8. Letting explicit storage `all` sentinels survive in canonical route state; shared storage URLs must collapse case- or whitespace-variant `all` values for the managed `node` filter back to the canonical unset state so copied links do not preserve a fake active node filter -9. Letting whitespace-padded recovery timeline params fall off canonical route state; shared recovery URLs must trim and normalize `day`, `range`, `scope`, `status`, `verification`, `cluster`, `node`, `namespace`, and adjacent history filters before the page model validates them so pasted or hand-edited links resolve to the same canonical timeline and filter state as UI-authored routes -10. Letting explicit recovery `all` sentinels survive in canonical route state; shared recovery URLs must collapse case- or whitespace-variant `all` values for `cluster`, `node`, and `namespace` back to the canonical unset route state so copied links do not preserve fake active filters +9. Letting whitespace-padded recovery timeline params fall off canonical route state; shared recovery URLs must trim and normalize `day`, `range`, `scope`, `status`, `verification`, `cluster`, `node`, `namespace`, `itemType`, and adjacent history filters before the page model validates them so pasted or hand-edited links resolve to the same canonical timeline and filter state as UI-authored routes +10. Letting explicit recovery `all` sentinels survive in canonical route state; shared recovery URLs must collapse case- or whitespace-variant `all` values for `cluster`, `node`, `namespace`, and `itemType` back to the canonical unset route state so copied links do not preserve fake active filters 11. Letting non-canonical recovery provider values survive in route or transport state; shared recovery URLs must collapse unsupported or fake `provider` values back to the canonical unset state, and only owned source-platform provider options or canonical aliases may reach rollups, points, series, and facets transport filters 12. Letting protected-item recovery outcome filtering fork from the canonical history status filter; the protected inventory status control must drive the same route-backed `status` field and the same rollups, points, series, and facets transport filters as the history surface instead of keeping a protected-only local outcome branch 13. Letting visible protected-item filters fall out of shared recovery links; the protected `Stale only` toggle must restore from the canonical recovery URL and rewrite to one owned `stale=1` route form instead of disappearing on refresh or copy/paste @@ -448,10 +448,13 @@ coverage only through pages or higher-level recovery components. Those recovery transport surfaces now also share one normalized filter contract: protected-item rollups, point history, facets, and chart series must -all honor the same provider, cluster, node, namespace, workload-scope, -verification, and route-backed free-text `q` filter so the protected-items -list cannot drift from the timeline and facet state under the same active -recovery view. +all honor the same provider, canonical `itemType`, cluster, node, namespace, +workload-scope, verification, and route-backed free-text `q` filter so the +protected-items list cannot drift from the timeline and facet state under the +same active recovery view. That same recovery filter contract now depends on +the canonical recovery index carrying a normalized `itemType` instead of +forcing each UI surface to re-derive protected item classes from raw +provider-native `subjectType` values. That same recovery product surface keeps the activity timeline available even when point-history loading fails: `frontend-modern/src/components/Recovery/Recovery.tsx` must continue to render `RecoveryActivitySection` and the point-history error diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8b0f82a34..4f38eb1a0 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -951,6 +951,13 @@ boolean filter encoding for protected-inventory drill-down state. Visible recovery toggles such as `stale` must round-trip through the owned `stale=1` query form instead of leaking ad hoc truthy strings or disappearing from shared links on reload. +That same route contract now also owns the canonical recovery `itemType` +query. `/recovery` links must round-trip a provider-neutral item category such +as `vm`, `dataset`, or `pvc`, and +`frontend-modern/src/routing/resourceLinks.ts` may canonicalize provider-native +aliases like `proxmox-vm` into that shared vocabulary during parse/build, but +recovery route state must not drift back to raw platform-specific +`subjectType` values in shared navigation. Shared API consumers now also depend on a single registry-list snapshot per request when deriving canonical type aggregations for resource list and stats responses. Re-reading `registry.List()` for the same `/api/resources` request diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 4624ee3cf..da8d0f027 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -28,6 +28,10 @@ import { getRecoveryPointTimestampMs, getRecoveryRollupSubjectLabel, } from '@/utils/recoveryRecordPresentation'; +import { + getRecoveryItemTypePresentation, + normalizeRecoveryItemTypeQueryValue, +} from '@/utils/recoveryItemTypePresentation'; import { getRecoveryArtifactColumnLabel, getRecoveryGroupNoTimestampLabel, @@ -53,6 +57,8 @@ const Recovery: Component = () => { currentPage, facets, historyOutcomeFilter, + itemTypeFilter, + itemTypeOptions, modeFilter, namespaceFilter, namespaceOptions, @@ -74,6 +80,7 @@ const Recovery: Component = () => { setClusterFilter, setCurrentPage, setHistoryOutcomeFilter, + setItemTypeFilter, setModeFilter, setNamespaceFilter, setNodeFilter, @@ -98,6 +105,7 @@ const Recovery: Component = () => { const baseRollups = createMemo(() => { const query = queryFilter().trim().toLowerCase(); const provider = providerFilter() === 'all' ? '' : providerFilter(); + const itemType = itemTypeFilter() === 'all' ? '' : itemTypeFilter(); const resourceIndex = resourcesById(); const result = rollups().filter((rollup) => { @@ -105,6 +113,10 @@ const Recovery: Component = () => { .map((entry) => String(entry || '').trim()) .filter(Boolean); if (provider && !providers.includes(provider)) return false; + const rollupItemType = normalizeRecoveryItemTypeQueryValue( + rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type, + ); + if (itemType && rollupItemType !== itemType) return false; if (!query) return true; const label = getRecoveryRollupSubjectLabel(rollup, resourceIndex); @@ -381,6 +393,10 @@ const Recovery: Component = () => { const activeNamespaceLabel = createMemo(() => namespaceFilter() === 'all' ? '' : namespaceFilter(), ); + const activeItemTypeLabel = createMemo(() => { + if (itemTypeFilter() === 'all') return ''; + return getRecoveryItemTypePresentation(itemTypeFilter())?.label || itemTypeFilter(); + }); const summaryRange = createMemo(() => { const range = chartRangeDays(); if (range === 7) return '7d'; @@ -393,6 +409,7 @@ const Recovery: Component = () => { () => queryFilter().trim() !== '' || providerFilter() !== 'all' || + itemTypeFilter() !== 'all' || clusterFilter() !== 'all' || modeFilter() !== 'all' || historyOutcomeFilter() !== 'all' || @@ -428,6 +445,7 @@ const Recovery: Component = () => { const resetAllArtifactFilters = () => { setQueryFilter(''); setProviderFilter('all'); + setItemTypeFilter('all'); setClusterFilter('all'); setModeFilter('all'); setHistoryOutcomeFilter('all'); @@ -464,6 +482,7 @@ const Recovery: Component = () => { { setCurrentPage(1); }} clearFocusedRollup={() => setRollupId('')} + clearItemTypeFilter={() => { + setItemTypeFilter('all'); + setCurrentPage(1); + }} clearNamespaceFilter={() => { setNamespaceFilter('all'); setCurrentPage(1); @@ -553,6 +576,8 @@ const Recovery: Component = () => { error={() => recoveryRollups.rollups.error} onSelectRollup={handleSelectRollup} protectedStaleOnly={protectedStaleOnly} + itemTypeFilter={itemTypeFilter} + itemTypeOptions={itemTypeOptions} providerFilter={providerFilter} providerOptions={providerOptions} queryFilter={queryFilter} @@ -560,6 +585,7 @@ const Recovery: Component = () => { rollups={rollups} rollupsSummary={rollupsSummary} setHistoryOutcomeFilter={setHistoryOutcomeFilter} + setItemTypeFilter={setItemTypeFilter} setProtectedStaleOnly={setProtectedStaleOnly} setProviderFilter={setProviderFilter} setQueryFilter={setQueryFilter} @@ -593,6 +619,8 @@ const Recovery: Component = () => { kioskMode={kioskMode()} mobileVisibleArtifactColumns={mobileVisibleArtifactColumns} modeFilter={modeFilter} + itemTypeFilter={itemTypeFilter} + itemTypeOptions={itemTypeOptions} namespaceFilter={namespaceFilter} namespaceOptions={namespaceOptions} nodeFilter={nodeFilter} @@ -608,6 +636,7 @@ const Recovery: Component = () => { setClusterFilter={setClusterFilter} setCurrentPage={setCurrentPage} setHistoryOutcomeFilter={setHistoryOutcomeFilter} + setItemTypeFilter={setItemTypeFilter} setModeFilter={setModeFilter} setNamespaceFilter={setNamespaceFilter} setNodeFilter={setNodeFilter} diff --git a/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx b/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx index 2d4c8a3ee..d25e237f4 100644 --- a/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx @@ -56,11 +56,13 @@ interface TimelineModel { interface RecoveryActivitySectionProps { activitySummary: Accessor; activeClusterLabel: Accessor; + activeItemTypeLabel: Accessor; activeNamespaceLabel: Accessor; activeNodeLabel: Accessor; chartRangeDays: Accessor<7 | 30 | 90 | 365>; clearClusterFilter: () => void; clearFocusedRollup: () => void; + clearItemTypeFilter: () => void; clearNamespaceFilter: () => void; clearNodeFilter: () => void; clearSelectedDate: () => void; @@ -142,6 +144,7 @@ export const RecoveryActivitySection: Component = = ); })()} + + {(() => { + const chip = getRecoveryFilterChipPresentation('item-type'); + return ( +
+ {chip.label} + + {props.activeItemTypeLabel()} + + +
+ ); + })()} +
{(() => { const chip = getRecoveryFilterChipPresentation('node'); diff --git a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx index 612396f61..e5ef217d9 100644 --- a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx @@ -18,6 +18,10 @@ import type { RecoveryOutcome } from '@/types/recovery'; import type { Resource } from '@/types/resource'; import { getRecoveryFilterPanelClearClass } from '@/utils/recoveryActionPresentation'; import { getRecoveryArtifactModePresentation, type RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation'; +import { + getRecoveryItemTypePresentation, + normalizeRecoveryItemTypeQueryValue, +} from '@/utils/recoveryItemTypePresentation'; import { normalizeRecoveryModeQueryValue } from '@/utils/recoveryRecordPresentation'; import { getRecoveryHistorySearchPlaceholder, @@ -54,6 +58,8 @@ interface RecoveryHistorySectionProps { groupedByDay: Accessor; hasActiveArtifactFilters: Accessor; historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; + itemTypeFilter: Accessor; + itemTypeOptions: Accessor; isMobile: boolean; kioskMode: boolean; mobileVisibleArtifactColumns: Accessor; @@ -73,6 +79,7 @@ interface RecoveryHistorySectionProps { setClusterFilter: (value: string) => void; setCurrentPage: (value: number) => void; setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void; + setItemTypeFilter: (value: string) => void; setModeFilter: (value: 'all' | ArtifactMode) => void; setNamespaceFilter: (value: string) => void; setNodeFilter: (value: string) => void; @@ -106,6 +113,7 @@ export const RecoveryHistorySection: Component = (p clusterFilter: props.clusterFilter, currentPage: props.currentPage, historyOutcomeFilter: props.historyOutcomeFilter, + itemTypeFilter: props.itemTypeFilter, modeFilter: props.modeFilter, namespaceFilter: props.namespaceFilter, nodeFilter: props.nodeFilter, @@ -322,6 +330,29 @@ export const RecoveryHistorySection: Component = (p showFilters={!props.isMobile || historyFiltersOpen()} toolbarClass="lg:flex-nowrap" > + { + props.setItemTypeFilter( + normalizeRecoveryItemTypeQueryValue(event.currentTarget.value) || 'all', + ); + props.setCurrentPage(1); + }} + selectClass="min-w-[10rem] max-w-[14rem]" + > + + {(itemType) => ( + + )} + + + ; historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; + itemTypeFilter: Accessor; + itemTypeOptions: Accessor; isMobile: boolean; kioskMode: boolean; onSelectRollup: (rollupId: string) => void; @@ -65,6 +71,7 @@ interface RecoveryProtectedInventorySectionProps { rollups: Accessor; rollupsSummary: Accessor; setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void; + setItemTypeFilter: (value: string) => void; setProtectedStaleOnly: (value: boolean | ((prev: boolean) => boolean)) => void; setProviderFilter: (value: string) => void; setQueryFilter: (value: string) => void; @@ -95,6 +102,7 @@ export const RecoveryProtectedInventorySection: Component< let count = 0; if (props.queryFilter().trim() !== '') count += 1; if (props.providerFilter() !== 'all') count += 1; + if (props.itemTypeFilter() !== 'all') count += 1; if (props.historyOutcomeFilter() !== 'all') count += 1; if (props.protectedStaleOnly()) count += 1; return count; @@ -197,6 +205,28 @@ export const RecoveryProtectedInventorySection: Component< showFilters={!props.isMobile || protectedFiltersOpen()} toolbarClass="lg:flex-nowrap" > + + props.setItemTypeFilter( + normalizeRecoveryItemTypeQueryValue(event.currentTarget.value) || 'all', + ) + } + selectClass="min-w-[10rem] max-w-[14rem]" + > + + {(itemType) => ( + + )} + + + { hasActiveArtifactFilters: () => false, hasFocusedRollup: () => false, historyOutcomeFilter: () => 'all', + itemTypeFilter: () => 'all', + itemTypeOptions: () => ['all'], isMobile: false, loading: () => false, modeFilter: () => 'all', @@ -105,6 +107,7 @@ describe('Recovery layout guards', () => { setClusterFilter: vi.fn(), setCurrentPage: vi.fn(), setHistoryOutcomeFilter: vi.fn(), + setItemTypeFilter: vi.fn(), setModeFilter: vi.fn(), setNamespaceFilter: vi.fn(), setNodeFilter: vi.fn(), diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index d66a9ef73..d438fd8a2 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -23,7 +23,7 @@ const rollupsPayload = [ { rollupId: 'res:vm-123', subjectResourceId: 'vm-123', - display: { subjectType: 'proxmox-vm' }, + display: { subjectType: 'proxmox-vm', itemType: 'vm' }, lastAttemptAt: '2026-02-14T10:00:00.000Z', lastSuccessAt: '2026-02-14T10:00:00.000Z', lastOutcome: 'success', @@ -32,6 +32,7 @@ const rollupsPayload = [ { rollupId: 'ext:truenas-1', subjectRef: { type: 'truenas-dataset', name: 'tank/apps', id: 'tank/apps' }, + display: { itemType: 'dataset' }, lastAttemptAt: '2026-02-13T09:00:00.000Z', lastSuccessAt: null, lastOutcome: 'failed', @@ -49,6 +50,7 @@ const pointsByRollupId: Record = { outcome: 'success', completedAt: '2026-02-14T10:00:00.000Z', sizeBytes: 1234, + display: { itemType: 'vm', subjectType: 'proxmox-vm' }, }, ], 'ext:truenas-1': [ @@ -60,6 +62,7 @@ const pointsByRollupId: Record = { outcome: 'failed', completedAt: '2026-02-13T09:00:00.000Z', sizeBytes: 0, + display: { itemType: 'dataset', subjectType: 'truenas-dataset' }, }, ], }; @@ -131,6 +134,7 @@ describe('Recovery', () => { clusters: [], nodesAgents: [], namespaces: [], + itemTypes: ['dataset', 'vm'], hasSize: true, hasVerification: false, hasEntityId: false, @@ -353,6 +357,37 @@ describe('Recovery', () => { }); }); + it('filters recovery transport by canonical item type', async () => { + render(() => ); + + expect(await screen.findByText('VM 123')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Item Type'), { target: { value: 'dataset' } }); + + await waitFor(() => { + expect(navigateSpy).toHaveBeenCalledWith('/recovery?itemType=dataset', { replace: true }); + expect(screen.queryByText('VM 123')).not.toBeInTheDocument(); + }); + expect(screen.getByText('tank/apps')).toBeInTheDocument(); + + await waitFor(() => { + const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); + const hasRollups = urls.some( + (url) => url.includes('/api/recovery/rollups') && url.includes('itemType=dataset'), + ); + const hasPoints = urls.some( + (url) => url.includes('/api/recovery/points') && url.includes('itemType=dataset'), + ); + const hasSeries = urls.some( + (url) => url.includes('/api/recovery/series') && url.includes('itemType=dataset'), + ); + const hasFacets = urls.some( + (url) => url.includes('/api/recovery/facets') && url.includes('itemType=dataset'), + ); + expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true); + }); + }); + it('uses one canonical free-text query across protected items and history transport', async () => { render(() => ); diff --git a/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx b/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx index 8b8fef34a..16d064ec8 100644 --- a/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx @@ -18,11 +18,13 @@ describe('RecoveryActivitySection', () => { ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })} activeClusterLabel={() => ''} + activeItemTypeLabel={() => ''} activeNamespaceLabel={() => ''} activeNodeLabel={() => ''} chartRangeDays={() => 30} clearClusterFilter={() => undefined} clearFocusedRollup={() => undefined} + clearItemTypeFilter={() => undefined} clearNamespaceFilter={() => undefined} clearNodeFilter={() => undefined} clearSelectedDate={() => undefined} diff --git a/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts b/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts index 37b253db9..9f5fa0b0e 100644 --- a/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts +++ b/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts @@ -11,6 +11,7 @@ interface UseRecoveryHistorySectionStateParams { clusterFilter: Accessor; currentPage: Accessor; historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; + itemTypeFilter: Accessor; modeFilter: Accessor<'all' | ArtifactMode>; namespaceFilter: Accessor; nodeFilter: Accessor; @@ -33,6 +34,7 @@ export function useRecoveryHistorySectionState( let count = 0; if (params.queryFilter().trim() !== '') count += 1; if (params.providerFilter() !== 'all') count += 1; + if (params.itemTypeFilter() !== 'all') count += 1; if (params.historyOutcomeFilter() !== 'all') count += 1; if (params.scopeFilter() !== 'all') count += 1; if (params.modeFilter() !== 'all') count += 1; @@ -46,6 +48,7 @@ export function useRecoveryHistorySectionState( createEffect(() => { params.currentPage(); params.providerFilter(); + params.itemTypeFilter(); params.historyOutcomeFilter(); params.scopeFilter(); params.modeFilter(); diff --git a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts index 9a6db5937..fa29675d1 100644 --- a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts +++ b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts @@ -17,6 +17,10 @@ import { buildSourcePlatformOptions } from '@/utils/sourcePlatformOptions'; import { normalizeRecoveryModeQueryValue, } from '@/utils/recoveryRecordPresentation'; +import { + getRecoveryItemTypePresentation, + normalizeRecoveryItemTypeQueryValue, +} from '@/utils/recoveryItemTypePresentation'; import { normalizeRecoveryOutcome as normalizeOutcome, } from '@/utils/recoveryOutcomePresentation'; @@ -60,6 +64,11 @@ const normalizeRecoveryProviderSelection = (value: string | null | undefined): s return normalizeSourcePlatformKey(normalized) || 'all'; }; +const normalizeRecoveryItemTypeSelection = (value: string | null | undefined): string => { + const normalized = normalizeRecoveryItemTypeQueryValue(value); + return normalized || 'all'; +}; + export function useRecoverySurfaceState() { const navigate = useNavigate(); const location = useLocation(); @@ -70,6 +79,7 @@ export function useRecoverySurfaceState() { const [workspaceView, setWorkspaceView] = createSignal('inventory'); const [queryFilter, setQueryFilter] = createSignal(''); const [providerFilter, setProviderFilter] = createSignal('all'); + const [itemTypeFilter, setItemTypeFilter] = createSignal('all'); const [clusterFilter, setClusterFilter] = createSignal('all'); const [modeFilter, setModeFilter] = createSignal<'all' | ArtifactMode>('all'); const [historyOutcomeFilter, setHistoryOutcomeFilter] = createSignal<'all' | RecoveryOutcome>( @@ -115,6 +125,7 @@ export function useRecoverySurfaceState() { return { rollupId: rid || null, provider: providerFilter() === 'all' ? null : providerFilter(), + itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(), mode: modeFilter() === 'all' ? null : modeFilter(), outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(), q: queryFilter().trim() || null, @@ -139,6 +150,7 @@ export function useRecoverySurfaceState() { limit: 200, rollupId: rid || null, provider: providerFilter() === 'all' ? null : providerFilter(), + itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(), cluster: clusterFilter() === 'all' ? null : clusterFilter(), mode: modeFilter() === 'all' ? null : modeFilter(), outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(), @@ -159,6 +171,7 @@ export function useRecoverySurfaceState() { return { rollupId: rid || null, provider: providerFilter() === 'all' ? null : providerFilter(), + itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(), cluster: clusterFilter() === 'all' ? null : clusterFilter(), mode: modeFilter() === 'all' ? null : modeFilter(), outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(), @@ -179,6 +192,7 @@ export function useRecoverySurfaceState() { return { rollupId: rid || null, provider: providerFilter() === 'all' ? null : providerFilter(), + itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(), cluster: clusterFilter() === 'all' ? null : clusterFilter(), mode: modeFilter() === 'all' ? null : modeFilter(), outcome: historyOutcomeFilter() === 'all' ? null : historyOutcomeFilter(), @@ -208,6 +222,7 @@ export function useRecoverySurfaceState() { const nextView = normalizeRecoveryWorkspaceViewValue(parsed.view); const nextQuery = normalizeRecoveryRouteValue(parsed.query); const nextProvider = normalizeRecoveryProviderSelection(parsed.provider || ''); + const nextItemType = normalizeRecoveryItemTypeSelection(parsed.itemType || ''); const nextStaleOnly = normalizeRecoveryBooleanFlag(parsed.stale); const normalizedRange = normalizeRecoveryRouteValue(parsed.range); const nextRange = isRecoveryRangeDays(normalizedRange) ? Number(normalizedRange) : 30; @@ -229,6 +244,7 @@ export function useRecoverySurfaceState() { if (resolvedView !== untrack(workspaceView)) setWorkspaceView(resolvedView); if (nextQuery !== untrack(queryFilter)) setQueryFilter(nextQuery); if (nextProvider !== untrack(providerFilter)) setProviderFilter(nextProvider); + if (nextItemType !== untrack(itemTypeFilter)) setItemTypeFilter(nextItemType); if (nextStaleOnly !== untrack(protectedStaleOnly)) setProtectedStaleOnly(nextStaleOnly); if (nextRange !== untrack(chartRangeDays)) setChartRangeDays(nextRange as 7 | 30 | 90 | 365); if (nextCluster !== untrack(clusterFilter)) setClusterFilter(nextCluster); @@ -265,6 +281,7 @@ export function useRecoverySurfaceState() { workspaceView(); queryFilter(); providerFilter(); + itemTypeFilter(); clusterFilter(); modeFilter(); historyOutcomeFilter(); @@ -289,6 +306,7 @@ export function useRecoverySurfaceState() { view: workspaceView() !== defaultView ? workspaceView() : null, query: queryFilter().trim() || null, provider: providerFilter() !== 'all' ? providerFilter() : null, + itemType: itemTypeFilter() !== 'all' ? itemTypeFilter() : null, stale: protectedStaleOnly() ? '1' : null, range: chartRangeDays() !== 30 ? String(chartRangeDays()) : null, cluster: clusterFilter() !== 'all' ? clusterFilter() : null, @@ -307,6 +325,8 @@ export function useRecoverySurfaceState() { } }); + const facets = createMemo(() => recoveryFacets.facets() || {}); + const providerOptions = createMemo(() => { const providers = new Set(); for (const rollup of rollups()) { @@ -322,7 +342,39 @@ export function useRecoverySurfaceState() { return ['all', ...buildSourcePlatformOptions(providers).map((option) => option.key)]; }); - const facets = createMemo(() => recoveryFacets.facets() || {}); + const itemTypeOptions = createMemo(() => { + const values = new Set(); + + for (const value of facets().itemTypes || []) { + const normalized = normalizeRecoveryItemTypeQueryValue(value); + if (normalized) values.add(normalized); + } + + for (const rollup of rollups()) { + const normalized = normalizeRecoveryItemTypeQueryValue( + rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type, + ); + if (normalized) values.add(normalized); + } + + for (const point of recoveryPoints.points() || []) { + const normalized = normalizeRecoveryItemTypeQueryValue( + point.display?.itemType || point.display?.subjectType || point.subjectRef?.type, + ); + if (normalized) values.add(normalized); + } + + const sorted = [...values].sort((left, right) => { + const leftLabel = getRecoveryItemTypePresentation(left)?.label || left; + const rightLabel = getRecoveryItemTypePresentation(right)?.label || right; + return leftLabel.localeCompare(rightLabel); + }); + + const selected = itemTypeFilter().trim(); + if (selected && selected !== 'all' && !sorted.includes(selected)) sorted.unshift(selected); + + return ['all', ...sorted]; + }); const clusterOptions = createMemo(() => { const values = (facets().clusters || []) @@ -376,6 +428,8 @@ export function useRecoverySurfaceState() { currentPage, facets, historyOutcomeFilter, + itemTypeFilter, + itemTypeOptions, modeFilter, namespaceFilter, namespaceOptions, @@ -398,6 +452,7 @@ export function useRecoverySurfaceState() { setClusterFilter, setCurrentPage, setHistoryOutcomeFilter, + setItemTypeFilter, setModeFilter, setNamespaceFilter, setNodeFilter, diff --git a/frontend-modern/src/hooks/useRecoveryPoints.ts b/frontend-modern/src/hooks/useRecoveryPoints.ts index 4169670ef..afaccecfd 100644 --- a/frontend-modern/src/hooks/useRecoveryPoints.ts +++ b/frontend-modern/src/hooks/useRecoveryPoints.ts @@ -17,6 +17,7 @@ export type RecoveryPointsQuery = { kind?: string | null; mode?: string | null; outcome?: string | null; + itemType?: string | null; subjectResourceId?: string | null; // Normalized filters (server-side) @@ -51,6 +52,7 @@ const normalizeQuery = (query: RecoveryPointsQuery | undefined): RecoveryPointsQ kind: norm(q.kind) || null, mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, + itemType: norm(q.itemType) || null, subjectResourceId: norm(q.subjectResourceId) || null, q: norm(q.q) || null, @@ -90,6 +92,7 @@ const buildURL = (query: RecoveryPointsQuery | undefined): string => { if (q.kind) params.set('kind', q.kind); if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); + if (q.itemType) params.set('itemType', q.itemType); if (q.subjectResourceId) params.set('subjectResourceId', q.subjectResourceId); if (q.q) params.set('q', q.q); diff --git a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts b/frontend-modern/src/hooks/useRecoveryPointsFacets.ts index 1d5b0dff7..c745c7d22 100644 --- a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts +++ b/frontend-modern/src/hooks/useRecoveryPointsFacets.ts @@ -11,6 +11,7 @@ export type RecoveryFacetsQuery = { kind?: string | null; mode?: string | null; outcome?: string | null; + itemType?: string | null; q?: string | null; cluster?: string | null; @@ -32,6 +33,7 @@ const normalizeQuery = (query: RecoveryFacetsQuery | undefined): RecoveryFacetsQ kind: norm(q.kind) || null, mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, + itemType: norm(q.itemType) || null, q: norm(q.q) || null, cluster: norm(q.cluster) || null, @@ -67,6 +69,7 @@ const buildURL = (query: RecoveryFacetsQuery | undefined): string => { if (q.kind) params.set('kind', q.kind); if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); + if (q.itemType) params.set('itemType', q.itemType); if (q.q) params.set('q', q.q); if (q.cluster) params.set('cluster', q.cluster); @@ -98,6 +101,7 @@ const normalizeFacetValues = (values: unknown): string[] => { const normalizeFacets = (facets: RawRecoveryPointsFacets): RecoveryPointsFacets => ({ ...facets, + itemTypes: normalizeFacetValues(facets.itemTypes), nodesAgents: normalizeFacetValues(facets.nodesAgents ?? facets.nodesHosts), }); diff --git a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts b/frontend-modern/src/hooks/useRecoveryPointsSeries.ts index e7dec8fd8..cefbaef9a 100644 --- a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts +++ b/frontend-modern/src/hooks/useRecoveryPointsSeries.ts @@ -11,6 +11,7 @@ export type RecoverySeriesQuery = { kind?: string | null; mode?: string | null; outcome?: string | null; + itemType?: string | null; q?: string | null; cluster?: string | null; @@ -38,6 +39,7 @@ const normalizeQuery = (query: RecoverySeriesQuery | undefined): RecoverySeriesQ kind: norm(q.kind) || null, mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, + itemType: norm(q.itemType) || null, q: norm(q.q) || null, cluster: norm(q.cluster) || null, @@ -75,6 +77,7 @@ const buildURL = (query: RecoverySeriesQuery | undefined): string => { if (q.kind) params.set('kind', q.kind); if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); + if (q.itemType) params.set('itemType', q.itemType); if (q.q) params.set('q', q.q); if (q.cluster) params.set('cluster', q.cluster); diff --git a/frontend-modern/src/hooks/useRecoveryRollups.ts b/frontend-modern/src/hooks/useRecoveryRollups.ts index 110d319d4..17b71b8ea 100644 --- a/frontend-modern/src/hooks/useRecoveryRollups.ts +++ b/frontend-modern/src/hooks/useRecoveryRollups.ts @@ -13,6 +13,7 @@ export type RecoveryRollupsQuery = { kind?: string | null; mode?: string | null; outcome?: string | null; + itemType?: string | null; subjectResourceId?: string | null; q?: string | null; cluster?: string | null; @@ -33,6 +34,7 @@ const normalizeQuery = (query: RecoveryRollupsQuery | undefined): RecoveryRollup kind: norm(q.kind) || null, mode: norm(q.mode) || null, outcome: norm(q.outcome) || null, + itemType: norm(q.itemType) || null, subjectResourceId: norm(q.subjectResourceId) || null, q: norm(q.q) || null, cluster: norm(q.cluster) || null, @@ -68,6 +70,7 @@ const buildURL = (page: number, limit: number, query: RecoveryRollupsQuery | und if (q.kind) params.set('kind', q.kind); if (q.mode) params.set('mode', q.mode); if (q.outcome) params.set('outcome', q.outcome); + if (q.itemType) params.set('itemType', q.itemType); if (q.subjectResourceId) params.set('subjectResourceId', q.subjectResourceId); if (q.q) params.set('q', q.q); if (q.cluster) params.set('cluster', q.cluster); diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 3754f229a..dad5585d1 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -216,6 +216,7 @@ describe('resource link routing contract', () => { day: '2026-02-13', namespace: 'tenant-a', mode: 'remote', + itemType: 'vm', status: 'failed', verification: 'verified', scope: 'workload', @@ -232,6 +233,7 @@ describe('resource link routing contract', () => { expect(url.searchParams.get('day')).toBe('2026-02-13'); expect(url.searchParams.get('namespace')).toBe('tenant-a'); expect(url.searchParams.get('mode')).toBe('remote'); + expect(url.searchParams.get('itemType')).toBe('vm'); expect(url.searchParams.get('scope')).toBe('workload'); expect(url.searchParams.get('status')).toBe('failed'); expect(url.searchParams.get('verification')).toBe('verified'); @@ -249,6 +251,7 @@ describe('resource link routing contract', () => { day: '2026-02-13', namespace: 'tenant-a', mode: 'remote', + itemType: 'vm', scope: 'workload', status: 'failed', verification: 'verified', @@ -264,6 +267,7 @@ describe('resource link routing contract', () => { expect(RECOVERY_QUERY_PARAMS.day).toBe('day'); expect(RECOVERY_QUERY_PARAMS.namespace).toBe('namespace'); expect(RECOVERY_QUERY_PARAMS.mode).toBe('mode'); + expect(RECOVERY_QUERY_PARAMS.itemType).toBe('itemType'); expect(RECOVERY_QUERY_PARAMS.scope).toBe('scope'); expect(RECOVERY_QUERY_PARAMS.verification).toBe('verification'); expect(RECOVERY_QUERY_PARAMS.query).toBe('q'); @@ -279,6 +283,9 @@ describe('resource link routing contract', () => { provider: 'proxmox-pve', mode: 'local', }); + expect(parseRecoveryLinkSearch('?itemType=proxmox-vm')).toMatchObject({ + itemType: 'vm', + }); }); it('canonicalizes stale-only recovery route flags to the owned query shape', () => { diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 2940311a5..0f969dc22 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -1,5 +1,6 @@ import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; import { normalizeStorageSourceKey } from '@/utils/storageSources'; +import { normalizeRecoveryItemTypeQueryValue } from '@/utils/recoveryItemTypePresentation'; import { canonicalizeWorkloadFilterType, resolveWorkloadType, @@ -54,6 +55,7 @@ export const RECOVERY_QUERY_PARAMS = { day: 'day', namespace: 'namespace', mode: 'mode', + itemType: 'itemType', scope: 'scope', status: 'status', verification: 'verification', @@ -118,6 +120,7 @@ type RecoveryLinkOptions = { day?: string | null; namespace?: string | null; mode?: string | null; + itemType?: string | null; scope?: string | null; status?: string | null; verification?: string | null; @@ -262,6 +265,7 @@ export const parseRecoveryLinkSearch = (search: string) => { day: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.day)), namespace: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.namespace)), mode: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.mode)), + itemType: normalizeRecoveryItemTypeQueryValue(params.get(RECOVERY_QUERY_PARAMS.itemType)), scope: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.scope)), status: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.status)), verification: normalizeQueryValue(params.get(RECOVERY_QUERY_PARAMS.verification)), @@ -281,6 +285,7 @@ export const buildRecoveryPath = (options: RecoveryLinkOptions = {}): string => const day = normalizeQueryValue(options.day); const namespace = normalizeQueryValue(options.namespace); const mode = normalizeQueryValue(options.mode); + const itemType = normalizeRecoveryItemTypeQueryValue(options.itemType); const scope = normalizeQueryValue(options.scope); const status = normalizeQueryValue(options.status); const verification = normalizeQueryValue(options.verification); @@ -296,6 +301,7 @@ export const buildRecoveryPath = (options: RecoveryLinkOptions = {}): string => if (day) params.set(RECOVERY_QUERY_PARAMS.day, day); if (namespace) params.set(RECOVERY_QUERY_PARAMS.namespace, namespace); if (mode) params.set(RECOVERY_QUERY_PARAMS.mode, mode); + if (itemType) params.set(RECOVERY_QUERY_PARAMS.itemType, itemType); if (scope) params.set(RECOVERY_QUERY_PARAMS.scope, scope); if (status) params.set(RECOVERY_QUERY_PARAMS.status, status); if (verification) params.set(RECOVERY_QUERY_PARAMS.verification, verification); diff --git a/frontend-modern/src/types/recovery.ts b/frontend-modern/src/types/recovery.ts index e72eb405e..e3cfa0430 100644 --- a/frontend-modern/src/types/recovery.ts +++ b/frontend-modern/src/types/recovery.ts @@ -22,6 +22,7 @@ export interface RecoveryExternalRef { export interface RecoveryPointDisplay { subjectLabel?: string; subjectType?: string; + itemType?: string; isWorkload?: boolean; clusterLabel?: string; nodeAgentLabel?: string; @@ -110,6 +111,7 @@ export interface RecoveryPointsFacets { clusters?: string[]; nodesAgents?: string[]; namespaces?: string[]; + itemTypes?: string[]; hasSize?: boolean; hasVerification?: boolean; hasEntityId?: boolean; diff --git a/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts new file mode 100644 index 000000000..da24b8214 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + getRecoveryItemTypeBadgeClass, + getRecoveryItemTypeLabel, + getRecoveryItemTypePresentation, + normalizeRecoveryItemTypeQueryValue, +} from '@/utils/recoveryItemTypePresentation'; + +describe('recoveryItemTypePresentation', () => { + it('normalizes canonical recovery item type query values', () => { + expect(normalizeRecoveryItemTypeQueryValue('proxmox-vm')).toBe('vm'); + expect(normalizeRecoveryItemTypeQueryValue('proxmox-vm-backup')).toBe('vm'); + expect(normalizeRecoveryItemTypeQueryValue('k8s-pvc')).toBe('pvc'); + expect(normalizeRecoveryItemTypeQueryValue('truenas-dataset')).toBe('dataset'); + expect(normalizeRecoveryItemTypeQueryValue('docker-container')).toBe('app-container'); + expect(normalizeRecoveryItemTypeQueryValue(' custom-thing ')).toBe('custom-thing'); + expect(normalizeRecoveryItemTypeQueryValue('all')).toBe(''); + }); + + it('returns canonical item type presentation for workload and storage subjects', () => { + expect(getRecoveryItemTypePresentation('vm')).toMatchObject({ + key: 'vm', + label: 'VM', + }); + expect(getRecoveryItemTypePresentation('system-container')).toMatchObject({ + key: 'system-container', + label: 'Container', + }); + expect(getRecoveryItemTypePresentation('app-container')).toMatchObject({ + key: 'app-container', + label: 'App Container', + }); + expect(getRecoveryItemTypePresentation('dataset')).toMatchObject({ + key: 'dataset', + label: 'Dataset', + }); + }); + + it('falls back cleanly for unknown item types', () => { + expect(getRecoveryItemTypeLabel('custom-thing')).toBe('Custom Thing'); + expect(getRecoveryItemTypeBadgeClass('custom-thing')).toBe('bg-surface-alt text-base-content'); + }); +}); diff --git a/frontend-modern/src/utils/recoveryFilterChipPresentation.ts b/frontend-modern/src/utils/recoveryFilterChipPresentation.ts index 4546dce20..48176a66b 100644 --- a/frontend-modern/src/utils/recoveryFilterChipPresentation.ts +++ b/frontend-modern/src/utils/recoveryFilterChipPresentation.ts @@ -1,4 +1,4 @@ -export type RecoveryFilterChipKind = 'day' | 'cluster' | 'node' | 'namespace'; +export type RecoveryFilterChipKind = 'day' | 'cluster' | 'item-type' | 'node' | 'namespace'; type RecoveryFilterChipPresentation = { clearButtonClass: string; @@ -20,6 +20,11 @@ const CHIP_PRESENTATION: Record { + const normalized = String(value || '').trim().toLowerCase(); + switch (normalized) { + case '': + case 'all': + return ''; + case 'proxmox-vm': + case 'proxmox-vm-backup': + case 'vm': + case 'vm-backup': + return 'vm'; + case 'proxmox-lxc': + case 'lxc': + case 'ct': + case 'container': + case 'system-container': + return 'system-container'; + case 'docker-container': + case 'docker': + case 'app-container': + return 'app-container'; + case 'oci-container': + return 'oci-container'; + case 'k8s-pod': + case 'pod': + return 'pod'; + case 'k8s-pvc': + case 'pvc': + return 'pvc'; + case 'truenas-dataset': + case 'dataset': + return 'dataset'; + case 'velero-backup': + return 'velero-backup'; + case 'proxmox-guest': + case 'guest': + return 'guest'; + default: + if (normalized.startsWith('proxmox-')) return normalized.slice('proxmox-'.length); + if (normalized.startsWith('truenas-')) return normalized.slice('truenas-'.length); + if (normalized.startsWith('k8s-')) return normalized.slice('k8s-'.length); + return normalized; + } +}; + +export const getRecoveryItemTypePresentation = ( + value: string | null | undefined, +): RecoveryItemTypePresentation | null => { + const key = normalizeRecoveryItemTypeQueryValue(value); + if (!key) return null; + + switch (key) { + case 'vm': { + const presentation = getWorkloadTypePresentation('vm'); + return { key, label: presentation.label, badgeClasses: presentation.className }; + } + case 'system-container': { + const presentation = getWorkloadTypePresentation('system-container'); + return { key, label: presentation.label, badgeClasses: presentation.className }; + } + case 'app-container': { + const presentation = getWorkloadTypePresentation('app-container', { + label: 'App Container', + title: 'Application Container', + }); + return { key, label: presentation.label, badgeClasses: presentation.className }; + } + case 'oci-container': { + const presentation = getWorkloadTypePresentation('oci-container'); + return { key, label: presentation.label, badgeClasses: presentation.className }; + } + case 'pod': { + const presentation = getWorkloadTypePresentation('pod'); + return { key, label: presentation.label, badgeClasses: presentation.className }; + } + case 'pvc': { + const presentation = getResourceTypePresentation('k8s-pvc'); + return { + key, + label: presentation?.label || 'PVC', + badgeClasses: presentation?.badgeClasses || DEFAULT_BADGE_CLASSES, + }; + } + default: { + const presentation = getResourceTypePresentation(key); + if (presentation) { + const label = + presentation.label === key + ? titleCaseDelimitedLabel(key, { + fallback: 'Unknown', + preserveShortAllCaps: true, + }) + : presentation.label; + return { + key, + label, + badgeClasses: presentation.badgeClasses, + }; + } + return { + key, + label: titleCaseDelimitedLabel(key, { + fallback: 'Unknown', + preserveShortAllCaps: true, + }), + badgeClasses: DEFAULT_BADGE_CLASSES, + }; + } + } +}; + +export const getRecoveryItemTypeLabel = (value: string | null | undefined): string => + getRecoveryItemTypePresentation(value)?.label || ''; + +export const getRecoveryItemTypeBadgeClass = (value: string | null | undefined): string => + getRecoveryItemTypePresentation(value)?.badgeClasses || DEFAULT_BADGE_CLASSES; diff --git a/frontend-modern/src/utils/recoverySummaryPresentation.ts b/frontend-modern/src/utils/recoverySummaryPresentation.ts index 541df7eb8..085ce54fd 100644 --- a/frontend-modern/src/utils/recoverySummaryPresentation.ts +++ b/frontend-modern/src/utils/recoverySummaryPresentation.ts @@ -11,12 +11,9 @@ import { getSourcePlatformPresentation, normalizeSourcePlatformQueryValue, } from '@/utils/sourcePlatforms'; -import { getResourceTypePresentation } from '@/utils/resourceTypePresentation'; import { - getWorkloadTypePresentation, - normalizeWorkloadTypePresentationKey, -} from '@/utils/workloadTypePresentation'; -import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; + getRecoveryItemTypePresentation, +} from '@/utils/recoveryItemTypePresentation'; export const RECOVERY_SUMMARY_TIME_RANGES = ['7d', '30d', '90d', '365d'] as const; @@ -178,74 +175,15 @@ const formatShortDate = (value: string): string => { return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); }; -const DEFAULT_RECOVERY_ITEM_TONE = - 'bg-surface-alt text-base-content dark:bg-surface-alt dark:text-base-content'; - -const normalizeRecoverySummarySubjectTypeKey = (value: string): string => - value - .replace(/^k8s-/, '') - .replace(/^proxmox-/, '') - .replace(/^truenas-/, ''); - -const normalizeRecoverySummaryWorkloadType = ( - value: string, -): Parameters[0] => { - const normalized = normalizeRecoverySummarySubjectTypeKey(value.trim().toLowerCase()); - switch (normalized) { - case 'lxc': - case 'ct': - case 'container': - return 'system-container'; - case 'vm-backup': - return 'vm'; - default: - return normalized; - } -}; - const getRecoverySummarySubjectTypePresentation = ( value: string | null | undefined, ): { key: string; label: string; toneClass: string } | null => { - const raw = String(value || '').trim().toLowerCase(); - if (!raw) return null; - - const workloadType = normalizeRecoverySummaryWorkloadType(raw); - if (normalizeWorkloadTypePresentationKey(workloadType)) { - const presentation = getWorkloadTypePresentation(workloadType); - return { - key: String(workloadType), - label: presentation.label, - toneClass: presentation.className, - }; - } - - const normalized = normalizeRecoverySummarySubjectTypeKey(raw); - const normalizedPresentation = normalized ? getResourceTypePresentation(normalized) : null; - if (normalizedPresentation && normalizedPresentation.label !== normalized) { - return { - key: normalized, - label: normalizedPresentation.label, - toneClass: normalizedPresentation.badgeClasses, - }; - } - - const rawPresentation = getResourceTypePresentation(raw); - if (rawPresentation && rawPresentation.label !== raw) { - return { - key: normalized || raw, - label: rawPresentation.label, - toneClass: rawPresentation.badgeClasses, - }; - } - - const fallbackKey = normalized || raw; + const presentation = getRecoveryItemTypePresentation(value); + if (!presentation) return null; return { - key: fallbackKey, - label: titleCaseDelimitedLabel(fallbackKey, { - fallback: 'Unknown', - preserveShortAllCaps: true, - }), - toneClass: DEFAULT_RECOVERY_ITEM_TONE, + key: presentation.key, + label: presentation.label, + toneClass: presentation.badgeClasses, }; }; @@ -493,11 +431,13 @@ export function buildRecoveryItemCoverage( for (const rollup of rollups) { const presentation = getRecoverySummarySubjectTypePresentation( - String(rollup.display?.subjectType || rollup.subjectRef?.type || '').trim(), + String( + rollup.display?.itemType || rollup.display?.subjectType || rollup.subjectRef?.type || '', + ).trim(), ) || { key: 'unknown', label: 'Unknown', - toneClass: DEFAULT_RECOVERY_ITEM_TONE, + toneClass: 'bg-surface-alt text-base-content', }; const existing = counts.get(presentation.key); diff --git a/frontend-modern/src/utils/recoveryTablePresentation.ts b/frontend-modern/src/utils/recoveryTablePresentation.ts index 1579ed8ae..16538eca7 100644 --- a/frontend-modern/src/utils/recoveryTablePresentation.ts +++ b/frontend-modern/src/utils/recoveryTablePresentation.ts @@ -1,12 +1,10 @@ import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import { getResourceTypePresentation } from '@/utils/resourceTypePresentation'; import { - getWorkloadTypePresentation, - normalizeWorkloadTypePresentationKey, -} from '@/utils/workloadTypePresentation'; + getRecoveryItemTypeBadgeClass, + getRecoveryItemTypeLabel, +} from '@/utils/recoveryItemTypePresentation'; import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation'; import type { RecoveryIssueTone } from '@/utils/recoveryIssuePresentation'; -import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; export const STALE_ISSUE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; export const AGING_THRESHOLD_MS = 2 * 24 * 60 * 60 * 1000; @@ -75,70 +73,14 @@ export function getRecoveryArtifactColumnLabel(id: string, fallback?: string): s return RECOVERY_ARTIFACT_COLUMN_LABELS[id] || fallback || id; } -const normalizeRecoverySubjectTypeKey = (value: string): string => - value - .replace(/^k8s-/, '') - .replace(/^proxmox-/, '') - .replace(/^truenas-/, ''); - -const normalizeRecoverySubjectWorkloadType = ( - value: string, -): Parameters[0] => { - const normalized = normalizeRecoverySubjectTypeKey(value.trim().toLowerCase()); - switch (normalized) { - case 'lxc': - case 'ct': - case 'container': - return 'system-container'; - case 'vm-backup': - return 'vm'; - default: - return normalized; - } -}; - -const getRecoverySubjectTypePresentation = (point: RecoveryPoint) => { - const raw = String(point.display?.subjectType || point.subjectRef?.type || '') - .trim() - .toLowerCase(); - if (!raw) return null; - - const workloadType = normalizeRecoverySubjectWorkloadType(raw); - if (normalizeWorkloadTypePresentationKey(workloadType)) { - const presentation = getWorkloadTypePresentation(workloadType); - return { - label: presentation.label, - badgeClasses: presentation.className, - }; - } - - const resourcePresentation = getResourceTypePresentation(normalizeRecoverySubjectTypeKey(raw)); - if (resourcePresentation) return resourcePresentation; - return null; -}; - export function getRecoverySubjectTypeBadgeClass(point: RecoveryPoint): string { - const presentation = getRecoverySubjectTypePresentation(point); - if (presentation) return presentation.badgeClasses; - return 'bg-surface-alt text-base-content'; + return getRecoveryItemTypeBadgeClass(point.display?.itemType || point.display?.subjectType || point.subjectRef?.type); } export function getRecoverySubjectTypeLabel(point: RecoveryPoint): string { - const raw = String(point.display?.subjectType || point.subjectRef?.type || '') - .trim() - .toLowerCase(); - if (!raw) return ''; - const presentation = getRecoverySubjectTypePresentation(point); - if (presentation) { - if ( - presentation.label === normalizeRecoverySubjectTypeKey(raw) && - presentation.badgeClasses === 'bg-surface-alt text-base-content' - ) { - return titleCaseDelimitedLabel(normalizeRecoverySubjectTypeKey(raw)); - } - return presentation.label; - } - return titleCaseDelimitedLabel(normalizeRecoverySubjectTypeKey(raw)); + return getRecoveryItemTypeLabel( + point.display?.itemType || point.display?.subjectType || point.subjectRef?.type, + ); } export function getRecoveryArtifactColumnHeaderClass(id: string): string { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 5bbe0a458..48ae3bf58 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -2049,6 +2049,7 @@ func TestContract_FilterRecoveryPointsForRollupsIncludesNormalizedFilters(t *tes Display: &recovery.RecoveryPointDisplay{ SubjectLabel: "pod-1", SubjectType: "pod", + ItemType: "pod", ClusterLabel: "prod-cluster", NodeHostLabel: "worker-1", NamespaceLabel: "default", @@ -2067,6 +2068,7 @@ func TestContract_FilterRecoveryPointsForRollupsIncludesNormalizedFilters(t *tes Display: &recovery.RecoveryPointDisplay{ SubjectLabel: "pod-2", SubjectType: "pod", + ItemType: "pod", ClusterLabel: "other-cluster", NodeHostLabel: "worker-2", NamespaceLabel: "kube-system", @@ -2077,6 +2079,7 @@ func TestContract_FilterRecoveryPointsForRollupsIncludesNormalizedFilters(t *tes filtered := filterRecoveryPointsForRollups(points, recovery.ListPointsOptions{ Query: "repo-a", + ItemType: "pod", ClusterLabel: "prod-cluster", NodeHostLabel: "worker-1", NamespaceLabel: "default", diff --git a/internal/api/recovery_handlers.go b/internal/api/recovery_handlers.go index 5ab7ff72e..235895719 100644 --- a/internal/api/recovery_handlers.go +++ b/internal/api/recovery_handlers.go @@ -108,6 +108,7 @@ func (h *RecoveryHandlers) HandleListPoints(w http.ResponseWriter, r *http.Reque Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))), Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), + ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, @@ -221,6 +222,7 @@ func (h *RecoveryHandlers) HandleListSeries(w http.ResponseWriter, r *http.Reque Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))), Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), + ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, @@ -286,6 +288,7 @@ func (h *RecoveryHandlers) HandleListFacets(w http.ResponseWriter, r *http.Reque Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))), Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), + ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, @@ -337,6 +340,7 @@ func filterRecoveryPoints(all []recovery.RecoveryPoint, opts recovery.ListPoints cluster := strings.TrimSpace(opts.ClusterLabel) node := strings.TrimSpace(opts.NodeHostLabel) namespace := strings.TrimSpace(opts.NamespaceLabel) + itemType := recovery.NormalizeRecoveryItemType(opts.ItemType) q := strings.ToLower(strings.TrimSpace(opts.Query)) workloadOnly := opts.WorkloadOnly verification := strings.ToLower(strings.TrimSpace(opts.Verification)) @@ -394,6 +398,9 @@ func filterRecoveryPoints(all []recovery.RecoveryPoint, opts recovery.ListPoints if namespace != "" && strings.TrimSpace(getDisplayNamespaceLabel(p.Display)) != namespace { continue } + if itemType != "" && strings.TrimSpace(getDisplayItemType(p.Display)) != itemType { + continue + } if workloadOnly && !(disp != nil && disp.IsWorkload) { continue } @@ -416,10 +423,11 @@ func filterRecoveryPoints(all []recovery.RecoveryPoint, opts recovery.ListPoints } if q != "" { // Best-effort match across normalized display fields. - var subjectLabel, subjectType, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string + var subjectLabel, subjectType, itemTypeLabel, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string if disp != nil { subjectLabel = disp.SubjectLabel subjectType = disp.SubjectType + itemTypeLabel = disp.ItemType clusterLabel = disp.ClusterLabel nodeLabel = disp.NodeHostLabel nsLabel = disp.NamespaceLabel @@ -436,6 +444,7 @@ func filterRecoveryPoints(all []recovery.RecoveryPoint, opts recovery.ListPoints strings.TrimSpace(string(p.Outcome)), strings.TrimSpace(subjectLabel), strings.TrimSpace(subjectType), + strings.TrimSpace(itemTypeLabel), strings.TrimSpace(clusterLabel), strings.TrimSpace(nodeLabel), strings.TrimSpace(nsLabel), @@ -474,6 +483,16 @@ func getDisplayNamespaceLabel(d *recovery.RecoveryPointDisplay) string { return d.NamespaceLabel } +func getDisplayItemType(d *recovery.RecoveryPointDisplay) string { + if d == nil { + return "" + } + if v := recovery.NormalizeRecoveryItemType(d.ItemType); v != "" { + return v + } + return recovery.NormalizeRecoveryItemType(d.SubjectType) +} + func paginateRecoveryPoints(filtered []recovery.RecoveryPoint, page int, limit int) []recovery.RecoveryPoint { if len(filtered) == 0 { return []recovery.RecoveryPoint{} @@ -531,6 +550,7 @@ func (h *RecoveryHandlers) HandleListRollups(w http.ResponseWriter, r *http.Requ Kind: recovery.Kind(strings.TrimSpace(qs.Get("kind"))), Mode: recovery.Mode(strings.TrimSpace(qs.Get("mode"))), Outcome: recovery.Outcome(strings.TrimSpace(qs.Get("outcome"))), + ItemType: recovery.NormalizeRecoveryItemType(firstNonEmpty(qs.Get("itemType"), qs.Get("type"))), SubjectResourceID: strings.TrimSpace(qs.Get("subjectResourceId")), RollupID: strings.TrimSpace(qs.Get("rollupId")), From: from, @@ -607,6 +627,7 @@ func filterRecoveryPointsForRollups(all []recovery.RecoveryPoint, opts recovery. cluster := strings.TrimSpace(opts.ClusterLabel) node := strings.TrimSpace(opts.NodeHostLabel) namespace := strings.TrimSpace(opts.NamespaceLabel) + itemType := recovery.NormalizeRecoveryItemType(opts.ItemType) q := strings.ToLower(strings.TrimSpace(opts.Query)) workloadOnly := opts.WorkloadOnly verification := strings.ToLower(strings.TrimSpace(opts.Verification)) @@ -658,6 +679,9 @@ func filterRecoveryPointsForRollups(all []recovery.RecoveryPoint, opts recovery. if namespace != "" && strings.TrimSpace(getDisplayNamespaceLabel(disp)) != namespace { continue } + if itemType != "" && strings.TrimSpace(getDisplayItemType(disp)) != itemType { + continue + } if workloadOnly && !(disp != nil && disp.IsWorkload) { continue } @@ -679,10 +703,11 @@ func filterRecoveryPointsForRollups(all []recovery.RecoveryPoint, opts recovery. } } if q != "" { - var subjectLabel, subjectType, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string + var subjectLabel, subjectType, itemTypeLabel, clusterLabel, nodeLabel, nsLabel, entityID, repoLabel, detailSummary string if disp != nil { subjectLabel = disp.SubjectLabel subjectType = disp.SubjectType + itemTypeLabel = disp.ItemType clusterLabel = disp.ClusterLabel nodeLabel = disp.NodeHostLabel nsLabel = disp.NamespaceLabel @@ -697,6 +722,7 @@ func filterRecoveryPointsForRollups(all []recovery.RecoveryPoint, opts recovery. strings.TrimSpace(string(p.Outcome)), strings.TrimSpace(subjectLabel), strings.TrimSpace(subjectType), + strings.TrimSpace(itemTypeLabel), strings.TrimSpace(clusterLabel), strings.TrimSpace(nodeLabel), strings.TrimSpace(nsLabel), @@ -830,6 +856,7 @@ func buildFacetsFromPoints(points []recovery.RecoveryPoint) recovery.PointsFacet clusters := map[string]struct{}{} nodes := map[string]struct{}{} namespaces := map[string]struct{}{} + itemTypes := map[string]struct{}{} var hasSize bool var hasVerification bool @@ -850,6 +877,9 @@ func buildFacetsFromPoints(points []recovery.RecoveryPoint) recovery.PointsFacet if v := strings.TrimSpace(p.Display.NamespaceLabel); v != "" { namespaces[v] = struct{}{} } + if v := getDisplayItemType(p.Display); v != "" { + itemTypes[v] = struct{}{} + } if v := strings.TrimSpace(p.Display.EntityIDLabel); v != "" { hasEntityID = true } @@ -872,6 +902,7 @@ func buildFacetsFromPoints(points []recovery.RecoveryPoint) recovery.PointsFacet } return recovery.PointsFacets{ + ItemTypes: toSorted(itemTypes), Clusters: toSorted(clusters), NodesHosts: toSorted(nodes), Namespaces: toSorted(namespaces), diff --git a/internal/recovery/index.go b/internal/recovery/index.go index 194fe72d0..d2274f967 100644 --- a/internal/recovery/index.go +++ b/internal/recovery/index.go @@ -10,6 +10,7 @@ import ( type PointIndex struct { SubjectLabel string SubjectType string + ItemType string IsWorkload bool ClusterLabel string NodeHostLabel string @@ -55,22 +56,50 @@ func detailsString(p RecoveryPoint, key string) string { return trimString(p.Details[key]) } +func NormalizeRecoveryItemType(value string) string { + t := strings.ToLower(strings.TrimSpace(value)) + switch t { + case "", "all": + return "" + case "proxmox-vm", "proxmox-vm-backup", "vm", "vm-backup": + return "vm" + case "proxmox-lxc", "lxc", "ct", "container", "system-container": + return "system-container" + case "docker-container", "docker", "app-container": + return "app-container" + case "oci-container": + return "oci-container" + case "k8s-pod", "pod": + return "pod" + case "k8s-pvc", "pvc": + return "pvc" + case "truenas-dataset", "dataset": + return "dataset" + case "velero-backup": + return "velero-backup" + case "proxmox-guest", "guest": + return "guest" + default: + if strings.HasPrefix(t, "proxmox-") { + return strings.TrimPrefix(t, "proxmox-") + } + if strings.HasPrefix(t, "truenas-") { + return strings.TrimPrefix(t, "truenas-") + } + if strings.HasPrefix(t, "k8s-") { + return strings.TrimPrefix(t, "k8s-") + } + return t + } +} + func isWorkloadSubjectType(subjectType string) bool { - t := strings.ToLower(strings.TrimSpace(subjectType)) - if t == "" { + switch NormalizeRecoveryItemType(subjectType) { + case "vm", "system-container", "app-container", "oci-container", "pod", "pvc", "agent": + return true + default: return false } - // Workload subjects (restore points for things you actually run). - if strings.Contains(t, "proxmox-vm") || strings.Contains(t, "proxmox-lxc") { - return true - } - if strings.Contains(t, "docker-container") || strings.Contains(t, "container") { - return true - } - if strings.Contains(t, "k8s-pvc") || strings.Contains(t, "k8s-pod") { - return true - } - return false } func subjectLabel(p RecoveryPoint) string { @@ -319,6 +348,7 @@ func DeriveIndex(p RecoveryPoint) PointIndex { return PointIndex{ SubjectLabel: subjectLabel(p), SubjectType: subjectType, + ItemType: NormalizeRecoveryItemType(subjectType), IsWorkload: isWorkload, ClusterLabel: clusterLabel(p), NodeHostLabel: nodeHostLabel(p), @@ -333,6 +363,7 @@ func DeriveIndex(p RecoveryPoint) PointIndex { func (idx PointIndex) ToDisplay() *RecoveryPointDisplay { if strings.TrimSpace(idx.SubjectLabel) == "" && strings.TrimSpace(idx.SubjectType) == "" && + strings.TrimSpace(idx.ItemType) == "" && strings.TrimSpace(idx.ClusterLabel) == "" && strings.TrimSpace(idx.NodeHostLabel) == "" && strings.TrimSpace(idx.NamespaceLabel) == "" && @@ -345,6 +376,7 @@ func (idx PointIndex) ToDisplay() *RecoveryPointDisplay { return &RecoveryPointDisplay{ SubjectLabel: idx.SubjectLabel, SubjectType: idx.SubjectType, + ItemType: idx.ItemType, IsWorkload: idx.IsWorkload, ClusterLabel: idx.ClusterLabel, NodeHostLabel: idx.NodeHostLabel, diff --git a/internal/recovery/model/types.go b/internal/recovery/model/types.go index 27b8ef6ae..603c74645 100644 --- a/internal/recovery/model/types.go +++ b/internal/recovery/model/types.go @@ -93,6 +93,7 @@ type RecoveryPoint struct { type RecoveryPointDisplay struct { SubjectLabel string `json:"subjectLabel,omitempty"` SubjectType string `json:"subjectType,omitempty"` + ItemType string `json:"itemType,omitempty"` IsWorkload bool `json:"isWorkload,omitempty"` ClusterLabel string `json:"clusterLabel,omitempty"` NodeHostLabel string `json:"nodeHostLabel,omitempty"` @@ -143,6 +144,9 @@ type ListPointsOptions struct { // Query is a best-effort free-text filter over normalized fields. Query string + // ItemType filters points to the canonical recovery item classification. + ItemType string + // Normalized filters derived from the recovery point index. ClusterLabel string NodeHostLabel string @@ -173,6 +177,7 @@ type PointsFacets struct { Clusters []string `json:"clusters,omitempty"` NodesHosts []string `json:"nodesHosts,omitempty"` Namespaces []string `json:"namespaces,omitempty"` + ItemTypes []string `json:"itemTypes,omitempty"` HasSize bool `json:"hasSize,omitempty"` HasVerification bool `json:"hasVerification,omitempty"` diff --git a/internal/recovery/recovery_test.go b/internal/recovery/recovery_test.go index be4039736..e832a0153 100644 --- a/internal/recovery/recovery_test.go +++ b/internal/recovery/recovery_test.go @@ -18,6 +18,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "", SubjectType: "", + ItemType: "", IsWorkload: false, ClusterLabel: "", NodeHostLabel: "", @@ -43,6 +44,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "web-server", SubjectType: "proxmox-vm", + ItemType: "vm", IsWorkload: true, ClusterLabel: "pve-cluster", NodeHostLabel: "pve1", @@ -69,6 +71,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "database", SubjectType: "proxmox-lxc", + ItemType: "system-container", IsWorkload: true, ClusterLabel: "prod-cluster", NodeHostLabel: "pve2", @@ -96,6 +99,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "production/data-volume", SubjectType: "k8s-pvc", + ItemType: "pvc", IsWorkload: true, ClusterLabel: "prod-eks", NodeHostLabel: "", @@ -126,6 +130,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "vm-100", SubjectType: "proxmox-vm-backup", + ItemType: "vm", IsWorkload: true, ClusterLabel: "", NodeHostLabel: "", @@ -153,6 +158,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "pool/data", SubjectType: "truenas-dataset", + ItemType: "dataset", IsWorkload: false, ClusterLabel: "truenas-01", NodeHostLabel: "truenas-01", @@ -178,6 +184,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "nginx", SubjectType: "docker-container", + ItemType: "app-container", IsWorkload: true, ClusterLabel: "", NodeHostLabel: "docker-host-1", @@ -203,6 +210,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "web-pod-0", SubjectType: "k8s-pod", + ItemType: "pod", IsWorkload: true, ClusterLabel: "", NodeHostLabel: "", @@ -228,6 +236,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "velero-backup-1", SubjectType: "velero-backup", + ItemType: "velero-backup", IsWorkload: false, ClusterLabel: "", NodeHostLabel: "", @@ -247,6 +256,7 @@ func TestDeriveIndex(t *testing.T) { expected: PointIndex{ SubjectLabel: "unified-resource-abc", SubjectType: "", + ItemType: "", IsWorkload: true, ClusterLabel: "", NodeHostLabel: "", @@ -267,6 +277,9 @@ func TestDeriveIndex(t *testing.T) { if result.SubjectType != tc.expected.SubjectType { t.Errorf("SubjectType = %q, want %q", result.SubjectType, tc.expected.SubjectType) } + if result.ItemType != tc.expected.ItemType { + t.Errorf("ItemType = %q, want %q", result.ItemType, tc.expected.ItemType) + } if result.IsWorkload != tc.expected.IsWorkload { t.Errorf("IsWorkload = %v, want %v", result.IsWorkload, tc.expected.IsWorkload) } @@ -317,6 +330,7 @@ func TestPointIndex_ToDisplay(t *testing.T) { index: PointIndex{ SubjectLabel: "web-server", SubjectType: "proxmox-vm", + ItemType: "vm", IsWorkload: true, ClusterLabel: "pve-cluster", NodeHostLabel: "pve1", @@ -328,6 +342,7 @@ func TestPointIndex_ToDisplay(t *testing.T) { expected: &RecoveryPointDisplay{ SubjectLabel: "web-server", SubjectType: "proxmox-vm", + ItemType: "vm", IsWorkload: true, ClusterLabel: "pve-cluster", NodeHostLabel: "pve1", @@ -369,6 +384,9 @@ func TestPointIndex_ToDisplay(t *testing.T) { if result.SubjectType != tc.expected.SubjectType { t.Errorf("SubjectType = %q, want %q", result.SubjectType, tc.expected.SubjectType) } + if result.ItemType != tc.expected.ItemType { + t.Errorf("ItemType = %q, want %q", result.ItemType, tc.expected.ItemType) + } if result.IsWorkload != tc.expected.IsWorkload { t.Errorf("IsWorkload = %v, want %v", result.IsWorkload, tc.expected.IsWorkload) } @@ -854,6 +872,7 @@ func TestBuildRollupsFromPoints_PreservesLatestDisplayLabels(t *testing.T) { Display: &RecoveryPointDisplay{ SubjectLabel: "Old Label", SubjectType: "proxmox-vm", + ItemType: "vm", }, }, { @@ -866,6 +885,7 @@ func TestBuildRollupsFromPoints_PreservesLatestDisplayLabels(t *testing.T) { Display: &RecoveryPointDisplay{ SubjectLabel: "Current Label", SubjectType: "proxmox-vm", + ItemType: "vm", IsWorkload: true, }, }, @@ -884,6 +904,9 @@ func TestBuildRollupsFromPoints_PreservesLatestDisplayLabels(t *testing.T) { if got := rollups[0].Display.SubjectType; got != "proxmox-vm" { t.Fatalf("Display.SubjectType = %q, want %q", got, "proxmox-vm") } + if got := rollups[0].Display.ItemType; got != "vm" { + t.Fatalf("Display.ItemType = %q, want %q", got, "vm") + } if !rollups[0].Display.IsWorkload { t.Fatal("expected rollup display workload marker to be preserved") } diff --git a/internal/recovery/store/store.go b/internal/recovery/store/store.go index 55bd8c106..a3eeb3c81 100644 --- a/internal/recovery/store/store.go +++ b/internal/recovery/store/store.go @@ -205,6 +205,7 @@ func (s *Store) initSchema() error { details_json TEXT, subject_label TEXT, subject_type TEXT, + item_type TEXT, is_workload INTEGER, cluster_label TEXT, node_host_label TEXT, @@ -222,6 +223,9 @@ func (s *Store) initSchema() error { CREATE INDEX IF NOT EXISTS idx_recovery_points_provider_completed ON recovery_points(provider, completed_at_ms); + CREATE INDEX IF NOT EXISTS idx_recovery_points_item_type_completed + ON recovery_points(item_type, completed_at_ms); + CREATE INDEX IF NOT EXISTS idx_recovery_points_subject_completed ON recovery_points(subject_resource_id, completed_at_ms); @@ -247,6 +251,7 @@ func (s *Store) initSchema() error { }{ {"subject_label", "TEXT"}, {"subject_type", "TEXT"}, + {"item_type", "TEXT"}, {"is_workload", "INTEGER"}, {"cluster_label", "TEXT"}, {"node_host_label", "TEXT"}, @@ -550,11 +555,11 @@ func (s *Store) UpsertPoints(ctx context.Context, points []recovery.RecoveryPoin subject_key, repository_key, subject_resource_id, repository_resource_id, subject_ref_json, repository_ref_json, details_json, - subject_label, subject_type, is_workload, + subject_label, subject_type, item_type, is_workload, cluster_label, node_host_label, namespace_label, entity_id_label, repository_label, details_summary, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET provider=excluded.provider, kind=excluded.kind, @@ -575,6 +580,7 @@ func (s *Store) UpsertPoints(ctx context.Context, points []recovery.RecoveryPoin details_json=excluded.details_json, subject_label=excluded.subject_label, subject_type=excluded.subject_type, + item_type=excluded.item_type, is_workload=excluded.is_workload, cluster_label=excluded.cluster_label, node_host_label=excluded.node_host_label, @@ -648,6 +654,7 @@ func (s *Store) UpsertPoints(ctx context.Context, points []recovery.RecoveryPoin nullStringToAny(details), strings.TrimSpace(idx.SubjectLabel), strings.TrimSpace(idx.SubjectType), + strings.TrimSpace(idx.ItemType), func() any { if idx.IsWorkload { return 1 @@ -767,6 +774,10 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) where = append(where, "namespace_label = ?") args = append(args, strings.TrimSpace(opts.NamespaceLabel)) } + if strings.TrimSpace(opts.ItemType) != "" { + where = append(where, "item_type = ?") + args = append(args, strings.TrimSpace(opts.ItemType)) + } if opts.WorkloadOnly { where = append(where, "is_workload = 1") } @@ -788,6 +799,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) LOWER(id) LIKE ? OR LOWER(subject_label) LIKE ? OR LOWER(subject_type) LIKE ? OR + LOWER(item_type) LIKE ? OR LOWER(cluster_label) LIKE ? OR LOWER(node_host_label) LIKE ? OR LOWER(namespace_label) LIKE ? OR @@ -796,7 +808,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) LOWER(details_summary) LIKE ? ) `) - for i := 0; i < 9; i++ { + for i := 0; i < 10; i++ { args = append(args, needle) } } @@ -819,7 +831,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) verified, encrypted, immutable, subject_resource_id, repository_resource_id, subject_ref_json, repository_ref_json, details_json - , subject_label, subject_type, is_workload, + , subject_label, subject_type, item_type, is_workload, cluster_label, node_host_label, namespace_label, entity_id_label, repository_label, details_summary FROM recovery_points @@ -845,6 +857,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) var subjectRID, repoRID sql.NullString var subjectRefRaw, repoRefRaw, detailsRaw sql.NullString var subjectLabel, subjectType sql.NullString + var itemType sql.NullString var isWorkload sql.NullInt64 var clusterLabel, nodeHostLabel, namespaceLabel, entityIDLabel sql.NullString var repositoryLabel, detailsSummary sql.NullString @@ -868,6 +881,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) &detailsRaw, &subjectLabel, &subjectType, + &itemType, &isWorkload, &clusterLabel, &nodeHostLabel, @@ -928,6 +942,7 @@ func (s *Store) ListPoints(ctx context.Context, opts recovery.ListPointsOptions) idx := recovery.PointIndex{ SubjectLabel: strings.TrimSpace(subjectLabel.String), SubjectType: strings.TrimSpace(subjectType.String), + ItemType: strings.TrimSpace(itemType.String), IsWorkload: isWorkload.Valid && isWorkload.Int64 != 0, ClusterLabel: strings.TrimSpace(clusterLabel.String), NodeHostLabel: strings.TrimSpace(nodeHostLabel.String), @@ -1016,6 +1031,10 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions where = append(where, "namespace_label = ?") args = append(args, strings.TrimSpace(opts.NamespaceLabel)) } + if strings.TrimSpace(opts.ItemType) != "" { + where = append(where, "item_type = ?") + args = append(args, strings.TrimSpace(opts.ItemType)) + } if opts.WorkloadOnly { where = append(where, "is_workload = 1") } @@ -1039,6 +1058,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions LOWER(outcome) LIKE ? OR LOWER(subject_label) LIKE ? OR LOWER(subject_type) LIKE ? OR + LOWER(item_type) LIKE ? OR LOWER(cluster_label) LIKE ? OR LOWER(node_host_label) LIKE ? OR LOWER(namespace_label) LIKE ? OR @@ -1047,7 +1067,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions LOWER(details_summary) LIKE ? ) `) - for i := 0; i < 12; i++ { + for i := 0; i < 13; i++ { args = append(args, needle) } } @@ -1079,6 +1099,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions subject_ref_json, subject_label, subject_type, + item_type, is_workload, cluster_label, node_host_label, @@ -1116,6 +1137,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions subject_ref_json, subject_label, subject_type, + item_type, is_workload, cluster_label, node_host_label, @@ -1130,6 +1152,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions subject_ref_json, subject_label, subject_type, + item_type, is_workload, cluster_label, node_host_label, @@ -1153,6 +1176,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions latest.subject_ref_json, latest.subject_label, latest.subject_type, + latest.item_type, latest.is_workload, latest.cluster_label, latest.node_host_label, @@ -1185,6 +1209,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions var subjectRefRaw sql.NullString var subjectLabel sql.NullString var subjectType sql.NullString + var itemType sql.NullString var isWorkload sql.NullInt64 var clusterLabel sql.NullString var nodeHostLabel sql.NullString @@ -1203,6 +1228,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions &subjectRefRaw, &subjectLabel, &subjectType, + &itemType, &isWorkload, &clusterLabel, &nodeHostLabel, @@ -1257,6 +1283,7 @@ func (s *Store) ListRollups(ctx context.Context, opts recovery.ListPointsOptions display := recovery.PointIndex{ SubjectLabel: strings.TrimSpace(subjectLabel.String), SubjectType: strings.TrimSpace(subjectType.String), + ItemType: strings.TrimSpace(itemType.String), IsWorkload: isWorkload.Valid && isWorkload.Int64 != 0, ClusterLabel: strings.TrimSpace(clusterLabel.String), NodeHostLabel: strings.TrimSpace(nodeHostLabel.String), diff --git a/internal/recovery/store/store_index_backfill.go b/internal/recovery/store/store_index_backfill.go index 5abc969cd..844303978 100644 --- a/internal/recovery/store/store_index_backfill.go +++ b/internal/recovery/store/store_index_backfill.go @@ -31,6 +31,7 @@ func (s *Store) BackfillIndex(ctx context.Context) error { WHERE (subject_label IS NULL OR subject_label = '' OR subject_type IS NULL OR + item_type IS NULL OR item_type = '' OR cluster_label IS NULL OR node_host_label IS NULL OR namespace_label IS NULL OR @@ -94,6 +95,7 @@ func (s *Store) BackfillIndex(ctx context.Context) error { SET subject_label = ?, subject_type = ?, + item_type = ?, is_workload = ?, cluster_label = ?, node_host_label = ?, @@ -147,6 +149,7 @@ func (s *Store) BackfillIndex(ctx context.Context) error { ctx, strings.TrimSpace(idx.SubjectLabel), strings.TrimSpace(idx.SubjectType), + strings.TrimSpace(idx.ItemType), isWorkload, strings.TrimSpace(idx.ClusterLabel), strings.TrimSpace(idx.NodeHostLabel), diff --git a/internal/recovery/store/store_rollups_test.go b/internal/recovery/store/store_rollups_test.go index a5be75001..f8f9c1d72 100644 --- a/internal/recovery/store/store_rollups_test.go +++ b/internal/recovery/store/store_rollups_test.go @@ -96,6 +96,9 @@ func TestStore_ListRollups(t *testing.T) { if got[0].Display == nil || got[0].Display.SubjectLabel != "tank/apps" { t.Fatalf("rollup[0].Display = %#v, want subject label tank/apps", got[0].Display) } + if got[0].Display == nil || got[0].Display.ItemType != "dataset" { + t.Fatalf("rollup[0].Display = %#v, want item type dataset", got[0].Display) + } // Second: vm-1 with latest failure at t2 and last success at t1. if got[1].RollupID != "res:vm-1" { @@ -110,6 +113,9 @@ func TestStore_ListRollups(t *testing.T) { if got[1].Display == nil || got[1].Display.SubjectLabel != "vm-1" { t.Fatalf("rollup[1].Display = %#v, want subject label vm-1", got[1].Display) } + if got[1].Display == nil || got[1].Display.ItemType != "" { + t.Fatalf("rollup[1].Display = %#v, want empty item type when subject type is unknown", got[1].Display) + } if got[1].LastAttemptAt == nil || !got[1].LastAttemptAt.Equal(t2) { t.Fatalf("rollup[1].LastAttemptAt = %v, want %v", got[1].LastAttemptAt, t2) } @@ -205,4 +211,19 @@ func TestStore_ListRollups(t *testing.T) { if got3[0].Display == nil || got3[0].Display.SubjectLabel != "default/pod-1" { t.Fatalf("rollup with normalized filters display = %#v, want subject label default/pod-1", got3[0].Display) } + if got3[0].Display == nil || got3[0].Display.ItemType != "pod" { + t.Fatalf("rollup with normalized filters display = %#v, want item type pod", got3[0].Display) + } + + got4, total4, err := store.ListRollups(context.Background(), recovery.ListPointsOptions{ + Page: 1, + Limit: 50, + ItemType: "dataset", + }) + if err != nil { + t.Fatalf("ListRollups(itemType) error = %v", err) + } + if total4 != 1 || len(got4) != 1 || got4[0].RollupID == "res:vm-1" { + t.Fatalf("ListRollups(itemType) = %#v total=%d, want only dataset rollup", got4, total4) + } } diff --git a/internal/recovery/store/store_series.go b/internal/recovery/store/store_series.go index 102065f68..17cf46717 100644 --- a/internal/recovery/store/store_series.go +++ b/internal/recovery/store/store_series.go @@ -83,6 +83,10 @@ func (s *Store) ListPointsSeries(ctx context.Context, opts recovery.ListPointsOp where = append(where, "namespace_label = ?") args = append(args, strings.TrimSpace(opts.NamespaceLabel)) } + if strings.TrimSpace(opts.ItemType) != "" { + where = append(where, "item_type = ?") + args = append(args, strings.TrimSpace(opts.ItemType)) + } if opts.WorkloadOnly { where = append(where, "is_workload = 1") } @@ -103,6 +107,7 @@ func (s *Store) ListPointsSeries(ctx context.Context, opts recovery.ListPointsOp LOWER(id) LIKE ? OR LOWER(subject_label) LIKE ? OR LOWER(subject_type) LIKE ? OR + LOWER(item_type) LIKE ? OR LOWER(cluster_label) LIKE ? OR LOWER(node_host_label) LIKE ? OR LOWER(namespace_label) LIKE ? OR @@ -111,7 +116,7 @@ func (s *Store) ListPointsSeries(ctx context.Context, opts recovery.ListPointsOp LOWER(details_summary) LIKE ? ) `) - for i := 0; i < 9; i++ { + for i := 0; i < 10; i++ { args = append(args, needle) } } @@ -266,6 +271,10 @@ func (s *Store) ListPointsFacets(ctx context.Context, opts recovery.ListPointsOp where = append(where, "namespace_label = ?") args = append(args, strings.TrimSpace(opts.NamespaceLabel)) } + if strings.TrimSpace(opts.ItemType) != "" { + where = append(where, "item_type = ?") + args = append(args, strings.TrimSpace(opts.ItemType)) + } if opts.WorkloadOnly { where = append(where, "is_workload = 1") } @@ -286,6 +295,7 @@ func (s *Store) ListPointsFacets(ctx context.Context, opts recovery.ListPointsOp LOWER(id) LIKE ? OR LOWER(subject_label) LIKE ? OR LOWER(subject_type) LIKE ? OR + LOWER(item_type) LIKE ? OR LOWER(cluster_label) LIKE ? OR LOWER(node_host_label) LIKE ? OR LOWER(namespace_label) LIKE ? OR @@ -294,7 +304,7 @@ func (s *Store) ListPointsFacets(ctx context.Context, opts recovery.ListPointsOp LOWER(details_summary) LIKE ? ) `) - for i := 0; i < 9; i++ { + for i := 0; i < 10; i++ { args = append(args, needle) } } @@ -339,6 +349,10 @@ func (s *Store) ListPointsFacets(ctx context.Context, opts recovery.ListPointsOp var facets recovery.PointsFacets var err1 error + facets.ItemTypes, err1 = distinctStrings("item_type") + if err1 != nil { + return recovery.PointsFacets{}, err1 + } facets.Clusters, err1 = distinctStrings("cluster_label") if err1 != nil { return recovery.PointsFacets{}, err1 diff --git a/internal/recovery/store/store_series_test.go b/internal/recovery/store/store_series_test.go index 2daa3d2b1..9d0393e39 100644 --- a/internal/recovery/store/store_series_test.go +++ b/internal/recovery/store/store_series_test.go @@ -114,4 +114,19 @@ func TestStore_ListSeriesAndFacets(t *testing.T) { if len(facets.NodesHosts) == 0 || facets.NodesHosts[0] != "truenas-1" { t.Fatalf("facets.NodesHosts = %#v, want truenas-1", facets.NodesHosts) } + if len(facets.ItemTypes) != 2 || facets.ItemTypes[0] != "dataset" || facets.ItemTypes[1] != "pvc" { + t.Fatalf("facets.ItemTypes = %#v, want [dataset pvc]", facets.ItemTypes) + } + + pvcOnly, err := store.ListPointsSeries(context.Background(), recovery.ListPointsOptions{ + From: &from, + To: &to, + ItemType: "pvc", + }, 0) + if err != nil { + t.Fatalf("ListPointsSeries(itemType) error = %v", err) + } + if len(pvcOnly) != 2 || pvcOnly[0].Total != 1 || pvcOnly[1].Total != 0 { + t.Fatalf("ListPointsSeries(itemType) = %#v, want only pvc activity on first day", pvcOnly) + } }