From f4367aa632c916e16978947e953f9ce2b00eb8d2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 17:01:58 +0000 Subject: [PATCH] Split recovery surface by product section --- .../internal/subsystems/storage-recovery.md | 35 +- .../src/components/Recovery/Recovery.tsx | 1902 ++--------------- .../Recovery/RecoveryActivitySection.tsx | 399 ++++ .../Recovery/RecoveryHistorySection.tsx | 862 ++++++++ .../RecoveryProtectedInventorySection.tsx | 425 ++++ .../monitoredSystemModelGuardrails.test.ts | 12 +- .../frontendResourceTypeBoundaries.test.ts | 17 +- 7 files changed, 1959 insertions(+), 1693 deletions(-) create mode 100644 frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx create mode 100644 frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx create mode 100644 frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index aa4f522b2..d541c2939 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -28,18 +28,21 @@ querying, and the operator-facing storage health presentation layer. 3. `internal/recovery/store/store.go` 4. `frontend-modern/src/components/Recovery/Recovery.tsx` 5. `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` -6. `frontend-modern/src/components/Storage/Storage.tsx` -7. `frontend-modern/src/features/storageBackups/storageModelCore.ts` -8. `frontend-modern/src/hooks/useRecoveryPoints.ts` -9. `frontend-modern/src/hooks/useRecoveryRollups.ts` -10. `frontend-modern/src/pages/RecoveryRoute.tsx` -11. `frontend-modern/src/pages/Dashboard.tsx` -12. `frontend-modern/src/pages/DashboardPanels/dashboardWidgets.ts` -13. `frontend-modern/src/pages/DashboardPanels/RecoveryStatusPanel.tsx` -14. `frontend-modern/src/pages/DashboardPanels/StoragePanel.tsx` -15. `frontend-modern/src/types/recovery.ts` -16. `frontend-modern/src/utils/recoveryTablePresentation.ts` -17. `frontend-modern/src/utils/textPresentation.ts` +6. `frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx` +7. `frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx` +8. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx` +9. `frontend-modern/src/components/Storage/Storage.tsx` +10. `frontend-modern/src/features/storageBackups/storageModelCore.ts` +11. `frontend-modern/src/hooks/useRecoveryPoints.ts` +12. `frontend-modern/src/hooks/useRecoveryRollups.ts` +13. `frontend-modern/src/pages/RecoveryRoute.tsx` +14. `frontend-modern/src/pages/Dashboard.tsx` +15. `frontend-modern/src/pages/DashboardPanels/dashboardWidgets.ts` +16. `frontend-modern/src/pages/DashboardPanels/RecoveryStatusPanel.tsx` +17. `frontend-modern/src/pages/DashboardPanels/StoragePanel.tsx` +18. `frontend-modern/src/types/recovery.ts` +19. `frontend-modern/src/utils/recoveryTablePresentation.ts` +20. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -105,10 +108,12 @@ The recovery backend is a real product boundary, not just a helper package: query filtering, and recovery-point indexing for the `/api/recovery/*` surfaces. The recovery frontend now also separates that ownership more explicitly: -`frontend-modern/src/components/Recovery/Recovery.tsx` is the product surface, -while `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns +`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns canonical route parsing, filter/query state, transport hook inputs, and URL -synchronization so those concerns do not drift back into the component shell. +synchronization, while `frontend-modern/src/components/Recovery/Recovery.tsx` +is the composition root for the operator-facing recovery surface and the split +section owners under `frontend-modern/src/components/Recovery/` hold the +protected inventory, activity, and history presentation layers. That same shared `internal/api/` dependency now also assumes tenant-scoped resource handlers seed registries from canonical unified resources only: recovery- and storage-adjacent API helpers may not fall back to raw tenant diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index fcfefcd2d..ebfeaa8e7 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -1,159 +1,45 @@ -import { - Component, - For, - Show, - createEffect, - createMemo, - createSignal, - onCleanup, -} from 'solid-js'; +import { Show, 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 { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; -import { - FilterActionButton, - FilterToolbarPanel, - LabeledFilterSelect, - filterPanelDescriptionClass, - filterPanelTitleClass, - filterUtilityBadgeClass, -} from '@/components/shared/FilterToolbar'; -import { PageControls } from '@/components/shared/PageControls'; -import { SearchInput } from '@/components/shared/SearchInput'; -import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; -import { - getSourcePlatformLabel, - normalizeSourcePlatformQueryValue, -} from '@/utils/sourcePlatforms'; -import { hideTooltip, showTooltip } from '@/components/shared/Tooltip'; -import { useColumnVisibility } from '@/hooks/useColumnVisibility'; +import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState'; import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { formatAbsoluteTime, formatBytes, formatRelativeTime } from '@/utils/format'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { segmentedButtonClass } from '@/utils/segmentedButton'; +import { useColumnVisibility } from '@/hooks/useColumnVisibility'; +import type { ColumnDef } from '@/hooks/useColumnVisibility'; import { useKioskMode } from '@/hooks/useKioskMode'; -import type { ProtectionRollup, RecoveryPoint } from '@/types/recovery'; -import type { RecoveryOutcome } from '@/types/recovery'; +import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery'; +import { STORAGE_KEYS } from '@/utils/localStorage'; import { - getRecoveryOutcomeBadgeClass, - normalizeRecoveryOutcome as normalizeOutcome, -} from '@/utils/recoveryOutcomePresentation'; -import { - getRecoveryArtifactModePresentation, - type RecoveryArtifactMode, -} from '@/utils/recoveryArtifactModePresentation'; -import { - getRecoveryIssueRailClass, - type RecoveryIssueTone, -} from '@/utils/recoveryIssuePresentation'; -import { getRecoveryTimelineColumnButtonClass } from '@/utils/recoveryTimelinePresentation'; -import { getRecoveryFilterChipPresentation } from '@/utils/recoveryFilterChipPresentation'; -import { - getRecoveryBreadcrumbLinkClass, - getRecoveryDrawerCloseButtonClass, - getRecoveryEmptyStateActionClass, - getRecoveryFilterPanelClearClass, -} from '@/utils/recoveryActionPresentation'; -import { - getRecoveryPointDetailsSummary, - getRecoveryPointRepositoryLabel, - getRecoveryPointSubjectLabel, - getRecoveryPointTimestampMs, - getRecoveryRollupSubjectLabel, - normalizeRecoveryModeQueryValue, -} from '@/utils/recoveryRecordPresentation'; -import { - getRecoveryActivityEmptyState, - getRecoveryActivityLoadingState, - getRecoveryHistoryEmptyState, - getRecoveryPointsFailureState, - getRecoveryPointsLoadingState, - getRecoveryProtectedItemsFailureState, - getRecoveryProtectedItemsLoadingState, - getRecoveryProtectedItemsEmptyState, -} from '@/utils/recoveryEmptyStatePresentation'; -import { - formatRecoveryTimeOnly, - getRecoveryCompactAxisLabel, getRecoveryFullDateLabel, getRecoveryNiceAxisMax, - getRecoveryPrettyDateLabel, parseRecoveryDateKey, recoveryDateKeyFromTimestamp, } from '@/utils/recoveryDatePresentation'; +import { getRecoveryPointsFailureState } from '@/utils/recoveryEmptyStatePresentation'; +import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation'; import { - getRecoveryProtectedToggleClass, - getRecoveryRollupStatusPillClass, - getRecoveryRollupStatusPillLabel, - getRecoverySpecialOutcomeTextClass, -} from '@/utils/recoveryStatusPresentation'; -import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; + getRecoveryPointSubjectLabel, + getRecoveryPointTimestampMs, + getRecoveryRollupSubjectLabel, +} from '@/utils/recoveryRecordPresentation'; import { getRecoveryGroupNoTimestampLabel, - getRecoveryHistorySearchPlaceholder, - getRecoveryProtectedSearchPlaceholder, - getRecoverySearchHistoryEmptyMessage, - getRecoveryArtifactColumnHeaderClass, - getRecoveryArtifactRowClass, - getRecoveryEventTimeTextClass, - getRecoveryRollupAgeTextClass, - getRecoveryRollupIssueTone, - getRecoverySubjectTypeBadgeClass, - getRecoverySubjectTypeLabel, - isRecoveryRollupStale, STALE_ISSUE_THRESHOLD_MS, - RECOVERY_ADVANCED_FILTER_FIELD_CLASS, - RECOVERY_ADVANCED_FILTER_LABEL_CLASS, - RECOVERY_GROUP_HEADER_ROW_CLASS, - RECOVERY_GROUP_HEADER_TEXT_CLASS, } from '@/utils/recoveryTablePresentation'; -import { - getRecoveryTimelineAxisLabelClass, - getRecoveryTimelineBarMinWidthClass, - getRecoveryTimelineLabelEvery, - RECOVERY_TIMELINE_LEGEND_ITEM_CLASS, - RECOVERY_TIMELINE_RANGE_GROUP_CLASS, -} from '@/utils/recoveryTimelineChartPresentation'; -import { RecoveryPointDetails } from '@/components/Recovery/RecoveryPointDetails'; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from '@/components/shared/Table'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; +import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation'; import { createHiddenCanonicalTypeColumn } from '@/utils/typeColumnDefinition'; -import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState'; -type ArtifactMode = RecoveryArtifactMode; -type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; - -type IssueTone = RecoveryIssueTone; +const MOBILE_RECOVERY_COLUMNS = new Set(['time', 'subject', 'outcome']); const Recovery: Component = () => { const kioskMode = useKioskMode(); - const [selectedPoint, setSelectedPoint] = createSignal(null); - const [moreFiltersOpen, setMoreFiltersOpen] = createSignal(false); const { isMobile } = useBreakpoint(); - const [protectedFiltersOpen, setProtectedFiltersOpen] = createSignal(false); - const [historyFiltersOpen, setHistoryFiltersOpen] = createSignal(false); - let advancedFiltersPanelRef: HTMLDivElement | undefined; - let advancedFiltersButtonRef: HTMLButtonElement | undefined; let historySectionRef: HTMLDivElement | undefined; - type ProtectedSortCol = 'subject' | 'source' | 'lastBackup' | 'outcome'; - type SortDir = 'asc' | 'desc'; - const [protectedSortCol, setProtectedSortCol] = createSignal('lastBackup'); - const [protectedSortDir, setProtectedSortDir] = createSignal('desc'); - const toggleProtectedSort = (col: ProtectedSortCol) => { - if (protectedSortCol() === col) { - setProtectedSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setProtectedSortCol(col); - setProtectedSortDir('asc'); - } - }; const { chartRangeDays, clusterFilter, @@ -201,74 +87,40 @@ const Recovery: Component = () => { verificationFilter, } = useRecoverySurfaceState(); - createEffect(() => { - // Avoid leaving modals open when filters or selection change. - rollupId(); - providerFilter(); - clusterFilter(); - modeFilter(); - historyOutcomeFilter(); - verificationFilter(); - nodeFilter(); - namespaceFilter(); - currentPage(); - 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); - }); - const baseRollups = createMemo(() => { - const q = queryFilter().trim().toLowerCase(); + const query = queryFilter().trim().toLowerCase(); const provider = providerFilter() === 'all' ? '' : providerFilter(); - const resIndex = resourcesById(); + const resourceIndex = resourcesById(); - const out = rollups().filter((r) => { - const providers = (r.providers || []) - .map((p) => normalizeSourcePlatformQueryValue(String(p || '').trim())) + const result = rollups().filter((rollup) => { + const providers = (rollup.providers || []) + .map((entry) => String(entry || '').trim()) .filter(Boolean); if (provider && !providers.includes(provider)) return false; - if (!q) return true; - const label = getRecoveryRollupSubjectLabel(r, resIndex); + if (!query) return true; + const label = getRecoveryRollupSubjectLabel(rollup, resourceIndex); const haystack = [ - r.rollupId, - r.subjectResourceId || '', + rollup.rollupId, + rollup.subjectResourceId || '', label, - r.subjectRef?.type || '', - r.subjectRef?.namespace || '', - r.subjectRef?.name || '', + rollup.subjectRef?.type || '', + rollup.subjectRef?.namespace || '', + rollup.subjectRef?.name || '', providers.join(' '), - r.lastOutcome || '', + rollup.lastOutcome || '', ] .filter(Boolean) .join(' ') .toLowerCase(); - return haystack.includes(q); + return haystack.includes(query); }); - return [...out].sort((a, b) => { - const aTs = a.lastAttemptAt ? Date.parse(a.lastAttemptAt) : 0; - const bTs = b.lastAttemptAt ? Date.parse(b.lastAttemptAt) : 0; - if (aTs !== bTs) return bTs - aTs; - return a.rollupId.localeCompare(b.rollupId); + 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); }); }); @@ -284,10 +136,11 @@ const Recovery: Component = () => { }; let stale = 0; let neverSucceeded = 0; - for (const r of items) { - counts[normalizeOutcome(r.lastOutcome)] += 1; - const attemptMs = r.lastAttemptAt ? Date.parse(r.lastAttemptAt) : 0; - const successMs = r.lastSuccessAt ? Date.parse(r.lastSuccessAt) : 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) { @@ -295,6 +148,7 @@ const Recovery: Component = () => { if (attemptMs < staleThreshold) stale += 1; } } + return { total: items.length, counts, stale, neverSucceeded }; }; @@ -309,128 +163,56 @@ const Recovery: Component = () => { const nowMs = Date.now(); const staleThreshold = nowMs - STALE_ISSUE_THRESHOLD_MS; - return (baseRollups() || []).filter((r) => { - if (selectedOutcome !== 'all' && normalizeOutcome(r.lastOutcome) !== selectedOutcome) + return baseRollups().filter((rollup) => { + if ( + selectedOutcome !== 'all' && + normalizeOutcome(rollup.lastOutcome) !== selectedOutcome + ) { return false; + } if (!staleOnly) return true; - const attemptMs = r.lastAttemptAt ? Date.parse(r.lastAttemptAt) : 0; - const successMs = r.lastSuccessAt ? Date.parse(r.lastSuccessAt) : 0; + const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; + const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; if (successMs > 0) return successMs < staleThreshold; if (attemptMs > 0) return attemptMs < staleThreshold; return false; }); }); - const sortedRollups = createMemo(() => { - const items = (filteredRollups() || []).slice(); - const col = protectedSortCol(); - const dir = protectedSortDir(); - const resIndex = resourcesById(); - const mul = dir === 'asc' ? 1 : -1; - - items.sort((a, b) => { - switch (col) { - case 'subject': { - const la = getRecoveryRollupSubjectLabel(a, resIndex).toLowerCase(); - const lb = getRecoveryRollupSubjectLabel(b, resIndex).toLowerCase(); - return mul * la.localeCompare(lb); - } - case 'source': { - const sa = (a.providers || []) - .map((p) => getSourcePlatformLabel(String(p))) - .sort() - .join(','); - const sb = (b.providers || []) - .map((p) => getSourcePlatformLabel(String(p))) - .sort() - .join(','); - return mul * sa.localeCompare(sb); - } - case 'lastBackup': { - const sa = a.lastSuccessAt ? Date.parse(a.lastSuccessAt) : 0; - const sb = b.lastSuccessAt ? Date.parse(b.lastSuccessAt) : 0; - return mul * (sa - sb); - } - case 'outcome': { - const oa = normalizeOutcome(a.lastOutcome); - const ob = normalizeOutcome(b.lastOutcome); - return mul * oa.localeCompare(ob); - } - default: - return 0; - } - }); - return items; - }); const selectedRollup = createMemo(() => { - const rid = rollupId().trim(); - if (!rid) return null; - return rollups().find((r) => r.rollupId === rid) || null; + const selected = rollupId().trim(); + if (!selected) return null; + return rollups().find((rollup) => rollup.rollupId === selected) || null; }); + const selectedHistorySubjectLabel = createMemo(() => { const rollup = selectedRollup(); return rollup ? getRecoveryRollupSubjectLabel(rollup, resourcesById()) : null; }); - const availableOutcomes = ['all', 'success', 'warning', 'failed', 'running'] as const; - - 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 protectedActiveFilterCount = createMemo(() => { - let count = 0; - if (queryFilter().trim() !== '') count++; - if (providerFilter() !== 'all') count++; - if (historyOutcomeFilter() !== 'all') count++; - if (protectedStaleOnly()) count++; - return count; - }); - const historyActiveFilterCount = createMemo(() => { - let count = 0; - if (queryFilter().trim() !== '') count++; - if (providerFilter() !== 'all') count++; - if (historyOutcomeFilter() !== 'all') count++; - if (scopeFilter() !== 'all') count++; - if (modeFilter() !== 'all') count++; - if (verificationFilter() !== 'all') count++; - if (clusterFilter() !== 'all') count++; - if (nodeFilter() !== 'all') count++; - if (namespaceFilter() !== 'all') count++; - if (selectedDateKey()) count++; - if (chartRangeDays() !== 30) count++; - return count; - }); - const filteredPoints = createMemo(() => { const points = recoveryPoints.points() || []; - const dateKey = selectedDateKey(); - if (!dateKey) return points; + const selected = selectedDateKey(); + if (!selected) return points; const { from, to } = tableWindow(); - const fromMs = Date.parse(from); - const toMs = Date.parse(to); - return points.filter((p) => { - const ts = getRecoveryPointTimestampMs(p); - return ts >= fromMs && ts <= toMs; + 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 resIndex = resourcesById(); - return [...(filteredPoints() || [])].sort((a, b) => { - const aTs = getRecoveryPointTimestampMs(a); - const bTs = getRecoveryPointTimestampMs(b); - if (aTs !== bTs) return bTs - aTs; - const aName = getRecoveryPointSubjectLabel(a, resIndex); - const bName = getRecoveryPointSubjectLabel(b, resIndex); - return aName.localeCompare(bName); + const resourceIndex = resourcesById(); + return [...filteredPoints()].sort((left, right) => { + const leftTimestamp = getRecoveryPointTimestampMs(left); + const rightTimestamp = getRecoveryPointTimestampMs(right); + if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp; + const leftName = getRecoveryPointSubjectLabel(left, resourceIndex); + const rightName = getRecoveryPointSubjectLabel(right, resourceIndex); + return leftName.localeCompare(rightName); }); }); @@ -449,20 +231,21 @@ const Recovery: Component = () => { string, { key: string; label: string; tone: 'recent' | 'default'; items: RecoveryPoint[] } >(); - for (const p of sortedPoints()) { - const key = p.completedAt - ? recoveryDateKeyFromTimestamp(Date.parse(p.completedAt)) + + 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 dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); - if (dateOnly === today) { + const dayTimestamp = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + if (dayTimestamp === today) { label = `Today (${getRecoveryFullDateLabel(key)})`; tone = 'recent'; - } else if (dateOnly === yesterday) { + } else if (dayTimestamp === yesterday) { label = `Yesterday (${getRecoveryFullDateLabel(key)})`; tone = 'recent'; } else { @@ -473,7 +256,7 @@ const Recovery: Component = () => { groupMap.set(key, group); groups.push(group); } - groupMap.get(key)!.items.push(p); + groupMap.get(key)!.items.push(point); } return groups; @@ -531,11 +314,9 @@ const Recovery: Component = () => { ); const visibleArtifactColumns = createMemo(() => artifactColumnVisibility.visibleColumns()); - // Mobile: show only the 3 essential columns — secondary data is in the expand drawer. - const MOBILE_RECOVERY_COLS = new Set(['time', 'subject', 'outcome']); const mobileVisibleArtifactColumns = createMemo(() => isMobile() - ? visibleArtifactColumns().filter((c) => MOBILE_RECOVERY_COLS.has(c.id)) + ? visibleArtifactColumns().filter((column) => MOBILE_RECOVERY_COLUMNS.has(column.id)) : visibleArtifactColumns(), ); const tableColumnCount = createMemo(() => mobileVisibleArtifactColumns().length); @@ -548,26 +329,21 @@ const Recovery: Component = () => { }); const timeline = createMemo(() => { - const series = recoverySeries.series() || []; - const points = series.map((bucket) => { - const key = String(bucket.day || '').trim(); - const date = parseRecoveryDateKey(key); - return { - key, - label: date.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((max, point) => Math.max(max, point.total), 0); + 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 axisTicks = [0, 1, 2, 3, 4].map((step) => Math.round((axisMax * step) / 4)); - const dayCount = points.length; - const labelEvery = getRecoveryTimelineLabelEvery(dayCount); - - return { points, maxValue, axisMax, axisTicks, labelEvery }; + const labelEvery = getRecoveryTimelineLabelEvery(points.length); + return { points, axisMax, labelEvery }; }); const activitySummary = createMemo(() => { @@ -575,12 +351,7 @@ const Recovery: Component = () => { 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, - }; + return { totalPoints, activeDays, averagePerDay }; }); const selectedDateLabel = createMemo(() => { @@ -588,8 +359,11 @@ const Recovery: Component = () => { if (!key) return ''; const [year, month, day] = key.split('-').map((value) => Number.parseInt(value, 10)); if (!year || !month || !day) return key; - const date = new Date(year, month - 1, day); - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + return new Date(year, month - 1, day).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); }); const activeClusterLabel = createMemo(() => (clusterFilter() === 'all' ? '' : clusterFilter())); @@ -613,6 +387,17 @@ const Recovery: Component = () => { 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 = () => { setScopeFilter('all'); setModeFilter('all'); @@ -638,280 +423,38 @@ const Recovery: Component = () => { setCurrentPage(1); }; + const handleSelectRollup = (nextRollupId: string) => { + setRollupId(nextRollupId); + requestAnimationFrame(() => + historySectionRef && typeof historySectionRef.scrollIntoView === 'function' + ? historySectionRef.scrollIntoView({ behavior: 'smooth', block: 'start' }) + : undefined, + ); + }; + return (
- -
-
-
- Protected Inventory -
-
- - {filteredRollups()?.length ?? 0} of {rollups().length} items shown - - 0}> - - {rollupsSummary().stale} stale - - - 0}> - - {rollupsSummary().neverSucceeded} never succeeded - - -
-
-
- -
- setQueryFilter(value)} - placeholder={getRecoveryProtectedSearchPlaceholder()} - class="w-full" - clearOnEscape - history={{ - storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY, - emptyMessage: getRecoverySearchHistoryEmptyMessage(), - }} - /> - } - mobileFilters={{ - enabled: isMobile(), - onToggle: () => setProtectedFiltersOpen((o) => !o), - count: protectedActiveFilterCount(), - }} - showFilters={!isMobile() || protectedFiltersOpen()} - toolbarClass="lg:flex-nowrap" - > - - setProviderFilter(normalizeSourcePlatformQueryValue(event.currentTarget.value)) - } - selectClass="min-w-[10rem] max-w-[14rem]" - > - - {(p) => ( - - )} - - - - { - const value = event.currentTarget.value as 'all' | RecoveryOutcome; - setHistoryOutcomeFilter(value); - if (value !== 'all') setVerificationFilter('all'); - }} - selectClass="min-w-[9rem]" - > - - {(outcome) => ( - - )} - - - - - -
-
- -
- {getRecoveryProtectedItemsLoadingState().text} -
-
- - -
- -
-
- - -
- -
-
- - 0}> -
- - - - {( - [ - ['subject', 'Subject'], - ['source', 'Source'], - ['lastBackup', 'Last Backup'], - ['outcome', 'Outcome'], - ] as const - ).map(([col, label]) => ( - - ))} - - - - - {(r) => { - const resIndex = resourcesById(); - const label = getRecoveryRollupSubjectLabel(r, resIndex); - const attemptMs = r.lastAttemptAt ? Date.parse(r.lastAttemptAt) : 0; - const successMs = r.lastSuccessAt ? Date.parse(r.lastSuccessAt) : 0; - const outcome = normalizeOutcome(r.lastOutcome); - const providers = (r.providers || []) - .slice() - .map((p) => String(p || '').trim()) - .filter(Boolean) - .sort((a, b) => - getSourcePlatformLabel(a).localeCompare(getSourcePlatformLabel(b)), - ); - const nowMs = Date.now(); - const issueTone: IssueTone = getRecoveryRollupIssueTone(r, nowMs); - const issueRailClass = - issueTone === 'none' ? '' : getRecoveryIssueRailClass(issueTone); - const stale = isRecoveryRollupStale(r, nowMs); - const neverSucceeded = - (!Number.isFinite(successMs) || successMs <= 0) && - Number.isFinite(attemptMs) && - attemptMs > 0; - return ( - { - setRollupId(r.rollupId); - requestAnimationFrame(() => - historySectionRef && - typeof historySectionRef.scrollIntoView === 'function' - ? historySectionRef.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - : undefined, - ); - }} - > - - - - -
- {label} - - - {getRecoveryRollupStatusPillLabel('never-succeeded')} - - - - - {getRecoveryRollupStatusPillLabel('stale')} - - -
-
- - 0 - ? formatAbsoluteTime(successMs) - : attemptMs > 0 - ? formatAbsoluteTime(attemptMs) - : undefined - } - > - {successMs > 0 ? ( - formatRelativeTime(successMs) - ) : neverSucceeded ? ( - never - ) : ( - '—' - )} - - - - {titleCaseDelimitedLabel(outcome)} - - -
- ); - }} -
-
-
-
-
-
+ recoveryRollups.rollups.loading} + error={() => recoveryRollups.rollups.error} + onSelectRollup={handleSelectRollup} + protectedStaleOnly={protectedStaleOnly} + providerFilter={providerFilter} + providerOptions={providerOptions} + queryFilter={queryFilter} + resourcesById={resourcesById} + rollups={rollups} + rollupsSummary={rollupsSummary} + setHistoryOutcomeFilter={setHistoryOutcomeFilter} + setProtectedStaleOnly={setProtectedStaleOnly} + setProviderFilter={setProviderFilter} + setQueryFilter={setQueryFilter} + setVerificationFilter={setVerificationFilter} + />
@@ -926,1084 +469,93 @@ const Recovery: Component = () => { - -
-
-
-
- - {overallRollupsSummary().total} protected - - 0}> - - {overallRollupsSummary().total - overallRollupsSummary().stale} healthy - - - 0}> - {overallRollupsSummary().stale} stale - - 0}> - - {overallRollupsSummary().neverSucceeded} never succeeded - - -
-
-
- Recovery Activity -
-
- Daily recovery points across the selected history window. -
-
-
-
- -
- Focused - - {selectedHistorySubjectLabel()} - -
-
- All protected items} - > - - -
-
-
- {activitySummary().totalPoints} recovery points - {activitySummary().averagePerDay.toFixed(1)} per day - {activitySummary().activeDays} active days - - {selectedDateLabel()} - -
-
+ { + setClusterFilter('all'); + setCurrentPage(1); + }} + clearFocusedRollup={() => setRollupId('')} + clearNamespaceFilter={() => { + setNamespaceFilter('all'); + setCurrentPage(1); + }} + clearNodeFilter={() => { + setNodeFilter('all'); + setCurrentPage(1); + }} + clearSelectedDate={() => { + setSelectedDateKey(null); + setCurrentPage(1); + }} + hasFocusedRollup={() => rollupId().trim().length > 0} + isMobile={isMobile()} + loading={() => recoverySeries.response.loading} + overallRollupsSummary={overallRollupsSummary} + selectedDateKey={selectedDateKey} + selectedDateLabel={selectedDateLabel} + selectedHistorySubjectLabel={selectedHistorySubjectLabel} + setChartRangeDays={(range) => { + setChartRangeDays(range); + setSelectedDateKey(null); + setCurrentPage(1); + }} + timeline={timeline} + toggleSelectedDate={(key) => { + setSelectedDateKey((previous) => (previous === key ? null : key)); + setCurrentPage(1); + }} + /> - -
- - {(() => { - const chip = getRecoveryFilterChipPresentation('day'); - return ( -
- {chip.label} - - {selectedDateLabel()} - - -
- ); - })()} -
- - {(() => { - const chip = getRecoveryFilterChipPresentation('cluster'); - return ( -
- {chip.label} - - {activeClusterLabel()} - - -
- ); - })()} -
- - {(() => { - const chip = getRecoveryFilterChipPresentation('node'); - return ( -
- {chip.label} - - {activeNodeLabel()} - - -
- ); - })()} -
- - {(() => { - const chip = getRecoveryFilterChipPresentation('namespace'); - return ( -
- {chip.label} - - {activeNamespaceLabel()} - - -
- ); - })()} -
-
-
- - 0 && timeline().maxValue > 0} - fallback={ -
- - {getRecoveryActivityLoadingState().text} - - - {getRecoveryActivityEmptyState().text} - -
- } - > -
-
- - - {getRecoveryArtifactModePresentation('snapshot').label} - - - - {getRecoveryArtifactModePresentation('local').label} - - - - {getRecoveryArtifactModePresentation('remote').label} - -
-
- - {(range) => ( - - )} - -
-
- -
-
-
- - {(tick) => {tick}} - -
-
-
-
-
- - {(tick) => { - const bottom = - timeline().axisMax > 0 ? (tick / timeline().axisMax) * 100 : 0; - return ( -
- ); - }} - -
- -
{ - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.max(0, e.touches[0].clientX - rect.left); - const pts = timeline().points; - const idx = Math.min( - Math.floor((x / rect.width) * pts.length), - pts.length - 1, - ); - const pt = pts[idx]; - if (pt) { - setSelectedDateKey(pt.key); - setCurrentPage(1); - } - }} - onTouchMove={(e) => { - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(e.touches[0].clientX - rect.left, rect.width - 1), - ); - const pts = timeline().points; - const idx = Math.min( - Math.floor((x / rect.width) * pts.length), - pts.length - 1, - ); - const pt = pts[idx]; - if (pt && pt.key !== selectedDateKey()) { - setSelectedDateKey(pt.key); - setCurrentPage(1); - } - }} - > - - {(point) => { - const total = point.total; - const heightPct = - timeline().axisMax > 0 ? (total / 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 = selectedDateKey() === point.key; - - return ( -
- -
- ); - }} -
-
- -
- - {(point, index) => { - const showLabel = - index() % timeline().labelEvery === 0 || - index() === timeline().points.length - 1; - const isSelected = selectedDateKey() === point.key; - const barMinWidth = getRecoveryTimelineBarMinWidthClass( - isMobile(), - chartRangeDays(), - ); - return ( -
- - - {getRecoveryCompactAxisLabel(point.key, chartRangeDays())} - - -
- ); - }} -
-
-
-
-
- - - - -
- Backups By Date -
- -
- { - setQueryFilter(value); - setCurrentPage(1); - }} - placeholder={getRecoveryHistorySearchPlaceholder()} - class="w-full" - clearOnEscape - history={{ - storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY, - emptyMessage: getRecoverySearchHistoryEmptyMessage(), - }} - /> - } - mobileFilters={{ - enabled: isMobile(), - onToggle: () => setHistoryFiltersOpen((o) => !o), - count: historyActiveFilterCount(), - }} - utilityActions={ -
-
- setMoreFiltersOpen((v) => !v)} - active={moreFiltersOpen() || activeAdvancedFilterCount() > 0} - > - Filter - 0}> - - {activeAdvancedFilterCount()} - - - - - - -
-
-
Filter results
-
- Narrow by scope, method, verification, or location. -
-
- 0}> - - -
- -
- - - - - - - - - - - - - - - - - - - -
-
-
-
-
- } - columnVisibility={artifactColumnVisibility} - resetAction={{ - show: hasActiveArtifactFilters(), - onClick: resetAllArtifactFilters, - label: 'Reset all', - }} - showFilters={!isMobile() || historyFiltersOpen()} - toolbarClass="lg:flex-nowrap" - > - { - setProviderFilter(normalizeSourcePlatformQueryValue(event.currentTarget.value)); - setCurrentPage(1); - }} - selectClass="min-w-[10rem] max-w-[14rem]" - > - - {(p) => ( - - )} - - - - { - const value = event.currentTarget.value as 'all' | RecoveryOutcome; - setHistoryOutcomeFilter(value); - if (value !== 'all') setVerificationFilter('all'); - setCurrentPage(1); - }} - selectClass="min-w-[7rem]" - > - - {(outcome) => ( - - )} - - -
-
-
- 0} - fallback={ -
- - - - } - /> - } - > -
{getRecoveryPointsLoadingState().text}
- -
- } - > -
- - - - - {(col) => ( - - {col.label} - - )} - - - - - - {(group) => ( - <> - - -
-
- - {group.label} - - - - {getRecoveryRollupStatusPillLabel('recent')} - - -
- - {group.items.length} - -
-
-
- - - {(p) => { - const resIndex = resourcesById(); - const subject = getRecoveryPointSubjectLabel(p, resIndex); - const subjectType = getRecoverySubjectTypeLabel(p); - const detailsSummary = getRecoveryPointDetailsSummary(p); - const mode = - (String(p.mode || '') - .trim() - .toLowerCase() as ArtifactMode) || 'local'; - const repoLabel = getRecoveryPointRepositoryLabel(p); - const provider = String(p.provider || '').trim(); - const outcome = normalizeOutcome(p.outcome); - const completedMs = p.completedAt ? Date.parse(p.completedAt) : 0; - const startedMs = p.startedAt ? Date.parse(p.startedAt) : 0; - const tsMs = completedMs || startedMs || 0; - const timeOnly = formatRecoveryTimeOnly(tsMs); - - const entityId = String(p.entityId || '').trim(); - const cluster = String(p.cluster || '').trim(); - const nodeAgent = String(p.node || '').trim(); - const namespace = String(p.namespace || '').trim(); - - return ( - <> - - setSelectedPoint(selectedPoint()?.id === p.id ? null : p) - } - > - - {(col) => { - switch (col.id) { - case 'time': - return ( - - {timeOnly} - - ); - case 'type': - return ( - - —} - > - - {subjectType} - - - - ); - case 'subject': - return ( - -
- - {subject} - - - - - - - - - -
-
- ); - case 'entityId': - return ( - - {entityId || '—'} - - ); - case 'cluster': - return ( - - {cluster || '—'} - - ); - case 'nodeAgent': - return ( - - {nodeAgent || '—'} - - ); - case 'namespace': - return ( - - {namespace || '—'} - - ); - case 'source': { - const badge = getSourcePlatformBadge(provider); - return ( - - - {badge?.label || getSourcePlatformLabel(provider)} - - - ); - } - case 'verified': - return ( - - {typeof p.verified === 'boolean' ? ( - p.verified ? ( - - - - - - ) : ( - - - - - - ) - ) : ( - - )} - - ); - case 'size': - return ( - - {p.sizeBytes && p.sizeBytes > 0 - ? formatBytes(p.sizeBytes) - : '—'} - - ); - case 'method': - return ( - - - {getRecoveryArtifactModePresentation(mode).label} - - - ); - 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 {(recoveryPoints.meta().page - 1) * recoveryPoints.meta().limit + 1} -{' '} - {Math.min( - recoveryPoints.meta().page * recoveryPoints.meta().limit, - recoveryPoints.meta().total, - )}{' '} - of {recoveryPoints.meta().total} recovery points - - -
-
- - - Page {currentPage()} / {totalPages()} - - -
-
-
-
+
diff --git a/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx b/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx new file mode 100644 index 000000000..486854b97 --- /dev/null +++ b/frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx @@ -0,0 +1,399 @@ +import { For, Show } from 'solid-js'; +import type { Accessor, Component } from 'solid-js'; + +import { Card } from '@/components/shared/Card'; +import { hideTooltip, showTooltip } from '@/components/shared/Tooltip'; +import { segmentedButtonClass } from '@/utils/segmentedButton'; +import { + getRecoveryBreadcrumbLinkClass, +} from '@/utils/recoveryActionPresentation'; +import { getRecoveryFilterChipPresentation } from '@/utils/recoveryFilterChipPresentation'; +import { + getRecoveryActivityEmptyState, + getRecoveryActivityLoadingState, +} from '@/utils/recoveryEmptyStatePresentation'; +import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation'; +import { + getRecoveryPrettyDateLabel, + getRecoveryCompactAxisLabel, +} from '@/utils/recoveryDatePresentation'; +import { + getRecoveryTimelineAxisLabelClass, + getRecoveryTimelineBarMinWidthClass, + getRecoveryTimelineLabelEvery, + RECOVERY_TIMELINE_LEGEND_ITEM_CLASS, + RECOVERY_TIMELINE_RANGE_GROUP_CLASS, +} from '@/utils/recoveryTimelineChartPresentation'; +import { getRecoveryTimelineColumnButtonClass } 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 RecoveryActivitySectionProps { + activitySummary: Accessor; + activeClusterLabel: Accessor; + activeNamespaceLabel: Accessor; + activeNodeLabel: Accessor; + chartRangeDays: Accessor<7 | 30 | 90 | 365>; + clearClusterFilter: () => void; + clearFocusedRollup: () => void; + clearNamespaceFilter: () => void; + clearNodeFilter: () => void; + clearSelectedDate: () => void; + hasFocusedRollup: Accessor; + isMobile: boolean; + loading: Accessor; + overallRollupsSummary: Accessor; + selectedDateKey: Accessor; + selectedDateLabel: Accessor; + selectedHistorySubjectLabel: Accessor; + setChartRangeDays: (value: 7 | 30 | 90 | 365) => void; + toggleSelectedDate: (key: string) => void; + timeline: Accessor; +} + +const rangeOptions: Array<7 | 30 | 90 | 365> = [7, 30, 90, 365]; + +export const RecoveryActivitySection: Component = (props) => ( + +
+
+
+
+ + {props.overallRollupsSummary().total} protected + + 0}> + + {props.overallRollupsSummary().total - props.overallRollupsSummary().stale} healthy + + + 0}> + {props.overallRollupsSummary().stale} stale + + 0}> + + {props.overallRollupsSummary().neverSucceeded} never succeeded + + +
+
+
+ Recovery Activity +
+
+ Daily recovery points across the selected history window. +
+
+
+
+ +
+ Focused + + {props.selectedHistorySubjectLabel()} + +
+
+ All protected items} + > + + +
+
+
+ {props.activitySummary().totalPoints} recovery points + {props.activitySummary().averagePerDay.toFixed(1)} per day + {props.activitySummary().activeDays} active days + + {props.selectedDateLabel()} + +
+
+ + +
+ + {(() => { + const chip = getRecoveryFilterChipPresentation('day'); + return ( +
+ {chip.label} + + {props.selectedDateLabel()} + + +
+ ); + })()} +
+ + {(() => { + const chip = getRecoveryFilterChipPresentation('cluster'); + return ( +
+ {chip.label} + + {props.activeClusterLabel()} + + +
+ ); + })()} +
+ + {(() => { + const chip = getRecoveryFilterChipPresentation('node'); + return ( +
+ {chip.label} + + {props.activeNodeLabel()} + + +
+ ); + })()} +
+ + {(() => { + const chip = getRecoveryFilterChipPresentation('namespace'); + return ( +
+ {chip.label} + + {props.activeNamespaceLabel()} + + +
+ ); + })()} +
+
+
+ +
+
+
+
+ Selected range +
+
+ + {(range) => ( + + )} + +
+
+ + 0} + fallback={ +
+ {props.loading() + ? getRecoveryActivityLoadingState().text + : getRecoveryActivityEmptyState().text} +
+ } + > +
+
+
+ + {getRecoveryArtifactModePresentation('snapshot').label} +
+
+ + {getRecoveryArtifactModePresentation('local').label} +
+
+ + {getRecoveryArtifactModePresentation('remote').label} +
+
+ +
+
+ + {(step) => { + const value = Math.round((props.timeline().axisMax * (4 - step)) / 4); + 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.selectedDateKey() === point.key; + + return ( +
+ +
+ ); + }} +
+
+ +
+ + {(point, index) => { + const showLabel = + index() % getRecoveryTimelineLabelEvery(props.timeline().points.length) === 0 || + index() === props.timeline().points.length - 1; + const isSelected = props.selectedDateKey() === point.key; + const barMinWidth = getRecoveryTimelineBarMinWidthClass( + props.isMobile, + props.chartRangeDays(), + ); + return ( +
+ + + {getRecoveryCompactAxisLabel(point.key, props.chartRangeDays())} + + +
+ ); + }} +
+
+
+
+
+
+
+
+
+); diff --git a/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx new file mode 100644 index 000000000..9a33f49b7 --- /dev/null +++ b/frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx @@ -0,0 +1,862 @@ +import { + For, + Show, + createEffect, + createSignal, + onCleanup, +} from 'solid-js'; +import type { Accessor, Component } from 'solid-js'; + +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { + FilterActionButton, + FilterToolbarPanel, + LabeledFilterSelect, + filterPanelDescriptionClass, + filterPanelTitleClass, + filterUtilityBadgeClass, +} from '@/components/shared/FilterToolbar'; +import { PageControls } from '@/components/shared/PageControls'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +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 { getRecoveryEmptyStateActionClass, getRecoveryFilterPanelClearClass, getRecoveryDrawerCloseButtonClass } from '@/utils/recoveryActionPresentation'; +import { getRecoveryArtifactModePresentation, type RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation'; +import { + getRecoveryHistoryEmptyState, + getRecoveryPointsLoadingState, +} from '@/utils/recoveryEmptyStatePresentation'; +import { + getRecoveryPointDetailsSummary, + getRecoveryPointRepositoryLabel, + getRecoveryPointSubjectLabel, + getRecoveryPointTimestampMs, + normalizeRecoveryModeQueryValue, +} from '@/utils/recoveryRecordPresentation'; +import { + getRecoveryArtifactColumnHeaderClass, + getRecoveryArtifactRowClass, + getRecoveryEventTimeTextClass, + getRecoveryHistorySearchPlaceholder, + getRecoverySearchHistoryEmptyMessage, + getRecoverySubjectTypeBadgeClass, + getRecoverySubjectTypeLabel, + RECOVERY_ADVANCED_FILTER_FIELD_CLASS, + RECOVERY_ADVANCED_FILTER_LABEL_CLASS, + RECOVERY_GROUP_HEADER_ROW_CLASS, + RECOVERY_GROUP_HEADER_TEXT_CLASS, +} from '@/utils/recoveryTablePresentation'; +import { getRecoveryOutcomeBadgeClass } from '@/utils/recoveryOutcomePresentation'; +import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; +import { formatBytes } from '@/utils/format'; +import { formatRecoveryTimeOnly } from '@/utils/recoveryDatePresentation'; +import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms'; +import { RecoveryPointDetails } from '@/components/Recovery/RecoveryPointDetails'; + +type ArtifactMode = RecoveryArtifactMode; +type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; + +interface RecoveryPointGroup { + key: string; + label: string; + tone: 'recent' | 'default'; + items: RecoveryPoint[]; +} + +interface RecoveryPointsMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +interface RecoveryPointsModel { + meta: Accessor; + response: { + loading: boolean; + error: unknown; + }; +} + +interface PageControlsColumnVisibility { + availableToggles: () => ColumnDef[]; + isHiddenByUser: (id: string) => boolean; + toggle: (id: string) => void; + resetToDefaults: () => void; +} + +interface RecoveryHistorySectionProps { + activeAdvancedFilterCount: Accessor; + artifactColumnVisibility: PageControlsColumnVisibility; + availableOutcomes: readonly ('all' | 'success' | 'warning' | 'failed' | 'running')[]; + clusterFilter: Accessor; + clusterOptions: Accessor; + currentPage: Accessor; + groupedByDay: Accessor; + hasActiveArtifactFilters: Accessor; + historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; + isMobile: boolean; + kioskMode: boolean; + mobileVisibleArtifactColumns: Accessor; + modeFilter: Accessor<'all' | ArtifactMode>; + namespaceFilter: Accessor; + namespaceOptions: Accessor; + nodeFilter: Accessor; + nodeOptions: Accessor; + providerFilter: Accessor; + providerOptions: Accessor; + queryFilter: Accessor; + recoveryPoints: RecoveryPointsModel; + resetAdvancedArtifactFilters: () => void; + resetAllArtifactFilters: () => void; + resourcesById: Accessor>; + scopeFilter: Accessor<'all' | 'workload'>; + setClusterFilter: (value: string) => void; + setCurrentPage: (value: number) => void; + setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void; + setModeFilter: (value: 'all' | ArtifactMode) => void; + setNamespaceFilter: (value: string) => void; + setNodeFilter: (value: string) => void; + setProviderFilter: (value: string) => void; + setQueryFilter: (value: string) => void; + setScopeFilter: (value: 'all' | 'workload') => 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 [selectedPoint, setSelectedPoint] = createSignal(null); + const [moreFiltersOpen, setMoreFiltersOpen] = createSignal(false); + const [historyFiltersOpen, setHistoryFiltersOpen] = createSignal(false); + let advancedFiltersPanelRef: HTMLDivElement | undefined; + let advancedFiltersButtonRef: HTMLButtonElement | undefined; + + const historyActiveFilterCount = () => { + let count = 0; + if (props.queryFilter().trim() !== '') count += 1; + if (props.providerFilter() !== 'all') count += 1; + if (props.historyOutcomeFilter() !== 'all') count += 1; + if (props.scopeFilter() !== 'all') count += 1; + if (props.modeFilter() !== 'all') count += 1; + if (props.verificationFilter() !== 'all') count += 1; + if (props.clusterFilter() !== 'all') count += 1; + if (props.nodeFilter() !== 'all') count += 1; + if (props.namespaceFilter() !== 'all') count += 1; + return count; + }; + + createEffect(() => { + props.currentPage(); + props.providerFilter(); + props.historyOutcomeFilter(); + props.scopeFilter(); + props.modeFilter(); + props.verificationFilter(); + props.clusterFilter(); + props.nodeFilter(); + props.namespaceFilter(); + props.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 ( + +
+ Backups By Date +
+ +
+ { + props.setQueryFilter(value); + props.setCurrentPage(1); + }} + placeholder={getRecoveryHistorySearchPlaceholder()} + class="w-full" + clearOnEscape + history={{ + storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY, + emptyMessage: getRecoverySearchHistoryEmptyMessage(), + }} + /> + } + mobileFilters={{ + enabled: props.isMobile, + onToggle: () => setHistoryFiltersOpen((open) => !open), + count: historyActiveFilterCount(), + }} + utilityActions={ +
+
+ setMoreFiltersOpen((open) => !open)} + active={moreFiltersOpen() || props.activeAdvancedFilterCount() > 0} + > + Filter + 0}> + + {props.activeAdvancedFilterCount()} + + + + + + +
+
+
Filter results
+
+ Narrow by scope, method, verification, or location. +
+
+ 0}> + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ } + columnVisibility={props.artifactColumnVisibility} + resetAction={{ + show: props.hasActiveArtifactFilters(), + onClick: props.resetAllArtifactFilters, + label: 'Reset all', + }} + showFilters={!props.isMobile || historyFiltersOpen()} + toolbarClass="lg:flex-nowrap" + > + { + props.setProviderFilter( + normalizeSourcePlatformQueryValue(event.currentTarget.value), + ); + props.setCurrentPage(1); + }} + selectClass="min-w-[10rem] max-w-[14rem]" + > + + {(provider) => ( + + )} + + + + { + const value = event.currentTarget.value as 'all' | RecoveryOutcome; + props.setHistoryOutcomeFilter(value); + if (value !== 'all') props.setVerificationFilter('all'); + props.setCurrentPage(1); + }} + selectClass="min-w-[7rem]" + > + + {(outcome) => ( + + )} + + +
+
+
+ + 0} + fallback={ +
+ + + + } + /> + } + > +
{getRecoveryPointsLoadingState().text}
+ +
+ } + > +
+ + + + + {(column) => ( + + {column.label} + + )} + + + + + + {(group) => ( + <> + + +
+
+ + {group.label} + + + + recent + + +
+ + {group.items.length} + +
+
+
+ + + {(point) => { + const resourceIndex = props.resourcesById(); + const subject = getRecoveryPointSubjectLabel(point, resourceIndex); + const tsMs = getRecoveryPointTimestampMs(point); + const timeOnly = + point.completedAt && Number.isFinite(tsMs) + ? formatRecoveryTimeOnly(tsMs) + : '—'; + const subjectType = getRecoverySubjectTypeLabel(point); + const provider = String(point.provider || '').trim(); + const mode = + (normalizeRecoveryModeQueryValue(String(point.mode || '').toLowerCase()) as ArtifactMode) || + 'local'; + const outcome = (String(point.outcome || 'unknown').toLowerCase() as RecoveryOutcome) || 'unknown'; + const repoLabel = getRecoveryPointRepositoryLabel(point); + const detailsSummary = getRecoveryPointDetailsSummary(point); + const entityId = String(point.entityId || '').trim(); + const cluster = String(point.cluster || '').trim(); + const nodeAgent = String(point.node || '').trim(); + const namespace = String(point.namespace || '').trim(); + + return ( + <> + setSelectedPoint(selectedPoint()?.id === point.id ? null : point)} + > + + {(column) => { + switch (column.id) { + case 'time': + return ( + + {timeOnly} + + ); + case 'type': + return ( + + —}> + + {subjectType} + + + + ); + case 'subject': + return ( + +
+ + {subject} + + + + + + + + + +
+
+ ); + case 'entityId': + return ( + + {entityId || '—'} + + ); + case 'cluster': + return ( + + {cluster || '—'} + + ); + case 'nodeAgent': + return ( + + {nodeAgent || '—'} + + ); + case 'namespace': + return ( + + {namespace || '—'} + + ); + case 'source': { + const badge = getSourcePlatformBadge(provider); + return ( + + + {badge?.label || getSourcePlatformLabel(provider)} + + + ); + } + case 'verified': + return ( + + {typeof point.verified === 'boolean' ? ( + point.verified ? ( + + + + + + ) : ( + + + + + + ) + ) : ( + + )} + + ); + case 'size': + return ( + + {point.sizeBytes && point.sizeBytes > 0 + ? formatBytes(point.sizeBytes) + : '—'} + + ); + case 'method': + return ( + + + {getRecoveryArtifactModePresentation(mode).label} + + + ); + 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 {(props.recoveryPoints.meta().page - 1) * props.recoveryPoints.meta().limit + 1} -{' '} + {Math.min( + props.recoveryPoints.meta().page * props.recoveryPoints.meta().limit, + props.recoveryPoints.meta().total, + )}{' '} + of {props.recoveryPoints.meta().total} recovery points + + +
+
+ + + Page {props.currentPage()} / {props.totalPages()} + + +
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx new file mode 100644 index 000000000..a6e23bfdf --- /dev/null +++ b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx @@ -0,0 +1,425 @@ +import { For, Show, createMemo, createSignal } from 'solid-js'; +import type { Accessor, Component } from 'solid-js'; + +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { LabeledFilterSelect } from '@/components/shared/FilterToolbar'; +import { PageControls } from '@/components/shared/PageControls'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shared/Table'; +import { STORAGE_KEYS } from '@/utils/localStorage'; +import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; +import type { ProtectionRollup, RecoveryOutcome } from '@/types/recovery'; +import type { Resource } from '@/types/resource'; +import { + getRecoveryProtectedToggleClass, + getRecoveryRollupStatusPillClass, + getRecoveryRollupStatusPillLabel, + getRecoverySpecialOutcomeTextClass, +} from '@/utils/recoveryStatusPresentation'; +import { + getRecoveryProtectedItemsEmptyState, + getRecoveryProtectedItemsFailureState, + getRecoveryProtectedItemsLoadingState, +} from '@/utils/recoveryEmptyStatePresentation'; +import { + getRecoveryRollupAgeTextClass, + getRecoveryRollupIssueTone, + getRecoveryProtectedSearchPlaceholder, + getRecoverySearchHistoryEmptyMessage, + isRecoveryRollupStale, +} from '@/utils/recoveryTablePresentation'; +import { + getRecoveryOutcomeBadgeClass, + normalizeRecoveryOutcome, +} from '@/utils/recoveryOutcomePresentation'; +import { getRecoveryIssueRailClass, type RecoveryIssueTone } from '@/utils/recoveryIssuePresentation'; +import { getRecoveryRollupSubjectLabel } from '@/utils/recoveryRecordPresentation'; +import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; + +type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown'; +type ProtectedSortCol = 'subject' | 'source' | 'lastBackup' | 'outcome'; +type SortDir = 'asc' | 'desc'; + +interface RecoveryRollupSummary { + total: number; + counts: Record; + stale: number; + neverSucceeded: number; +} + +interface RecoveryProtectedInventorySectionProps { + filteredRollups: Accessor; + historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>; + isMobile: boolean; + kioskMode: boolean; + onSelectRollup: (rollupId: string) => void; + protectedStaleOnly: Accessor; + providerFilter: Accessor; + providerOptions: Accessor; + queryFilter: Accessor; + resourcesById: Accessor>; + rollups: Accessor; + rollupsSummary: Accessor; + setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void; + setProtectedStaleOnly: (value: boolean | ((prev: boolean) => boolean)) => void; + setProviderFilter: (value: string) => void; + setQueryFilter: (value: string) => void; + setVerificationFilter: (value: VerificationFilter) => void; + loading: Accessor; + error: Accessor; +} + +const availableOutcomes = ['all', 'success', 'warning', 'failed', 'running'] as const; + +export const RecoveryProtectedInventorySection: Component< + RecoveryProtectedInventorySectionProps +> = (props) => { + const [protectedFiltersOpen, setProtectedFiltersOpen] = createSignal(false); + const [protectedSortCol, setProtectedSortCol] = createSignal('lastBackup'); + const [protectedSortDir, setProtectedSortDir] = createSignal('desc'); + + const toggleProtectedSort = (col: ProtectedSortCol) => { + if (protectedSortCol() === col) { + setProtectedSortDir((direction) => (direction === 'asc' ? 'desc' : 'asc')); + } else { + setProtectedSortCol(col); + setProtectedSortDir('asc'); + } + }; + + const protectedActiveFilterCount = createMemo(() => { + let count = 0; + if (props.queryFilter().trim() !== '') count += 1; + if (props.providerFilter() !== 'all') count += 1; + if (props.historyOutcomeFilter() !== 'all') count += 1; + if (props.protectedStaleOnly()) count += 1; + return count; + }); + + 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 'subject': { + const leftLabel = getRecoveryRollupSubjectLabel(left, resourceIndex).toLowerCase(); + const rightLabel = getRecoveryRollupSubjectLabel(right, resourceIndex).toLowerCase(); + return multiplier * leftLabel.localeCompare(rightLabel); + } + case 'source': { + const leftSource = (left.providers || []) + .map((provider) => getSourcePlatformLabel(String(provider))) + .sort() + .join(','); + const rightSource = (right.providers || []) + .map((provider) => getSourcePlatformLabel(String(provider))) + .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 leftOutcome = normalizeRecoveryOutcome(left.lastOutcome); + const rightOutcome = normalizeRecoveryOutcome(right.lastOutcome); + return multiplier * leftOutcome.localeCompare(rightOutcome); + } + default: + return 0; + } + }); + + return items; + }); + + return ( + +
+
+
+ Protected Inventory +
+
+ + {props.filteredRollups().length} of {props.rollups().length} items shown + + 0}> + + {props.rollupsSummary().stale} stale + + + 0}> + + {props.rollupsSummary().neverSucceeded} never succeeded + + +
+
+
+ + +
+ props.setQueryFilter(value)} + placeholder={getRecoveryProtectedSearchPlaceholder()} + class="w-full" + clearOnEscape + history={{ + storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY, + emptyMessage: getRecoverySearchHistoryEmptyMessage(), + }} + /> + } + mobileFilters={{ + enabled: props.isMobile, + onToggle: () => setProtectedFiltersOpen((open) => !open), + count: protectedActiveFilterCount(), + }} + showFilters={!props.isMobile || protectedFiltersOpen()} + toolbarClass="lg:flex-nowrap" + > + + props.setProviderFilter( + normalizeSourcePlatformQueryValue(event.currentTarget.value), + ) + } + selectClass="min-w-[10rem] max-w-[14rem]" + > + + {(provider) => ( + + )} + + + + { + const value = event.currentTarget.value as 'all' | RecoveryOutcome; + props.setHistoryOutcomeFilter(value); + if (value !== 'all') props.setVerificationFilter('all'); + }} + selectClass="min-w-[9rem]" + > + + {(outcome) => ( + + )} + + + + + +
+
+ + +
+ {getRecoveryProtectedItemsLoadingState().text} +
+
+ + +
+ +
+
+ + +
+ +
+
+ + 0}> +
+ + + + {( + [ + ['subject', 'Subject'], + ['source', 'Source'], + ['lastBackup', 'Last Backup'], + ['outcome', 'Outcome'], + ] as const + ).map(([column, label]) => ( + + ))} + + + + + {(rollup) => { + const resourceIndex = props.resourcesById(); + const label = getRecoveryRollupSubjectLabel(rollup, resourceIndex); + const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0; + const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0; + const outcome = normalizeRecoveryOutcome(rollup.lastOutcome); + const providers = (rollup.providers || []) + .slice() + .map((provider) => String(provider || '').trim()) + .filter(Boolean) + .sort((left, right) => + getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)), + ); + const nowMs = Date.now(); + const issueTone: RecoveryIssueTone = getRecoveryRollupIssueTone(rollup, nowMs); + const issueRailClass = + issueTone === 'none' ? '' : getRecoveryIssueRailClass(issueTone); + const stale = isRecoveryRollupStale(rollup, nowMs); + const neverSucceeded = + (!Number.isFinite(successMs) || successMs <= 0) && + Number.isFinite(attemptMs) && + attemptMs > 0; + + return ( + props.onSelectRollup(rollup.rollupId)} + > + + + + +
+ {label} + + + {getRecoveryRollupStatusPillLabel('never-succeeded')} + + + + + {getRecoveryRollupStatusPillLabel('stale')} + + +
+
+ + + + 0 + ? formatAbsoluteTime(successMs) + : attemptMs > 0 + ? formatAbsoluteTime(attemptMs) + : undefined + } + > + {successMs > 0 ? ( + formatRelativeTime(successMs) + ) : neverSucceeded ? ( + never + ) : ( + '—' + )} + + + + + {titleCaseDelimitedLabel(outcome)} + + +
+ ); + }} +
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 9d33f9094..15350e96f 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -68,7 +68,10 @@ import resourceStateAdaptersSource from '@/utils/resourceStateAdapters.ts?raw'; import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw'; import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw'; import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw'; -import recoverySource from '@/components/Recovery/Recovery.tsx?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 recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw'; import problemResourcesTableSource from '@/pages/DashboardPanels/ProblemResourcesTable.tsx?raw'; import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw'; @@ -102,6 +105,13 @@ import apiTokenPresentationSource from '@/utils/apiTokenPresentation.ts?raw'; import ssoProviderPresentationSource from '@/utils/ssoProviderPresentation.ts?raw'; import systemSettingsPresentationSource from '@/utils/systemSettingsPresentation.ts?raw'; +const recoverySource = [ + recoveryComponentSource, + recoveryProtectedInventorySectionSource, + recoveryActivitySectionSource, + recoveryHistorySectionSource, +].join('\n'); + describe('monitored-system model guardrails', () => { it('keeps AgentProfilesPanel on unified resources (not host-only slices)', () => { expect(agentProfilesPanelSource).toContain('const { resources } = useResources()'); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 0a89ca340..46718bfcd 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -27,7 +27,10 @@ import infrastructurePageSource from '@/pages/Infrastructure.tsx?raw'; import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw'; import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw'; import recoverySummarySource from '@/components/Recovery/RecoverySummary.tsx?raw'; -import recoverySource from '@/components/Recovery/Recovery.tsx?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 recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw'; import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw'; import dashboardRecoverySource from '@/hooks/useDashboardRecovery.ts?raw'; import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw'; @@ -258,6 +261,13 @@ import organizationOverviewPanelSource from '@/components/Settings/OrganizationO import organizationSharingPanelSource from '@/components/Settings/OrganizationSharingPanel.tsx?raw'; import organizationRolePresentationSource from '@/utils/organizationRolePresentation.ts?raw'; import organizationSettingsPresentationSource from '@/utils/organizationSettingsPresentation.ts?raw'; + +const recoverySource = [ + recoveryComponentSource, + recoveryProtectedInventorySectionSource, + recoveryActivitySectionSource, + recoveryHistorySectionSource, +].join('\n'); import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw'; import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw'; import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?raw'; @@ -418,6 +428,7 @@ describe('frontend resource type boundaries', () => { expect(recoverySource).toContain( "import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';", ); + expect(recoveryComponentSource).toContain('useRecoverySurfaceState'); expect(recoverySource).not.toContain('parseRecoveryLinkSearch'); expect(recoverySource).not.toContain('buildRecoveryPath'); expect(recoverySource).not.toContain('useRecoveryRollups'); @@ -431,7 +442,9 @@ describe('frontend resource type boundaries', () => { expect(recoverySurfaceStateSource).toContain('useRecoveryPointsSeries'); expect(recoverySource).toContain('getRecoveryFilterChipPresentation'); expect(recoverySource).not.toContain('const titleize ='); - expect(recoverySource).toContain("segmentedButtonClass(chartRangeDays() === range, false, 'accent')"); + expect(recoverySource).toContain( + "segmentedButtonClass(props.chartRangeDays() === range, false, 'accent')", + ); expect(recoverySource).not.toContain('border-blue-200 bg-blue-50 px-2 py-0.5'); expect(recoverySource).not.toContain('border-cyan-200 bg-cyan-50 px-2 py-0.5'); expect(recoverySource).not.toContain('border-emerald-200 bg-emerald-50 px-2 py-0.5');