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');", - ); - }); -});