From 66fb198cc67da9cd2c12d6b6d123e33f6cbf98ff Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 26 May 2026 15:27:50 +0100 Subject: [PATCH] Delete orphaned Recovery aggregate surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform-first IA migration retired /recovery on 2026-05-25 (commit 8b0b1b0c6) but left the Recovery surface components and their guardrail tests in the tree, where they read like live code to anyone grepping the codebase. Recovery.tsx, RecoveryActivitySection, RecoveryHistorySection, RecoveryProtectedInventorySection, RecoveryHistoryTable, RecoveryHistoryItemFilter, RecoveryPointDetails, useRecoveryHistorySectionState, and features/recovery/useRecoverySurfaceState have no live consumer post-migration — each was only reachable through the retired /recovery route. Drops the orphan files, their test files, and the guardrail assertions in frontendResourceTypeBoundaries / PageControls / SharedPrimitives / TypeColumn that exercised the dead surface. Recovery-related utility modules (recoveryRecordPresentation, recoveryArtifactModePresentation, recoveryTablePresentation, etc.) remain — they are still consumed by the per-platform recovery integrations (Proxmox backups, etc.) and are not part of the dead aggregate surface. Updates a separate PageControls guardrail array to add the AgentsMachinesTable ColumnPicker consumer that landed in the Standalone rename series but wasn't reflected in the test's allowlist. --- .../src/components/Recovery/Recovery.tsx | 771 ------ .../Recovery/RecoveryActivitySection.tsx | 412 ---- .../Recovery/RecoveryHistoryItemFilter.tsx | 246 -- .../Recovery/RecoveryHistorySection.tsx | 441 ---- .../Recovery/RecoveryHistoryTable.tsx | 531 ----- .../Recovery/RecoveryPointDetails.test.tsx | 306 --- .../Recovery/RecoveryPointDetails.tsx | 1068 --------- .../RecoveryProtectedInventorySection.tsx | 594 ----- .../__tests__/Recovery.layout-guards.test.tsx | 174 -- .../Recovery/__tests__/Recovery.test.tsx | 2081 ----------------- .../RecoveryActivitySection.test.tsx | 151 -- ...RecoveryProtectedInventorySection.test.tsx | 80 - .../useRecoveryHistorySectionState.ts | 103 - .../shared/PageControls.guardrails.test.ts | 32 +- .../SharedPrimitives.guardrails.test.ts | 54 - .../shared/TypeColumn.guardrails.test.ts | 8 - .../recovery/useRecoverySurfaceState.ts | 612 ----- .../frontendResourceTypeBoundaries.test.ts | 204 -- .../recoveryCanonicalVocabulary.test.ts | 44 - 19 files changed, 5 insertions(+), 7907 deletions(-) delete mode 100644 frontend-modern/src/components/Recovery/Recovery.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx delete mode 100644 frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx delete mode 100644 frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx delete mode 100644 frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx delete mode 100644 frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx delete mode 100644 frontend-modern/src/components/Recovery/__tests__/RecoveryProtectedInventorySection.test.tsx delete mode 100644 frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts delete mode 100644 frontend-modern/src/features/recovery/useRecoverySurfaceState.ts delete mode 100644 frontend-modern/src/utils/__tests__/recoveryCanonicalVocabulary.test.ts diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx deleted file mode 100644 index c9b263be72..0000000000 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ /dev/null @@ -1,771 +0,0 @@ -import { Show, batch, createEffect, createMemo } from 'solid-js'; -import type { Component } from 'solid-js'; - -import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection'; -import { RecoveryHistorySection } from '@/components/Recovery/RecoveryHistorySection'; -import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { PageHeader } from '@/components/shared/PageHeader'; -import { TableCard } from '@/components/shared/TableCard'; -import type { FilterSelectOption } from '@/components/shared/FilterBar'; -import { - FilterSegmentedControl, - type FilterSegmentOption, -} from '@/components/shared/FilterToolbar'; -import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState'; -import type { - ProtectedStateFilter, - RecoveryWorkspaceView, -} from '@/features/recovery/useRecoverySurfaceState'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { useColumnVisibility } from '@/hooks/useColumnVisibility'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; -import { useKioskMode } from '@/hooks/useKioskMode'; -import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { - getRecoveryFilterDateLabel, - getRecoveryFullDateLabel, - getRecoveryNiceAxisMax, - parseRecoveryDateKey, - recoveryDateKeyFromTimestamp, - resolveRecoveryDateSearchKey, -} from '@/utils/recoveryDatePresentation'; -import { getRecoveryPointsFailureState } from '@/utils/recoveryEmptyStatePresentation'; -import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation'; -import { - getRecoveryPointItemLabel, - getRecoveryPointTimestampMs, - getRecoveryRollupItemLabel, -} from '@/utils/recoveryRecordPresentation'; -import { getRecoveryRollupItemTypeKey } from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryArtifactColumnLabel, - getRecoveryGroupNoTimestampLabel, - getRecoveryArtifactTableMinWidth, - getRecoveryRollupInventoryStatus, - STALE_ISSUE_THRESHOLD_MS, -} from '@/utils/recoveryTablePresentation'; -import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation'; -import { getRecoveryTimelineDayFilterLabel } from '@/utils/recoveryTimelinePresentation'; -import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel'; -import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition'; - -const MOBILE_RECOVERY_COLUMNS = new Set(['time', 'item', 'outcome']); -const LEGACY_RECOVERY_COLUMN_ID_ALIASES = { - subject: 'item', - source: 'platform', -} as const; -type RecoveryActivityRangeDays = 7 | 30 | 90 | 365; - -type RecoveryProps = { - embedded?: boolean; - tableOnly?: boolean; - forcedPlatformFilter?: string; - defaultWorkspaceView?: RecoveryWorkspaceView; - showEmbeddedWorkspaceSwitcher?: boolean; -}; - -const RECOVERY_WORKSPACE_OPTIONS: FilterSegmentOption[] = [ - { value: 'inventory', label: 'Protection', ariaLabel: 'Protection' }, - { value: 'events', label: 'Events', ariaLabel: 'Events' }, -]; - -const Recovery: Component = (props) => { - const kioskMode = useKioskMode(); - const { isMobile } = useBreakpoint(); - - const { - chartRangeDays, - clusterFilter, - clusterOptions, - currentPage, - facets, - historyOutcomeFilter, - historyItemOptions, - itemTypeFilter, - itemTypeOptions, - modeFilter, - namespaceFilter, - namespaceOptions, - nodeFilter, - nodeOptions, - protectedStateFilter, - platformFilter, - platformOptions, - queryFilter, - recoveryPoints, - recoveryRollups, - recoverySeries, - resourcesById, - rollupId, - rollups, - scopeFilter, - selectedDateKey, - selectedHistoryItemLabel, - setChartRangeDays, - setClusterFilter, - setCurrentPage, - setHistoryOutcomeFilter, - setItemTypeFilter, - setModeFilter, - setNamespaceFilter, - setNodeFilter, - setProtectedStateFilter, - setPlatformFilter, - setQueryFilter, - setRollupId, - setScopeFilter, - setSelectedDateKey, - setVerificationFilter, - setWorkspaceView, - showClusterFilter, - showNamespaceFilter, - showNodeFilter, - showVerificationFilter, - tableWindow, - totalPages, - verificationFilter, - workspaceView, - } = useRecoverySurfaceState({ - defaultWorkspaceView: props.defaultWorkspaceView, - }); - - createEffect(() => { - const forcedPlatform = props.forcedPlatformFilter?.trim(); - if (!forcedPlatform) return; - if (platformFilter() !== forcedPlatform) { - setPlatformFilter(forcedPlatform); - } - }); - - const baseRollups = createMemo(() => { - const query = queryFilter().trim().toLowerCase(); - const platform = platformFilter() === 'all' ? '' : platformFilter(); - const itemType = itemTypeFilter() === 'all' ? '' : itemTypeFilter(); - const resourceIndex = resourcesById(); - - const result = rollups().filter((rollup) => { - const platforms = getRecoveryRollupPlatforms(rollup) - .map((entry) => String(entry || '').trim()) - .filter(Boolean); - if (platform && !platforms.includes(platform)) return false; - const rollupItemType = getRecoveryRollupItemTypeKey(rollup); - if (itemType && rollupItemType !== itemType) return false; - - if (!query) return true; - const label = getRecoveryRollupItemLabel(rollup, resourceIndex); - const haystack = [ - rollup.rollupId, - rollup.itemResourceId || '', - label, - rollupItemType, - rollup.itemRef?.namespace || rollup.subjectRef?.namespace || '', - rollup.itemRef?.name || rollup.subjectRef?.name || '', - platforms.join(' '), - rollup.lastOutcome || '', - ] - .filter(Boolean) - .join(' ') - .toLowerCase(); - return haystack.includes(query); - }); - - return [...result].sort((left, right) => { - const leftTimestamp = left.lastAttemptAt ? Date.parse(left.lastAttemptAt) : 0; - const rightTimestamp = right.lastAttemptAt ? Date.parse(right.lastAttemptAt) : 0; - if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp; - return left.rollupId.localeCompare(right.rollupId); - }); - }); - - const summarizeRollups = (items: ProtectionRollup[]) => { - const nowMs = Date.now(); - const staleThreshold = nowMs - STALE_ISSUE_THRESHOLD_MS; - const counts: Record = { - success: 0, - warning: 0, - failed: 0, - running: 0, - unknown: 0, - }; - let stale = 0; - let neverSucceeded = 0; - - for (const rollup of items) { - counts[normalizeOutcome(rollup.lastOutcome)] += 1; - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - if (successMs > 0) { - if (successMs < staleThreshold) stale += 1; - } else if (attemptMs > 0) { - neverSucceeded += 1; - if (attemptMs < staleThreshold) stale += 1; - } - } - - return { total: items.length, counts, stale, neverSucceeded }; - }; - - const rollupsSummary = createMemo(() => summarizeRollups(baseRollups())); - const overallRollupsSummary = createMemo(() => summarizeRollups(rollups())); - - const filteredRollups = createMemo(() => { - const selectedState = protectedStateFilter(); - if (selectedState === 'all') return baseRollups(); - - const nowMs = Date.now(); - - return baseRollups().filter((rollup) => { - return getRecoveryRollupInventoryStatus(rollup, nowMs) === selectedState; - }); - }); - - const filteredPoints = createMemo(() => { - const points = recoveryPoints.points() || []; - const selected = selectedDateKey(); - if (!selected) return points; - const { from, to } = tableWindow(); - const fromMs = from ? Date.parse(from) : -Infinity; - const toMs = to ? Date.parse(to) : Infinity; - return points.filter((point) => { - const timestamp = getRecoveryPointTimestampMs(point); - return timestamp >= fromMs && timestamp <= toMs; - }); - }); - - const sortedPoints = createMemo(() => { - const resourceIndex = resourcesById(); - return [...filteredPoints()].sort((left, right) => { - const leftTimestamp = getRecoveryPointTimestampMs(left); - const rightTimestamp = getRecoveryPointTimestampMs(right); - if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp; - const leftName = getRecoveryPointItemLabel(left, resourceIndex); - const rightName = getRecoveryPointItemLabel(right, resourceIndex); - return leftName.localeCompare(rightName); - }); - }); - - const groupedByDay = createMemo(() => { - const groups: Array<{ - key: string; - label: string; - tone: 'recent' | 'default'; - items: RecoveryPoint[]; - }> = []; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const yesterday = today - 24 * 60 * 60 * 1000; - - const groupMap = new Map< - string, - { key: string; label: string; tone: 'recent' | 'default'; items: RecoveryPoint[] } - >(); - - for (const point of sortedPoints()) { - const key = point.completedAt - ? recoveryDateKeyFromTimestamp(Date.parse(point.completedAt)) - : 'unknown'; - if (!groupMap.has(key)) { - let label = getRecoveryGroupNoTimestampLabel(); - let tone: 'recent' | 'default' = 'default'; - if (key !== 'unknown') { - const date = parseRecoveryDateKey(key); - const dayTimestamp = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - ).getTime(); - if (dayTimestamp === today) { - label = `Today (${getRecoveryFullDateLabel(key)})`; - tone = 'recent'; - } else if (dayTimestamp === yesterday) { - label = `Yesterday (${getRecoveryFullDateLabel(key)})`; - tone = 'recent'; - } else { - label = getRecoveryFullDateLabel(key); - } - } - const group = { key, label, tone, items: [] as RecoveryPoint[] }; - groupMap.set(key, group); - groups.push(group); - } - groupMap.get(key)!.items.push(point); - } - - return groups; - }); - - const hasSizeData = createMemo(() => Boolean(facets().hasSize)); - const hasVerificationData = createMemo(() => Boolean(facets().hasVerification)); - const hasClusterData = createMemo(() => (facets().clusters || []).length > 0); - const hasNodeData = createMemo(() => (facets().nodesAgents || []).length > 0); - const hasNamespaceData = createMemo(() => (facets().namespaces || []).length > 0); - const hasEntityIDData = createMemo(() => Boolean(facets().hasEntityId)); - const recoveryRollupsLoaded = createMemo(() => recoveryRollups.resolvedOnce()); - const recoveryRollupsLoading = createMemo( - () => !recoveryRollupsLoaded() || recoveryRollups.rollups.loading, - ); - const recoverySeriesLoaded = createMemo(() => recoverySeries.resolvedOnce()); - const recoverySeriesLoading = createMemo( - () => !recoverySeriesLoaded() || recoverySeries.response.loading, - ); - const recoveryPointsLoaded = createMemo(() => recoveryPoints.resolvedOnce()); - const recoveryPointsLoading = createMemo( - () => !recoveryPointsLoaded() || recoveryPoints.response.loading, - ); - const recoveryPointsModel = { - meta: recoveryPoints.meta, - response: { - get loading() { - return recoveryPointsLoading(); - }, - get error() { - return recoveryPoints.response.error; - }, - }, - }; - - const artifactColumns: ColumnDef[] = [ - { id: 'time', label: 'Time' }, - { - ...createVisibleCanonicalTypeColumn(), - label: getRecoveryArtifactColumnLabel('type', 'Type'), - }, - { id: 'item', label: getRecoveryArtifactColumnLabel('item', 'Item') }, - { id: 'entityId', label: 'ID', toggleable: true }, - { - id: 'cluster', - label: getRecoveryArtifactColumnLabel('cluster', 'Cluster'), - toggleable: true, - }, - { - id: 'nodeAgent', - label: getRecoveryArtifactColumnLabel('nodeAgent', 'Node/Agent'), - toggleable: true, - }, - { - id: 'namespace', - label: getRecoveryArtifactColumnLabel('namespace', 'Namespace'), - toggleable: true, - }, - { id: 'platform', label: getRecoveryArtifactColumnLabel('platform', 'Platform') }, - { id: 'verified', label: 'Verified', toggleable: true }, - { id: 'size', label: 'Size', toggleable: true }, - { id: 'method', label: 'Method' }, - { id: 'repository', label: 'Target', toggleable: true }, - { id: 'details', label: 'Details', toggleable: true }, - { id: 'outcome', label: 'Outcome' }, - ]; - - const relevantArtifactColumnIDs = createMemo(() => { - const ids = new Set([ - 'time', - 'type', - 'item', - 'platform', - 'method', - 'repository', - 'details', - 'outcome', - ]); - if (hasVerificationData()) ids.add('verified'); - if (hasSizeData()) ids.add('size'); - if (hasClusterData()) ids.add('cluster'); - if (hasNodeData()) ids.add('nodeAgent'); - if (hasNamespaceData()) ids.add('namespace'); - if (hasEntityIDData()) ids.add('entityId'); - return ids; - }); - - const artifactColumnVisibility = useColumnVisibility( - STORAGE_KEYS.RECOVERY_HIDDEN_COLUMNS, - artifactColumns, - ['entityId', 'cluster', 'nodeAgent', 'namespace', 'size', 'details'], - relevantArtifactColumnIDs, - LEGACY_RECOVERY_COLUMN_ID_ALIASES, - ); - - const visibleArtifactColumns = createMemo(() => artifactColumnVisibility.visibleColumns()); - const mobileVisibleArtifactColumns = createMemo(() => - isMobile() - ? visibleArtifactColumns().filter((column) => MOBILE_RECOVERY_COLUMNS.has(column.id)) - : visibleArtifactColumns(), - ); - const tableColumnCount = createMemo(() => mobileVisibleArtifactColumns().length); - const tableMinWidth = createMemo(() => - isMobile() - ? 'auto' - : getRecoveryArtifactTableMinWidth(mobileVisibleArtifactColumns().map((column) => column.id)), - ); - - createEffect(() => { - if (currentPage() > totalPages()) setCurrentPage(totalPages()); - }); - - const timeline = createMemo(() => { - const points = (recoverySeries.series() || []).map((bucket) => ({ - key: String(bucket.day || '').trim(), - label: parseRecoveryDateKey(String(bucket.day || '').trim()).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - }), - total: Number(bucket.total || 0), - snapshot: Number(bucket.snapshot || 0), - local: Number(bucket.local || 0), - remote: Number(bucket.remote || 0), - })); - const maxValue = points.reduce((maximum, point) => Math.max(maximum, point.total), 0); - const axisMax = getRecoveryNiceAxisMax(maxValue); - const labelEvery = getRecoveryTimelineLabelEvery(points.length, isMobile()); - return { points, axisMax, labelEvery }; - }); - - const activitySummary = createMemo(() => { - const points = timeline().points; - const totalPoints = points.reduce((sum, point) => sum + point.total, 0); - const activeDays = points.filter((point) => point.total > 0).length; - const averagePerDay = points.length > 0 ? totalPoints / points.length : 0; - return { totalPoints, activeDays, averagePerDay }; - }); - - const selectedDateLabel = createMemo(() => { - const key = selectedDateKey(); - return key ? getRecoveryFilterDateLabel(key) : ''; - }); - - const dayFilterOptions = createMemo(() => { - const options = timeline().points.map((point) => ({ - value: point.key, - label: getRecoveryTimelineDayFilterLabel(getRecoveryFilterDateLabel(point.key), point.total), - count: point.total, - })); - - const selected = selectedDateKey(); - if (selected && !options.some((option) => option.value === selected)) { - options.push({ - value: selected, - label: getRecoveryTimelineDayFilterLabel(selectedDateLabel() || selected, 0), - count: 0, - }); - } - - return [{ value: 'all', label: 'Any day' }, ...options]; - }); - const setActivityRange = (range: RecoveryActivityRangeDays) => { - setChartRangeDays(range); - setSelectedDateKey(null); - setCurrentPage(1); - }; - - const setDayFilter = (key: string | null) => { - batch(() => { - setWorkspaceView('events'); - setSelectedDateKey(key); - setCurrentPage(1); - }); - }; - - const toggleDayFilter = (key: string) => { - setDayFilter(selectedDateKey() === key ? null : key); - }; - - const setHistorySearchFilter = (value: string) => { - const dayKey = resolveRecoveryDateSearchKey( - value, - timeline().points.map((point) => point.key), - ); - - if (dayKey) { - // Let the controlled search value observe the typed text before it is promoted to a filter. - setQueryFilter(value); - queueMicrotask(() => { - batch(() => { - setWorkspaceView('events'); - setSelectedDateKey(dayKey); - setQueryFilter(''); - setCurrentPage(1); - }); - }); - return; - } - - batch(() => { - setQueryFilter(value); - setCurrentPage(1); - }); - }; - - const hasActiveArtifactFilters = createMemo( - () => - rollupId().trim() !== '' || - queryFilter().trim() !== '' || - platformFilter() !== 'all' || - itemTypeFilter() !== 'all' || - clusterFilter() !== 'all' || - modeFilter() !== 'all' || - historyOutcomeFilter() !== 'all' || - scopeFilter() !== 'all' || - nodeFilter() !== 'all' || - namespaceFilter() !== 'all' || - verificationFilter() !== 'all' || - chartRangeDays() !== 30 || - selectedDateKey() !== null, - ); - - const activeAdvancedFilterCount = createMemo(() => { - let count = 0; - if (scopeFilter() !== 'all') count += 1; - if (modeFilter() !== 'all') count += 1; - if (verificationFilter() !== 'all') count += 1; - if (clusterFilter() !== 'all') count += 1; - if (nodeFilter() !== 'all') count += 1; - if (namespaceFilter() !== 'all') count += 1; - return count; - }); - - const resetAdvancedArtifactFilters = () => { - batch(() => { - setScopeFilter('all'); - setModeFilter('all'); - setVerificationFilter('all'); - setClusterFilter('all'); - setNodeFilter('all'); - setNamespaceFilter('all'); - setCurrentPage(1); - }); - }; - - const resetAllArtifactFilters = () => { - batch(() => { - setRollupId(''); - setQueryFilter(''); - setPlatformFilter('all'); - setItemTypeFilter('all'); - setClusterFilter('all'); - setModeFilter('all'); - setHistoryOutcomeFilter('all'); - setScopeFilter('all'); - setNodeFilter('all'); - setNamespaceFilter('all'); - setVerificationFilter('all'); - setProtectedStateFilter('all'); - setChartRangeDays(30); - setSelectedDateKey(null); - setCurrentPage(1); - }); - }; - - const openEventsView = () => { - batch(() => { - setWorkspaceView('events'); - setProtectedStateFilter('all'); - setCurrentPage(1); - }); - }; - - const openCoverageView = (state: ProtectedStateFilter = 'all') => { - batch(() => { - setWorkspaceView('inventory'); - setRollupId(''); - setSelectedDateKey(null); - setHistoryOutcomeFilter('all'); - setProtectedStateFilter(state); - setCurrentPage(1); - }); - }; - - const setEmbeddedWorkspaceView = (value: string) => { - if (value === 'inventory') { - openCoverageView('all'); - return; - } - openEventsView(); - }; - - const handleSelectRollup = (nextRollupId: string) => { - batch(() => { - setWorkspaceView('events'); - setRollupId(nextRollupId); - setProtectedStateFilter('all'); - setSelectedDateKey(null); - setCurrentPage(1); - }); - }; - - const eventsActivity = () => ( - - ); - - const headerActionClass = - 'inline-flex items-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover'; - - return ( -
- - - - - - - - - - } - /> - - -
- -
- -
-
- - {(() => { - return ( - <> - - recoveryRollups.rollups.error} - onOpenEvents={openEventsView} - onSelectRollup={handleSelectRollup} - protectedStateFilter={protectedStateFilter} - itemTypeFilter={itemTypeFilter} - itemTypeOptions={itemTypeOptions} - platformFilter={platformFilter} - platformOptions={platformOptions} - queryFilter={queryFilter} - resourcesById={resourcesById} - rollups={rollups} - rollupsSummary={rollupsSummary} - setItemTypeFilter={setItemTypeFilter} - setProtectedStateFilter={(value: ProtectedStateFilter) => - setProtectedStateFilter(value) - } - setPlatformFilter={setPlatformFilter} - setQueryFilter={setQueryFilter} - setVerificationFilter={setVerificationFilter} - /> - - - - {eventsActivity()} - - - -
- -
-
-
- - - rollupId().trim().length > 0} - historyOutcomeFilter={historyOutcomeFilter} - historyItemOptions={historyItemOptions} - isMobile={isMobile()} - kioskMode={kioskMode()} - mobileVisibleArtifactColumns={mobileVisibleArtifactColumns} - modeFilter={modeFilter} - itemTypeFilter={itemTypeFilter} - itemTypeOptions={itemTypeOptions} - namespaceFilter={namespaceFilter} - namespaceOptions={namespaceOptions} - nodeFilter={nodeFilter} - nodeOptions={nodeOptions} - platformFilter={platformFilter} - platformOptions={platformOptions} - queryFilter={queryFilter} - recoveryPoints={recoveryPointsModel} - relatedPoints={sortedPoints} - resetAdvancedArtifactFilters={resetAdvancedArtifactFilters} - resetAllArtifactFilters={resetAllArtifactFilters} - resourcesById={resourcesById} - rollupId={rollupId} - scopeFilter={scopeFilter} - selectedHistoryItemLabel={selectedHistoryItemLabel} - setClusterFilter={setClusterFilter} - setCurrentPage={setCurrentPage} - setHistoryOutcomeFilter={setHistoryOutcomeFilter} - setItemTypeFilter={setItemTypeFilter} - setModeFilter={setModeFilter} - setNamespaceFilter={setNamespaceFilter} - setNodeFilter={setNodeFilter} - setPlatformFilter={setPlatformFilter} - setQueryFilter={setHistorySearchFilter} - setRollupId={setRollupId} - setScopeFilter={setScopeFilter} - setDayFilter={setDayFilter} - setVerificationFilter={setVerificationFilter} - showClusterFilter={showClusterFilter} - showNamespaceFilter={showNamespaceFilter} - showNodeFilter={showNodeFilter} - showVerificationFilter={showVerificationFilter} - tableColumnCount={tableColumnCount} - tableMinWidth={tableMinWidth} - totalPages={totalPages} - verificationFilter={verificationFilter} - /> - -
- - ); - })()} -
-
- ); -}; - -export default Recovery; diff --git a/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx b/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx deleted file mode 100644 index 3af14d4dae..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import { createMemo, createSignal, For, Show } from 'solid-js'; -import type { Accessor, Component, JSX } from 'solid-js'; - -import { Card } from '@/components/shared/Card'; -import { - SUMMARY_CHART_PLOT_AREA_CLASS, - SUMMARY_CHART_SLOT_CLASS, -} from '@/components/shared/summaryChartLayout'; -import { TooltipPortal } from '@/components/shared/TooltipPortal'; -import { - getRecoveryActivityEmptyState, - getRecoveryActivityLoadingState, -} from '@/utils/recoveryEmptyStatePresentation'; -import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation'; -import { - getRecoveryPrettyDateLabel, - getRecoveryCompactAxisLabel, -} from '@/utils/recoveryDatePresentation'; -import { - getRecoveryTimelineAxisLabelClass, - getRecoveryTimelineAxisTicks, - getRecoveryTimelineChartGapPx, - getRecoveryTimelineChartMinWidthPx, - RECOVERY_TIMELINE_LEGEND_ITEM_CLASS, -} from '@/utils/recoveryTimelineChartPresentation'; -import { - getRecoveryTimelineBarMarkerClass, - getRecoveryTimelineDayFilterStateLabel, - getRecoveryTimelineColumnAriaLabel, - getRecoveryTimelineColumnButtonClass, - getRecoveryTimelineEmptyMarkerClass, - getRecoveryTimelinePointTotalLabel, - getRecoveryTimelineTooltipRows, -} from '@/utils/recoveryTimelinePresentation'; - -interface RecoveryRollupSummary { - total: number; - stale: number; - neverSucceeded: number; -} - -interface ActivitySummary { - totalPoints: number; - activeDays: number; - averagePerDay: number; -} - -interface TimelinePoint { - key: string; - label: string; - total: number; - snapshot: number; - local: number; - remote: number; -} - -interface TimelineModel { - points: TimelinePoint[]; - axisMax: number; - labelEvery: number; -} - -interface ActivityTooltipState { - dateLabel: string; - point: TimelinePoint; - x: number; - y: number; -} - -const RECOVERY_ACTIVITY_RANGE_DAYS = [7, 30, 90, 365] as const; - -interface RecoveryActivitySectionProps { - activitySummary: Accessor; - chartRangeDays: Accessor<7 | 30 | 90 | 365>; - isMobile: boolean; - loading: Accessor; - overallRollupsSummary: Accessor; - onRangeChange: (days: (typeof RECOVERY_ACTIVITY_RANGE_DAYS)[number]) => void; - dayFilterKey: Accessor; - toggleDayFilter: (key: string) => void; - timeline: Accessor; -} - -function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JSX.Element { - const [activityTooltip, setActivityTooltip] = createSignal(null); - const timelineAxisTicks = () => - getRecoveryTimelineAxisTicks( - props.timeline().points.length, - props.isMobile, - props.timeline().labelEvery, - ); - const chartMinWidthStyle = () => { - const minWidth = getRecoveryTimelineChartMinWidthPx( - props.isMobile, - props.chartRangeDays(), - props.timeline().points.length, - ); - return minWidth > 0 ? `${minWidth}px` : '100%'; - }; - const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.chartRangeDays())}px`; - const filteredTimelinePoint = createMemo(() => { - const selected = props.dayFilterKey(); - if (!selected) return null; - return props.timeline().points.find((point) => point.key === selected) ?? null; - }); - const timelineHasDayFilter = () => filteredTimelinePoint() !== null; - const showActivityTooltip = (target: HTMLElement, point: TimelinePoint, dateLabel: string) => { - const rect = target.getBoundingClientRect(); - setActivityTooltip({ - dateLabel, - point, - x: rect.left + rect.width / 2, - y: rect.top, - }); - }; - const hideActivityTooltip = () => setActivityTooltip(null); - const formatActivityAverage = (value: number): string => { - if (!Number.isFinite(value) || value <= 0) return '0/day'; - if (value >= 10) return `${Math.round(value)}/day`; - return `${Number(value.toFixed(1))}/day`; - }; - return ( - <> -
-
-
-
- Recovery Activity -
- - {props.activitySummary().totalPoints} recovery points - - {props.activitySummary().activeDays} active days - 0}> - - {props.overallRollupsSummary().stale} stale - - -
-
-
- - {(days) => { - const selected = () => props.chartRangeDays() === days; - return ( - - ); - }} - -
- - Avg {formatActivityAverage(props.activitySummary().averagePerDay)} - -
-
-
- -
-
-
-
- - {getRecoveryArtifactModePresentation('snapshot').aggregateLabel} -
-
- - {getRecoveryArtifactModePresentation('local').aggregateLabel} -
-
- - {getRecoveryArtifactModePresentation('remote').aggregateLabel} -
-
-
- - 0} - fallback={ -
- {props.loading() - ? getRecoveryActivityLoadingState().text - : getRecoveryActivityEmptyState().text} -
- } - > -
-
-
- - {(step) => { - const value = Math.round((props.timeline().axisMax * (2 - step)) / 2); - return {value}; - }} - -
- -
-
-
- - {(point) => { - const total = point.total; - const heightPct = - props.timeline().axisMax > 0 - ? (total / props.timeline().axisMax) * 100 - : 0; - const columnHeight = Math.max(0, Math.min(100, heightPct)); - const snapshotHeight = total > 0 ? (point.snapshot / total) * 100 : 0; - const localHeight = total > 0 ? (point.local / total) * 100 : 0; - const remoteHeight = total > 0 ? (point.remote / total) * 100 : 0; - const isSelected = () => props.dayFilterKey() === point.key; - const dateLabel = getRecoveryPrettyDateLabel(point.key); - - return ( -
- -
- ); - }} -
-
- -
- - {(tick) => { - const point = props.timeline().points[tick.index]; - const isSelected = () => props.dayFilterKey() === point.key; - const alignmentClass = - tick.align === 'start' - ? 'left-0 text-left' - : tick.align === 'end' - ? 'left-full -translate-x-full text-right' - : '-translate-x-1/2 text-center'; - - return ( - - {getRecoveryCompactAxisLabel(point.key, props.chartRangeDays())} - - ); - }} - -
-
-
-
- - - - {(tooltip) => ( -
-
-
-
- {tooltip().dateLabel} -
-
- {getRecoveryTimelinePointTotalLabel(tooltip().point.total)} -
-
-
- {getRecoveryTimelineDayFilterStateLabel( - props.dayFilterKey() === tooltip().point.key, - timelineHasDayFilter(), - )} -
-
- -
    - - {(row) => ( -
  • - - - {row.label} - - {row.value} -
  • - )} -
    -
-
- )} -
-
-
-
-
- - ); -} - -export const RecoveryActivitySection: Component = (props) => ( - - - -); diff --git a/frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx b/frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx deleted file mode 100644 index 7a2b49e543..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; -import type { Accessor, Component } from 'solid-js'; -import ChevronDownIcon from 'lucide-solid/icons/chevron-down'; - -import { SearchField } from '@/components/shared/SearchField'; -import { - filterGroupClass, - filterLabelClass, - filterPanelDescriptionClass, - filterPanelTitleClass, - filterSelectClass, -} from '@/components/shared/FilterToolbar'; -import { getRecoveryAnyItemLabel } from '@/utils/recoveryTablePresentation'; - -export interface RecoveryHistoryItemFilterOption { - rollupId: string; - label: string; - secondaryLabel?: string | null; - contextLabel?: string | null; -} - -interface RecoveryHistoryItemFilterProps { - options: Accessor; - selectedRollupId: Accessor; - selectedLabel: Accessor; - onSelect: (rollupId: string) => void; - onClear: () => void; -} - -const OPTION_LIMIT = 40; - -export const RecoveryHistoryItemFilter: Component = (props) => { - const [isOpen, setIsOpen] = createSignal(false); - const [query, setQuery] = createSignal(''); - const [activeIndex, setActiveIndex] = createSignal(0); - let rootRef: HTMLDivElement | undefined; - let searchInputRef: HTMLInputElement | undefined; - - const filteredOptions = createMemo(() => { - const normalizedQuery = query().trim().toLowerCase(); - const selected = props.selectedRollupId().trim(); - const ranked = props - .options() - .filter((option) => { - if (!normalizedQuery) return true; - const haystack = [ - option.label, - option.secondaryLabel || '', - option.contextLabel || '', - option.rollupId, - ] - .join(' ') - .toLowerCase(); - return haystack.includes(normalizedQuery); - }) - .sort((left, right) => { - const leftSelected = left.rollupId === selected ? 1 : 0; - const rightSelected = right.rollupId === selected ? 1 : 0; - if (leftSelected !== rightSelected) return rightSelected - leftSelected; - return left.label.localeCompare(right.label); - }); - - return ranked.slice(0, OPTION_LIMIT); - }); - - const close = () => { - setIsOpen(false); - setQuery(''); - setActiveIndex(0); - }; - - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node | null; - if (target && rootRef?.contains(target)) return; - close(); - }; - - createEffect(() => { - if (!isOpen()) return; - document.addEventListener('mousedown', handleClickOutside); - queueMicrotask(() => searchInputRef?.focus()); - onCleanup(() => document.removeEventListener('mousedown', handleClickOutside)); - }); - - onCleanup(() => { - document.removeEventListener('mousedown', handleClickOutside); - }); - - createEffect(() => { - filteredOptions(); - setActiveIndex(0); - }); - - return ( -
- Item - - - - - -
- ); -}; diff --git a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx deleted file mode 100644 index 9f9d867949..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx +++ /dev/null @@ -1,441 +0,0 @@ -import { Show, createMemo } from 'solid-js'; -import type { Accessor, Component } from 'solid-js'; - -import { ColumnPicker } from '@/components/shared/ColumnPicker'; -import { FilterBar, type FilterDef } from '@/components/shared/FilterBar'; -import type { FilterSelectOption } from '@/components/shared/FilterBar'; -import { TableCard } from '@/components/shared/TableCard'; -import { TableCardHeader } from '@/components/shared/TableCardHeader'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import type { Resource } from '@/types/resource'; -import { getRecoveryBreadcrumbLinkClass } from '@/utils/recoveryActionPresentation'; -import { - getRecoveryArtifactModePresentation, - type RecoveryArtifactMode, -} from '@/utils/recoveryArtifactModePresentation'; -import { - getRecoveryItemTypePresentation, - normalizeRecoveryItemTypeQueryValue, -} from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryLocationFacetAllLabel, - getRecoveryLocationFacetLabel, -} from '@/utils/recoveryLocationPresentation'; -import { normalizeRecoveryModeQueryValue } from '@/utils/recoveryRecordPresentation'; -import { - getRecoveryArtifactColumnLabel, - getRecoveryAllHistoryLabel, - getRecoveryAllItemTypesLabel, - getRecoveryAllPlatformsLabel, - getRecoveryHistorySearchPlaceholder, - getRecoverySearchHistoryEmptyMessage, -} from '@/utils/recoveryTablePresentation'; -import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; -import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms'; -import { - RecoveryHistoryTable, - type RecoveryPointGroup, - type RecoveryPointsModel, -} from '@/components/Recovery/RecoveryHistoryTable'; -import { - RecoveryHistoryItemFilter, - type RecoveryHistoryItemFilterOption, -} from '@/components/Recovery/RecoveryHistoryItemFilter'; -import { useRecoveryHistorySectionState } from '@/components/Recovery/useRecoveryHistorySectionState'; - -type ArtifactMode = RecoveryArtifactMode; -type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; - -interface ArtifactColumnVisibility { - availableToggles: () => ColumnDef[]; - isHiddenByUser: (id: string) => boolean; - toggle: (id: string) => void; - resetToDefaults: () => void; -} - -interface RecoveryHistorySectionProps { - activeAdvancedFilterCount: Accessor; - artifactColumnVisibility: ArtifactColumnVisibility; - availableOutcomes: readonly ('all' | 'success' | 'warning' | 'failed' | 'running')[]; - clusterFilter: Accessor; - clusterOptions: Accessor; - currentPage: Accessor; - dayFilterKey: Accessor; - dayFilterLabel: Accessor; - dayFilterOptions: Accessor; - groupedByDay: Accessor; - hasActiveArtifactFilters: Accessor; - hasFocusedRollup: Accessor; - historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; - historyItemOptions: Accessor; - itemTypeFilter: Accessor; - itemTypeOptions: Accessor; - isMobile: boolean; - kioskMode: boolean; - mobileVisibleArtifactColumns: Accessor; - modeFilter: Accessor<'all' | ArtifactMode>; - namespaceFilter: Accessor; - namespaceOptions: Accessor; - nodeFilter: Accessor; - nodeOptions: Accessor; - platformFilter: Accessor; - platformOptions: Accessor; - queryFilter: Accessor; - recoveryPoints: RecoveryPointsModel; - relatedPoints: Accessor; - resetAdvancedArtifactFilters: () => void; - resetAllArtifactFilters: () => void; - resourcesById: Accessor>; - rollupId: Accessor; - scopeFilter: Accessor<'all' | 'workload'>; - selectedHistoryItemLabel: Accessor; - 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; - setPlatformFilter: (value: string) => void; - setQueryFilter: (value: string) => void; - setRollupId: (value: string) => void; - setScopeFilter: (value: 'all' | 'workload') => void; - setDayFilter: (value: string | null) => void; - setVerificationFilter: (value: VerificationFilter) => void; - showClusterFilter: Accessor; - showNamespaceFilter: Accessor; - showNodeFilter: Accessor; - showVerificationFilter: Accessor; - tableColumnCount: Accessor; - tableMinWidth: Accessor; - totalPages: Accessor; - verificationFilter: Accessor; -} - -export const RecoveryHistorySection: Component = (props) => { - const { clearSelectedPoint, selectedPoint, toggleSelectedPoint } = useRecoveryHistorySectionState( - { - clusterFilter: props.clusterFilter, - currentPage: props.currentPage, - hasFocusedRollup: props.hasFocusedRollup, - historyOutcomeFilter: props.historyOutcomeFilter, - itemTypeFilter: props.itemTypeFilter, - modeFilter: props.modeFilter, - namespaceFilter: props.namespaceFilter, - nodeFilter: props.nodeFilter, - platformFilter: props.platformFilter, - queryFilter: props.queryFilter, - scopeFilter: props.scopeFilter, - verificationFilter: props.verificationFilter, - }, - ); - - const isMobileAccessor = createMemo(() => props.isMobile); - - const setItemType = (value: string) => { - props.setItemTypeFilter(normalizeRecoveryItemTypeQueryValue(value) || 'all'); - props.setCurrentPage(1); - }; - const setPlatform = (value: string) => { - props.setPlatformFilter(normalizeSourcePlatformQueryValue(value)); - props.setCurrentPage(1); - }; - const setOutcome = (value: string) => { - const outcome = value as 'all' | RecoveryOutcome; - props.setHistoryOutcomeFilter(outcome); - if (outcome !== 'all') props.setVerificationFilter('all'); - props.setCurrentPage(1); - }; - const setScope = (value: string) => { - props.setScopeFilter(value === 'workload' ? 'workload' : 'all'); - props.setCurrentPage(1); - }; - const setMode = (value: string) => { - props.setModeFilter(normalizeRecoveryModeQueryValue(value)); - props.setCurrentPage(1); - }; - const setVerification = (value: string) => { - props.setVerificationFilter(value as VerificationFilter); - if (value !== 'all') props.setHistoryOutcomeFilter('all'); - props.setCurrentPage(1); - }; - const setCluster = (value: string) => { - props.setClusterFilter(value); - props.setCurrentPage(1); - }; - const setNode = (value: string) => { - props.setNodeFilter(value); - props.setCurrentPage(1); - }; - const setNamespace = (value: string) => { - props.setNamespaceFilter(value); - props.setCurrentPage(1); - }; - const selectedDayFilterValue = () => props.dayFilterKey() ?? 'all'; - const setDay = (value: string) => { - props.setDayFilter(value === 'all' ? null : value); - }; - - const buildFilters = (): FilterDef[] => { - const filters: FilterDef[] = [ - { - id: 'day', - label: 'Day', - group: 'scope', - value: selectedDayFilterValue, - setValue: setDay, - defaultValue: 'all', - options: props.dayFilterOptions, - formatChipValue: (value, options) => - options.find((option) => option.value === value)?.label || - props.dayFilterLabel() || - value, - }, - { - id: 'item-type', - label: getRecoveryArtifactColumnLabel('type', 'Item Type'), - group: 'properties', - value: props.itemTypeFilter, - setValue: setItemType, - defaultValue: 'all', - options: () => - props.itemTypeOptions().map((itemType) => ({ - value: itemType, - label: - itemType === 'all' - ? getRecoveryAllItemTypesLabel() - : getRecoveryItemTypePresentation(itemType)?.label || itemType, - })), - }, - { - id: 'platform', - label: 'Platform', - group: 'scope', - value: props.platformFilter, - setValue: setPlatform, - defaultValue: 'all', - options: () => - props.platformOptions().map((platform) => ({ - value: platform, - label: - platform === 'all' - ? getRecoveryAllPlatformsLabel() - : getSourcePlatformLabel(platform), - })), - }, - { - id: 'outcome', - label: 'Status', - group: 'status', - value: props.historyOutcomeFilter, - setValue: setOutcome, - defaultValue: 'all', - options: () => - props.availableOutcomes.map((outcome) => ({ - value: outcome, - label: outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome), - })), - }, - { - id: 'scope', - label: 'Scope', - group: 'properties', - value: props.scopeFilter, - setValue: setScope, - defaultValue: 'all', - options: () => [ - { value: 'all', label: getRecoveryAllHistoryLabel() }, - { value: 'workload', label: 'Workloads only' }, - ], - }, - { - id: 'method', - label: 'Method', - group: 'properties', - value: props.modeFilter, - setValue: setMode, - defaultValue: 'all', - options: () => [ - { value: 'all', label: 'Any method' }, - { value: 'snapshot', label: getRecoveryArtifactModePresentation('snapshot').label }, - { value: 'local', label: getRecoveryArtifactModePresentation('local').label }, - { value: 'remote', label: getRecoveryArtifactModePresentation('remote').label }, - ], - }, - ]; - - if (props.showVerificationFilter()) { - filters.push({ - id: 'verification', - label: 'Verification', - group: 'status', - value: props.verificationFilter, - setValue: setVerification, - defaultValue: 'all', - options: () => [ - { value: 'all', label: 'Any verification' }, - { value: 'verified', label: 'Verified' }, - { value: 'unverified', label: 'Unverified' }, - { value: 'unknown', label: 'Unknown' }, - ], - }); - } - - if (props.showClusterFilter()) { - filters.push({ - id: 'cluster', - label: getRecoveryLocationFacetLabel('cluster'), - group: 'scope', - value: props.clusterFilter, - setValue: setCluster, - defaultValue: 'all', - options: () => [ - { value: 'all', label: getRecoveryLocationFacetAllLabel('cluster') }, - ...props - .clusterOptions() - .filter((value) => value !== 'all') - .map((cluster) => ({ value: cluster, label: cluster })), - ], - }); - } - - if (props.showNodeFilter()) { - filters.push({ - id: 'node', - label: getRecoveryLocationFacetLabel('node'), - group: 'scope', - value: props.nodeFilter, - setValue: setNode, - defaultValue: 'all', - options: () => [ - { value: 'all', label: getRecoveryLocationFacetAllLabel('node') }, - ...props - .nodeOptions() - .filter((value) => value !== 'all') - .map((node) => ({ value: node, label: node })), - ], - }); - } - - if (props.showNamespaceFilter()) { - filters.push({ - id: 'namespace', - label: getRecoveryLocationFacetLabel('namespace'), - group: 'scope', - value: props.namespaceFilter, - setValue: setNamespace, - defaultValue: 'all', - options: () => [ - { value: 'all', label: getRecoveryLocationFacetAllLabel('namespace') }, - ...props - .namespaceOptions() - .filter((value) => value !== 'all') - .map((namespace) => ({ value: namespace, label: namespace })), - ], - }); - } - - return filters; - }; - - return ( -
- - -
- - / - - {props.selectedHistoryItemLabel()} - -
-
- - { - props.setQueryFilter(value); - props.setCurrentPage(1); - }, - placeholder: getRecoveryHistorySearchPlaceholder(), - historyKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY, - emptyMessage: getRecoverySearchHistoryEmptyMessage(), - clearOnEscape: true, - }} - searchTrailing={ - { - props.setRollupId(value); - props.setCurrentPage(1); - }} - onClear={() => { - props.setRollupId(''); - props.setCurrentPage(1); - }} - /> - } - filters={buildFilters()} - viewOptionsTrailing={ - - } - onClearAll={() => { - if (props.hasActiveArtifactFilters()) { - props.resetAllArtifactFilters(); - } - }} - showClearAll={props.hasActiveArtifactFilters} - /> -
- - - - - -
- ); -}; diff --git a/frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx b/frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx deleted file mode 100644 index ec4aaedd0b..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { For, Show } from 'solid-js'; -import type { Accessor, Component } from 'solid-js'; - -import { RecoveryPointDetails } from '@/components/Recovery/RecoveryPointDetails'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; -import { formatBytes } from '@/utils/format'; -import { - getRecoveryDrawerCloseButtonClass, - getRecoveryEmptyStateActionClass, -} from '@/utils/recoveryActionPresentation'; -import { - getRecoveryHistoryEmptyState, - getRecoveryPointsLoadingState, -} from '@/utils/recoveryEmptyStatePresentation'; -import { getRecoveryOutcomeLabel } from '@/utils/recoveryOutcomePresentation'; -import { - getRecoveryPointDetailsSummary, - getRecoveryPointItemLabel, - getRecoveryPointItemSecondaryLabel, - getRecoveryPointModeLabel, - getRecoveryPointRepositoryLabel, - getRecoveryPointTimestampMs, -} from '@/utils/recoveryRecordPresentation'; -import { getRecoveryPointPlatform } from '@/utils/recoveryPlatformModel'; -import { - getRecoveryItemTypePresentation, - getRecoveryPointItemTypeKey, -} from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryArtifactColumnHeaderClass, - getRecoveryArtifactMetadataTextClass, - getRecoveryArtifactOutcomeTextClass, - getRecoveryArtifactRowClass, - getRecoveryEventTimeTextClass, - RECOVERY_GROUP_HEADER_ROW_CLASS, - RECOVERY_GROUP_HEADER_TEXT_CLASS, -} from '@/utils/recoveryTablePresentation'; -import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms'; -import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; -import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import type { Resource } from '@/types/resource'; -import { formatRecoveryTimeOnly } from '@/utils/recoveryDatePresentation'; - -export interface RecoveryPointGroup { - key: string; - label: string; - tone: 'recent' | 'default'; - items: RecoveryPoint[]; -} - -interface RecoveryPointsMeta { - page: number; - limit: number; - total: number; - totalPages: number; -} - -export interface RecoveryPointsModel { - meta: Accessor; - response: { - loading: boolean; - error: unknown; - }; -} - -interface RecoveryHistoryTableProps { - currentPage: Accessor; - dayFilterKey: Accessor; - groupedByDay: Accessor; - hasActiveArtifactFilters: Accessor; - isMobile: boolean; - mobileVisibleArtifactColumns: Accessor; - recoveryPoints: RecoveryPointsModel; - relatedPoints: Accessor; - resetAllArtifactFilters: () => void; - resourcesById: Accessor>; - selectedPoint: Accessor; - setCurrentPage: (value: number) => void; - tableColumnCount: Accessor; - tableMinWidth: Accessor; - toggleSelectedPoint: (point: RecoveryPoint) => void; - totalPages: Accessor; - clearSelectedPoint: () => void; -} - -export const RecoveryHistoryTable: Component = (props) => { - const visiblePointCount = () => - props.groupedByDay().reduce((total, group) => total + group.items.length, 0); - const useVisibleFooterCount = () => Boolean(props.dayFilterKey()); - const footerTotal = () => - useVisibleFooterCount() ? visiblePointCount() : props.recoveryPoints.meta().total || 0; - const footerPage = () => (useVisibleFooterCount() ? 1 : props.recoveryPoints.meta().page); - const footerLimit = () => - useVisibleFooterCount() - ? Math.max(visiblePointCount(), 1) - : props.recoveryPoints.meta().limit || 1; - const footerStart = () => - footerTotal() > 0 ? (footerPage() - 1) * footerLimit() + 1 : 0; - const footerEnd = () => Math.min(footerPage() * footerLimit(), footerTotal()); - - return ( - 0} - fallback={ -
- - - - } - /> - } - > -
{getRecoveryPointsLoadingState().text}
- -
- } - > - - - - - {(column) => ( - - {column.label} - - )} - - - - - - {(group) => ( - <> - - -
-
- - {group.label} - -
- - {group.items.length} event{group.items.length === 1 ? '' : 's'} - -
-
-
- - - {(point) => { - const resourceIndex = props.resourcesById(); - const item = getRecoveryPointItemLabel(point, resourceIndex); - const itemSecondary = getRecoveryPointItemSecondaryLabel(point); - const tsMs = getRecoveryPointTimestampMs(point); - const timeOnly = - point.completedAt && Number.isFinite(tsMs) ? formatRecoveryTimeOnly(tsMs) : '—'; - const itemTypePresentation = getRecoveryItemTypePresentation( - getRecoveryPointItemTypeKey(point), - ); - const platform = normalizeSourcePlatformQueryValue( - getRecoveryPointPlatform(point), - ); - const outcome = - (String(point.outcome || 'unknown').toLowerCase() as RecoveryOutcome) || - 'unknown'; - const outcomeVariant = (() => { - switch (outcome) { - case 'success': - return 'success' as const; - case 'warning': - return 'warning' as const; - case 'failed': - return 'danger' as const; - default: - return 'muted' as const; - } - })(); - const repoLabel = getRecoveryPointRepositoryLabel(point); - const detailsSummary = getRecoveryPointDetailsSummary(point); - const verificationLabel = - point.verified === true - ? 'Verified' - : point.verified === false - ? 'Verification failed' - : 'Verification unknown'; - const entityId = String(point.entityId || '').trim(); - const cluster = String(point.display?.clusterLabel || point.cluster || '').trim(); - const nodeAgent = String( - point.display?.nodeHostLabel || - point.display?.nodeAgentLabel || - point.node || - '', - ).trim(); - const namespace = String( - point.display?.namespaceLabel || point.namespace || '', - ).trim(); - - return ( - <> - props.toggleSelectedPoint(point)} - > - - {(column) => { - switch (column.id) { - case 'time': - return ( - - {timeOnly} - - ); - case 'type': - return ( - - - - ); - case 'item': - return ( - -
- -
-
- - {item} - - - - {itemSecondary} - - -
- -
- {getRecoveryPointModeLabel(point.mode)} - {getSourcePlatformLabel(platform)} - - {repoLabel} - - {verificationLabel} -
-
-
-
-
- ); - case 'entityId': - return ( - - {entityId || '—'} - - ); - case 'cluster': - return ( - - {cluster || '—'} - - ); - case 'nodeAgent': - return ( - - {nodeAgent || '—'} - - ); - case 'namespace': - return ( - - {namespace || '—'} - - ); - case 'platform': { - const badge = getSourcePlatformBadge(platform); - return ( - - - {badge?.label || getSourcePlatformLabel(platform)} - - - ); - } - case 'verified': - return ( - - {typeof point.verified === 'boolean' ? ( - point.verified ? ( - - - - - - ) : ( - - - - - - ) - ) : ( - - )} - - ); - case 'size': - return ( - - {point.sizeBytes && point.sizeBytes > 0 - ? formatBytes(point.sizeBytes) - : '—'} - - ); - case 'method': - return ( - - - {getRecoveryPointModeLabel(point.mode)} - - - ); - case 'repository': - return ( - - {repoLabel || '—'} - - ); - case 'details': - return ( - - {detailsSummary || '—'} - - ); - case 'outcome': - return ( - - - {titleCaseDelimitedLabel(outcome)} - - - ); - default: - return ( - - - - - ); - } - }} -
-
- - - - -
-

- Recovery Point Details -

- -
-
- -
-
-
-
- - ); - }} -
- - )} -
-
-
- -
-
- 0} fallback={Showing 0 of 0 recovery points}> - - Showing {footerStart()} - {footerEnd()} of {footerTotal()} recovery points - - -
-
- - - Page {props.currentPage()} / {props.totalPages()} - - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx deleted file mode 100644 index 47fe75b68b..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.test.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { render, screen } from '@solidjs/testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { Resource } from '@/types/resource'; -import { RecoveryPointDetails } from './RecoveryPointDetails'; - -const wsState = vi.hoisted(() => ({ resources: [] as Resource[] })); - -vi.mock('@/contexts/appRuntime', () => ({ - useWebSocket: () => ({ - state: wsState, - }), -})); - -describe('RecoveryPointDetails', () => { - beforeEach(() => { - wsState.resources = []; - }); - - it('renders platform-neutral details framing while preserving PBS-specific metadata', () => { - wsState.resources = [ - { - id: 'pbs-resource-1', - type: 'pbs', - name: 'pbs-main', - displayName: 'pbs-main', - platformId: 'pbs-main', - platformType: 'proxmox-pbs', - sourceType: 'api', - status: 'online', - lastSeen: Date.parse('2026-03-10T10:00:00Z'), - platformData: { - pbs: { - instanceId: 'pbs-main', - datastores: [ - { - name: 'fast-store', - used: 500, - total: 1000, - usage: 50, - status: 'ok', - deduplicationFactor: 2.25, - }, - ], - }, - }, - } as Resource, - ]; - - render(() => ( - - )); - - expect(screen.getByText('Target Details')).toBeInTheDocument(); - expect(screen.queryByText('Platform Details')).not.toBeInTheDocument(); - expect(screen.queryByText('PBS Details')).not.toBeInTheDocument(); - expect( - screen.getByText( - 'Repository owner, target capacity, and file inventory for this recovery point.', - ), - ).toBeInTheDocument(); - expect(screen.queryByText('Restore action path')).not.toBeInTheDocument(); - expect(screen.getByText('Restore readiness')).toBeInTheDocument(); - expect(screen.getByText('Verified candidate')).toBeInTheDocument(); - expect(screen.getByText('Verification provenance')).toBeInTheDocument(); - expect(screen.getByText('PBS catalog verification')).toBeInTheDocument(); - expect(screen.getAllByText('High confidence').length).toBeGreaterThan(0); - expect(screen.getByText('Catalog check passed')).toBeInTheDocument(); - expect(screen.queryByText('State: ok')).not.toBeInTheDocument(); - expect(screen.getByText('Target Health')).toBeInTheDocument(); - expect(screen.queryByText('UPID:')).not.toBeInTheDocument(); - expect(screen.getByText('Recovery metadata')).toBeInTheDocument(); - expect(screen.getByText('Item Type')).toBeInTheDocument(); - expect(screen.getByText('Cluster / Site')).toBeInTheDocument(); - expect(screen.getByText('Host / Agent')).toBeInTheDocument(); - expect(screen.getByText('Namespace / Group')).toBeInTheDocument(); - expect(screen.getAllByText('Outcome').length).toBeGreaterThan(0); - expect(screen.getByText('VM')).toBeInTheDocument(); - expect(screen.getByText('Lab Cluster')).toBeInTheDocument(); - expect(screen.getByText('pve-01')).toBeInTheDocument(); - expect(screen.getByText('Finance')).toBeInTheDocument(); - expect(screen.getByText('Backup / Remote Copy')).toBeInTheDocument(); - expect(screen.getAllByText('Success').length).toBeGreaterThan(0); - expect(screen.queryByText('Target Ref')).not.toBeInTheDocument(); - expect(screen.queryByText('Item Ref')).not.toBeInTheDocument(); - expect(screen.getByText('VMID')).toBeInTheDocument(); - expect(screen.getByText('Datastore: fast-store')).toBeInTheDocument(); - expect(screen.queryByText('Namespace')).not.toBeInTheDocument(); - expect(screen.queryByText('details.vmid')).not.toBeInTheDocument(); - - expect(screen.getAllByText('PBS').length).toBeGreaterThan(0); - }); - - it('surfaces restore readiness when a successful point has not been verified', () => { - render(() => ( - - )); - - expect(screen.queryByText('Restore action path')).not.toBeInTheDocument(); - expect(screen.getByText('Restore readiness')).toBeInTheDocument(); - expect(screen.getByText('Available candidate')).toBeInTheDocument(); - expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument(); - expect(screen.queryByText('Needs verification')).not.toBeInTheDocument(); - expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument(); - }); - - it('keeps PBS container identifiers and empty verification details operator-safe', () => { - render(() => ( - - )); - - expect(screen.getByText('Verification provenance')).toBeInTheDocument(); - expect(screen.getByText('PBS catalog verification')).toBeInTheDocument(); - expect(screen.getByText('Catalog check passed')).toBeInTheDocument(); - expect(screen.queryByText('State: ok')).not.toBeInTheDocument(); - expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument(); - expect(screen.getByText('CTID')).toBeInTheDocument(); - expect(screen.queryByText('VMID')).not.toBeInTheDocument(); - expect(screen.queryByText('Namespace / Group')).not.toBeInTheDocument(); - }); - - it('uses canonical platform labels without forcing provider detail panels for other platforms', () => { - render(() => ( - - )); - - expect(screen.queryByText('Target Details')).not.toBeInTheDocument(); - expect(screen.queryByText('Platform Details')).not.toBeInTheDocument(); - expect(screen.queryByText('PBS Details')).not.toBeInTheDocument(); - expect(screen.getByText('Item Type')).toBeInTheDocument(); - expect(screen.getByText('Host / Agent')).toBeInTheDocument(); - expect(screen.getByText('Dataset')).toBeInTheDocument(); - expect(screen.getByText('tn-scale-01')).toBeInTheDocument(); - expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0); - expect(screen.queryByText('Repository Ref')).not.toBeInTheDocument(); - - expect(screen.getAllByText('TrueNAS').length).toBeGreaterThan(0); - }); - - it('uses platform-aware PVE wording and suppresses meaningless task details', () => { - render(() => ( - - )); - - expect(screen.queryByText('Snapshot / Snapshot')).not.toBeInTheDocument(); - expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0); - expect(screen.getByText('Local snapshot on delly')).toBeInTheDocument(); - expect(screen.getByText('Protection chain')).toBeInTheDocument(); - expect(screen.getByText('Remote copy')).toBeInTheDocument(); - expect(screen.getByText(/Verified/)).toBeInTheDocument(); - }); - - it('turns generic PVE job errors into actionable failure copy and hides VMID zero', () => { - render(() => ( - - )); - - expect(screen.getByText('Failure detail')).toBeInTheDocument(); - expect( - screen.getAllByText(/Inspect the platform task log for the failing step/i).length, - ).toBeGreaterThan(0); - expect(screen.getByText('Not restorable')).toBeInTheDocument(); - expect(screen.queryByText('Investigate source task')).not.toBeInTheDocument(); - expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument(); - expect(screen.queryByText('VMID')).not.toBeInTheDocument(); - expect(screen.queryByText('0')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx b/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx deleted file mode 100644 index 3750859ee5..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx +++ /dev/null @@ -1,1068 +0,0 @@ -import type { Component } from 'solid-js'; -import { For, Show, createMemo, createSignal } from 'solid-js'; -import { useWebSocket } from '@/contexts/appRuntime'; -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, - getRecoveryPointItemTypeKey, -} from '@/utils/recoveryItemTypePresentation'; -import { getRecoveryPointLocationEntries } from '@/utils/recoveryLocationPresentation'; -import { - getRecoveryOutcomeTextClass, - normalizeRecoveryOutcome, -} from '@/utils/recoveryOutcomePresentation'; -import { - getRecoveryPointKindLabel, - getRecoveryPointModeLabel, - getRecoveryPointOutcomeLabel, - getRecoveryPointRepositoryLabel, -} from '@/utils/recoveryRecordPresentation'; -import { pbsInstanceFromResource } from '@/utils/resourceStateAdapters'; -import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; -import { getRecoveryPointPlatform } from '@/utils/recoveryPlatformModel'; - -interface RecoveryPointDetailsProps { - point: RecoveryPoint; - relatedPoints?: RecoveryPoint[]; -} - -const detailNumber = (p: RecoveryPoint, key: string): number | null => { - const value = p.details?.[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : null; -}; - -const detailString = (p: RecoveryPoint, key: string): string => { - const value = p.details?.[key]; - return typeof value === 'string' ? value.trim() : ''; -}; - -const COMMON_DETAIL_LABELS: Record = { - instance: 'Instance', - vmid: 'VMID', - node: 'Node', - snapshotName: 'Snapshot', - volid: 'Volume', - datastore: 'Datastore', - namespace: 'Namespace', - storage: 'Storage', - taskName: 'Task', - phase: 'Phase', - hostname: 'Hostname', - dataset: 'Dataset', - k8sClusterName: 'Kubernetes Cluster', - storageLocation: 'Storage Location', - veleroName: 'Velero Backup', -}; - -const getRecoveryPointNumericIdLabel = (p: RecoveryPoint): string => { - const itemTypeKey = getRecoveryPointItemTypeKey(p); - return itemTypeKey === 'system-container' ? 'CTID' : 'VMID'; -}; - -const formatDurationFromISO = ( - startedAt: string | null | undefined, - completedAt: string | null | undefined, -): string => { - const s = startedAt ? Date.parse(startedAt) : 0; - const c = completedAt ? Date.parse(completedAt) : 0; - if (!s || !c || c < s) return 'n/a'; - const seconds = Math.floor((c - s) / 1000); - if (seconds < 60) return '<1m'; - return formatUptime(seconds, true); -}; - -const labelForRef = (ref: RecoveryExternalRef | null | undefined): string => { - if (!ref) return 'n/a'; - const parts: string[] = []; - if (ref.type) parts.push(ref.type); - if (ref.namespace && ref.name) parts.push(`${ref.namespace}/${ref.name}`); - else if (ref.name) parts.push(ref.name); - if (ref.class) parts.push(`class=${ref.class}`); - if (ref.uid) parts.push(`uid=${ref.uid}`); - if (ref.id) parts.push(`id=${ref.id}`); - return parts.length > 0 ? parts.join(' ') : 'n/a'; -}; - -type RecoveryChainStage = 'snapshot' | 'local' | 'remote'; - -const CHAIN_STAGES: Array<{ id: RecoveryChainStage; label: string }> = [ - { id: 'snapshot', label: 'Local snapshot' }, - { id: 'local', label: 'Local copy' }, - { id: 'remote', label: 'Remote copy' }, -]; - -interface ProtectionChainStageSummary { - id: RecoveryChainStage; - label: string; - point: RecoveryPoint | null; - outcome: ReturnType | null; - outcomeLabel: string; - detail: string; -} - -interface RecoveryDetailPair { - k: string; - v: string; - valueClass?: string; -} - -const normalizeDetailText = (value: unknown): string => { - if (typeof value === 'string') return value.trim(); - if (typeof value === 'number' && Number.isFinite(value)) return String(value); - if (typeof value === 'boolean') return value ? 'true' : 'false'; - return ''; -}; - -const normalizeComparableText = (value: string): string => - value.trim().replace(/\s+/g, ' ').toLowerCase(); - -const getPointTimestampMs = (p: RecoveryPoint): number => { - const raw = p.completedAt || p.startedAt || ''; - const ms = raw ? Date.parse(raw) : 0; - return Number.isFinite(ms) ? ms : 0; -}; - -const getPointChainStage = (p: RecoveryPoint): RecoveryChainStage | null => { - const mode = String(p.mode || '') - .trim() - .toLowerCase(); - const kind = String(p.kind || '') - .trim() - .toLowerCase(); - if (mode === 'remote') return 'remote'; - if (mode === 'local') return 'local'; - if (mode === 'snapshot' || kind === 'snapshot') return 'snapshot'; - return null; -}; - -const getPointItemIdentity = (p: RecoveryPoint): string => { - const refs = [p.itemRef, p.subjectRef]; - const refKey = refs - .map((ref) => { - if (!ref) return ''; - if (ref.uid) return `uid:${ref.uid}`; - if (ref.id) return `id:${ref.id}`; - return [ref.type, ref.namespace, ref.name].filter(Boolean).join(':'); - }) - .find(Boolean); - return ( - String(p.itemResourceId || '').trim() || - refKey || - String(p.entityId || '').trim() || - String(p.display?.itemLabel || p.display?.subjectLabel || '').trim() || - p.id - ); -}; - -const getLocalPlacementLabel = (p: RecoveryPoint): string => - String( - p.display?.nodeHostLabel || - p.display?.nodeAgentLabel || - p.node || - p.details?.node || - p.details?.instance || - '', - ).trim(); - -const getArtifactSummaryLabel = (p: RecoveryPoint): string => { - const kindLabel = getRecoveryPointKindLabel(p.kind); - const modeLabel = getRecoveryPointModeLabel(p.mode); - return kindLabel === modeLabel ? kindLabel : `${kindLabel} / ${modeLabel}`; -}; - -const getVerificationTimestampMs = (p: RecoveryPoint): number => { - const timestampKeys = [ - 'verifiedAt', - 'lastVerifiedAt', - 'verificationAt', - 'verificationTime', - 'verificationTimestamp', - 'verificationStartedAt', - 'verificationCompletedAt', - ]; - - for (const key of timestampKeys) { - const value = p.details?.[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - const ms = value > 10_000_000_000 ? value : value * 1000; - if (Number.isFinite(ms) && ms > 0) return ms; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) continue; - const parsed = Date.parse(trimmed); - if (Number.isFinite(parsed) && parsed > 0) return parsed; - const numeric = Number(trimmed); - if (Number.isFinite(numeric) && numeric > 0) { - return numeric > 10_000_000_000 ? numeric : numeric * 1000; - } - } - } - - return 0; -}; - -const getTargetSummaryLabel = (p: RecoveryPoint): string => { - const repositoryLabel = getRecoveryPointRepositoryLabel(p); - if (repositoryLabel) return repositoryLabel; - const refLabel = labelForRef(p.repositoryRef); - if (refLabel !== 'n/a') return refLabel; - - const placement = getLocalPlacementLabel(p); - const stage = getPointChainStage(p); - if (stage === 'snapshot') return placement ? `Local snapshot on ${placement}` : 'Local snapshot'; - if (stage === 'local') return placement ? `Local copy on ${placement}` : 'Local copy'; - if (stage === 'remote') return 'Remote target not recorded'; - return 'Target not recorded'; -}; - -const formatChainPointDetail = (p: RecoveryPoint): string => { - const timestamp = getPointTimestampMs(p); - const platform = getSourcePlatformLabel( - normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(p)) || getRecoveryPointPlatform(p), - ); - const target = getTargetSummaryLabel(p); - const verification = - p.verified === true ? 'Verified' : p.verified === false ? 'Verification failed' : ''; - return [target, platform, timestamp > 0 ? formatAbsoluteTime(timestamp) : '', verification] - .filter(Boolean) - .join(' · '); -}; - -const formatVerificationStateEvidence = (p: RecoveryPoint, state: string): string => { - const normalizedState = state.trim().toLowerCase(); - const platform = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(p)); - const isPbs = platform === 'proxmox-pbs'; - if (['ok', 'passed', 'pass', 'success', 'successful', 'verified'].includes(normalizedState)) { - return isPbs ? 'Catalog check passed' : 'Verification passed'; - } - if (['failed', 'fail', 'error', 'errored'].includes(normalizedState)) { - return isPbs ? 'Catalog check failed' : 'Verification failed'; - } - if (['running', 'pending', 'started', 'in-progress', 'in_progress'].includes(normalizedState)) { - return isPbs ? 'Catalog check in progress' : 'Verification in progress'; - } - return `Verification state: ${state}`; -}; - -const getVerificationEvidenceLabel = (p: RecoveryPoint): string => { - const state = detailString(p, 'verificationState') || detailString(p, 'verificationStatus'); - if (state) return formatVerificationStateEvidence(p, state); - const rawVerification = - detailString(p, 'verification') || - detailString(p, 'verificationResult') || - detailString(p, 'verifyResult'); - if (rawVerification) return `Source: ${rawVerification}`; - const upid = detailString(p, 'verificationUpid') || detailString(p, 'verificationTaskId'); - if (upid) return 'Verification task reference recorded'; - if (p.verified === true) return 'Verified flag recorded by recovery ingest'; - if (p.verified === false) return 'Failed verification flag recorded by recovery ingest'; - return 'No verification evidence recorded'; -}; - -const hasVerificationEvidence = (p: RecoveryPoint): boolean => { - const directEvidenceKeys = [ - 'verificationState', - 'verificationStatus', - 'verification', - 'verificationResult', - 'verifyResult', - 'verificationUpid', - 'verificationTaskId', - ]; - return directEvidenceKeys.some((key) => detailString(p, key)) || p.verified != null; -}; - -const hasVerificationMethod = (p: RecoveryPoint): boolean => { - const explicitMethodKeys = ['verificationMethod', 'verificationSource', 'verifier']; - if (explicitMethodKeys.some((key) => detailString(p, key))) return true; - - const platform = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(p)); - if ( - platform === 'proxmox-pbs' && - (detailString(p, 'verificationState') || detailString(p, 'verificationUpid')) - ) { - return true; - } - return Boolean(detailString(p, 'verification') || p.verified != null); -}; - -const getVerificationMethodLabel = (p: RecoveryPoint): string => { - const explicit = - detailString(p, 'verificationMethod') || - detailString(p, 'verificationSource') || - detailString(p, 'verifier'); - if (explicit) return explicit; - - const platform = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(p)); - if ( - platform === 'proxmox-pbs' && - (detailString(p, 'verificationState') || detailString(p, 'verificationUpid')) - ) { - return 'PBS catalog verification'; - } - if (detailString(p, 'verification')) return 'Backup metadata verification flag'; - if (p.verified != null) return 'Recovery ingest verification flag'; - return 'No verifier recorded'; -}; - -const getVerificationConfidence = ( - p: RecoveryPoint, - outcome: ReturnType, -): { label: string; className: string } => { - if (p.verified === true) { - const hasSourceEvidence = - Boolean(detailString(p, 'verificationState')) || - Boolean(detailString(p, 'verification')) || - Boolean(detailString(p, 'verificationUpid')); - return { - label: hasSourceEvidence ? 'High confidence' : 'Recorded verified', - className: 'text-emerald-600 dark:text-emerald-400', - }; - } - if (p.verified === false) { - return { - label: 'Failed verification', - className: 'text-rose-600 dark:text-rose-400', - }; - } - if (outcome === 'success' || outcome === 'warning') { - return { - label: 'Needs verification', - className: 'text-amber-600 dark:text-amber-400', - }; - } - return { - label: 'Not assessed', - className: 'text-muted', - }; -}; - -const computeDatastoreUsagePercent = (datastore: PBSDatastore): number => { - if (Number.isFinite(datastore.total) && datastore.total > 0 && Number.isFinite(datastore.used)) { - const calculated = (datastore.used / datastore.total) * 100; - return Math.min(100, Math.max(0, calculated)); - } - if (Number.isFinite(datastore.usage)) { - return Math.min(100, Math.max(0, datastore.usage)); - } - return 0; -}; - -const usageBarColorClass = (usagePercent: number): string => { - if (usagePercent > 85) return 'bg-red-500'; - if (usagePercent >= 70) return 'bg-amber-500'; - return 'bg-emerald-500'; -}; - -export const RecoveryPointDetails: Component = (props) => { - const { state } = useWebSocket(); - const point = () => props.point; - const platformKey = createMemo(() => - normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(point())), - ); - const platformLabel = createMemo(() => - getSourcePlatformLabel(platformKey() || getRecoveryPointPlatform(point())), - ); - const platformBadge = createMemo(() => - getSourcePlatformBadge(platformKey() || getRecoveryPointPlatform(point())), - ); - const isPbsPlatform = createMemo(() => platformKey() === 'proxmox-pbs'); - - const pbsComment = createMemo(() => { - const value = point().details?.comment; - return typeof value === 'string' ? value.trim() : ''; - }); - - const pbsOwner = createMemo(() => { - const value = point().details?.owner; - return typeof value === 'string' ? value.trim() : ''; - }); - - const pbsFiles = createMemo(() => { - const value = point().details?.files; - if (!Array.isArray(value)) return []; - return value - .map((file) => (typeof file === 'string' ? file.trim() : String(file ?? '').trim())) - .filter(Boolean); - }); - - const matchedDatastore = createMemo<{ datastore: PBSDatastore; instanceName: string } | null>( - () => { - if (!isPbsPlatform() || typeof window === 'undefined') return null; - - const repositoryRef = point().repositoryRef; - const instanceName = - typeof repositoryRef?.namespace === 'string' ? repositoryRef.namespace.trim() : ''; - const datastoreName = - typeof repositoryRef?.name === 'string' ? repositoryRef.name.trim() : ''; - if (!instanceName || !datastoreName) return null; - - const instances = (state.resources || []) - .filter((resource) => resource.type === 'pbs') - .map(pbsInstanceFromResource) - .filter((instance): instance is NonNullable => Boolean(instance)); - const instance = instances.find((item) => item.name === instanceName); - if (!instance) return null; - - const datastore = (instance.datastores || []).find((item) => item.name === datastoreName); - if (!datastore) return null; - - return { datastore, instanceName: instance.name }; - }, - ); - - const startedMs = createMemo(() => { - const startedAt = point().startedAt; - return startedAt ? Date.parse(startedAt) : 0; - }); - - const completedMs = createMemo(() => { - const completedAt = point().completedAt; - return completedAt ? Date.parse(completedAt) : 0; - }); - - const sizeBytes = createMemo(() => { - const top = point().sizeBytes; - if (typeof top === 'number' && Number.isFinite(top)) return top; - return detailNumber(point(), 'sizeBytes'); - }); - const itemLabel = createMemo(() => { - const displayLabel = String(point().display?.itemLabel || '').trim(); - if (displayLabel) return displayLabel; - const refLabel = labelForRef(point().itemRef || point().subjectRef); - if (refLabel !== 'n/a') return refLabel; - return point().itemResourceId || point().id; - }); - const pbsDisplayComment = createMemo(() => { - const comment = pbsComment(); - if (!comment) return ''; - const duplicateCandidates = [ - itemLabel(), - typeof point().display?.detailsSummary === 'string' ? point().display?.detailsSummary : '', - ]; - const normalizedComment = normalizeComparableText(comment); - const isDuplicate = duplicateCandidates.some( - (candidate) => candidate && normalizeComparableText(candidate) === normalizedComment, - ); - return isDuplicate ? '' : comment; - }); - const hasPlatformDetails = createMemo( - () => - isPbsPlatform() && - (pbsDisplayComment().length > 0 || - pbsOwner().length > 0 || - point().immutable === true || - pbsFiles().length > 0 || - matchedDatastore() != null), - ); - const targetLabel = createMemo(() => getTargetSummaryLabel(point())); - const normalizedOutcome = createMemo(() => normalizeRecoveryOutcome(point().outcome)); - const outcomeLabel = createMemo(() => getRecoveryPointOutcomeLabel(point().outcome)); - const readiness = createMemo(() => { - const outcome = normalizedOutcome(); - if (outcome === 'failed') { - return { - label: 'Not restorable', - detail: 'No usable recovery point was produced.', - className: 'text-rose-600 dark:text-rose-400', - }; - } - if (outcome === 'running') { - return { - label: 'In progress', - detail: 'Recovery metadata is still running.', - className: 'text-blue-600 dark:text-blue-400', - }; - } - if (point().verified === true) { - return { - label: 'Verified candidate', - detail: 'Verification completed for this point.', - className: 'text-emerald-600 dark:text-emerald-400', - }; - } - if (point().verified === false) { - return { - label: 'Verification failed', - detail: 'Use another point until verification passes.', - className: 'text-amber-600 dark:text-amber-400', - }; - } - if (outcome === 'success') { - return { - label: 'Available candidate', - detail: 'Succeeded; verification is not recorded.', - className: 'text-emerald-600 dark:text-emerald-400', - }; - } - return { - label: 'Review required', - detail: 'Review the evidence before restore use.', - className: 'text-amber-600 dark:text-amber-400', - }; - }); - const failureDetail = createMemo(() => { - const p = point(); - const directKeys = [ - 'error', - 'errorMessage', - 'failure', - 'failureReason', - 'message', - 'taskError', - 'taskStatus', - 'statusMessage', - 'lastError', - ]; - for (const key of directKeys) { - const value = detailString(p, key); - if (value) return value; - } - - const errors = p.details?.errors; - if (Array.isArray(errors)) { - const joined = errors - .map((entry) => normalizeDetailText(entry)) - .filter(Boolean) - .slice(0, 3) - .join('; '); - if (joined) return joined; - } - - const status = detailString(p, 'status'); - if (normalizedOutcome() === 'failed' && status) { - const genericJobErrors = status.toLowerCase() === 'job errors'; - if (genericJobErrors) { - const taskName = detailString(p, 'taskName'); - const upid = detailString(p, 'upid') || detailString(p, 'taskId'); - return [ - 'Source task reported job errors.', - taskName ? `Task: ${taskName}.` : '', - upid ? `UPID: ${upid}.` : '', - 'Inspect the platform task log for the failing step.', - ] - .filter(Boolean) - .join(' '); - } - return status; - } - return ''; - }); - const protectionChain = createMemo(() => { - const selected = point(); - const selectedIdentity = getPointItemIdentity(selected); - const byId = new Map(); - byId.set(selected.id, selected); - for (const related of props.relatedPoints || []) { - if (getPointItemIdentity(related) !== selectedIdentity) continue; - byId.set(related.id, related); - } - - const candidates = [...byId.values()]; - return CHAIN_STAGES.map((stage): ProtectionChainStageSummary => { - const stagePoint = - candidates - .filter((candidate) => getPointChainStage(candidate) === stage.id) - .sort((left, right) => getPointTimestampMs(right) - getPointTimestampMs(left))[0] || null; - const outcome = stagePoint ? normalizeRecoveryOutcome(stagePoint.outcome) : null; - return { - ...stage, - point: stagePoint, - outcome, - outcomeLabel: stagePoint ? getRecoveryPointOutcomeLabel(stagePoint.outcome) : '', - detail: stagePoint ? formatChainPointDetail(stagePoint) : 'Not shown in current result set', - }; - }); - }); - const visibleProtectionChain = createMemo(() => - protectionChain().filter((stage) => stage.point !== null), - ); - const hasProtectionChain = createMemo(() => visibleProtectionChain().length > 1); - const verificationTimestamp = createMemo(() => getVerificationTimestampMs(point())); - const verificationConfidence = createMemo(() => - getVerificationConfidence(point(), normalizedOutcome()), - ); - const hasVerificationProvenance = createMemo( - () => - verificationTimestamp() > 0 || - hasVerificationMethod(point()) || - hasVerificationEvidence(point()), - ); - const verificationProvenancePairs = createMemo(() => { - const pairs: RecoveryDetailPair[] = [ - { - k: 'Verifier', - v: getVerificationMethodLabel(point()), - valueClass: 'text-base-content', - }, - ]; - - if (verificationTimestamp() > 0) { - pairs.push({ - k: 'Checked', - v: formatAbsoluteTime(verificationTimestamp()), - valueClass: 'text-base-content', - }); - } - - pairs.push({ - k: 'Evidence', - v: getVerificationEvidenceLabel(point()), - valueClass: 'text-base-content', - }); - - return pairs; - }); - const operatorSummaryPairs = createMemo(() => [ - { - k: 'Outcome', - v: outcomeLabel(), - valueClass: getRecoveryOutcomeTextClass(normalizedOutcome()), - detail: formatDurationFromISO(point().startedAt, point().completedAt), - }, - { - k: 'Artifact', - v: getArtifactSummaryLabel(point()), - valueClass: 'text-base-content', - detail: itemLabel(), - }, - { - k: 'Target', - v: targetLabel(), - valueClass: 'text-base-content', - detail: platformLabel() || 'n/a', - }, - { - k: 'Restore readiness', - v: readiness().label, - valueClass: readiness().className, - detail: readiness().detail, - }, - ]); - - const metadataPairs = createMemo(() => { - const p = point(); - const pairs: RecoveryDetailPair[] = []; - const seen = new Set(); - const addPair = (k: string, v: string, valueClass = 'text-base-content') => { - const trimmedValue = v.trim(); - if (!trimmedValue || trimmedValue === 'n/a') return; - const key = `${k}:${normalizeComparableText(trimmedValue)}`; - if (seen.has(key)) return; - seen.add(key); - pairs.push({ k, v: trimmedValue, valueClass }); - }; - const itemType = getRecoveryItemTypeLabel(getRecoveryPointItemTypeKey(p)); - const locationEntries = getRecoveryPointLocationEntries(p); - const visibleLocationEntries: typeof locationEntries = []; - const locationValues = new Set(); - for (const entry of locationEntries) { - const normalizedValue = normalizeComparableText(entry.value); - if (entry.key === 'namespace' && locationValues.has(normalizedValue)) continue; - visibleLocationEntries.push(entry); - locationValues.add(normalizedValue); - } - const placementValues = new Set( - visibleLocationEntries.map((entry) => normalizeComparableText(entry.value)), - ); - const targetDatastoreValues = [ - typeof p.repositoryRef?.name === 'string' ? p.repositoryRef.name.trim() : '', - matchedDatastore()?.datastore.name || '', - ] - .filter(Boolean) - .map(normalizeComparableText); - - if (itemType && itemType !== 'Unknown') addPair('Item Type', itemType); - for (const entry of visibleLocationEntries) { - addPair(entry.label, entry.value); - } - if (typeof sizeBytes() === 'number') addPair('Size', formatBytes(sizeBytes()!)); - if (p.encrypted != null) addPair('Encryption', p.encrypted ? 'Encrypted' : 'Not encrypted'); - if (p.immutable === true) addPair('Retention', 'Immutable'); - if (startedMs() > 0) addPair('Started', formatAbsoluteTime(startedMs())); - if (completedMs() > 0) addPair('Completed', formatAbsoluteTime(completedMs())); - - const commonDetailKeys = [ - 'vmid', - 'snapshotName', - 'volid', - 'datastore', - 'storage', - 'taskName', - 'phase', - 'hostname', - 'dataset', - 'k8sClusterName', - 'storageLocation', - 'veleroName', - ]; - for (const k of commonDetailKeys) { - const v = p.details?.[k]; - if (v == null) continue; - const displayValue = normalizeDetailText(v); - if (!displayValue) continue; - if (k === 'vmid' && displayValue === '0') continue; - if ( - k === 'datastore' && - targetDatastoreValues.includes(normalizeComparableText(displayValue)) - ) { - continue; - } - if (placementValues.has(normalizeComparableText(displayValue))) continue; - addPair( - k === 'vmid' ? getRecoveryPointNumericIdLabel(p) : COMMON_DETAIL_LABELS[k] || k, - displayValue, - ); - } - - return pairs; - }); - - const prettyJSON = createMemo(() => { - const p = point(); - const payload = { - ...p, - details: p.details || undefined, - }; - try { - return JSON.stringify(payload, null, 2); - } catch { - return String(payload); - } - }); - - const [copied, setCopied] = createSignal(false); - const [showPbsFiles, setShowPbsFiles] = createSignal(false); - const copyJSON = async () => { - try { - await navigator.clipboard.writeText(prettyJSON()); - setCopied(true); - window.setTimeout(() => setCopied(false), 1200); - } catch { - // Ignore clipboard failures (e.g. insecure context). - } - }; - - return ( -
-
-
-
-
- Recovery Point Summary -
-
{itemLabel()}
-
- - {(badge) => ( - - {badge().label} - - )} - -
-
- - {(pair) => ( -
-
- {pair.k} -
-
{pair.v}
-
{pair.detail}
-
- )} -
-
- -
-
Failure detail
-
{failureDetail()}
-
-
- -
-
-
-
Protection chain
-
- Matching stages for this item in the current result set. -
-
-
-
- - {(stage) => ( -
-
- {stage.label} - - {(outcome) => ( - - {stage.outcomeLabel} - - )} - -
-
- {stage.detail} -
-
- )} -
-
-
-
-
- - 0}> -
-
- Recovery metadata -
-
- - {(pair) => ( -
-
- {pair.k} -
-
- {pair.v} -
-
- )} -
-
-
-
- - -
-
-
-
- Verification provenance -
-
- Recorded verification evidence for this recovery point. -
-
- - {verificationConfidence().label} - -
-
- - {(pair) => ( -
-
- {pair.k} -
-
- {pair.v} -
-
- )} -
-
-
-
- - -
-
-
-
- Target Details -
-
- Repository owner, target capacity, and file inventory for this recovery point. -
-
- - {(badge) => ( - - {badge().label} - - )} - -
-
- -
- {pbsDisplayComment()} -
-
- - -
-
- Repository owner -
-
- {pbsOwner()} -
-
-
- - -
- - - Protected - -
-
- - - {(match) => { - const datastore = match().datastore; - const usagePercent = computeDatastoreUsagePercent(datastore); - const status = String(datastore.status || '').trim(); - const dedupFactor = datastore.deduplicationFactor; - return ( -
-
-
- Target Health -
- 0}> - - {status} - - -
- -
- Datastore: {datastore.name} -
- -
-
- Space Usage -
-
- {formatBytes(datastore.used, 2)} / {formatBytes(datastore.total, 2)} ( - {Math.round(usagePercent)}%) -
-
-
-
-
- - 0 - } - > -
-
- Dedup Factor -
-
- {Number(dedupFactor).toFixed(2)}x -
-
-
-
- ); - }} - - - 0}> -
- - -
-
    - - {(file) => ( -
  • {file}
  • - )} -
    -
-
-
-
-
-
-
- - -
- - Technical details - -
-
- -
-
-            {prettyJSON()}
-          
-
-
-
- ); -}; - -export default RecoveryPointDetails; diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx deleted file mode 100644 index fbf6b50405..0000000000 --- a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx +++ /dev/null @@ -1,594 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; -import type { Accessor, Component } from 'solid-js'; - -import { EmptyState } from '@/components/shared/EmptyState'; -import { FilterBar, type FilterDef } from '@/components/shared/FilterBar'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { TableCard } from '@/components/shared/TableCard'; -import { TableCardHeader } from '@/components/shared/TableCardHeader'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; -import type { ProtectionRollup } from '@/types/recovery'; -import type { Resource } from '@/types/resource'; -import { - getRecoveryRollupInventoryStatusLabel, - getRecoveryRollupInventoryStatusTextClass, - getRecoveryRollupInventoryStatusVariant, - getRecoveryRollupStatusPillClass, - getRecoverySpecialOutcomeTextClass, -} from '@/utils/recoveryStatusPresentation'; -import { - getRecoveryProtectedItemsEmptyState, - getRecoveryProtectedItemsFailureState, - getRecoveryProtectedItemsLoadingState, -} from '@/utils/recoveryEmptyStatePresentation'; -import { - getRecoveryItemTypePresentation, - getRecoveryRollupItemTypeKey, - normalizeRecoveryItemTypeQueryValue, -} from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryArtifactColumnLabel, - getRecoveryRollupInventoryStatus, - getRecoveryRollupInventoryPriority, - getRecoveryRollupAgeTextClass, - getRecoveryProtectedSearchPlaceholder, - getRecoverySearchHistoryEmptyMessage, - getRecoveryRollupTimestampMs, - getRecoveryAllItemTypesLabel, - getRecoveryAllPlatformsLabel, - STALE_ISSUE_THRESHOLD_MS, - type RecoveryRollupInventoryStatus, -} from '@/utils/recoveryTablePresentation'; -import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation'; -import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel'; -import { - getRecoveryRollupItemLabel, - getRecoveryRollupItemSecondaryLabel, -} from '@/utils/recoveryRecordPresentation'; -import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; - -type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; -type ProtectedStateFilter = 'all' | RecoveryRollupInventoryStatus; -type ProtectedSortCol = 'item' | 'type' | 'platform' | 'lastBackup' | 'outcome'; -type SortDir = 'asc' | 'desc'; - -interface RecoveryRollupSummary { - total: number; - counts: Record; - stale: number; - neverSucceeded: number; -} - -interface RecoveryProtectedInventorySectionProps { - filteredRollups: Accessor; - itemTypeFilter: Accessor; - itemTypeOptions: Accessor; - isMobile: boolean; - kioskMode: boolean; - onOpenEvents?: () => void; - onSelectRollup: (rollupId: string) => void; - protectedStateFilter: Accessor; - platformFilter: Accessor; - platformOptions: Accessor; - queryFilter: Accessor; - resourcesById: Accessor>; - rollups: Accessor; - rollupsSummary: Accessor; - setItemTypeFilter: (value: string) => void; - setProtectedStateFilter: (value: ProtectedStateFilter) => void; - setPlatformFilter: (value: string) => void; - setQueryFilter: (value: string) => void; - setVerificationFilter: (value: VerificationFilter) => void; - loading: Accessor; - error: Accessor; -} - -const availableProtectionStates = [ - 'all', - 'healthy', - 'stale', - 'never-succeeded', - 'failed', - 'warning', - 'running', - 'unknown', -] as const satisfies readonly ProtectedStateFilter[]; -const STALE_THRESHOLD_DAYS = Math.max( - 1, - Math.round(STALE_ISSUE_THRESHOLD_MS / (24 * 60 * 60 * 1000)), -); - -const getProtectionInsight = ( - rollup: ProtectionRollup, - inventoryStatus: ReturnType, - nowMs: number, -): string => { - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - - if (inventoryStatus === 'never-succeeded') { - return attemptMs > 0 - ? `No successful point recorded after ${formatRelativeTime(attemptMs)}` - : 'No successful point recorded yet'; - } - - if (inventoryStatus === 'stale') { - if (successMs > 0 && Number.isFinite(successMs)) { - const ageDays = Math.max(1, Math.floor((nowMs - successMs) / (24 * 60 * 60 * 1000))); - return `Last success is ${ageDays} days old`; - } - return `No successful point within ${STALE_THRESHOLD_DAYS} days`; - } - - if (inventoryStatus === 'failed') return 'Latest protection event failed'; - if (inventoryStatus === 'warning') return 'Latest event completed with warnings'; - if (inventoryStatus === 'running') return 'Protection event in progress'; - return ''; -}; - -const normalizeProtectedStateFilter = (value: string): ProtectedStateFilter => { - const normalized = value.trim().toLowerCase(); - if (normalized === 'success') return 'healthy'; - return availableProtectionStates.includes(normalized as ProtectedStateFilter) - ? (normalized as ProtectedStateFilter) - : 'all'; -}; - -export const RecoveryProtectedInventorySection: Component< - RecoveryProtectedInventorySectionProps -> = (props) => { - const [protectedSortCol, setProtectedSortCol] = createSignal('outcome'); - const [protectedSortDir, setProtectedSortDir] = createSignal('desc'); - - const toggleProtectedSort = (col: ProtectedSortCol) => { - if (protectedSortCol() === col) { - setProtectedSortDir((direction) => (direction === 'asc' ? 'desc' : 'asc')); - } else { - setProtectedSortCol(col); - setProtectedSortDir('asc'); - } - }; - - const isMobileAccessor = createMemo(() => props.isMobile); - - const sortedRollups = createMemo(() => { - const items = props.filteredRollups().slice(); - const sortColumn = protectedSortCol(); - const sortDirection = protectedSortDir(); - const resourceIndex = props.resourcesById(); - const multiplier = sortDirection === 'asc' ? 1 : -1; - - items.sort((left, right) => { - switch (sortColumn) { - case 'item': { - const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase(); - const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase(); - return multiplier * leftLabel.localeCompare(rightLabel); - } - case 'type': { - const leftType = getRecoveryItemTypePresentation( - getRecoveryRollupItemTypeKey(left), - )?.label.toLowerCase(); - const rightType = getRecoveryItemTypePresentation( - getRecoveryRollupItemTypeKey(right), - )?.label.toLowerCase(); - return multiplier * (leftType || '').localeCompare(rightType || ''); - } - case 'platform': { - const leftSource = getRecoveryRollupPlatforms(left) - .map((platform) => getSourcePlatformLabel(String(platform))) - .sort() - .join(','); - const rightSource = getRecoveryRollupPlatforms(right) - .map((platform) => getSourcePlatformLabel(String(platform))) - .sort() - .join(','); - return multiplier * leftSource.localeCompare(rightSource); - } - case 'lastBackup': { - const leftSuccess = left.lastSuccessAt ? Date.parse(left.lastSuccessAt) : 0; - const rightSuccess = right.lastSuccessAt ? Date.parse(right.lastSuccessAt) : 0; - return multiplier * (leftSuccess - rightSuccess); - } - case 'outcome': { - const leftPriority = getRecoveryRollupInventoryPriority(left); - const rightPriority = getRecoveryRollupInventoryPriority(right); - if (leftPriority !== rightPriority) { - return multiplier * (leftPriority - rightPriority); - } - - const leftTimestamp = getRecoveryRollupTimestampMs(left); - const rightTimestamp = getRecoveryRollupTimestampMs(right); - const naturalTieBreak = - leftPriority === 4 ? leftTimestamp - rightTimestamp : rightTimestamp - leftTimestamp; - if (naturalTieBreak !== 0) return multiplier * naturalTieBreak; - - const leftOutcome = normalizeRecoveryOutcome(left.lastOutcome); - const rightOutcome = normalizeRecoveryOutcome(right.lastOutcome); - const outcomeCmp = leftOutcome.localeCompare(rightOutcome); - if (outcomeCmp !== 0) return multiplier * outcomeCmp; - - const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase(); - const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase(); - return multiplier * leftLabel.localeCompare(rightLabel); - } - default: - return 0; - } - }); - - return items; - }); - - createEffect(() => { - props.queryFilter(); - props.platformFilter(); - props.itemTypeFilter(); - props.protectedStateFilter(); - protectedSortCol(); - protectedSortDir(); - }); - - return ( -
- - - props.setItemTypeFilter(normalizeRecoveryItemTypeQueryValue(value) || 'all'), - defaultValue: 'all', - options: () => - props.itemTypeOptions().map((itemType) => ({ - value: itemType, - label: - itemType === 'all' - ? getRecoveryAllItemTypesLabel() - : getRecoveryItemTypePresentation(itemType)?.label || itemType, - })), - }, - { - id: 'platform', - label: 'Platform', - group: 'scope', - value: props.platformFilter, - setValue: (value: string) => - props.setPlatformFilter(normalizeSourcePlatformQueryValue(value)), - defaultValue: 'all', - options: () => - props.platformOptions().map((platform) => ({ - value: platform, - label: - platform === 'all' - ? getRecoveryAllPlatformsLabel() - : getSourcePlatformLabel(platform), - })), - }, - { - id: 'protected-state', - label: 'Protection state', - group: 'status', - value: props.protectedStateFilter, - setValue: (value: string) => { - const normalized = normalizeProtectedStateFilter(value); - props.setProtectedStateFilter(normalized); - props.setVerificationFilter('all'); - }, - defaultValue: 'all', - options: () => - availableProtectionStates.map((state) => ({ - value: state, - label: - state === 'all' ? 'Any state' : getRecoveryRollupInventoryStatusLabel(state), - })), - }, - ] as FilterDef[] - } - /> - - - - - -
-
- {getRecoveryProtectedItemsLoadingState().text} -
-
-
-
-
-
-
-
- - {() => ( -
-
-
-
-
-
-
-
-
-
- )} - -
-
- - - -
- -
-
- - -
- -
-
- - 0}> - - - - {( - [ - ['item', getRecoveryArtifactColumnLabel('item', 'Item')], - ['type', getRecoveryArtifactColumnLabel('type', 'Item Type')], - ['platform', getRecoveryArtifactColumnLabel('platform', 'Platform')], - ['lastBackup', 'Latest Point'], - ['outcome', 'Status'], - ] as const - ).map(([column, label]) => ( - - ))} - - - - - {(rollup) => { - const resourceIndex = props.resourcesById(); - const label = getRecoveryRollupItemLabel(rollup, resourceIndex); - const secondaryLabel = getRecoveryRollupItemSecondaryLabel(rollup); - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - const inventoryStatus = getRecoveryRollupInventoryStatus(rollup); - const inventoryStatusLabel = - getRecoveryRollupInventoryStatusLabel(inventoryStatus); - const platforms = getRecoveryRollupPlatforms(rollup) - .map((platform) => String(platform || '').trim()) - .filter(Boolean) - .sort((left, right) => - getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)), - ); - const itemTypePresentation = - getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null; - const nowMs = Date.now(); - const neverSucceeded = inventoryStatus === 'never-succeeded'; - const protectionInsight = getProtectionInsight(rollup, inventoryStatus, nowMs); - - return ( - props.onSelectRollup(rollup.rollupId)} - > - -
-
- -
- {label} - - - {secondaryLabel} - - - - - Verify due - - -
-
- -
- {protectionInsight} -
-
-
- - - {itemTypePresentation?.label} - - - 0}> - - {(platform) => { - const badge = getSourcePlatformBadge(platform); - return ( - - {badge?.label || getSourcePlatformLabel(platform)} - - ); - }} - - -
-
-
- - - - - - 0 - ? formatAbsoluteTime(successMs) - : attemptMs > 0 - ? formatAbsoluteTime(attemptMs) - : undefined - } - > -
- - {successMs > 0 ? ( - formatRelativeTime(successMs) - ) : neverSucceeded ? ( - never - ) : ( - '—' - )} - - 0}> - - Attempt {formatRelativeTime(attemptMs)} - - -
-
- - - - {inventoryStatusLabel} - - -
- ); - }} -
-
-
-
-
- 0} - fallback={Showing 0 of 0 coverage items} - > - Showing {sortedRollups().length} coverage items - -
-
-
- -
- ); -}; diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx deleted file mode 100644 index c162e67776..0000000000 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { createSignal } from 'solid-js'; -import { render, screen } from '@solidjs/testing-library'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -let mockRecoverySurfaceState: any; - -vi.mock('@/features/recovery/useRecoverySurfaceState', () => ({ - useRecoverySurfaceState: () => mockRecoverySurfaceState, -})); - -vi.mock('@/hooks/useBreakpoint', () => ({ - useBreakpoint: () => ({ isMobile: () => false }), -})); - -vi.mock('@/hooks/useKioskMode', () => ({ - useKioskMode: () => () => false, -})); - -vi.mock('@/hooks/useColumnVisibility', () => ({ - useColumnVisibility: () => ({ - availableToggles: () => [], - isHiddenByUser: () => false, - resetToDefaults: vi.fn(), - toggle: vi.fn(), - visibleColumns: () => [], - }), -})); - -vi.mock('@/components/Recovery/RecoveryProtectedInventorySection', () => ({ - RecoveryProtectedInventorySection: () =>
, -})); - -vi.mock('@/components/Recovery/RecoveryActivitySection', () => ({ - RecoveryActivitySection: () =>
, -})); - -vi.mock('@/components/Recovery/RecoveryHistorySection', () => ({ - RecoveryHistorySection: () =>
, -})); - -import Recovery from '@/components/Recovery/Recovery'; - -describe('Recovery layout guards', () => { - beforeEach(() => { - const [workspaceView, setWorkspaceView] = createSignal<'inventory' | 'events'>('events'); - mockRecoverySurfaceState = { - activitySummary: () => ({ totalPoints: 2, activeDays: 2, averagePerDay: 1 }), - activeAdvancedFilterCount: () => 0, - activeClusterLabel: () => '', - activeNamespaceLabel: () => '', - activeNodeLabel: () => '', - artifactColumnVisibility: undefined, - chartRangeDays: () => 30, - clearClusterFilter: vi.fn(), - clearFocusedRollup: vi.fn(), - clearNamespaceFilter: vi.fn(), - clearNodeFilter: vi.fn(), - clearSelectedDate: vi.fn(), - clusterFilter: () => 'all', - clusterOptions: () => ['all'], - currentPage: () => 1, - facets: () => ({}), - hasActiveArtifactFilters: () => false, - hasFocusedRollup: () => false, - historyOutcomeFilter: () => 'all', - itemTypeFilter: () => 'all', - itemTypeOptions: () => ['all'], - isMobile: false, - loading: () => false, - modeFilter: () => 'all', - namespaceFilter: () => 'all', - namespaceOptions: () => ['all'], - nodeFilter: () => 'all', - nodeOptions: () => ['all'], - overallRollupsSummary: () => ({ total: 2, stale: 0, neverSucceeded: 0 }), - protectedStateFilter: () => 'all', - platformFilter: () => 'all', - platformOptions: () => ['all'], - queryFilter: () => '', - recoveryPoints: { - meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }), - points: () => [], - resolvedOnce: () => true, - response: { - error: new Error('history points unavailable'), - loading: false, - }, - }, - recoveryRollups: { - rollups: () => [], - resolvedOnce: () => true, - response: { error: undefined, loading: false }, - }, - recoverySeries: { - resolvedOnce: () => true, - response: { error: undefined, loading: false }, - series: () => [ - { day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { day: '2026-02-14', total: 1, snapshot: 0, local: 1, remote: 0 }, - ], - }, - resourcesById: () => new Map(), - rollupId: () => '', - rollups: () => [], - scopeFilter: () => 'all', - selectedDateKey: () => null, - selectedDateLabel: () => '', - selectedHistoryItemLabel: () => null, - setChartRangeDays: vi.fn(), - setClusterFilter: vi.fn(), - setCurrentPage: vi.fn(), - setHistoryOutcomeFilter: vi.fn(), - setItemTypeFilter: vi.fn(), - setModeFilter: vi.fn(), - setNamespaceFilter: vi.fn(), - setNodeFilter: vi.fn(), - setProtectedStateFilter: vi.fn(), - setPlatformFilter: vi.fn(), - setQueryFilter: vi.fn(), - setRollupId: vi.fn(), - setScopeFilter: vi.fn(), - setSelectedDateKey: vi.fn(), - setVerificationFilter: vi.fn(), - showClusterFilter: () => false, - showNamespaceFilter: () => false, - showNodeFilter: () => false, - showVerificationFilter: () => false, - tableWindow: () => ({ from: null, to: null }), - tableColumnCount: () => 0, - tableMinWidth: () => 'auto', - totalPages: () => 1, - verificationFilter: () => 'all', - workspaceView, - setWorkspaceView, - }; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('mounts the activity chart only in the recovery events workspace and keeps it there when events fail', () => { - render(() => ); - - expect(screen.getByTestId('activity-chart')).toBeInTheDocument(); - expect(screen.queryByTestId('protected-inventory')).not.toBeInTheDocument(); - expect(screen.queryByTestId('history-section')).not.toBeInTheDocument(); - expect(screen.getByText('Failed to load recovery points')).toBeInTheDocument(); - }); - - it('keeps the event workspace mounted without a decorative summary shell while data hydrates', () => { - mockRecoverySurfaceState.recoveryRollups = { - rollups: Object.assign(() => [], { loading: false }), - resolvedOnce: () => false, - response: { error: undefined, loading: false }, - }; - mockRecoverySurfaceState.recoverySeries = { - resolvedOnce: () => false, - response: { error: undefined, loading: false }, - series: () => [], - }; - mockRecoverySurfaceState.recoveryPoints = { - meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }), - points: () => [], - resolvedOnce: () => false, - response: { error: undefined, loading: false }, - }; - - render(() => ); - - expect(screen.queryByTestId('recovery-summary')).not.toBeInTheDocument(); - expect(screen.getByTestId('activity-chart')).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx deleted file mode 100644 index 3f1c2ca7f7..0000000000 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ /dev/null @@ -1,2081 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import Recovery from '@/components/Recovery/Recovery'; -import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; -import createNonSuspendingQuerySource from '@/hooks/createNonSuspendingQuery.ts?raw'; -import useRecoveryPointsFacetsSource from '@/hooks/useRecoveryPointsFacets.ts?raw'; -import useRecoveryPointsSeriesSource from '@/hooks/useRecoveryPointsSeries.ts?raw'; -import useRecoveryPointsSource from '@/hooks/useRecoveryPoints.ts?raw'; -import useRecoveryRollupsSource from '@/hooks/useRecoveryRollups.ts?raw'; -import type { - ProtectionRollup, - ProtectionRollupTransport, - RecoveryPoint, - RecoveryPointTransport, -} from '@/types/recovery'; -import { parseRecoveryDateKey } from '@/utils/recoveryDatePresentation'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { ROUTE_STATE_REPLACE_OPTIONS } from '@/utils/routeStateNavigation'; -import { - getRecoveryPointItemTypeKey, - getRecoveryRollupItemTypeKey, -} from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryPointPlatform, - getRecoveryRollupPlatforms, -} from '@/utils/recoveryPlatformModel'; - -let mockLocationSearch = ''; -let mockLocationPath = '/proxmox/backups'; -const navigateSpy = vi.hoisted(() => vi.fn()); -const mockLocationVersion = vi.hoisted(() => ({ - read: undefined as undefined | (() => number), - bump: undefined as undefined | (() => void), -})); - -const apiFetchMock = vi.hoisted(() => vi.fn()); -const wsState = vi.hoisted(() => ({ resources: [] as any[] })); - -vi.mock('@solidjs/router', async () => { - const actual = await vi.importActual('@solidjs/router'); - const solid = await vi.importActual('solid-js'); - if (!mockLocationVersion.read || !mockLocationVersion.bump) { - const [version, setVersion] = solid.createSignal(0); - mockLocationVersion.read = version; - mockLocationVersion.bump = () => setVersion((current) => current + 1); - } - return { - ...actual, - useLocation: () => ({ - get pathname() { - mockLocationVersion.read?.(); - return mockLocationPath; - }, - get search() { - mockLocationVersion.read?.(); - return mockLocationSearch; - }, - }), - useNavigate: () => navigateSpy, - }; -}); - -const rollupsPayload: Array = [ - { - rollupId: 'res:vm-123', - itemResourceId: 'vm-123', - display: { subjectType: 'proxmox-vm', itemType: 'vm', entityIdLabel: '123' }, - lastAttemptAt: '2026-02-14T10:00:00.000Z', - lastSuccessAt: '2026-02-14T10:00:00.000Z', - lastOutcome: 'success', - platforms: ['proxmox-pve'], - }, - { - rollupId: 'ext:truenas-1', - itemRef: { type: 'truenas-dataset', name: 'tank/apps', id: 'tank/apps' }, - display: { itemType: 'dataset' }, - lastAttemptAt: '2026-02-13T09:00:00.000Z', - lastSuccessAt: null, - lastOutcome: 'failed', - platforms: ['truenas'], - }, -]; - -const pointsByRollupId: Record> = { - 'res:vm-123': [ - { - id: 'p1', - platform: 'proxmox-pve', - kind: 'backup', - mode: 'local', - outcome: 'success', - itemResourceId: 'vm-123', - completedAt: '2026-02-14T10:00:00.000Z', - sizeBytes: 1234, - cluster: 'lab-cluster', - node: 'pve-01', - namespace: 'finance', - repositoryRef: { - type: 'recovery-target', - name: 'fast-store', - }, - display: { - itemLabel: 'VM 123', - itemType: 'vm', - subjectType: 'proxmox-vm', - clusterLabel: 'Lab Cluster', - nodeHostLabel: 'pve-01', - namespaceLabel: 'Finance', - entityIdLabel: '123', - }, - }, - ], - 'ext:truenas-1': [ - { - id: 't1', - platform: 'truenas', - kind: 'snapshot', - mode: 'snapshot', - outcome: 'failed', - completedAt: '2026-02-13T09:00:00.000Z', - sizeBytes: 0, - display: { itemLabel: 'tank/apps', itemType: 'dataset', subjectType: 'truenas-dataset' }, - }, - ], -}; - -let facetsPayload: any; -type RecoveryPointsResponse = { - data: any[]; - meta: { page: number; limit: number; total: number; totalPages: number }; -}; - -const getAddFilterSelect = (container?: HTMLElement): HTMLSelectElement => - (container ? within(container) : screen).getByRole('combobox', { - name: 'Filter', - }) as HTMLSelectElement; - -const getAddFilterOption = ( - filterLabel: string, - optionLabel: string, - container?: HTMLElement, -): HTMLOptionElement => - within(getAddFilterSelect(container)).getByRole('option', { - name: `${filterLabel}: ${optionLabel}`, - }) as HTMLOptionElement; - -const findAddFilterOption = async ( - filterLabel: string, - optionLabel: string, - container?: HTMLElement, -): Promise => { - let option: HTMLOptionElement | undefined; - await waitFor(() => { - option = getAddFilterOption(filterLabel, optionLabel, container); - expect(option).toBeInTheDocument(); - }); - return option!; -}; - -const selectAddFilterOption = async ( - filterLabel: string, - optionLabel: string, - container?: HTMLElement, -) => { - const option = await findAddFilterOption(filterLabel, optionLabel, container); - const select = getAddFilterSelect(container); - fireEvent.change(select, { - target: { value: option.value }, - }); -}; - -const requestParam = (url: URL, key: string) => String(url.searchParams.get(key) || '').trim(); - -const matchesTextQuery = (haystackParts: unknown[], query: string): boolean => { - if (!query) return true; - return haystackParts - .flatMap((part) => { - if (part && typeof part === 'object') return Object.values(part as Record); - return [part]; - }) - .filter((part) => part !== null && part !== undefined) - .join(' ') - .toLowerCase() - .includes(query.toLowerCase()); -}; - -const filterRollupsForRequest = (url: URL) => { - const platform = requestParam(url, 'platform'); - const itemType = requestParam(url, 'itemType'); - const query = requestParam(url, 'q'); - - return rollupsPayload.filter((rollup) => { - if ( - platform && - !getRecoveryRollupPlatforms(rollup).some((entry) => String(entry || '').trim() === platform) - ) { - return false; - } - if (itemType && getRecoveryRollupItemTypeKey(rollup) !== itemType) return false; - return matchesTextQuery( - [ - rollup.rollupId, - rollup.itemResourceId, - rollup.itemRef, - rollup.subjectRef, - rollup.display, - getRecoveryRollupPlatforms(rollup).join(' '), - rollup.lastOutcome, - ], - query, - ); - }); -}; - -const filterPointsForRequest = (url: URL) => { - const rollupId = requestParam(url, 'rollupId'); - const platform = requestParam(url, 'platform'); - const itemType = requestParam(url, 'itemType'); - const query = requestParam(url, 'q'); - const cluster = requestParam(url, 'cluster'); - const node = requestParam(url, 'node'); - const namespace = requestParam(url, 'namespace'); - const mode = requestParam(url, 'mode'); - const outcome = requestParam(url, 'outcome'); - const data = rollupId ? pointsByRollupId[rollupId] || [] : Object.values(pointsByRollupId).flat(); - - return data.filter((point) => { - if (platform && getRecoveryPointPlatform(point) !== platform) return false; - if (itemType && getRecoveryPointItemTypeKey(point) !== itemType) return false; - if (mode && String(point.mode || '').trim() !== mode) return false; - if (outcome && String(point.outcome || '').trim() !== outcome) return false; - if (cluster && String(point.display?.clusterLabel || point.cluster || '').trim() !== cluster) { - return false; - } - if ( - node && - String( - point.display?.nodeHostLabel || point.display?.nodeAgentLabel || point.node || '', - ).trim() !== node - ) { - return false; - } - if ( - namespace && - String(point.display?.namespaceLabel || point.namespace || '').trim() !== namespace - ) { - return false; - } - return matchesTextQuery( - [ - point.id, - point.itemResourceId, - point.itemRef, - point.subjectRef, - point.display, - point.platform, - (point as any).provider, - point.kind, - point.mode, - point.outcome, - point.cluster, - point.node, - point.namespace, - point.repositoryRef, - ], - query, - ); - }); -}; - -vi.mock('@/utils/apiClient', () => ({ - apiFetch: apiFetchMock, - apiFetchJSON: apiFetchMock, -})); - -vi.mock('@/contexts/appRuntime', () => ({ - useWebSocket: () => ({ - state: wsState, - }), -})); - -vi.mock('@/hooks/useUnifiedResources', () => ({ - useStorageRecoveryResources: () => ({ - resources: () => [{ id: 'vm-123', name: 'VM 123' }], - loading: () => false, - error: () => undefined, - refetch: vi.fn(), - mutate: vi.fn(), - }), -})); - -describe('Recovery', () => { - beforeEach(() => { - resetCreateNonSuspendingQueryCacheForTest(); - localStorage.clear(); - navigateSpy.mockReset(); - navigateSpy.mockImplementation((path: string) => { - const next = new URL(String(path || '/proxmox/backups'), 'http://localhost'); - mockLocationPath = next.pathname; - mockLocationSearch = next.search; - mockLocationVersion.bump?.(); - }); - apiFetchMock.mockClear(); - mockLocationSearch = ''; - mockLocationPath = '/proxmox/backups'; - wsState.resources = [ - { - id: 'pbs-resource-1', - type: 'pbs', - name: 'pbs-main', - displayName: 'pbs-main', - platformId: 'pbs-main', - platformType: 'proxmox-pbs', - sourceType: 'api', - status: 'online', - lastSeen: Date.parse('2026-03-10T10:00:00Z'), - platformData: { - pbs: { - instanceId: 'pbs-main', - datastores: [ - { - name: 'fast-store', - used: 500, - total: 1000, - usage: 50, - status: 'ok', - deduplicationFactor: 2.25, - }, - ], - }, - }, - }, - ]; - - facetsPayload = { - clusters: [], - nodesAgents: [], - namespaces: [], - itemTypes: ['dataset', 'vm'], - hasSize: true, - hasVerification: false, - hasEntityId: false, - }; - - apiFetchMock.mockImplementation(async (url: string) => { - const u = new URL(url, 'http://localhost'); - if (u.pathname === '/api/recovery/rollups') { - const data = filterRollupsForRequest(u); - return { - data, - meta: { page: 1, limit: 500, total: data.length, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/points') { - const data = filterPointsForRequest(u); - return { - data, - meta: { page: 1, limit: 500, total: data.length, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/facets') { - return { - data: facetsPayload, - }; - } - if (u.pathname === '/api/recovery/series') { - const from = u.searchParams.get('from'); - const to = u.searchParams.get('to'); - if (from && to) { - const fromDate = new Date(from); - const toDate = new Date(to); - const daySpan = Math.round( - (toDate.getTime() - fromDate.getTime()) / (24 * 60 * 60 * 1000), - ); - if (daySpan >= 300) { - return { - data: Array.from({ length: 365 }, (_, index) => { - const pointDate = new Date(fromDate); - pointDate.setDate(pointDate.getDate() + index); - const isoDay = pointDate.toISOString().slice(0, 10); - const recent = index > 320 ? 1 : 0; - return { - day: isoDay, - total: recent, - snapshot: 0, - local: 0, - remote: recent, - }; - }), - }; - } - } - return { - data: [ - { day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { day: '2026-02-14', total: 1, snapshot: 0, local: 1, remote: 0 }, - ], - }; - } - throw new Error(`Unhandled apiFetch URL: ${url}`); - }); - }); - - afterEach(() => { - resetCreateNonSuspendingQueryCacheForTest(); - cleanup(); - }); - - it('keeps recovery polling on the shared retained-value query primitive', () => { - expect(createNonSuspendingQuerySource).toContain('export function createNonSuspendingQuery'); - expect(createNonSuspendingQuerySource).toContain('retaining the last fulfilled value'); - expect(useRecoveryRollupsSource).toContain("from '@/hooks/createNonSuspendingQuery'"); - expect(useRecoveryPointsSource).toContain("from '@/hooks/createNonSuspendingQuery'"); - expect(useRecoveryPointsFacetsSource).toContain("from '@/hooks/createNonSuspendingQuery'"); - expect(useRecoveryPointsSeriesSource).toContain("from '@/hooks/createNonSuspendingQuery'"); - expect(useRecoveryRollupsSource).not.toContain( - "from '@/features/recovery/createNonSuspendingQuery'", - ); - expect(useRecoveryPointsSource).not.toContain( - "from '@/features/recovery/createNonSuspendingQuery'", - ); - expect(useRecoveryPointsFacetsSource).not.toContain( - "from '@/features/recovery/createNonSuspendingQuery'", - ); - expect(useRecoveryPointsSeriesSource).not.toContain( - "from '@/features/recovery/createNonSuspendingQuery'", - ); - }); - - it('renders protected rollups and resolves unified resource names', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - - expect(await screen.findByText('VM 123')).toBeInTheDocument(); - expect(screen.getByText('tank/apps')).toBeInTheDocument(); - }); - - it('shows one primary recovery table at a time with unified recovery labels', async () => { - render(() => ); - - expect(screen.getByTestId('recovery-page').className).toContain('gap-2'); - expect(screen.getByTestId('recovery-page').className).not.toContain('gap-3'); - const recoveryPage = screen.getByTestId('recovery-page'); - expect(screen.queryByTestId('recovery-summary')).not.toBeInTheDocument(); - expect(screen.queryByText('Posture')).not.toBeInTheDocument(); - expect(screen.queryByText('Freshness')).not.toBeInTheDocument(); - expect(screen.queryByText('Focused drill-in')).not.toBeInTheDocument(); - expect(screen.queryByRole('tablist', { name: /recovery data view/i })).not.toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Protection coverage' })).toBeInTheDocument(); - - await waitFor(() => { - expect(screen.getAllByRole('table')).toHaveLength(1); - }); - const historyTable = screen.getAllByRole('table')[0]; - const historyControls = screen.getByRole('group', { name: /recovery events controls/i }); - const activityBars = screen.getByTestId('recovery-activity-bars'); - expect(recoveryPage).toContainElement(historyControls); - expect(recoveryPage).toContainElement(historyTable); - const activityHeading = screen.getByText('Recovery Activity'); - expect(activityBars.parentElement?.className).toContain('h-[136px] sm:h-[150px]'); - expect( - screen.queryByText('Daily recovery points across the selected history window.'), - ).not.toBeInTheDocument(); - expect(screen.queryByText(/^Range$/)).not.toBeInTheDocument(); - expect(screen.queryByText(/\/ day/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Lowest active day')).not.toBeInTheDocument(); - expect(screen.queryByText('Below normal')).not.toBeInTheDocument(); - expect(recoveryPage).toContainElement(activityHeading); - expect( - activityHeading.compareDocumentPosition(historyControls) & Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - expect( - historyControls.compareDocumentPosition(historyTable) & Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - expect(activityHeading.closest('.rounded-md')).not.toBeNull(); - expect(historyControls.closest('.rounded-md')).not.toBeNull(); - expect(historyTable.closest('.rounded-md')).not.toBeNull(); - expect(activityHeading.closest('.rounded-md')).not.toBe(historyControls.closest('.rounded-md')); - expect(historyControls.closest('.rounded-md')).not.toBe(historyTable.closest('.rounded-md')); - const historySearch = within(historyControls).getByPlaceholderText( - 'Search recovery history or enter a date...', - ); - expect(historySearch).toBeInTheDocument(); - expect(historySearch.closest('div.relative')?.className).toContain('w-full'); - expect(screen.getByText(/Showing 1 - 2 of 2 recovery points/i)).toBeInTheDocument(); - expect(within(historyControls).queryByText(/day group/i)).not.toBeInTheDocument(); - expect(within(historyControls).getByRole('combobox', { name: 'Filter' })).toBeInTheDocument(); - const rangeControls = screen.getByRole('group', { name: 'Recovery activity range' }); - expect(within(rangeControls).getByRole('button', { name: '7d' })).toBeInTheDocument(); - expect(within(rangeControls).getByRole('button', { name: '30d' })).toBeInTheDocument(); - expect(within(rangeControls).getByRole('button', { name: '90d' })).toBeInTheDocument(); - expect(within(rangeControls).getByRole('button', { name: '365d' })).toBeInTheDocument(); - expect(within(historyControls).queryByRole('button', { name: '7d' })).not.toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: '7d' })).toHaveLength(1); - expect(within(historyTable).getByText('Item Type')).toBeInTheDocument(); - expect(within(historyTable).getByText('Item')).toBeInTheDocument(); - expect(within(historyTable).getByText('Platform')).toBeInTheDocument(); - expect(within(historyTable).queryByText('ITEM TYPE')).not.toBeInTheDocument(); - expect(within(historyTable).queryByText('PLATFORM')).not.toBeInTheDocument(); - expect(within(historyTable).getByText('Target')).toBeInTheDocument(); - expect(within(historyTable).queryByText('Details')).not.toBeInTheDocument(); - const historyRow = within(historyTable).getByLabelText('Healthy').closest('tr'); - expect(historyRow).not.toBeNull(); - expect(within(historyRow!).getByLabelText('Healthy')).toBeInTheDocument(); - expect(within(historyRow!).getByText('VMID 123')).toBeInTheDocument(); - expect(within(historyRow!).getByText('VMID 123').parentElement?.className).toContain( - 'items-baseline', - ); - expect(within(historyRow!).getByText('VM').className).toContain('bg-'); - expect(within(historyRow!).getByText('VM').className).toContain('rounded'); - expect(within(historyRow!).getByText('VM').className).toContain('px-1 py-0.5'); - expect(within(historyRow!).getByText('Local Copy').className).not.toContain('rounded'); - expect(within(historyRow!).getByText('Success').className).not.toContain('rounded'); - expect(activityHeading).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Protection coverage' })); - - await screen.findByText('VM 123'); - const inventoryControls = screen.getByRole('group', { name: /protection coverage controls/i }); - const protectedSearch = within(inventoryControls).getByPlaceholderText( - 'Search protection coverage...', - ); - expect(protectedSearch).toBeInTheDocument(); - expect(protectedSearch.closest('div.relative')?.className).toContain('w-full'); - expect(within(inventoryControls).queryByText(/^\d+ stale$/i)).not.toBeInTheDocument(); - expect(within(inventoryControls).queryByText(/^\d+ never succeeded$/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument(); - expect(screen.queryByText('Needs Attention')).not.toBeInTheDocument(); - expect(screen.queryByText(/^2 protected resources$/i)).not.toBeInTheDocument(); - expect(screen.getByText('Showing 2 coverage items')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Prev' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); - await waitFor(() => { - expect(screen.getAllByRole('table')).toHaveLength(1); - }); - const inventoryTable = screen.getAllByRole('table')[0]; - const inventoryBody = inventoryTable.querySelector('tbody'); - expect(recoveryPage).toContainElement(inventoryControls); - expect(recoveryPage).toContainElement(inventoryTable); - expect( - inventoryControls.compareDocumentPosition(inventoryTable) & Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - expect(inventoryControls.closest('.rounded-md')).not.toBeNull(); - expect(inventoryTable.closest('.rounded-md')).not.toBeNull(); - expect(inventoryControls.closest('.rounded-md')).not.toBe( - inventoryTable.closest('.rounded-md'), - ); - expect(within(inventoryTable).getByText('Item')).toBeInTheDocument(); - expect(within(inventoryTable).getByText('Item Type')).toBeInTheDocument(); - expect(within(inventoryTable).getByText('Platform')).toBeInTheDocument(); - expect(within(inventoryTable).getByText('Status')).toBeInTheDocument(); - expect(within(inventoryTable).queryByText('ITEM TYPE')).not.toBeInTheDocument(); - expect(within(inventoryTable).queryByText('PLATFORM')).not.toBeInTheDocument(); - expect(inventoryBody?.className).toContain('divide-y'); - expect(inventoryBody?.className).toContain('divide-border'); - const inventoryRows = inventoryBody ? Array.from(inventoryBody.querySelectorAll('tr')) : []; - expect(inventoryRows.length).toBeGreaterThan(1); - expect(within(inventoryRows[0]!).getByText('tank/apps')).toBeInTheDocument(); - expect(within(inventoryRows[0]!).getByText('Never succeeded')).toBeInTheDocument(); - expect(within(inventoryTable).getAllByText('VM').length).toBeGreaterThan(0); - const vmRow = screen.getByText('VM 123').closest('tr'); - expect(vmRow).not.toBeNull(); - expect(within(vmRow!).getByLabelText('Stale')).toBeInTheDocument(); - expect(within(vmRow!).getAllByText('PVE').length).toBeGreaterThan(0); - expect(within(vmRow!).getAllByText('VM').length).toBeGreaterThan(0); - expect(within(vmRow!).getByText('VMID 123')).toBeInTheDocument(); - expect(within(vmRow!).getByText('VMID 123').parentElement?.className).toContain( - 'items-baseline', - ); - expect(within(vmRow!).getAllByText('VM')[0].className).toContain('bg-'); - expect(within(vmRow!).getAllByText('VM')[0].className).toContain('rounded'); - expect(within(vmRow!).getAllByText('VM')[0].className).toContain('px-1 py-0.5'); - expect(within(vmRow!).getByText('Stale').className).not.toContain('rounded'); - expect(screen.queryByText('Backups By Date')).not.toBeInTheDocument(); - expect(screen.queryByText('Recovery Activity')).not.toBeInTheDocument(); - - fireEvent.click(await screen.findByText('VM 123')); - - await waitFor(() => { - expect(screen.getAllByRole('table')).toHaveLength(1); - }); - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument(); - const focusedHistoryTable = screen.getAllByRole('table')[0]; - const focusedHistoryControls = screen.getByRole('group', { name: /recovery events controls/i }); - expect(within(focusedHistoryTable).getAllByText(/^1 event$/i)).toHaveLength(1); - expect(within(focusedHistoryControls).getByText('VM 123')).toBeInTheDocument(); - expect(within(focusedHistoryTable).getByText('VMID 123')).toBeInTheDocument(); - }); - - it('persists the secondary protection coverage view in the route when explicitly opened', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - }); - - it('offers a reciprocal page-header return from protection coverage to recovery events', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - mockLocationSearch = '?view=inventory'; - mockLocationVersion.bump?.(); - - expect( - await screen.findByRole('button', { name: 'Back to recovery events' }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('group', { name: /recovery events controls/i }), - ).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Back to recovery events' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - mockLocationSearch = ''; - mockLocationVersion.bump?.(); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('group', { name: /protection coverage controls/i }), - ).not.toBeInTheDocument(); - }); - - it('restores the explicit recovery workspace view from the route', async () => { - mockLocationSearch = '?view=events'; - - render(() => ); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('group', { name: /protection coverage controls/i }), - ).not.toBeInTheDocument(); - expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument(); - }); - - it('derives the recovery events workspace from focused route state when no explicit view is set', async () => { - mockLocationSearch = '?rollupId=res%3Avm-123'; - - render(() => ); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole('group', { name: /protection coverage controls/i }), - ).not.toBeInTheDocument(); - expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument(); - }); - - it('renders canonical rollup and history item labels when linked resources are unavailable', async () => { - rollupsPayload.push({ - rollupId: 'res:vm-404', - itemResourceId: 'vm-404', - display: { itemLabel: 'Archive VM' }, - lastAttemptAt: '2026-02-12T08:00:00.000Z', - lastSuccessAt: '2026-02-12T08:00:00.000Z', - lastOutcome: 'success', - platforms: ['proxmox-pve'], - }); - pointsByRollupId['res:vm-404'] = [ - { - id: 'p404', - platform: 'proxmox-pve', - kind: 'backup', - mode: 'local', - outcome: 'success', - completedAt: '2026-02-12T08:00:00.000Z', - display: { itemLabel: 'Archive VM' }, - }, - ]; - - try { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - const item = await screen.findByText('Archive VM'); - fireEvent.click(item); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?rollupId=res%3Avm-404', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - const tables = await screen.findAllByRole('table'); - const historyTable = tables[tables.length - 1]; - expect(within(historyTable).getByText('Archive VM')).toBeInTheDocument(); - expect(within(historyTable).queryByText('vm-404')).not.toBeInTheDocument(); - } finally { - rollupsPayload.pop(); - delete pointsByRollupId['res:vm-404']; - } - }); - - it('does not render decorative recovery summary cards on the event-first surface', async () => { - render(() => ); - - expect(screen.queryByTestId('recovery-summary')).not.toBeInTheDocument(); - expect(screen.queryByText(/^Coverage$/)).not.toBeInTheDocument(); - expect(screen.queryByText(/\d+ protected resources/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/^attention$/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Primary Item')).not.toBeInTheDocument(); - expect(screen.queryByText(/^types$/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Primary Platform')).not.toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Protection coverage' })).toBeInTheDocument(); - expect(screen.getByRole('group', { name: 'Recovery activity range' })).toBeInTheDocument(); - }); - - it('normalizes legacy provider-shaped recovery payloads before rendering', async () => { - rollupsPayload.push({ - rollupId: 'ext:legacy-provider-rollup', - subjectRef: { type: 'truenas-dataset', name: 'tank/legacy', id: 'tank/legacy' }, - lastAttemptAt: '2026-02-12T08:00:00.000Z', - lastSuccessAt: '2026-02-12T08:00:00.000Z', - lastOutcome: 'success', - providers: ['truenas'], - }); - pointsByRollupId['ext:legacy-provider-rollup'] = [ - { - id: 'legacy-point', - provider: 'truenas', - kind: 'snapshot', - mode: 'snapshot', - outcome: 'success', - completedAt: '2026-02-12T08:00:00.000Z', - display: { itemType: 'dataset', subjectType: 'truenas-dataset' }, - }, - ]; - - try { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('tank/legacy')).toBeInTheDocument(); - expect(screen.getAllByText('TrueNAS').length).toBeGreaterThan(0); - - fireEvent.click(screen.getByText('tank/legacy')); - // The legacy `providers: ['truenas']` payload should normalize into the - // canonical "TrueNAS" platform label on whichever surface renders it - // (events table badge, direct Filter selector, or active chip). - expect(screen.getAllByText('TrueNAS').length).toBeGreaterThan(0); - } finally { - rollupsPayload.pop(); - delete pointsByRollupId['ext:legacy-provider-rollup']; - } - }); - - it('surfaces stale protected inventory health even when the latest outcome was successful', async () => { - rollupsPayload.push({ - rollupId: 'res:vm-stale', - itemResourceId: 'vm-stale', - display: { itemLabel: 'Stale VM', itemType: 'vm', entityIdLabel: '999' }, - lastAttemptAt: '2026-02-01T10:00:00.000Z', - lastSuccessAt: '2026-02-01T10:00:00.000Z', - lastOutcome: 'success', - platforms: ['proxmox-pve'], - }); - - try { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - const staleRow = (await screen.findByText('Stale VM')).closest('tr'); - expect(staleRow).not.toBeNull(); - expect(within(staleRow!).getByLabelText('Stale')).toBeInTheDocument(); - expect(within(staleRow!).getByText('Stale')).toBeInTheDocument(); - expect(within(staleRow!).queryByText('Success')).not.toBeInTheDocument(); - } finally { - rollupsPayload.pop(); - } - }); - - 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(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - 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')); - - 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('labels Kubernetes cluster recovery subjects without generic cluster wording', async () => { - rollupsPayload.push({ - rollupId: 'res:k8s-production-1', - itemResourceId: 'k8s-production-1', - display: { - itemLabel: 'Healthy Production', - itemType: 'cluster', - }, - lastAttemptAt: '2026-02-15T11:00:00.000Z', - lastSuccessAt: '2026-02-15T11:00:00.000Z', - lastOutcome: 'success', - platforms: ['kubernetes'], - }); - pointsByRollupId['res:k8s-production-1'] = []; - - try { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('Healthy Production')).toBeInTheDocument(); - const inventoryTable = (await screen.findAllByRole('table'))[0]; - expect(within(inventoryTable).getAllByText('K8s Cluster').length).toBeGreaterThan(0); - expect( - within(inventoryTable).queryByText('Cluster', { exact: true }), - ).not.toBeInTheDocument(); - // Assert the inventory FilterBar's direct item-type vocabulary without - // the generic alias. - expect(await findAddFilterOption('Item Type', 'K8s Cluster')).toBeInTheDocument(); - expect( - within(getAddFilterSelect()).queryByRole('option', { name: 'Item Type: Cluster' }), - ).not.toBeInTheDocument(); - } finally { - rollupsPayload.pop(); - delete pointsByRollupId['res:k8s-production-1']; - } - }); - - it('renders degraded recovery records when item refs and details metadata are omitted', async () => { - rollupsPayload.push({ - rollupId: 'res:pvc-1', - itemResourceId: 'pvc-1', - display: { - itemLabel: 'default/data', - itemType: 'pvc', - }, - lastAttemptAt: '2026-02-15T11:00:00.000Z', - lastSuccessAt: '2026-02-15T11:00:00.000Z', - lastOutcome: 'success', - platforms: ['kubernetes'], - }); - pointsByRollupId['res:pvc-1'] = [ - { - id: 'pvc-point-1', - platform: 'kubernetes', - kind: 'snapshot', - mode: 'snapshot', - outcome: 'success', - completedAt: '2026-02-15T11:00:00.000Z', - display: { - itemLabel: 'default/data', - itemType: 'pvc', - namespaceLabel: 'default', - detailsSummary: 'Immutable copy', - }, - }, - ]; - - try { - render(() => ); - - await screen.findByText('default/data'); - const tables = await screen.findAllByRole('table'); - const historyTable = tables[tables.length - 1]; - expect(within(historyTable).getByText('default/data')).toBeInTheDocument(); - expect(within(historyTable).getByText('PVC')).toBeInTheDocument(); - expect(within(historyTable).getByText('K8s')).toBeInTheDocument(); - expect(within(historyTable).queryByText('Immutable copy')).not.toBeInTheDocument(); - - fireEvent.click(within(historyTable).getByText('11:00')); - - const detailsPanel = await screen.findByText('Recovery Point Details'); - const detailsCell = detailsPanel.closest('td'); - expect(detailsCell).not.toBeNull(); - expect( - within(detailsCell as HTMLTableCellElement).getByText('Item Type'), - ).toBeInTheDocument(); - expect(within(detailsCell as HTMLTableCellElement).getByText('PVC')).toBeInTheDocument(); - expect( - within(detailsCell as HTMLTableCellElement).getByText('Namespace / Group'), - ).toBeInTheDocument(); - expect(within(detailsCell as HTMLTableCellElement).getByText('default')).toBeInTheDocument(); - expect( - within(detailsCell as HTMLTableCellElement).queryByText('Item Ref'), - ).not.toBeInTheDocument(); - expect( - within(detailsCell as HTMLTableCellElement).queryByText('Target Ref'), - ).not.toBeInTheDocument(); - } finally { - rollupsPayload.pop(); - delete pointsByRollupId['res:pvc-1']; - } - }); - - it('keeps recovery history width aligned with canonical column specs', async () => { - mockLocationSearch = '?rollupId=res%3Avm-123'; - - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - const tables = await screen.findAllByRole('table'); - const historyTable = tables.find((table) => within(table).queryByText('Local Copy')); - expect(historyTable).toBeDefined(); - expect(historyTable).toHaveStyle({ 'min-width': '980px', 'table-layout': 'fixed' }); - }); - - it('keeps the protection coverage table CSP-safe when coverage is opened', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - const protectedInventoryTable = await screen.findByRole('table'); - expect(protectedInventoryTable).not.toHaveAttribute('style'); - expect(protectedInventoryTable.className).toContain('table-fixed'); - expect(protectedInventoryTable.className).toContain('min-w-[640px]'); - }); - - it('keeps canonical item and platform columns visible when legacy hidden-column ids exist', async () => { - mockLocationSearch = '?rollupId=res%3Avm-123'; - facetsPayload.clusters = ['lab-cluster']; - localStorage.setItem( - STORAGE_KEYS.RECOVERY_HIDDEN_COLUMNS, - JSON.stringify(['subject', 'source', 'cluster']), - ); - - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - - const tables = await screen.findAllByRole('table'); - const historyTable = tables[tables.length - 1]; - expect(within(historyTable).getByText('Item')).toBeInTheDocument(); - expect(within(historyTable).getByText('Platform')).toBeInTheDocument(); - expect(within(historyTable).queryByText('Subject')).not.toBeInTheDocument(); - expect(within(historyTable).queryByText('Source')).not.toBeInTheDocument(); - expect(within(historyTable).queryByText('Cluster / Site')).not.toBeInTheDocument(); - }); - - it('focuses history when a rollup is clicked', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - const item = await screen.findByText('VM 123'); - fireEvent.click(item); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?rollupId=res%3Avm-123', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - const itemFilterTrigger = within(controls).getByTestId('recovery-history-item-filter-trigger'); - expect(itemFilterTrigger).toHaveTextContent('VM 123'); - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - const tables = await screen.findAllByRole('table'); - const table = tables[tables.length - 1]; - expect(within(table).getAllByText('Local Copy').length).toBeGreaterThan(0); - expect(within(table).getAllByText('Success').length).toBeGreaterThan(0); - - fireEvent.click(within(table).getByText('10:00')); - - expect(await screen.findByText('Recovery Point Details')).toBeInTheDocument(); - const detailsPanel = screen.getByText('Recovery Point Details').closest('td'); - expect(detailsPanel).not.toBeNull(); - expect( - within(detailsPanel as HTMLTableCellElement).queryByText('Restore action path'), - ).not.toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Restore readiness'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Available candidate'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText( - 'Succeeded; verification is not recorded.', - ), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).queryByText('Verification provenance'), - ).not.toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).queryByText('Target Details'), - ).not.toBeInTheDocument(); - expect(within(detailsPanel as HTMLTableCellElement).getByText('Item Type')).toBeInTheDocument(); - expect(within(detailsPanel as HTMLTableCellElement).getByText('VM')).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Cluster / Site'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Host / Agent'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Namespace / Group'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).queryByText('Target Ref'), - ).not.toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getByText('Lab Cluster'), - ).toBeInTheDocument(); - expect( - within(detailsPanel as HTMLTableCellElement).getAllByText('pve-01').length, - ).toBeGreaterThan(0); - expect(within(detailsPanel as HTMLTableCellElement).getByText('Finance')).toBeInTheDocument(); - }); - - it('uses the current history result set for cross-day protection chain context', async () => { - const originalPoints = pointsByRollupId['res:vm-123']; - pointsByRollupId['res:vm-123'] = [ - { - ...originalPoints[0], - id: 'p1-local', - itemResourceId: 'vm-123', - completedAt: '2026-02-14T10:00:00.000Z', - }, - { - id: 'p1-remote', - platform: 'proxmox-pbs', - itemResourceId: 'vm-123', - kind: 'backup', - mode: 'remote', - outcome: 'success', - verified: true, - completedAt: '2026-02-13T09:30:00.000Z', - details: { - verificationState: 'ok', - }, - repositoryRef: { - type: 'pbs-datastore', - namespace: 'pbs-main', - name: 'fast-store', - }, - display: { - itemType: 'vm', - subjectType: 'proxmox-vm', - entityIdLabel: '123', - }, - }, - ]; - - try { - mockLocationSearch = '?rollupId=res%3Avm-123'; - render(() => ); - - await screen.findByText(/Showing 1 - 2 of 2 recovery points/i); - fireEvent.click(screen.getByText('10:00')); - - const detailsPanel = await screen.findByText('Recovery Point Details'); - const detailsCell = detailsPanel.closest('td'); - expect(detailsCell).not.toBeNull(); - expect( - within(detailsCell as HTMLTableCellElement).getByText('Protection chain'), - ).toBeInTheDocument(); - expect( - within(detailsCell as HTMLTableCellElement).getByText('Remote copy'), - ).toBeInTheDocument(); - expect( - within(detailsCell as HTMLTableCellElement).getByText(/fast-store · PBS/i), - ).toBeInTheDocument(); - - fireEvent.click(screen.getByText('09:30')); - const remoteDetailsPanel = await screen.findByText('Recovery Point Details'); - const remoteDetailsCell = remoteDetailsPanel.closest('td'); - expect(remoteDetailsCell).not.toBeNull(); - expect( - within(remoteDetailsCell as HTMLTableCellElement).getByText('Catalog check passed'), - ).toBeInTheDocument(); - expect( - within(remoteDetailsCell as HTMLTableCellElement).queryByText('State: ok'), - ).not.toBeInTheDocument(); - } finally { - pointsByRollupId['res:vm-123'] = originalPoints; - } - }); - - it('keeps recovery event context visible when mobile columns collapse', async () => { - const originalWidth = window.innerWidth; - Object.defineProperty(window, 'innerWidth', { - configurable: true, - writable: true, - value: 500, - }); - - try { - mockLocationSearch = '?rollupId=res%3Avm-123'; - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - - const tables = await screen.findAllByRole('table'); - const historyTable = tables[tables.length - 1]; - const historyRow = within(historyTable).getByText('VMID 123').closest('tr'); - expect(historyRow).not.toBeNull(); - expect(within(historyRow!).getByText('Local Copy')).toBeInTheDocument(); - expect(within(historyRow!).getByText('PVE')).toBeInTheDocument(); - expect(within(historyRow!).getByText('fast-store')).toBeInTheDocument(); - expect(within(historyRow!).getByText('Verification unknown')).toBeInTheDocument(); - expect(within(historyTable).queryByText('Method')).not.toBeInTheDocument(); - expect(within(historyTable).queryByText('Target')).not.toBeInTheDocument(); - } finally { - Object.defineProperty(window, 'innerWidth', { - configurable: true, - writable: true, - value: originalWidth, - }); - } - }); - - it('keeps optional history placement columns on the neutral recovery vocabulary', async () => { - facetsPayload.clusters = ['lab-cluster']; - facetsPayload.nodesAgents = ['pve-01']; - facetsPayload.namespaces = ['finance']; - mockLocationSearch = '?rollupId=res%3Avm-123'; - - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - - fireEvent.click(screen.getByRole('button', { name: /columns/i })); - - expect(await screen.findByText('Cluster / Site')).toBeInTheDocument(); - expect(screen.getByText('Host / Agent')).toBeInTheDocument(); - expect(screen.getByText('Namespace / Group')).toBeInTheDocument(); - - fireEvent.click(screen.getByLabelText('Cluster / Site')); - - const tables = await screen.findAllByRole('table'); - const table = tables[tables.length - 1]; - expect(within(table).getByText('Cluster / Site')).toBeInTheDocument(); - expect(within(table).getByText('Lab Cluster')).toBeInTheDocument(); - }); - - it('filters protected rollups by platform', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('VM 123')).toBeInTheDocument(); - - await selectAddFilterOption('Platform', 'TrueNAS'); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory&platform=truenas', - ROUTE_STATE_REPLACE_OPTIONS, - ); - 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('platform=truenas'), - ); - const hasPoints = urls.some( - (url) => url.includes('/api/recovery/points') && url.includes('platform=truenas'), - ); - const hasSeries = urls.some( - (url) => url.includes('/api/recovery/series') && url.includes('platform=truenas'), - ); - const hasFacets = urls.some( - (url) => url.includes('/api/recovery/facets') && url.includes('platform=truenas'), - ); - expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true); - }); - }); - - it('supports Proxmox platform table embedding without standalone page chrome', async () => { - render(() => ); - - expect( - screen.queryByText( - 'Review recovery activity, restore posture, and backup history across platforms.', - ), - ).not.toBeInTheDocument(); - expect(screen.queryByText('Recovery Activity')).not.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('platform=proxmox-pve'), - ); - const hasPoints = urls.some( - (url) => url.includes('/api/recovery/points') && url.includes('platform=proxmox-pve'), - ); - expect(hasRollups && hasPoints).toBe(true); - }); - expect(await screen.findByText(/Showing 1 - 1 of 1 recovery points/i)).toBeInTheDocument(); - }); - - it('supports platform protection embedding with a compact workspace switcher', async () => { - mockLocationPath = '/truenas/protection'; - - render(() => ( - - )); - - expect( - screen.queryByText( - 'Review recovery activity, restore posture, and backup history across platforms.', - ), - ).not.toBeInTheDocument(); - - const workspaceControl = await screen.findByRole('group', { name: 'Recovery workspace' }); - expect(within(workspaceControl).getByRole('button', { name: 'Protection' })).toHaveAttribute( - 'aria-pressed', - 'true', - ); - expect(await screen.findByText('Protection coverage')).toBeInTheDocument(); - expect(await screen.findByText('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('platform=truenas'), - ); - expect(hasRollups).toBe(true); - }); - - fireEvent.click(within(workspaceControl).getByRole('button', { name: 'Events' })); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - expect(within(workspaceControl).getByRole('button', { name: 'Events' })).toHaveAttribute( - 'aria-pressed', - 'true', - ); - expect(navigateSpy).not.toHaveBeenCalled(); - }); - - it('keeps route-owned recovery platform and node filters visible while options hydrate', async () => { - mockLocationSearch = '?view=events&platform=truenas&node=tower'; - apiFetchMock.mockImplementation(() => new Promise(() => {})); - - render(() => ); - - // FilterBar renders active filters as chips. Route-owned platform and - // node values become removable chips in the events bar. - expect(await screen.findByRole('button', { name: /^Platform: TrueNAS/ })).toBeInTheDocument(); - expect( - await screen.findByRole('button', { name: /^Host \/ Agent: tower/ }), - ).toBeInTheDocument(); - }); - - it('uses the shared reset action for protection coverage filters', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('VM 123')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Clear all' })).not.toBeInTheDocument(); - - await selectAddFilterOption('Platform', 'TrueNAS'); - - const clearAllButton = await screen.findByRole('button', { name: 'Clear all' }); - fireEvent.click(clearAllButton); - - await waitFor(() => { - expect(screen.queryByRole('button', { name: /^Platform: TrueNAS/ })).not.toBeInTheDocument(); - expect(screen.getByText('VM 123')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Clear all' })).not.toBeInTheDocument(); - }); - }); - - it('treats the focused rollup as part of the recovery events filter surface', async () => { - mockLocationSearch = '?rollupId=res%3Avm-123'; - - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - expect(within(controls).getByTestId('recovery-history-item-filter-trigger')).toHaveTextContent( - 'VM 123', - ); - - fireEvent.click(within(controls).getByRole('button', { name: 'Clear all' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - expect(screen.getByTestId('recovery-history-item-filter-trigger')).toHaveTextContent( - 'Any item', - ); - expect(screen.queryByRole('button', { name: 'Clear all' })).not.toBeInTheDocument(); - }); - }); - - it('treats chart day selection as a shared recovery events filter', async () => { - render(() => ); - - const bars = await screen.findByTestId('recovery-activity-bars'); - fireEvent.click(within(bars).getByRole('button', { name: /Feb 14: 1 recovery point/ })); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - expect( - await within(controls).findByRole('button', { - name: /^Day: Feb 14, 2026 - 1 recovery point/, - }), - ).toBeInTheDocument(); - expect(within(controls).getByRole('button', { name: 'Remove Day filter' })).toBeInTheDocument(); - expect(screen.getByText(/Showing 1 - 1 of 1 recovery points/i)).toBeInTheDocument(); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?day=2026-02-14', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - fireEvent.click(within(controls).getByRole('button', { name: 'Clear all' })); - - await waitFor(() => { - expect( - within(controls).queryByRole('button', { - name: /^Day: Feb 14, 2026 - 1 recovery point/, - }), - ).not.toBeInTheDocument(); - expect(within(bars).queryByRole('button', { pressed: true })).not.toBeInTheDocument(); - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - }); - - it('keeps recovery event utility controls grouped on the shared toolbar rail', async () => { - mockLocationSearch = '?view=events'; - - render(() => ); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - // Both the direct Filter selector and the Columns picker live inside the - // events controls group; the item filter trigger is also grouped here. - expect(within(controls).getByRole('combobox', { name: 'Filter' })).toBeInTheDocument(); - expect(within(controls).getByRole('button', { name: /columns/i })).toBeInTheDocument(); - expect( - within(controls).getByTestId('recovery-history-item-filter-trigger'), - ).toBeInTheDocument(); - // Recovery events opted into shared saved views — the bookmark menu - // sits next to Filter and persists named filter combos under the - // canonical localStorage key. - expect(within(controls).getByRole('button', { name: 'Saved views' })).toBeInTheDocument(); - }); - - it('lets operators create and clear the item filter from the recovery events controls', async () => { - render(() => ); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - const itemFilterTrigger = within(controls).getByTestId('recovery-history-item-filter-trigger'); - expect(itemFilterTrigger).toHaveTextContent('Any item'); - - fireEvent.click(itemFilterTrigger); - expect(await screen.findByTestId('recovery-history-item-filter-panel')).toBeInTheDocument(); - - fireEvent.click(await screen.findByTestId('recovery-history-item-filter-option-ext:truenas-1')); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?rollupId=ext%3Atruenas-1', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - expect(screen.getByTestId('recovery-history-item-filter-trigger')).toHaveTextContent( - 'tank/apps', - ); - - fireEvent.click(screen.getByTestId('recovery-history-item-filter-trigger')); - fireEvent.click(await screen.findByTestId('recovery-history-item-filter-clear')); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - expect(screen.getByTestId('recovery-history-item-filter-trigger')).toHaveTextContent( - 'Any item', - ); - }); - }); - - it('keeps recovery filter surfaces on canonical platform vocabulary', async () => { - render(() => ); - expect(await screen.findByText('VM 123')).toBeInTheDocument(); - - // Events FilterBar surfaces "Platform" via the direct Filter selector, so - // assert the selector vocabulary instead. - expect(await findAddFilterOption('Platform', 'PVE')).toBeInTheDocument(); - expect(screen.queryByText('All Providers')).not.toBeInTheDocument(); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('tank/apps')).toBeInTheDocument(); - expect(await findAddFilterOption('Platform', 'PVE')).toBeInTheDocument(); - expect(screen.queryByText('All Providers')).not.toBeInTheDocument(); - }); - - it('filters recovery transport by canonical item type', async () => { - render(() => ); - - expect(await screen.findByText('VM 123')).toBeInTheDocument(); - - await selectAddFilterOption('Item Type', 'Dataset'); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?itemType=dataset', - ROUTE_STATE_REPLACE_OPTIONS, - ); - 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 protection coverage and history transport', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - const protectedSearch = await screen.findByPlaceholderText('Search protection coverage...'); - fireEvent.input(protectedSearch, { target: { value: 'tank' } }); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory&q=tank', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - await waitFor(() => { - 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('q=tank'), - ); - const hasPoints = urls.some( - (url) => url.includes('/api/recovery/points') && url.includes('q=tank'), - ); - const hasSeries = urls.some( - (url) => url.includes('/api/recovery/series') && url.includes('q=tank'), - ); - const hasFacets = urls.some( - (url) => url.includes('/api/recovery/facets') && url.includes('q=tank'), - ); - expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true); - }); - }); - - it('promotes clear day text in recovery search to the shared Day filter', async () => { - render(() => ); - - const historyControls = await screen.findByRole('group', { - name: /recovery events controls/i, - }); - const historySearch = within(historyControls).getByPlaceholderText( - 'Search recovery history or enter a date...', - ); - - fireEvent.input(historySearch, { target: { value: 'Feb 14' } }); - - await waitFor(() => { - expect( - within(historyControls).getByText('Feb 14, 2026 - 1 recovery point'), - ).toBeInTheDocument(); - expect(historySearch).toHaveValue(''); - expect(navigateSpy).toHaveBeenCalledWith( - expect.stringContaining('day=2026-02-14'), - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - const selectedStart = parseRecoveryDateKey('2026-02-14'); - selectedStart.setHours(0, 0, 0, 0); - const selectedEnd = new Date(selectedStart); - selectedEnd.setHours(23, 59, 59, 999); - - await waitFor(() => { - const pointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - const latestPointsUrl = pointUrls[pointUrls.length - 1]; - expect(latestPointsUrl).toContain(`from=${encodeURIComponent(selectedStart.toISOString())}`); - expect(latestPointsUrl).toContain(`to=${encodeURIComponent(selectedEnd.toISOString())}`); - expect(latestPointsUrl).not.toContain('q=Feb'); - }); - }); - - it('uses canonical protection state filtering for protected inventory', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - await selectAddFilterOption('Protection state', 'Never succeeded'); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory&state=never-succeeded', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - await waitFor(() => { - expect(screen.queryByText('VM 123')).not.toBeInTheDocument(); - expect(screen.getByText('tank/apps')).toBeInTheDocument(); - }); - - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - expect(urls.some((url) => url.includes('outcome=failed'))).toBe(false); - }); - - it('bounds the protected inventory surface with pagination for larger estates', async () => { - const largeRollups = Array.from({ length: 26 }, (_, index) => ({ - rollupId: `estate:item-${index + 1}`, - itemResourceId: `estate-item-${index + 1}`, - display: { - itemLabel: `estate-item-${index + 1}`, - itemType: index % 2 === 0 ? 'container' : 'vm', - }, - lastAttemptAt: '2026-02-14T10:00:00.000Z', - lastSuccessAt: '2026-02-14T10:00:00.000Z', - lastOutcome: 'success', - platforms: ['proxmox-pbs'], - })); - - apiFetchMock.mockImplementation(async (url: string) => { - const u = new URL(url, 'http://localhost'); - if (u.pathname === '/api/recovery/rollups') { - return { - data: largeRollups, - meta: { page: 1, limit: 500, total: largeRollups.length, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/points') { - return { - data: [], - meta: { page: 1, limit: 500, total: 0, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/facets') { - return { - data: { - clusters: [], - nodesAgents: [], - namespaces: [], - itemTypes: ['container', 'vm'], - hasSize: true, - hasVerification: false, - hasEntityId: false, - }, - }; - } - if (u.pathname === '/api/recovery/series') { - return { - data: [ - { day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { day: '2026-02-14', total: 1, snapshot: 0, local: 1, remote: 0 }, - ], - }; - } - throw new Error(`Unhandled apiFetch URL: ${url}`); - }); - - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: 'Protection coverage' })); - expect(await screen.findByText('Showing 26 coverage items')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Prev' })).not.toBeInTheDocument(); - }); - - it('canonicalizes legacy stale flags into protected inventory state', async () => { - mockLocationSearch = '?stale=%20TRUE%20'; - - render(() => ); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?view=inventory&state=stale', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - // FilterBar surfaces the active value as a chip rather than a labelled - // select. Assert the canonical chip text instead. - expect( - await screen.findByRole('button', { name: /^Protection state: Stale$/ }), - ).toBeInTheDocument(); - }); - - it('drops protected-only stale state when the recovery events view owns the route', async () => { - mockLocationSearch = '?view=events&stale=1&range=90&status=failed'; - - render(() => ); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?range=90&status=failed', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - const controls = await screen.findByRole('group', { name: /recovery events controls/i }); - fireEvent.click(within(controls).getByRole('button', { name: 'Clear all' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - }); - - it('normalizes legacy provider aliases into canonical platform route state', async () => { - mockLocationSearch = '?provider=proxmox'; - render(() => ); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?platform=proxmox-pve', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - }); - - it('collapses unknown recovery platform values back to canonical unset state', async () => { - mockLocationSearch = '?provider=%20custom-provider%20'; - - render(() => ); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - - await waitFor(() => { - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - const filteredUrls = urls.filter( - (url) => - url.includes('/api/recovery/rollups') || - url.includes('/api/recovery/points') || - url.includes('/api/recovery/facets') || - url.includes('/api/recovery/series'), - ); - expect( - filteredUrls.some( - (url) => - url.includes('platform=custom-provider') || url.includes('provider=custom-provider'), - ), - ).toBe(false); - }); - }); - - it('adds cluster to the URL and API queries when the cluster filter changes', async () => { - facetsPayload.clusters = ['dev-cluster', 'prod-cluster']; - - render(() => ); - - await selectAddFilterOption('Cluster / Site', 'dev-cluster'); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?cluster=dev-cluster', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - await waitFor(() => { - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - const hasRollups = urls.some( - (url) => url.includes('/api/recovery/rollups') && url.includes('cluster=dev-cluster'), - ); - const hasPoints = urls.some( - (url) => url.includes('/api/recovery/points') && url.includes('cluster=dev-cluster'), - ); - const hasSeries = urls.some( - (url) => url.includes('/api/recovery/series') && url.includes('cluster=dev-cluster'), - ); - const hasFacets = urls.some( - (url) => url.includes('/api/recovery/facets') && url.includes('cluster=dev-cluster'), - ); - expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true); - }); - }); - - it('collapses explicit all recovery filters back to canonical route state', async () => { - mockLocationSearch = - '?provider=%20ALL%20&scope=%20ALL%20&mode=%20all%20&status=%20ALL%20&verification=%20All%20&cluster=%20ALL%20&node=%20all%20&namespace=%20All%20'; - - render(() => ); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - - await waitFor(() => { - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - const filteredUrls = urls.filter( - (url) => - url.includes('/api/recovery/rollups') || - url.includes('/api/recovery/points') || - url.includes('/api/recovery/facets') || - url.includes('/api/recovery/series'), - ); - expect( - filteredUrls.some( - (url) => - url.includes('platform=all') || - url.includes('platform=ALL') || - url.includes('provider=all') || - url.includes('provider=ALL') || - url.includes('scope=all') || - url.includes('scope=ALL') || - url.includes('mode=all') || - url.includes('mode=ALL') || - url.includes('status=all') || - url.includes('status=ALL') || - url.includes('verification=all') || - url.includes('verification=ALL') || - url.includes('cluster=all') || - url.includes('cluster=ALL') || - url.includes('node=all') || - url.includes('node=ALL') || - url.includes('namespace=all') || - url.includes('namespace=All'), - ), - ).toBe(false); - }); - }); - - it('keeps facets aligned with node and namespace history filters', async () => { - facetsPayload.nodesAgents = ['node-agent-1', 'node-agent-2']; - facetsPayload.namespaces = ['tenant-a', 'tenant-b']; - - render(() => ); - - await selectAddFilterOption('Host / Agent', 'node-agent-1'); - await selectAddFilterOption('Namespace / Group', 'tenant-a'); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?namespace=tenant-a&node=node-agent-1', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - - await waitFor(() => { - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - const hasRollups = urls.some( - (url) => - url.includes('/api/recovery/rollups') && - url.includes('node=node-agent-1') && - url.includes('namespace=tenant-a'), - ); - const hasPoints = urls.some( - (url) => - url.includes('/api/recovery/points') && - url.includes('node=node-agent-1') && - url.includes('namespace=tenant-a'), - ); - const hasSeries = urls.some( - (url) => - url.includes('/api/recovery/series') && - url.includes('node=node-agent-1') && - url.includes('namespace=tenant-a'), - ); - const hasFacets = urls.some( - (url) => - url.includes('/api/recovery/facets') && - url.includes('node=node-agent-1') && - url.includes('namespace=tenant-a'), - ); - expect(hasRollups && hasPoints && hasSeries && hasFacets).toBe(true); - }); - }); - - it('keeps the history card mounted while filter refetches are in flight', async () => { - mockLocationSearch = '?rollupId=res%3Avm-123'; - facetsPayload.clusters = ['dev-cluster']; - - let delayedPointsReady = false; - let resolveDelayedPoints!: (value: RecoveryPointsResponse) => void; - - apiFetchMock.mockImplementation(async (url: string) => { - const u = new URL(url, 'http://localhost'); - if (u.pathname === '/api/recovery/rollups') { - return { - data: rollupsPayload, - meta: { page: 1, limit: 500, total: rollupsPayload.length, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/points') { - const rid = u.searchParams.get('rollupId') || ''; - const data = pointsByRollupId[rid] || []; - if (u.searchParams.get('cluster') === 'dev-cluster') { - return await new Promise((resolve) => { - delayedPointsReady = true; - resolveDelayedPoints = resolve as (value: RecoveryPointsResponse) => void; - }); - } - return { - data, - meta: { page: 1, limit: 500, total: data.length, totalPages: 1 }, - }; - } - if (u.pathname === '/api/recovery/facets') { - return { - data: facetsPayload, - }; - } - if (u.pathname === '/api/recovery/series') { - return { - data: [ - { day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { day: '2026-02-14', total: 1, snapshot: 0, local: 1, remote: 0 }, - ], - }; - } - throw new Error(`Unhandled apiFetch URL: ${url}`); - }); - - render(() => ); - - await screen.findByText(/Showing 1 - 1 of 1 recovery points/i); - - await selectAddFilterOption('Cluster / Site', 'dev-cluster'); - - await waitFor(() => { - const urls = apiFetchMock.mock.calls.map((call) => String(call[0] || '')); - expect( - urls.some( - (url) => url.includes('/api/recovery/points') && url.includes('cluster=dev-cluster'), - ), - ).toBe(true); - }); - - expect(screen.getByText(/Showing 1 - 1 of 1 recovery points/i)).toBeInTheDocument(); - - if (!delayedPointsReady) { - throw new Error('Expected delayed recovery points request to be pending'); - } - resolveDelayedPoints({ - data: pointsByRollupId['res:vm-123'], - meta: { page: 1, limit: 500, total: 1, totalPages: 1 }, - }); - - await waitFor(() => { - expect(screen.getByText(/Showing 1 - 1 of 1 recovery points/i)).toBeInTheDocument(); - }); - }); - - it('narrows recovery point history to the selected timeline day', async () => { - render(() => ); - - await waitFor(() => { - const pointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - expect(pointUrls.length).toBeGreaterThan(0); - }); - - const initialPointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - const initialPointsUrl = initialPointUrls[initialPointUrls.length - 1]; - - const timelineButtons = await screen.findAllByRole('button', { name: /recovery point/i }); - fireEvent.click(timelineButtons[0]); - - const selectedDay = '2026-02-13'; - const selectedStart = parseRecoveryDateKey(selectedDay); - selectedStart.setHours(0, 0, 0, 0); - const selectedEnd = new Date(selectedStart); - selectedEnd.setHours(23, 59, 59, 999); - - await waitFor(() => { - const pointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - const latestPointsUrl = pointUrls[pointUrls.length - 1]; - expect(latestPointsUrl).not.toBe(initialPointsUrl); - expect(latestPointsUrl).toContain(`from=${encodeURIComponent(selectedStart.toISOString())}`); - expect(latestPointsUrl).toContain(`to=${encodeURIComponent(selectedEnd.toISOString())}`); - }); - - await waitFor(() => { - const facetUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/facets')); - const latestFacetsUrl = facetUrls[facetUrls.length - 1]; - expect(latestFacetsUrl).toContain(`from=${encodeURIComponent(selectedStart.toISOString())}`); - expect(latestFacetsUrl).toContain(`to=${encodeURIComponent(selectedEnd.toISOString())}`); - }); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - expect.stringContaining('day=2026-02-13'), - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - }); - - it('restores selected timeline day from the recovery URL', async () => { - mockLocationSearch = '?day=2026-02-13'; - - render(() => ); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - - const selectedStart = parseRecoveryDateKey('2026-02-13'); - selectedStart.setHours(0, 0, 0, 0); - const selectedEnd = new Date(selectedStart); - selectedEnd.setHours(23, 59, 59, 999); - - await waitFor(() => { - const pointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - const latestPointsUrl = pointUrls[pointUrls.length - 1]; - expect(latestPointsUrl).toContain(`from=${encodeURIComponent(selectedStart.toISOString())}`); - expect(latestPointsUrl).toContain(`to=${encodeURIComponent(selectedEnd.toISOString())}`); - }); - }); - - it('canonicalizes whitespace-padded timeline day and range from the recovery URL', async () => { - mockLocationSearch = '?day=%202026-02-13%20&range=%207%20'; - - render(() => ); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - - const selectedStart = parseRecoveryDateKey('2026-02-13'); - selectedStart.setHours(0, 0, 0, 0); - const selectedEnd = new Date(selectedStart); - selectedEnd.setHours(23, 59, 59, 999); - - await waitFor(() => { - const pointUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/points')); - const latestPointsUrl = pointUrls[pointUrls.length - 1]; - expect(latestPointsUrl).toContain(`from=${encodeURIComponent(selectedStart.toISOString())}`); - expect(latestPointsUrl).toContain(`to=${encodeURIComponent(selectedEnd.toISOString())}`); - }); - - await waitFor(() => { - const rollupUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/rollups')); - const latestRollupsUrl = rollupUrls[rollupUrls.length - 1]; - expect(latestRollupsUrl).toContain('from='); - expect(latestRollupsUrl).toContain('to='); - }); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith( - '/proxmox/backups?range=7&day=2026-02-13', - ROUTE_STATE_REPLACE_OPTIONS, - ); - }); - }); - - it('persists the selected timeline range in the recovery URL', async () => { - render(() => ); - - fireEvent.click(await screen.findByRole('button', { name: '7d' })); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups?range=7', ROUTE_STATE_REPLACE_OPTIONS); - }); - }); - - it('restores the selected timeline range from the recovery URL', async () => { - mockLocationSearch = '?range=7'; - - render(() => ); - - await screen.findByRole('group', { name: /recovery events controls/i }); - - const end = new Date(); - end.setHours(23, 59, 59, 999); - const start = new Date(end); - start.setDate(start.getDate() - 6); - start.setHours(0, 0, 0, 0); - - await waitFor(() => { - const rollupUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/rollups')); - const seriesUrls = apiFetchMock.mock.calls - .map((call) => String(call[0] || '')) - .filter((url) => url.includes('/api/recovery/series')); - const latestRollupsUrl = rollupUrls[rollupUrls.length - 1]; - const latestSeriesUrl = seriesUrls[seriesUrls.length - 1]; - expect(latestRollupsUrl).toContain(`from=${encodeURIComponent(start.toISOString())}`); - expect(latestRollupsUrl).toContain(`to=${encodeURIComponent(end.toISOString())}`); - expect(latestSeriesUrl).toContain(`from=${encodeURIComponent(start.toISOString())}`); - expect(latestSeriesUrl).toContain(`to=${encodeURIComponent(end.toISOString())}`); - }); - }); - - it('renders sparse 365d timeline ticks instead of one label slot per day', async () => { - mockLocationSearch = '?range=365'; - - const { container } = render(() => ); - - expect( - await screen.findByRole('group', { name: /recovery events controls/i }), - ).toBeInTheDocument(); - - const labels = await waitFor(() => { - const labelsRow = container - .querySelector('[data-testid="recovery-activity-bars"]') - ?.parentElement?.querySelector('.pointer-events-none.absolute.inset-x-0.bottom-0.h-4'); - const renderedLabels = Array.from(labelsRow?.querySelectorAll('span') || []); - expect(renderedLabels.length).toBeGreaterThan(0); - return renderedLabels; - }); - - expect(labels.length).toBeLessThan(50); - expect(labels[0]?.textContent).toBeTruthy(); - expect(labels.at(-1)?.textContent).toBeTruthy(); - }); - - it('clears the shared recovery search query on Escape', async () => { - mockLocationSearch = '?q=gdfdgd'; - render(() => ); - - fireEvent.keyDown(document, { key: 'Escape' }); - - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/backups', ROUTE_STATE_REPLACE_OPTIONS); - }); - }); -}); diff --git a/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx b/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx deleted file mode 100644 index 392b09c30d..0000000000 --- a/frontend-modern/src/components/Recovery/__tests__/RecoveryActivitySection.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { fireEvent, render, screen, within } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; -import { describe, expect, it } from 'vitest'; - -import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection'; - -describe('RecoveryActivitySection', () => { - it('renders the bar row with stretch sizing so bars can take visible height', () => { - const timeline = () => ({ - points: [ - { key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 }, - ], - axisMax: 2, - labelEvery: 1, - }); - - render(() => ( - ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })} - chartRangeDays={() => 30} - isMobile={false} - loading={() => false} - onRangeChange={() => undefined} - overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })} - dayFilterKey={() => null} - timeline={timeline} - toggleDayFilter={() => undefined} - /> - )); - - const bars = screen.getByTestId('recovery-activity-bars'); - expect(bars.className).toContain('items-stretch'); - expect(bars.style.gap).toBe('3px'); - expect(bars.parentElement?.className).toContain('h-[136px] sm:h-[150px]'); - expect(bars.closest('.grid')?.firstElementChild?.className).toContain('h-[120px] sm:h-[134px]'); - expect(screen.getByTestId('recovery-activity-chart-scroll').className).toContain( - 'overflow-x-auto', - ); - expect( - screen.queryByText('Daily recovery points across the selected history window.'), - ).not.toBeInTheDocument(); - expect(screen.queryByText(/^Range$/)).not.toBeInTheDocument(); - expect(screen.queryByText(/1.5 \/ day/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Lowest active day')).not.toBeInTheDocument(); - expect(screen.queryByText('Below normal')).not.toBeInTheDocument(); - expect(within(bars).getAllByRole('button', { name: /recovery point/i })).toHaveLength(2); - expect(within(bars).getAllByRole('button', { pressed: false })).toHaveLength(2); - }); - - it('uses day-filter styling and a structured activity tooltip', () => { - const timeline = () => ({ - points: [ - { key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 }, - ], - axisMax: 2, - labelEvery: 1, - }); - - render(() => ( - ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })} - chartRangeDays={() => 30} - isMobile={false} - loading={() => false} - onRangeChange={() => undefined} - overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })} - dayFilterKey={() => '2026-02-14'} - timeline={timeline} - toggleDayFilter={() => undefined} - /> - )); - - expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument(); - - const bars = screen.getByTestId('recovery-activity-bars'); - const buttons = within(bars).getAllByRole('button', { name: /recovery point/i }); - expect(buttons[0].className).not.toContain('opacity-40'); - expect(buttons[1].className).not.toContain('ring-blue-500'); - expect(within(buttons[0]).getByTestId('recovery-activity-bar-stack').className).toContain( - 'opacity-40', - ); - expect(within(buttons[1]).getByTestId('recovery-activity-bar-stack').className).toContain( - 'ring-blue-500', - ); - expect(within(bars).getByRole('button', { pressed: true })).toBe(buttons[1]); - - fireEvent.mouseEnter(buttons[1]); - - const tooltip = screen.getByTestId('recovery-activity-tooltip'); - expect(within(tooltip).getByText('Day filter')).toBeInTheDocument(); - expect(within(tooltip).getByText('2 recovery points')).toBeInTheDocument(); - expect(within(tooltip).getByText('Snapshots')).toBeInTheDocument(); - expect(within(tooltip).getByText('Local Copies')).toBeInTheDocument(); - expect(within(tooltip).getByText('Remote Copies')).toBeInTheDocument(); - expect(within(tooltip).getByText('2 (100%)')).toBeInTheDocument(); - expect(tooltip.textContent).not.toContain(' • '); - - fireEvent.mouseLeave(buttons[1]); - expect(screen.queryByTestId('recovery-activity-tooltip')).not.toBeInTheDocument(); - }); - - it('updates an open tooltip when the hovered day becomes selected', () => { - const [selectedDateKey, setSelectedDateKey] = createSignal(null); - const timeline = () => ({ - points: [ - { key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 }, - { key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 }, - ], - axisMax: 2, - labelEvery: 1, - }); - - render(() => ( - ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })} - chartRangeDays={() => 30} - isMobile={false} - loading={() => false} - onRangeChange={() => undefined} - overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })} - dayFilterKey={selectedDateKey} - timeline={timeline} - toggleDayFilter={(key) => setSelectedDateKey((previous) => (previous === key ? null : key))} - /> - )); - - const bars = screen.getByTestId('recovery-activity-bars'); - const buttons = within(bars).getAllByRole('button', { name: /recovery point/i }); - - fireEvent.mouseEnter(buttons[1]); - const tooltip = screen.getByTestId('recovery-activity-tooltip'); - expect(within(tooltip).getByText('Timeline day')).toBeInTheDocument(); - - fireEvent.click(buttons[1]); - - expect(within(tooltip).getByText('Day filter')).toBeInTheDocument(); - expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument(); - const selectedButton = within(bars).getByRole('button', { pressed: true }); - const dimmedButton = within(bars).getByRole('button', { pressed: false }); - expect(selectedButton.className).not.toContain('ring-blue-500'); - expect(dimmedButton.className).not.toContain('opacity-40'); - expect(within(selectedButton).getByTestId('recovery-activity-bar-stack').className).toContain( - 'ring-blue-500', - ); - expect(within(dimmedButton).getByTestId('recovery-activity-bar-stack').className).toContain( - 'opacity-40', - ); - }); -}); diff --git a/frontend-modern/src/components/Recovery/__tests__/RecoveryProtectedInventorySection.test.tsx b/frontend-modern/src/components/Recovery/__tests__/RecoveryProtectedInventorySection.test.tsx deleted file mode 100644 index f29464b0f3..0000000000 --- a/frontend-modern/src/components/Recovery/__tests__/RecoveryProtectedInventorySection.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { render, screen } from '@solidjs/testing-library'; -import { describe, expect, it } from 'vitest'; - -import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection'; -import type { ProtectionRollup, VerifyIntent } from '@/types/recovery'; -import type { Resource } from '@/types/resource'; - -const baseRollup: ProtectionRollup = { - rollupId: 'res:vm-100', - itemResourceId: 'vm-100', - display: { - subjectLabel: 'web-prod', - subjectType: 'proxmox-vm', - }, - lastAttemptAt: '2026-05-10T12:00:00Z', - lastSuccessAt: '2026-05-10T12:00:00Z', - lastOutcome: 'success', - platforms: ['proxmox-pbs'], -}; - -const renderSection = (rollup: ProtectionRollup) => { - const rollups = [rollup]; - return render(() => ( - rollups} - itemTypeFilter={() => 'all'} - itemTypeOptions={() => ['all']} - isMobile={false} - kioskMode - onSelectRollup={() => undefined} - protectedStateFilter={() => 'all'} - platformFilter={() => 'all'} - platformOptions={() => ['all']} - queryFilter={() => ''} - resourcesById={() => new Map()} - rollups={() => rollups} - rollupsSummary={() => ({ - total: rollups.length, - counts: {}, - stale: 0, - neverSucceeded: 0, - })} - setItemTypeFilter={() => undefined} - setProtectedStateFilter={() => undefined} - setPlatformFilter={() => undefined} - setQueryFilter={() => undefined} - setVerificationFilter={() => undefined} - loading={() => false} - error={() => undefined} - /> - )); -}; - -describe('RecoveryProtectedInventorySection — Verify due badge', () => { - it('renders the Verify due badge when verifyIntent is stale', () => { - renderSection({ ...baseRollup, verifyIntent: 'stale' }); - - const badge = screen.getByTestId('recovery-protected-verify-due-badge'); - expect(badge).toBeInTheDocument(); - expect(badge.textContent).toMatch(/verify due/i); - }); - - it('does not render the Verify due badge when verifyIntent is verified', () => { - renderSection({ ...baseRollup, verifyIntent: 'verified' }); - - expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull(); - }); - - it('does not render the Verify due badge when verifyIntent is unknown', () => { - renderSection({ ...baseRollup, verifyIntent: 'unknown' as VerifyIntent }); - - expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull(); - }); - - it('does not render the Verify due badge when verifyIntent is omitted (legacy backend)', () => { - renderSection({ ...baseRollup }); - - expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull(); - }); -}); diff --git a/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts b/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts deleted file mode 100644 index 8b68c37776..0000000000 --- a/frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; -import type { Accessor } from 'solid-js'; - -import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; -import type { RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation'; - -type ArtifactMode = RecoveryArtifactMode; -type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; - -interface UseRecoveryHistorySectionStateParams { - clusterFilter: Accessor; - currentPage: Accessor; - hasFocusedRollup: Accessor; - historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; - itemTypeFilter: Accessor; - modeFilter: Accessor<'all' | ArtifactMode>; - namespaceFilter: Accessor; - nodeFilter: Accessor; - platformFilter: Accessor; - queryFilter: Accessor; - scopeFilter: Accessor<'all' | 'workload'>; - verificationFilter: Accessor; -} - -export function useRecoveryHistorySectionState( - params: UseRecoveryHistorySectionStateParams, -) { - const [selectedPoint, setSelectedPoint] = createSignal(null); - const [moreFiltersOpen, setMoreFiltersOpen] = createSignal(false); - const [historyFiltersOpen, setHistoryFiltersOpen] = createSignal(false); - let advancedFiltersPanelRef: HTMLDivElement | undefined; - let advancedFiltersButtonRef: HTMLButtonElement | undefined; - - const historyActiveFilterCount = createMemo(() => { - let count = 0; - if (params.hasFocusedRollup()) count += 1; - if (params.queryFilter().trim() !== '') count += 1; - if (params.platformFilter() !== '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; - if (params.verificationFilter() !== 'all') count += 1; - if (params.clusterFilter() !== 'all') count += 1; - if (params.nodeFilter() !== 'all') count += 1; - if (params.namespaceFilter() !== 'all') count += 1; - return count; - }); - - createEffect(() => { - params.currentPage(); - params.hasFocusedRollup(); - params.platformFilter(); - params.itemTypeFilter(); - params.historyOutcomeFilter(); - params.scopeFilter(); - params.modeFilter(); - params.verificationFilter(); - params.clusterFilter(); - params.nodeFilter(); - params.namespaceFilter(); - params.queryFilter(); - setSelectedPoint(null); - }); - - const handleAdvancedFiltersClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (advancedFiltersPanelRef?.contains(target) || advancedFiltersButtonRef?.contains(target)) { - return; - } - setMoreFiltersOpen(false); - }; - - createEffect(() => { - if (moreFiltersOpen()) { - document.addEventListener('mousedown', handleAdvancedFiltersClickOutside); - } else { - document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside); - } - }); - - onCleanup(() => { - document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside); - }); - - return { - advancedFiltersButtonRef: (element: HTMLButtonElement) => { - advancedFiltersButtonRef = element; - }, - advancedFiltersPanelRef: (element: HTMLDivElement) => { - advancedFiltersPanelRef = element; - }, - clearSelectedPoint: () => setSelectedPoint(null), - historyActiveFilterCount, - historyFiltersOpen, - moreFiltersOpen, - selectedPoint, - setHistoryFiltersOpen, - setMoreFiltersOpen, - toggleSelectedPoint: (point: RecoveryPoint) => - setSelectedPoint((current) => (current?.id === point.id ? null : point)), - }; -} diff --git a/frontend-modern/src/components/shared/PageControls.guardrails.test.ts b/frontend-modern/src/components/shared/PageControls.guardrails.test.ts index b7e7cf684b..cff633eff7 100644 --- a/frontend-modern/src/components/shared/PageControls.guardrails.test.ts +++ b/frontend-modern/src/components/shared/PageControls.guardrails.test.ts @@ -2,9 +2,6 @@ import { describe, expect, it } from 'vitest'; import pageControlsSource from '@/components/shared/PageControls.tsx?raw'; import filterToolbarSource from '@/components/shared/FilterToolbar.tsx?raw'; import workloadsFilterSource from '@/components/Workloads/WorkloadsFilter.tsx?raw'; -import recoveryPageSource from '@/components/Recovery/Recovery.tsx?raw'; -import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw'; -import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; import subtabsSource from '@/components/shared/Subtabs.tsx?raw'; const tsxSources = import.meta.glob('../../**/*.tsx', { @@ -51,11 +48,8 @@ describe('page controls guardrails', () => { // RecoveryProtectedInventorySection migrated to FilterBar; defensive // assertions remain to catch any regression that reintroduces FilterHeader // / ColumnPicker forks here. - expect(recoveryProtectedInventorySectionSource).not.toContain(' { './PageControls.tsx', ]); - // RecoveryHistorySection and WorkloadsFilter compose ColumnPicker via - // FilterBar's viewOptionsTrailing slot; PageControls plus those two - // surfaces are the canonical ColumnPicker consumers. + // WorkloadsFilter and the Standalone AgentsMachinesTable compose + // ColumnPicker via FilterBar's viewOptionsTrailing slot; PageControls + // plus those two surfaces are the canonical ColumnPicker consumers. expect(columnPickerUsers).toEqual([ - '../Recovery/RecoveryHistorySection.tsx', + '../../features/standalone/AgentsMachinesTable.tsx', '../Workloads/WorkloadsFilter.tsx', './PageControls.tsx', ]); expect(columnPickerImportUsers).toEqual([ - '../Recovery/RecoveryHistorySection.tsx', + '../../features/standalone/AgentsMachinesTable.tsx', '../Workloads/WorkloadsFilter.tsx', './PageControls.tsx', ]); @@ -127,12 +121,6 @@ describe('page controls guardrails', () => { expect(pageControlsSource).toContain('searchLeading?: JSX.Element'); expect(pageControlsSource).toContain('searchLeading={local.searchLeading}'); expect(pageControlsSource).toContain('{...divProps}'); - expect(recoveryProtectedInventorySectionSource).not.toContain(' { @@ -157,8 +145,6 @@ describe('page controls guardrails', () => { 'shrink-0 flex-wrap items-center justify-end gap-2 self-start', ); expect(pageControlsSource).not.toContain('2xl:ml-auto'); - expect(recoveryHistorySectionSource).not.toContain('toolbarClass="lg:flex-nowrap"'); - expect(recoveryHistorySectionSource).not.toContain('ml-auto flex items-center gap-2'); }); it('keeps compact stable filters on the shared labeled toggle primitive', () => { @@ -180,17 +166,9 @@ describe('page controls guardrails', () => { expect(workloadsFilterSource).not.toContain('xl:flex-col xl:items-start'); // RecoveryHistorySection migrated to FilterBar; LabeledFilterToggleGroup // / LabeledFilterSelect no longer rendered. - expect(recoveryHistorySectionSource).not.toContain('LabeledFilterToggleGroup'); - expect(recoveryHistorySectionSource).not.toContain('LabeledFilterSelect'); }); it('keeps embedded workspace tabs on the canonical shared segmented-control pattern', () => { expect(subtabsSource).not.toContain("variant?: 'default' | 'control'"); - expect(recoveryPageSource).not.toContain('variant="control"'); - expect(recoveryPageSource).not.toContain('listClass='); - expect(recoveryPageSource).not.toContain('tabClass='); - expect(recoveryPageSource).toContain('FilterSegmentedControl'); - expect(recoveryPageSource).toContain('RECOVERY_WORKSPACE_OPTIONS'); - expect(recoveryPageSource).not.toContain('Focused drill-in'); }); }); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index f16228eb34..ee7486c6bb 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -144,10 +144,6 @@ import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx? import diskListSource from '@/components/Storage/DiskList.tsx?raw'; import storageSummarySource from '@/components/Storage/StorageSummary.tsx?raw'; import workloadsSummarySource from '@/components/Workloads/WorkloadsSummary.tsx?raw'; -import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw'; -import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw'; -import recoveryHistoryTableSource from '@/components/Recovery/RecoveryHistoryTable.tsx?raw'; -import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw'; import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw'; import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw'; @@ -351,8 +347,6 @@ describe('shared primitive guardrails', () => { unifiedResourceHostTableCardSource, unifiedResourceServiceInfrastructureCardSource, storageContentCardSource, - recoveryHistorySectionSource, - recoveryProtectedInventorySectionSource, ]) { expect(source).toContain('TableCardHeader'); expect(source).not.toContain('SummaryTableCardHeader'); @@ -374,9 +368,6 @@ describe('shared primitive guardrails', () => { unifiedResourceHostTableCardSource, unifiedResourceServiceInfrastructureCardSource, storageContentCardSource, - recoveryComponentSource, - recoveryHistorySectionSource, - recoveryProtectedInventorySectionSource, ]) { expect(source).toContain('TableCard'); expect(source).not.toContain('overflow-hidden border-border-subtle bg-surface'); @@ -567,8 +558,6 @@ describe('shared primitive guardrails', () => { unifiedResourceHostTableCardSource, unifiedResourcePBSTableSectionSource, unifiedResourcePMGTableSectionSource, - recoveryHistoryTableSource, - recoveryProtectedInventorySectionSource, alertHistoryTableSectionSource, workloadsTableSource, storagePoolsTableSource, @@ -597,8 +586,6 @@ describe('shared primitive guardrails', () => { for (const source of [ unifiedResourceHostTableCardSource, - recoveryHistorySectionSource, - recoveryProtectedInventorySectionSource, alertHistoryTableSectionSource, workloadsTableSource, storageContentCardSource, @@ -608,8 +595,6 @@ describe('shared primitive guardrails', () => { for (const source of [ unifiedResourceHostTableCardSource, - recoveryHistorySectionSource, - recoveryProtectedInventorySectionSource, storageContentCardSource, ]) { expect(source).toContain('TableCardHeader'); @@ -621,7 +606,6 @@ describe('shared primitive guardrails', () => { expect(storagePoolsTableSource).not.toContain('STORAGE_POOLS_SCROLL_WRAP_CLASS'); expect(diskListSource).not.toContain("from '@/components/shared/Card'"); expect(diskListSource).not.toContain('PHYSICAL_DISK_TABLE_SCROLL_CLASS'); - expect(recoveryProtectedInventorySectionSource).toContain('wrapperClass="bg-surface"'); expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"'); expect(storageContentCardSource).toContain(' { // FilterDef entries rather than rendering the labelled-select row from // PageControls. Guards against regression to the old layout-per-page // pattern. - expect(recoveryProtectedInventorySectionSource).toContain( - "import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';", - ); - expect(recoveryProtectedInventorySectionSource).toContain(' { expect(guestRowModelSource).not.toContain('createCanonicalTypeColumn'); expect(guestRowModelSource).not.toMatch(INLINE_TYPE_COLUMN_PATTERN); expect(guestRowModelSource).not.toContain("defaultVisibility:"); - - expect(recoverySource).toContain('createVisibleCanonicalTypeColumn()'); - expect(recoverySource).not.toContain('createCanonicalTypeColumn'); - expect(recoverySource).not.toMatch(INLINE_TYPE_COLUMN_PATTERN); - expect(recoverySource).not.toContain("defaultVisibility:"); }); it('limits runtime Type columns to the known allowlist', () => { @@ -92,7 +86,6 @@ describe('type column guardrails', () => { .sort(); expect(typeColumnUsers).toEqual([ - '../Recovery/Recovery.tsx', '../Workloads/guestRowModel.tsx', ]); @@ -127,7 +120,6 @@ describe('type column guardrails', () => { .sort(); expect(directHelperImportUsers).toEqual([ - '../Recovery/Recovery.tsx', '../Workloads/guestRowModel.tsx', ]); }); diff --git a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts b/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts deleted file mode 100644 index 211c1c5b1e..0000000000 --- a/frontend-modern/src/features/recovery/useRecoverySurfaceState.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js'; -import { useLocation, useNavigate } from '@solidjs/router'; - -import { useStorageRecoveryResources } from '@/hooks/useUnifiedResources'; -import { useRecoveryRollups, type RecoveryRollupsQuery } from '@/hooks/useRecoveryRollups'; -import { useRecoveryPoints } from '@/hooks/useRecoveryPoints'; -import { useRecoveryPointsFacets } from '@/hooks/useRecoveryPointsFacets'; -import { useRecoveryPointsSeries } from '@/hooks/useRecoveryPointsSeries'; -import { buildRecoveryRouteSearch, parseRecoveryLinkSearch } from '@/routing/resourceLinks'; -import type { ProtectionRollup, RecoveryOutcome } from '@/types/recovery'; -import type { Resource } from '@/types/resource'; -import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation'; -import { - getSourcePlatformLabel, - normalizeSourcePlatformKey, - normalizeSourcePlatformQueryValue, -} from '@/utils/sourcePlatforms'; -import { buildSourcePlatformOptions } from '@/utils/sourcePlatformOptions'; -import { - getRecoveryPointPlatform, - getRecoveryRollupPlatforms, -} from '@/utils/recoveryPlatformModel'; -import { - getRecoveryRollupItemLabel, - getRecoveryRollupItemSecondaryLabel, - normalizeRecoveryModeQueryValue, -} from '@/utils/recoveryRecordPresentation'; -import { - getRecoveryPointItemTypeKey, - getRecoveryRollupItemTypeKey, - getRecoveryItemTypePresentation, - normalizeRecoveryItemTypeQueryValue, -} from '@/utils/recoveryItemTypePresentation'; -import { - getRecoveryRollupInventoryStatus, - type RecoveryRollupInventoryStatus, -} from '@/utils/recoveryTablePresentation'; -import { getRecoveryRollupInventoryStatusLabel } from '@/utils/recoveryStatusPresentation'; -import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation'; -import type { RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation'; -import { parseRecoveryDateKey } from '@/utils/recoveryDatePresentation'; -import { formatRelativeTime } from '@/utils/format'; - -type ArtifactMode = RecoveryArtifactMode; -type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; -export type RecoveryWorkspaceView = 'inventory' | 'events'; -export type ProtectedStateFilter = 'all' | RecoveryRollupInventoryStatus; - -type RecoverySurfaceStateOptions = { - defaultWorkspaceView?: RecoveryWorkspaceView; -}; - -const isRecoveryDateKey = (value: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(value); -const isRecoveryRangeDays = (value: string): value is '7' | '30' | '90' | '365' => - value === '7' || value === '30' || value === '90' || value === '365'; - -const normalizeRecoveryRouteValue = (value: string | null | undefined): string => - String(value || '').trim(); - -const normalizeRecoveryWorkspaceViewValue = ( - value: string | null | undefined, -): RecoveryWorkspaceView | '' => { - const normalized = normalizeRecoveryRouteValue(value).toLowerCase(); - if (normalized === 'inventory' || normalized === 'events') { - return normalized; - } - return ''; -}; - -const normalizeRecoveryRouteSelection = (value: string | null | undefined): string => { - const normalized = normalizeRecoveryRouteValue(value); - return normalized.toLowerCase() === 'all' ? 'all' : normalized; -}; - -const normalizeRecoveryBooleanFlag = (value: string | null | undefined): boolean => { - const normalized = normalizeRecoveryRouteValue(value).toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; -}; - -const normalizeRecoveryPlatformSelection = (value: string | null | undefined): string => { - const normalized = normalizeSourcePlatformQueryValue(value); - if (!normalized || normalized === 'all') return 'all'; - return normalizeSourcePlatformKey(normalized) || 'all'; -}; - -const normalizeRecoveryItemTypeSelection = (value: string | null | undefined): string => { - const normalized = normalizeRecoveryItemTypeQueryValue(value); - return normalized || 'all'; -}; - -const normalizeProtectedStateFilter = (value: string | null | undefined): ProtectedStateFilter => { - const normalized = normalizeRecoveryRouteValue(value).toLowerCase(); - if (normalized === 'success') return 'healthy'; - if ( - normalized === 'healthy' || - normalized === 'warning' || - normalized === 'failed' || - normalized === 'running' || - normalized === 'stale' || - normalized === 'never-succeeded' || - normalized === 'unknown' - ) { - return normalized as ProtectedStateFilter; - } - return 'all'; -}; - -const getRollupFreshnessLabel = (rollup: ProtectionRollup): string => { - const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; - if (Number.isFinite(successMs) && successMs > 0) { - return `Last success ${formatRelativeTime(successMs)}`; - } - const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; - if (Number.isFinite(attemptMs) && attemptMs > 0) { - return `Last attempt ${formatRelativeTime(attemptMs)}`; - } - return 'No attempts recorded'; -}; - -export function useRecoverySurfaceState(options: RecoverySurfaceStateOptions = {}) { - const navigate = useNavigate(); - const location = useLocation(); - const defaultWorkspaceView = options.defaultWorkspaceView ?? 'events'; - const routeStateNavigate = createRouteStateNavigateScheduler( - navigate, - () => `${untrack(() => location.pathname)}${untrack(() => location.search || '')}`, - ); - - const storageRecoveryResources = useStorageRecoveryResources(); - - const [rollupId, setRollupId] = createSignal(''); - const [workspaceView, setWorkspaceView] = createSignal('events'); - const [queryFilter, setQueryFilter] = createSignal(''); - const [platformFilter, setPlatformFilter] = 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>( - 'all', - ); - const [verificationFilter, setVerificationFilter] = createSignal('all'); - const [scopeFilter, setScopeFilter] = createSignal<'all' | 'workload'>('all'); - const [nodeFilter, setNodeFilter] = createSignal('all'); - const [namespaceFilter, setNamespaceFilter] = createSignal('all'); - const [protectedStateFilter, setProtectedStateFilter] = createSignal('all'); - const [chartRangeDays, setChartRangeDays] = createSignal<7 | 30 | 90 | 365>(30); - const [selectedDateKey, setSelectedDateKey] = createSignal(null); - const [currentPage, setCurrentPage] = createSignal(1); - const [hydratedLocationKey, setHydratedLocationKey] = createSignal(''); - - const tzOffsetMinutes = createMemo(() => -new Date().getTimezoneOffset()); - - const chartWindow = createMemo(() => { - const days = chartRangeDays(); - const end = new Date(); - end.setHours(23, 59, 59, 999); - const start = new Date(end); - start.setDate(start.getDate() - (days - 1)); - start.setHours(0, 0, 0, 0); - return { from: start.toISOString(), to: end.toISOString() }; - }); - - const tableWindow = createMemo(() => { - const selected = selectedDateKey(); - if (selected) { - const start = parseRecoveryDateKey(selected); - start.setHours(0, 0, 0, 0); - const end = new Date(start); - end.setHours(23, 59, 59, 999); - return { from: start.toISOString(), to: end.toISOString() }; - } - return chartWindow(); - }); - - const buildRecoveryTransportQuery = ( - window: { from: string; to: string }, - includeRollupId = true, - ): RecoveryRollupsQuery => { - const rid = rollupId().trim(); - const mode = modeFilter(); - const outcome = historyOutcomeFilter(); - const scope: 'workload' | null = scopeFilter() === 'workload' ? 'workload' : null; - const vf = verificationFilter(); - return { - rollupId: includeRollupId && rid ? rid : null, - platform: platformFilter() === 'all' ? null : platformFilter(), - itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(), - mode: mode === 'all' ? null : mode, - outcome: outcome === 'all' ? null : outcome, - q: queryFilter().trim() || null, - cluster: clusterFilter() === 'all' ? null : clusterFilter(), - node: nodeFilter() === 'all' ? null : nodeFilter(), - namespace: namespaceFilter() === 'all' ? null : namespaceFilter(), - scope, - verification: vf === 'all' ? null : vf, - from: window.from, - to: window.to, - }; - }; - - const chartTransportQuery = createMemo(() => buildRecoveryTransportQuery(chartWindow())); - const tableTransportQuery = createMemo(() => buildRecoveryTransportQuery(tableWindow())); - const recoverySeriesTransportQuery = createMemo(() => ({ - ...chartTransportQuery(), - tzOffsetMinutes: tzOffsetMinutes(), - })); - - const recoveryRollups = useRecoveryRollups(() => chartTransportQuery()); - - const rollups = createMemo(() => recoveryRollups.rollups() || []); - const selectedRollup = createMemo(() => { - const selected = rollupId().trim(); - if (!selected) return null; - return rollups().find((rollup) => rollup.rollupId === selected) || null; - }); - - const recoveryHistoryItemCatalog = useRecoveryRollups(() => { - if (!rollupId().trim()) return null; - return buildRecoveryTransportQuery(chartWindow(), false); - }); - - const selectableRollups = createMemo(() => { - const selected = rollupId().trim(); - if (!selected) return rollups(); - const catalog = recoveryHistoryItemCatalog.rollups() || []; - if (catalog.length > 0) return catalog; - const focused = selectedRollup(); - return focused ? [focused] : []; - }); - - const selectedHistoryRollup = createMemo(() => { - const selected = rollupId().trim(); - if (!selected) return null; - return ( - selectableRollups().find((rollup) => rollup.rollupId === selected) || selectedRollup() || null - ); - }); - - const resourcesById = createMemo(() => { - const map = new Map(); - for (const resource of storageRecoveryResources.resources() || []) { - if (resource?.id) map.set(resource.id, resource); - } - return map; - }); - - const selectedHistoryItemLabel = createMemo(() => { - const rollup = selectedHistoryRollup(); - return rollup ? getRecoveryRollupItemLabel(rollup, resourcesById()) : null; - }); - - const historyItemOptions = createMemo(() => { - const resourceIndex = resourcesById(); - const options = selectableRollups().map((rollup) => { - const itemTypeLabel = - getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup))?.label || null; - const platformLabels = getRecoveryRollupPlatforms(rollup) - .map((platform) => getSourcePlatformLabel(String(platform || '').trim())) - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); - const stateLabel = getRecoveryRollupInventoryStatusLabel( - getRecoveryRollupInventoryStatus(rollup), - ); - return { - rollupId: rollup.rollupId, - label: getRecoveryRollupItemLabel(rollup, resourceIndex), - secondaryLabel: getRecoveryRollupItemSecondaryLabel(rollup), - contextLabel: [ - stateLabel, - getRollupFreshnessLabel(rollup), - itemTypeLabel, - platformLabels.join(', '), - ] - .filter(Boolean) - .join(' · '), - }; - }); - - const selected = selectedHistoryRollup(); - if (!selected) return options; - if (options.some((option) => option.rollupId === selected.rollupId)) return options; - - const itemTypeLabel = - getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(selected))?.label || null; - const platformLabels = getRecoveryRollupPlatforms(selected) - .map((platform) => getSourcePlatformLabel(String(platform || '').trim())) - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)); - const stateLabel = getRecoveryRollupInventoryStatusLabel( - getRecoveryRollupInventoryStatus(selected), - ); - - return [ - { - rollupId: selected.rollupId, - label: getRecoveryRollupItemLabel(selected, resourceIndex), - secondaryLabel: getRecoveryRollupItemSecondaryLabel(selected), - contextLabel: [ - stateLabel, - getRollupFreshnessLabel(selected), - itemTypeLabel, - platformLabels.join(', '), - ] - .filter(Boolean) - .join(' · '), - }, - ...options, - ]; - }); - - const recoveryPoints = useRecoveryPoints(() => { - return { - ...tableTransportQuery(), - page: currentPage(), - limit: 200, - }; - }); - - const recoveryFacets = useRecoveryPointsFacets(() => tableTransportQuery()); - - const recoverySeries = useRecoveryPointsSeries(() => recoverySeriesTransportQuery()); - - createEffect(() => { - const currentPath = `${location.pathname}${location.search || ''}`; - const parsed = parseRecoveryLinkSearch(location.search); - - const nextRollup = normalizeRecoveryRouteValue(parsed.rollupId); - const nextView = normalizeRecoveryWorkspaceViewValue(parsed.view); - const nextQuery = normalizeRecoveryRouteValue(parsed.query); - const nextPlatform = normalizeRecoveryPlatformSelection(parsed.platform || ''); - const nextItemType = normalizeRecoveryItemTypeSelection(parsed.itemType || ''); - const normalizedRange = normalizeRecoveryRouteValue(parsed.range); - const nextRange = isRecoveryRangeDays(normalizedRange) ? Number(normalizedRange) : 30; - const nextCluster = normalizeRecoveryRouteSelection(parsed.cluster || 'all') || 'all'; - const normalizedDay = normalizeRecoveryRouteValue(parsed.day); - const nextDay = isRecoveryDateKey(normalizedDay) ? normalizedDay : ''; - const legacyStaleState = normalizeRecoveryBooleanFlag(parsed.stale) ? 'stale' : ''; - const hasInventoryState = Boolean( - normalizeRecoveryRouteValue(parsed.state) || legacyStaleState, - ); - const derivedDefaultView: RecoveryWorkspaceView = hasInventoryState - ? 'inventory' - : defaultWorkspaceView; - const resolvedView = (nextView || derivedDefaultView) as RecoveryWorkspaceView; - const visibleRollup = resolvedView === 'events' ? nextRollup : ''; - const visibleDay = resolvedView === 'events' ? nextDay : ''; - const nextMode = normalizeRecoveryModeQueryValue(parsed.mode); - const rawScope = normalizeRecoveryRouteValue(parsed.scope).toLowerCase(); - const nextScope: 'all' | 'workload' = rawScope === 'workload' ? 'workload' : 'all'; - const nextNode = normalizeRecoveryRouteSelection(parsed.node || 'all') || 'all'; - const nextNamespace = normalizeRecoveryRouteSelection(parsed.namespace || 'all') || 'all'; - const verificationValue = normalizeRecoveryRouteValue(parsed.verification).toLowerCase(); - const statusValue = normalizeRecoveryRouteValue(parsed.status).toLowerCase(); - const nextProtectedState = - resolvedView === 'inventory' - ? normalizeProtectedStateFilter(parsed.state || legacyStaleState || statusValue) - : 'all'; - - if (visibleRollup !== untrack(rollupId)) setRollupId(visibleRollup); - if (resolvedView !== untrack(workspaceView)) setWorkspaceView(resolvedView); - if (nextQuery !== untrack(queryFilter)) setQueryFilter(nextQuery); - if (nextPlatform !== untrack(platformFilter)) setPlatformFilter(nextPlatform); - if (nextItemType !== untrack(itemTypeFilter)) setItemTypeFilter(nextItemType); - if (nextProtectedState !== untrack(protectedStateFilter)) { - setProtectedStateFilter(nextProtectedState); - } - if (nextRange !== untrack(chartRangeDays)) setChartRangeDays(nextRange as 7 | 30 | 90 | 365); - if (nextCluster !== untrack(clusterFilter)) setClusterFilter(nextCluster); - if ((visibleDay || null) !== untrack(selectedDateKey)) setSelectedDateKey(visibleDay || null); - if (nextMode !== untrack(modeFilter)) setModeFilter(nextMode); - if (nextScope !== untrack(scopeFilter)) setScopeFilter(nextScope); - if (nextNode !== untrack(nodeFilter)) setNodeFilter(nextNode); - if (nextNamespace !== untrack(namespaceFilter)) setNamespaceFilter(nextNamespace); - - if (resolvedView === 'inventory') { - if (untrack(verificationFilter) !== 'all') setVerificationFilter('all'); - if (untrack(historyOutcomeFilter) !== 'all') setHistoryOutcomeFilter('all'); - } else if ( - verificationValue === 'verified' || - verificationValue === 'unverified' || - verificationValue === 'unknown' - ) { - if (verificationValue !== untrack(verificationFilter)) { - setVerificationFilter(verificationValue as VerificationFilter); - } - if (untrack(historyOutcomeFilter) !== 'all') setHistoryOutcomeFilter('all'); - } else { - if (untrack(verificationFilter) !== 'all') setVerificationFilter('all'); - const normalizedOutcome = normalizeOutcome(statusValue); - if (statusValue && normalizedOutcome !== 'unknown') { - if (normalizedOutcome !== untrack(historyOutcomeFilter)) { - setHistoryOutcomeFilter(normalizedOutcome); - } - } else if (untrack(historyOutcomeFilter) !== 'all') { - setHistoryOutcomeFilter('all'); - } - } - - if (hydratedLocationKey() !== currentPath) { - setHydratedLocationKey(currentPath); - } - }); - - createEffect(() => { - rollupId(); - workspaceView(); - queryFilter(); - platformFilter(); - itemTypeFilter(); - clusterFilter(); - modeFilter(); - historyOutcomeFilter(); - verificationFilter(); - nodeFilter(); - namespaceFilter(); - scopeFilter(); - protectedStateFilter(); - chartRangeDays(); - selectedDateKey(); - if (untrack(currentPage) !== 1) setCurrentPage(1); - }); - - createEffect(() => { - const rid = rollupId().trim(); - const defaultView: RecoveryWorkspaceView = 'events'; - const status = historyOutcomeFilter() !== 'all' ? historyOutcomeFilter() : null; - const verification = verificationFilter() !== 'all' ? verificationFilter() : null; - const currentPath = `${location.pathname}${location.search || ''}`; - if (hydratedLocationKey() !== currentPath) return; - - const nextSearch = buildRecoveryRouteSearch({ - rollupId: rid || null, - view: workspaceView() !== defaultView ? workspaceView() : null, - query: queryFilter().trim() || null, - platform: platformFilter() !== 'all' ? platformFilter() : null, - itemType: itemTypeFilter() !== 'all' ? itemTypeFilter() : null, - state: - workspaceView() === 'inventory' && protectedStateFilter() !== 'all' - ? protectedStateFilter() - : null, - stale: null, - range: chartRangeDays() !== 30 ? String(chartRangeDays()) : null, - cluster: clusterFilter() !== 'all' ? clusterFilter() : null, - day: selectedDateKey(), - mode: modeFilter() !== 'all' ? modeFilter() : null, - status, - verification, - scope: scopeFilter() === 'workload' ? 'workload' : null, - node: nodeFilter() !== 'all' ? nodeFilter() : null, - namespace: namespaceFilter() !== 'all' ? namespaceFilter() : null, - }); - const nextPath = `${location.pathname}${nextSearch}`; - - if (nextPath !== currentPath) { - routeStateNavigate.schedule(nextPath); - } - }); - - onCleanup(() => { - routeStateNavigate.cleanup(); - }); - - const facets = createMemo(() => recoveryFacets.facets() || {}); - - const platformOptions = createMemo(() => { - const platforms = new Set(); - for (const rollup of rollups()) { - for (const platform of getRecoveryRollupPlatforms(rollup)) { - const normalized = normalizeSourcePlatformQueryValue(String(platform || '').trim()); - if (normalized) platforms.add(normalized); - } - } - for (const point of recoveryPoints.points() || []) { - const normalized = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(point)); - if (normalized) platforms.add(normalized); - } - const selected = normalizeRecoveryPlatformSelection(platformFilter()); - if (selected !== 'all') platforms.add(selected); - return ['all', ...buildSourcePlatformOptions(platforms).map((option) => option.key)]; - }); - - 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 = getRecoveryRollupItemTypeKey(rollup); - if (normalized) values.add(normalized); - } - - for (const point of recoveryPoints.points() || []) { - const normalized = getRecoveryPointItemTypeKey(point); - 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 || []) - .slice() - .map((value) => String(value || '').trim()) - .filter(Boolean) - .sort(); - const selected = clusterFilter().trim(); - if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected); - return ['all', ...values]; - }); - - const nodeOptions = createMemo(() => { - const values = (facets().nodesAgents || []) - .slice() - .map((value) => String(value || '').trim()) - .filter(Boolean) - .sort(); - const selected = nodeFilter().trim(); - if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected); - return ['all', ...values]; - }); - - const namespaceOptions = createMemo(() => { - const values = (facets().namespaces || []) - .slice() - .map((value) => String(value || '').trim()) - .filter(Boolean) - .sort(); - const selected = namespaceFilter().trim(); - if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected); - return ['all', ...values]; - }); - - const showClusterFilter = createMemo( - () => clusterOptions().length > 1 || clusterFilter() !== 'all', - ); - const showNodeFilter = createMemo(() => nodeOptions().length > 1 || nodeFilter() !== 'all'); - const showNamespaceFilter = createMemo( - () => namespaceOptions().length > 1 || namespaceFilter() !== 'all', - ); - const showVerificationFilter = createMemo( - () => Boolean(facets().hasVerification) || verificationFilter() !== 'all', - ); - const totalPages = createMemo(() => Math.max(1, recoveryPoints.meta().totalPages || 1)); - - return { - chartRangeDays, - clusterFilter, - clusterOptions, - currentPage, - facets, - historyOutcomeFilter, - historyItemOptions, - itemTypeFilter, - itemTypeOptions, - modeFilter, - namespaceFilter, - namespaceOptions, - nodeFilter, - nodeOptions, - protectedStateFilter, - platformFilter, - platformOptions, - queryFilter, - recoveryFacets, - recoveryPoints, - recoveryRollups, - recoverySeries, - resourcesById, - rollupId, - rollups, - scopeFilter, - selectedDateKey, - selectedHistoryItemLabel, - setChartRangeDays, - setClusterFilter, - setCurrentPage, - setHistoryOutcomeFilter, - setItemTypeFilter, - setModeFilter, - setNamespaceFilter, - setNodeFilter, - setProtectedStateFilter, - setPlatformFilter, - setQueryFilter, - setRollupId, - setScopeFilter, - setSelectedDateKey, - setVerificationFilter, - setWorkspaceView, - showClusterFilter, - showNamespaceFilter, - showNodeFilter, - showVerificationFilter, - storageRecoveryResources, - tableWindow, - totalPages, - verificationFilter, - workspaceView, - }; -} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 7e22d8c71f..6544bf8cf8 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -142,13 +142,6 @@ import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw'; import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw'; import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw'; -import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw'; -import recoveryActivitySectionSource from '@/components/Recovery/RecoveryActivitySection.tsx?raw'; -import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw'; -import recoveryHistoryTableSource from '@/components/Recovery/RecoveryHistoryTable.tsx?raw'; -import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; -import recoveryHistorySectionStateSource from '@/components/Recovery/useRecoveryHistorySectionState.ts?raw'; -import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw'; import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw'; import recoveryArtifactModePresentationSource from '@/utils/recoveryArtifactModePresentation.ts?raw'; import recoveryFilterChipPresentationSource from '@/utils/recoveryFilterChipPresentation.ts?raw'; @@ -510,14 +503,6 @@ import organizationSettingsPresentationSource from '@/utils/organizationSettings import rbacFeatureGateSectionSource from '@/components/Settings/RBACFeatureGateSection.tsx?raw'; import rbacFeatureGateStateSource from '@/components/Settings/useRBACFeatureGateState.ts?raw'; -const recoverySource = [ - recoveryComponentSource, - recoveryProtectedInventorySectionSource, - recoveryActivitySectionSource, - recoveryHistorySectionSource, - recoveryHistoryTableSource, - recoveryHistorySectionStateSource, -].join('\n'); import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw'; import rolesPanelStateSource from '@/components/Settings/useRolesPanelState.ts?raw'; import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw'; @@ -1039,196 +1024,11 @@ describe('frontend resource type boundaries', () => { expect(emptyStatePresentationSource).toContain('export function getEmptyStatePresentation'); expect(discoveryTargetSource).toContain('canonicalizeFrontendResourceType'); expect(recoveryOutcomePresentationSource).toContain('import type { RecoveryOutcome }'); - expect(recoveryComponentSource).not.toContain('RecoverySummary'); - expect(recoveryComponentSource).not.toContain('recovery-summary'); - expect(recoveryActivitySectionSource).toContain('RECOVERY_ACTIVITY_RANGE_DAYS'); - expect(recoveryActivitySectionSource).toContain('Recovery activity range'); - expect(recoverySource).not.toContain( - "const RECOVERY_TIME_RANGES: readonly string[] = ['7d', '30d', '90d']", - ); - expect(recoverySource).not.toContain( - 'const RECOVERY_TIME_RANGE_LABELS: Record', - ); - expect(recoverySource).not.toContain('const FRESHNESS_LABELS:'); - expect(recoverySource).toContain('getRecoveryArtifactModePresentation'); - expect(recoverySource).not.toContain('const MODE_LABELS: Record'); - expect(recoverySource).not.toContain('const MODE_BADGE_CLASS: Record'); - expect(recoverySource).not.toContain('const CHART_SEGMENT_CLASS: Record'); - expect(recoverySource).not.toContain( - "const ISSUE_RAIL_CLASS: Record, string>", - ); - expect(recoverySource).not.toContain('const normalizeOutcome ='); expect(recoveryArtifactModePresentationSource).toContain( 'export function getRecoveryArtifactModePresentation', ); expect(recoveryIssuePresentationSource).toContain('export function getRecoveryIssueRailClass'); - expect(recoverySource).toContain( - "import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';", - ); - expect(recoveryComponentSource).toContain('useRecoverySurfaceState'); - expect(recoveryComponentSource).toContain('TableCard'); - expect(recoveryHistorySectionSource).toContain('useRecoveryHistorySectionState'); - expect(recoveryHistorySectionSource).toContain('RecoveryHistoryTable'); - expect(recoveryHistorySectionSource).toContain('TableCard'); - expect(recoveryHistorySectionSource).toContain('getRecoveryAllHistoryLabel'); - expect(recoveryHistoryTableSource).toMatch(/)/); - expect(recoveryHistoryTableSource).not.toContain('
'); - expect(recoveryHistoryTableSource).not.toContain('
'); expect(recoveryTablePresentationSource).toContain('getAllFilterOptionLabel'); - expect(recoveryHistorySectionSource).not.toContain( - 'overflow-hidden border-border-subtle bg-surface', - ); - expect(recoveryProtectedInventorySectionSource).toContain('TableCard'); - expect(recoveryProtectedInventorySectionSource).toMatch(/)/); - expect(recoveryProtectedInventorySectionSource).toContain('wrapperClass="bg-surface"'); - expect(recoveryProtectedInventorySectionSource).not.toContain('
'); - expect(recoveryProtectedInventorySectionSource).not.toContain( - '
', - ); - expect(recoveryProtectedInventorySectionSource).not.toContain( - 'overflow-hidden border-border-subtle bg-surface', - ); - expect(recoveryHistorySectionStateSource).toContain( - 'export function useRecoveryHistorySectionState', - ); - expect(recoverySource).not.toContain('parseRecoveryLinkSearch'); - expect(recoverySource).not.toContain('buildRecoveryRouteSearch'); - expect(recoverySource).not.toContain('useRecoveryRollups'); - expect(recoverySource).not.toContain('useRecoveryPointsFacets'); - expect(recoverySurfaceStateSource).toContain('export function useRecoverySurfaceState'); - expect(recoverySurfaceStateSource).toContain('parseRecoveryLinkSearch'); - expect(recoverySurfaceStateSource).toContain('buildRecoveryRouteSearch'); - expect(recoverySurfaceStateSource).toContain('useRecoveryRollups'); - expect(recoverySurfaceStateSource).toContain('useRecoveryPoints'); - expect(recoverySurfaceStateSource).toContain('useRecoveryPointsFacets'); - expect(recoverySurfaceStateSource).toContain('useRecoveryPointsSeries'); - expect(recoveryComponentSource.indexOf('"), - ); - expect(recoverySource).not.toContain('getRecoveryFilterChipPresentation'); - expect(recoverySource).not.toContain('const titleize ='); - expect(recoverySource).not.toContain(' { expect(recoveryTimelinePresentationSource).toContain( 'export function getRecoveryTimelineColumnButtonClass', ); - expect(recoverySource).not.toContain('RECOVERY_SUMMARY_TIME_RANGES'); - expect(recoverySource).not.toContain('RECOVERY_FRESHNESS_BUCKETS'); - expect(recoverySource).not.toContain('function getAttentionChipClass('); - expect(recoverySource).not.toContain('function getAttentionDotClass('); expect(systemSettingsPresentationSource).toContain('export const PVE_POLLING_PRESETS'); expect(systemSettingsPresentationSource).toContain('export const BACKUP_INTERVAL_OPTIONS'); expect(systemSettingsPresentationSource).toContain( diff --git a/frontend-modern/src/utils/__tests__/recoveryCanonicalVocabulary.test.ts b/frontend-modern/src/utils/__tests__/recoveryCanonicalVocabulary.test.ts deleted file mode 100644 index 9475d1274d..0000000000 --- a/frontend-modern/src/utils/__tests__/recoveryCanonicalVocabulary.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import recoveryPointDetailsSource from '@/components/Recovery/RecoveryPointDetails.tsx?raw'; -import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw'; -import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw'; -import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; - -describe('recovery canonical vocabulary', () => { - it('keeps recovery platform filter iterators platform-first', () => { - // The intent of this guardrail is the canonical "platform" naming over - // legacy "provider", not a specific callback shape. Both files now use - // block-body callbacks (`(platform) => { const badge = ...; return ... }`) - // because they need a local for the badge resolution, so the original - // expression-body pattern `(platform) => (` no longer matches. - expect(recoveryProtectedInventorySectionSource).toContain('(platform) =>'); - expect(recoveryHistorySectionSource).toContain('(platform) =>'); - expect(recoveryProtectedInventorySectionSource).not.toContain('(provider) =>'); - expect(recoveryHistorySectionSource).not.toContain('(provider) =>'); - }); - - it('keeps recovery activity focus labels item-first', () => { - expect(recoverySurfaceStateSource).toContain( - 'const selectedHistoryItemLabel = createMemo(() => {', - ); - expect(recoverySurfaceStateSource).not.toContain( - 'const selectedHistorySubjectLabel = createMemo(() => {', - ); - expect(recoveryHistorySectionSource).toContain( - 'selectedHistoryItemLabel: Accessor;', - ); - expect(recoveryHistorySectionSource).not.toContain( - 'selectedHistorySubjectLabel: Accessor;', - ); - }); - - it('keeps recovery detail platform helpers platform-first', () => { - expect(recoveryPointDetailsSource).toContain( - "const isPbsPlatform = createMemo(() => platformKey() === 'proxmox-pbs');", - ); - expect(recoveryPointDetailsSource).not.toContain( - "const isPbsProvider = createMemo(() => platformKey() === 'proxmox-pbs');", - ); - }); -});