Split recovery surface by product section

This commit is contained in:
rcourtman
2026-03-20 17:01:58 +00:00
parent 02d5ee1d84
commit f4367aa632
7 changed files with 1959 additions and 1693 deletions
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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<ActivitySummary>;
activeClusterLabel: Accessor<string>;
activeNamespaceLabel: Accessor<string>;
activeNodeLabel: Accessor<string>;
chartRangeDays: Accessor<7 | 30 | 90 | 365>;
clearClusterFilter: () => void;
clearFocusedRollup: () => void;
clearNamespaceFilter: () => void;
clearNodeFilter: () => void;
clearSelectedDate: () => void;
hasFocusedRollup: Accessor<boolean>;
isMobile: boolean;
loading: Accessor<boolean>;
overallRollupsSummary: Accessor<RecoveryRollupSummary>;
selectedDateKey: Accessor<string | null>;
selectedDateLabel: Accessor<string>;
selectedHistorySubjectLabel: Accessor<string | null>;
setChartRangeDays: (value: 7 | 30 | 90 | 365) => void;
toggleSelectedDate: (key: string) => void;
timeline: Accessor<TimelineModel>;
}
const rangeOptions: Array<7 | 30 | 90 | 365> = [7, 30, 90, 365];
export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> = (props) => (
<Card padding="sm" class="h-full">
<div class="mb-3 flex flex-col gap-3">
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-3 text-sm">
<span class="font-medium text-base-content">
{props.overallRollupsSummary().total} protected
</span>
<Show when={props.overallRollupsSummary().total - props.overallRollupsSummary().stale > 0}>
<span class="text-emerald-600 dark:text-emerald-400">
{props.overallRollupsSummary().total - props.overallRollupsSummary().stale} healthy
</span>
</Show>
<Show when={props.overallRollupsSummary().stale > 0}>
<span class="text-amber-500">{props.overallRollupsSummary().stale} stale</span>
</Show>
<Show when={props.overallRollupsSummary().neverSucceeded > 0}>
<span class="text-rose-500">
{props.overallRollupsSummary().neverSucceeded} never succeeded
</span>
</Show>
</div>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Recovery Activity
</div>
<div class="text-xs text-muted">
Daily recovery points across the selected history window.
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.selectedHistorySubjectLabel()}>
<div class="inline-flex items-center gap-2 rounded-md border border-border bg-surface-alt px-2.5 py-1.5 text-xs">
<span class="font-semibold uppercase tracking-wide text-muted">Focused</span>
<span class="max-w-[18rem] truncate font-medium text-base-content">
{props.selectedHistorySubjectLabel()}
</span>
</div>
</Show>
<Show
when={props.hasFocusedRollup()}
fallback={<span class="text-xs text-muted">All protected items</span>}
>
<button type="button" onClick={props.clearFocusedRollup} class={getRecoveryBreadcrumbLinkClass()}>
All history
</button>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
<span>{props.activitySummary().totalPoints} recovery points</span>
<span>{props.activitySummary().averagePerDay.toFixed(1)} per day</span>
<span>{props.activitySummary().activeDays} active days</span>
<Show when={props.selectedDateKey()}>
<span>{props.selectedDateLabel()}</span>
</Show>
</div>
</div>
<Show
when={
props.selectedDateKey() ||
props.activeClusterLabel() ||
props.activeNodeLabel() ||
props.activeNamespaceLabel()
}
>
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<Show when={props.selectedDateKey()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('day');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.selectedDateLabel()}>
{props.selectedDateLabel()}
</span>
<button type="button" onClick={props.clearSelectedDate} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeClusterLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('cluster');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeClusterLabel()}>
{props.activeClusterLabel()}
</span>
<button type="button" onClick={props.clearClusterFilter} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeNodeLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('node');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeNodeLabel()}>
{props.activeNodeLabel()}
</span>
<button type="button" onClick={props.clearNodeFilter} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeNamespaceLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('namespace');
return (
<div data-testid="active-namespace-chip" class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeNamespaceLabel()}>
{props.activeNamespaceLabel()}
</span>
<button
type="button"
onClick={props.clearNamespaceFilter}
class={chip.clearButtonClass}
>
Clear
</button>
</div>
);
})()}
</Show>
</div>
</Show>
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div class="rounded-md border border-border bg-surface p-3">
<div class={`mb-3 flex flex-wrap items-center justify-between gap-3 ${RECOVERY_TIMELINE_RANGE_GROUP_CLASS}`}>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Selected range
</div>
<div class={RECOVERY_TIMELINE_RANGE_GROUP_CLASS}>
<For each={rangeOptions}>
{(range) => (
<button
type="button"
onClick={() => props.setChartRangeDays(range)}
class={`px-2 py-1 ${segmentedButtonClass(props.chartRangeDays() === range, false, 'accent')}`}
>
{range}d
</button>
)}
</For>
</div>
</div>
<Show
when={!props.loading() && props.timeline().points.length > 0}
fallback={
<div class="flex h-52 items-center justify-center text-sm text-muted">
{props.loading()
? getRecoveryActivityLoadingState().text
: getRecoveryActivityEmptyState().text}
</div>
}
>
<div class="relative">
<div class="mb-2 flex items-center justify-end gap-3 text-[10px] text-muted">
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`} />
{getRecoveryArtifactModePresentation('snapshot').label}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('local').segmentClassName}`} />
{getRecoveryArtifactModePresentation('local').label}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('remote').segmentClassName}`} />
{getRecoveryArtifactModePresentation('remote').label}
</div>
</div>
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<div class="flex h-40 flex-col justify-between text-[10px] text-muted">
<For each={[0, 1, 2, 3, 4]}>
{(step) => {
const value = Math.round((props.timeline().axisMax * (4 - step)) / 4);
return <span>{value}</span>;
}}
</For>
</div>
<div class="relative h-44">
<div class="absolute inset-x-0 bottom-4 top-0 flex items-end gap-[3px]">
<For each={props.timeline().points}>
{(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 (
<div class="flex-1">
<button
type="button"
class={`h-full w-full rounded-sm ${getRecoveryTimelineColumnButtonClass(isSelected)}`}
aria-label={`${getRecoveryPrettyDateLabel(point.key)}: ${total} recovery points`}
onClick={() => props.toggleSelectedDate(point.key)}
onMouseEnter={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const breakdown: string[] = [];
if (point.snapshot > 0) breakdown.push(`Snapshots: ${point.snapshot}`);
if (point.local > 0) breakdown.push(`Local: ${point.local}`);
if (point.remote > 0) breakdown.push(`Remote: ${point.remote}`);
const tooltipText =
point.total > 0
? `${getRecoveryPrettyDateLabel(point.key)}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}\n${breakdown.join(' • ')}`
: `${getRecoveryPrettyDateLabel(point.key)}\nNo recovery points available`;
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
onFocus={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const tooltipText = `${getRecoveryPrettyDateLabel(point.key)}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}`;
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onBlur={() => hideTooltip()}
>
<div class="relative h-full w-full">
<Show when={total > 0}>
<div
class="absolute inset-x-0 bottom-0"
style={{ height: `${columnHeight}%` }}
>
<Show when={remoteHeight > 0}>
<div
class={`w-full ${getRecoveryArtifactModePresentation('remote').segmentClassName}`}
style={{ height: `${remoteHeight}%` }}
/>
</Show>
<Show when={localHeight > 0}>
<div
class={`w-full ${getRecoveryArtifactModePresentation('local').segmentClassName}`}
style={{ height: `${localHeight}%` }}
/>
</Show>
<Show when={snapshotHeight > 0}>
<div
class={`w-full ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`}
style={{ height: `${snapshotHeight}%` }}
/>
</Show>
</div>
</Show>
</div>
</button>
</div>
);
}}
</For>
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-4 flex items-end gap-[3px]">
<For each={props.timeline().points}>
{(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 (
<div
class={`relative flex-1 ${props.isMobile ? '' : 'shrink-0'} ${barMinWidth}`}
>
<Show when={showLabel}>
<span
class={`absolute bottom-0 left-1/2 -translate-x-1/2 whitespace-nowrap text-[9px] ${
isSelected
? getRecoveryTimelineAxisLabelClass(true)
: getRecoveryTimelineAxisLabelClass(false)
}`}
>
{getRecoveryCompactAxisLabel(point.key, props.chartRangeDays())}
</span>
</Show>
</div>
);
}}
</For>
</div>
</div>
</div>
</div>
</Show>
</div>
</div>
</Card>
);
@@ -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<RecoveryPointsMeta>;
response: {
loading: boolean;
error: unknown;
};
}
interface PageControlsColumnVisibility {
availableToggles: () => ColumnDef[];
isHiddenByUser: (id: string) => boolean;
toggle: (id: string) => void;
resetToDefaults: () => void;
}
interface RecoveryHistorySectionProps {
activeAdvancedFilterCount: Accessor<number>;
artifactColumnVisibility: PageControlsColumnVisibility;
availableOutcomes: readonly ('all' | 'success' | 'warning' | 'failed' | 'running')[];
clusterFilter: Accessor<string>;
clusterOptions: Accessor<string[]>;
currentPage: Accessor<number>;
groupedByDay: Accessor<RecoveryPointGroup[]>;
hasActiveArtifactFilters: Accessor<boolean>;
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
isMobile: boolean;
kioskMode: boolean;
mobileVisibleArtifactColumns: Accessor<ColumnDef[]>;
modeFilter: Accessor<'all' | ArtifactMode>;
namespaceFilter: Accessor<string>;
namespaceOptions: Accessor<string[]>;
nodeFilter: Accessor<string>;
nodeOptions: Accessor<string[]>;
providerFilter: Accessor<string>;
providerOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
recoveryPoints: RecoveryPointsModel;
resetAdvancedArtifactFilters: () => void;
resetAllArtifactFilters: () => void;
resourcesById: Accessor<Map<string, Resource>>;
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<boolean>;
showNamespaceFilter: Accessor<boolean>;
showNodeFilter: Accessor<boolean>;
showVerificationFilter: Accessor<boolean>;
tableColumnCount: Accessor<number>;
tableMinWidth: Accessor<string>;
totalPages: Accessor<number>;
verificationFilter: Accessor<VerificationFilter>;
}
export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (props) => {
const [selectedPoint, setSelectedPoint] = createSignal<RecoveryPoint | null>(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 (
<Card padding="none" tone="card" class="mb-4 overflow-hidden">
<div class="border-b border-border bg-surface-hover px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
Backups By Date
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border px-3 py-3">
<PageControls
search={
<SearchInput
value={props.queryFilter}
onChange={(value) => {
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={
<div class="ml-auto flex items-center gap-2">
<div class="relative">
<FilterActionButton
ref={advancedFiltersButtonRef}
aria-label="Filter"
aria-expanded={moreFiltersOpen()}
aria-controls="recovery-filter-panel"
aria-haspopup="dialog"
onClick={() => setMoreFiltersOpen((open) => !open)}
active={moreFiltersOpen() || props.activeAdvancedFilterCount() > 0}
>
<span>Filter</span>
<Show when={props.activeAdvancedFilterCount() > 0}>
<span class={filterUtilityBadgeClass}>
{props.activeAdvancedFilterCount()}
</span>
</Show>
</FilterActionButton>
<Show when={moreFiltersOpen()}>
<FilterToolbarPanel ref={advancedFiltersPanelRef} id="recovery-filter-panel">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<div class={filterPanelTitleClass}>Filter results</div>
<div class={filterPanelDescriptionClass}>
Narrow by scope, method, verification, or location.
</div>
</div>
<Show when={props.activeAdvancedFilterCount() > 0}>
<button
type="button"
onClick={props.resetAdvancedArtifactFilters}
class={getRecoveryFilterPanelClearClass()}
>
Clear filters
</button>
</Show>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Scope</span>
<select
value={props.scopeFilter()}
onChange={(event) => {
props.setScopeFilter(
event.currentTarget.value === 'workload' ? 'workload' : 'all',
);
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">All history</option>
<option value="workload">Workloads only</option>
</select>
</label>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Method</span>
<select
value={props.modeFilter()}
onChange={(event) => {
props.setModeFilter(
normalizeRecoveryModeQueryValue(event.currentTarget.value),
);
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any method</option>
<option value="snapshot">
{getRecoveryArtifactModePresentation('snapshot').label}
</option>
<option value="local">
{getRecoveryArtifactModePresentation('local').label}
</option>
<option value="remote">
{getRecoveryArtifactModePresentation('remote').label}
</option>
</select>
</label>
<Show when={props.showVerificationFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Verification</span>
<select
value={props.verificationFilter()}
onChange={(event) => {
props.setVerificationFilter(
event.currentTarget.value as VerificationFilter,
);
if (event.currentTarget.value !== 'all') {
props.setHistoryOutcomeFilter('all');
}
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any verification</option>
<option value="verified">Verified</option>
<option value="unverified">Unverified</option>
<option value="unknown">Unknown</option>
</select>
</label>
</Show>
<Show when={props.showClusterFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Cluster</span>
<select
value={props.clusterFilter()}
onChange={(event) => {
props.setClusterFilter(event.currentTarget.value);
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any cluster</option>
<For each={props.clusterOptions().filter((value) => value !== 'all')}>
{(cluster) => <option value={cluster}>{cluster}</option>}
</For>
</select>
</label>
</Show>
<Show when={props.showNodeFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Node or agent</span>
<select
value={props.nodeFilter()}
onChange={(event) => {
props.setNodeFilter(event.currentTarget.value);
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any node or agent</option>
<For each={props.nodeOptions().filter((value) => value !== 'all')}>
{(node) => <option value={node}>{node}</option>}
</For>
</select>
</label>
</Show>
<Show when={props.showNamespaceFilter()}>
<label class="flex min-w-0 flex-col gap-1">
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Namespace</span>
<select
value={props.namespaceFilter()}
onChange={(event) => {
props.setNamespaceFilter(event.currentTarget.value);
props.setCurrentPage(1);
}}
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
>
<option value="all">Any namespace</option>
<For each={props.namespaceOptions().filter((value) => value !== 'all')}>
{(namespace) => <option value={namespace}>{namespace}</option>}
</For>
</select>
</label>
</Show>
</div>
</FilterToolbarPanel>
</Show>
</div>
</div>
}
columnVisibility={props.artifactColumnVisibility}
resetAction={{
show: props.hasActiveArtifactFilters(),
onClick: props.resetAllArtifactFilters,
label: 'Reset all',
}}
showFilters={!props.isMobile || historyFiltersOpen()}
toolbarClass="lg:flex-nowrap"
>
<LabeledFilterSelect
id="recovery-provider-filter-history"
label="History provider"
value={props.providerFilter()}
onChange={(event) => {
props.setProviderFilter(
normalizeSourcePlatformQueryValue(event.currentTarget.value),
);
props.setCurrentPage(1);
}}
selectClass="min-w-[10rem] max-w-[14rem]"
>
<For each={props.providerOptions()}>
{(provider) => (
<option value={provider}>
{provider === 'all' ? 'All Providers' : getSourcePlatformLabel(provider)}
</option>
)}
</For>
</LabeledFilterSelect>
<LabeledFilterSelect
id="recovery-status-filter"
label="History status"
value={props.historyOutcomeFilter()}
onChange={(event) => {
const value = event.currentTarget.value as 'all' | RecoveryOutcome;
props.setHistoryOutcomeFilter(value);
if (value !== 'all') props.setVerificationFilter('all');
props.setCurrentPage(1);
}}
selectClass="min-w-[7rem]"
>
<For each={props.availableOutcomes}>
{(outcome) => (
<option value={outcome}>
{outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome)}
</option>
)}
</For>
</LabeledFilterSelect>
</PageControls>
</div>
</Show>
<Show
when={props.groupedByDay().length > 0}
fallback={
<div class="p-6">
<Show
when={props.recoveryPoints.response.loading}
fallback={
<EmptyState
{...getRecoveryHistoryEmptyState()}
actions={
<Show when={props.hasActiveArtifactFilters()}>
<button
type="button"
onClick={props.resetAllArtifactFilters}
class={getRecoveryEmptyStateActionClass()}
>
Clear filters
</button>
</Show>
}
/>
}
>
<div class="text-sm text-muted">{getRecoveryPointsLoadingState().text}</div>
</Show>
</div>
}
>
<div class="overflow-x-auto">
<Table
class="w-full border-collapse text-xs whitespace-nowrap"
style={{ 'min-width': props.tableMinWidth(), 'table-layout': 'fixed' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<For each={props.mobileVisibleArtifactColumns()}>
{(column) => (
<TableHead
class={`py-0.5 px-3 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap ${getRecoveryArtifactColumnHeaderClass(
column.id,
)}`}
>
{column.label}
</TableHead>
)}
</For>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border">
<For each={props.groupedByDay()}>
{(group) => (
<>
<TableRow class={RECOVERY_GROUP_HEADER_ROW_CLASS}>
<TableCell colSpan={props.tableColumnCount()} class={RECOVERY_GROUP_HEADER_TEXT_CLASS}>
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate" title={group.label}>
{group.label}
</span>
<Show when={group.tone === 'recent'}>
<span class="inline-flex rounded px-1.5 py-px text-[9px] font-medium border border-border bg-surface">
recent
</span>
</Show>
</div>
<span class="shrink-0 font-mono text-[10px] tabular-nums text-muted">
{group.items.length}
</span>
</div>
</TableCell>
</TableRow>
<For each={group.items}>
{(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 (
<>
<TableRow
class={`cursor-pointer ${getRecoveryArtifactRowClass(selectedPoint()?.id === point.id)}`}
onClick={() => setSelectedPoint(selectedPoint()?.id === point.id ? null : point)}
>
<For each={props.mobileVisibleArtifactColumns()}>
{(column) => {
switch (column.id) {
case 'time':
return (
<TableCell
class={`whitespace-nowrap px-3 py-0.5 text-right font-mono text-[11px] tabular-nums ${getRecoveryEventTimeTextClass(
tsMs,
)}`}
>
{timeOnly}
</TableCell>
);
case 'type':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
<Show when={subjectType} fallback={<span class="text-muted">—</span>}>
<span
class={`inline-flex min-w-[2.75rem] justify-center rounded px-1.5 py-px text-[9px] font-medium leading-none ${getRecoverySubjectTypeBadgeClass(
point,
)}`}
>
{subjectType}
</span>
</Show>
</TableCell>
);
case 'subject':
return (
<TableCell
class="max-w-[420px] whitespace-nowrap px-3 py-0.5 text-base-content"
title={subject}
>
<div class="flex min-w-0 max-w-full items-center gap-2">
<span class="min-w-0 flex-1 truncate font-medium">
{subject}
</span>
<span class="inline-flex shrink-0 items-center gap-1">
<Show when={point.immutable === true}>
<svg
class="h-3 w-3 text-emerald-500 dark:text-emerald-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3l7 4v5c0 5-3.5 7.5-7 9-3.5-1.5-7-4-7-9V7l7-4z"
/>
</svg>
</Show>
<Show when={point.encrypted === true}>
<svg
class="h-3 w-3 text-amber-500 dark:text-amber-400"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2V7a3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</Show>
</span>
</div>
</TableCell>
);
case 'entityId':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono tabular-nums">
{entityId || '—'}
</TableCell>
);
case 'cluster':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
{cluster || '—'}
</TableCell>
);
case 'nodeAgent':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
{nodeAgent || '—'}
</TableCell>
);
case 'namespace':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
{namespace || '—'}
</TableCell>
);
case 'source': {
const badge = getSourcePlatformBadge(provider);
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
<span
class={`${badge?.classes || ''} inline-flex min-w-[3.25rem] justify-center px-1.5 py-px text-[9px] font-medium`}
>
{badge?.label || getSourcePlatformLabel(provider)}
</span>
</TableCell>
);
}
case 'verified':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
{typeof point.verified === 'boolean' ? (
point.verified ? (
<span
class="inline-flex min-w-[1.25rem] items-center justify-center text-green-600 dark:text-green-400"
title="Verified"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M5 13l4 4L19 7"
/>
</svg>
</span>
) : (
<span
class="inline-flex min-w-[1.25rem] items-center justify-center text-amber-500 dark:text-amber-400"
title="Unverified"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2.5"
d="M12 9v2m0 4h.01M12 3a9 9 0 100 18 9 9 0 000-18z"
/>
</svg>
</span>
)
) : (
<span class="text-muted">—</span>
)}
</TableCell>
);
case 'size':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-right font-mono text-[11px] tabular-nums text-muted">
{point.sizeBytes && point.sizeBytes > 0
? formatBytes(point.sizeBytes)
: '—'}
</TableCell>
);
case 'method':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
<span
class={`inline-flex min-w-[3.5rem] justify-center rounded px-1.5 py-px text-[9px] font-medium ${getRecoveryArtifactModePresentation(
mode,
).badgeClassName}`}
>
{getRecoveryArtifactModePresentation(mode).label}
</span>
</TableCell>
);
case 'repository':
return (
<TableCell
class="max-w-[220px] truncate whitespace-nowrap px-3 py-0.5 text-[11px] leading-4 text-base-content"
title={repoLabel}
>
{repoLabel || '—'}
</TableCell>
);
case 'details':
return (
<TableCell
class="max-w-[280px] truncate whitespace-nowrap px-3 py-0.5 text-[10px] leading-4 text-muted"
title={detailsSummary}
>
{detailsSummary || '—'}
</TableCell>
);
case 'outcome':
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
<span
class={`inline-flex min-w-[4.5rem] justify-center rounded px-1.5 py-px text-[9px] font-medium ${getRecoveryOutcomeBadgeClass(
outcome,
)}`}
>
{titleCaseDelimitedLabel(outcome)}
</span>
</TableCell>
);
default:
return (
<TableCell class="whitespace-nowrap px-3 py-0.5 text-muted">
-
</TableCell>
);
}
}}
</For>
</TableRow>
<Show when={selectedPoint()?.id === point.id}>
<TableRow>
<TableCell
colSpan={props.tableColumnCount()}
class="bg-surface-alt px-0 sm:px-4 py-4 relative"
>
<div class="flex items-center justify-between px-4 pb-2 mb-2 border-b border-border">
<h2 class="text-sm font-semibold text-base-content">
Recovery Point Details
</h2>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedPoint(null);
}}
class={getRecoveryDrawerCloseButtonClass()}
aria-label="Close details"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="px-4">
<RecoveryPointDetails point={point} />
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>
</>
)}
</For>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-between gap-2 px-3 py-2 text-xs text-muted border-t border-border">
<div>
<Show
when={(props.recoveryPoints.meta().total || 0) > 0}
fallback={<span>Showing 0 of 0 recovery points</span>}
>
<span>
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
</span>
</Show>
</div>
<div class="flex items-center gap-2">
<button
type="button"
disabled={props.currentPage() <= 1}
onClick={() => props.setCurrentPage(Math.max(1, props.currentPage() - 1))}
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
>
Prev
</button>
<span>
Page {props.currentPage()} / {props.totalPages()}
</span>
<button
type="button"
disabled={props.currentPage() >= props.totalPages()}
onClick={() => props.setCurrentPage(Math.min(props.totalPages(), props.currentPage() + 1))}
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
>
Next
</button>
</div>
</div>
</Show>
</Card>
);
};
@@ -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<RecoveryOutcome, number>;
stale: number;
neverSucceeded: number;
}
interface RecoveryProtectedInventorySectionProps {
filteredRollups: Accessor<ProtectionRollup[]>;
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
isMobile: boolean;
kioskMode: boolean;
onSelectRollup: (rollupId: string) => void;
protectedStaleOnly: Accessor<boolean>;
providerFilter: Accessor<string>;
providerOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
resourcesById: Accessor<Map<string, Resource>>;
rollups: Accessor<ProtectionRollup[]>;
rollupsSummary: Accessor<RecoveryRollupSummary>;
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<boolean>;
error: Accessor<unknown>;
}
const availableOutcomes = ['all', 'success', 'warning', 'failed', 'running'] as const;
export const RecoveryProtectedInventorySection: Component<
RecoveryProtectedInventorySectionProps
> = (props) => {
const [protectedFiltersOpen, setProtectedFiltersOpen] = createSignal(false);
const [protectedSortCol, setProtectedSortCol] = createSignal<ProtectedSortCol>('lastBackup');
const [protectedSortDir, setProtectedSortDir] = createSignal<SortDir>('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<ProtectionRollup[]>(() => {
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 (
<Card padding="none" tone="card" class="order-3 overflow-hidden">
<div class="border-b border-border bg-surface-hover px-3 py-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Protected Inventory
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
{props.filteredRollups().length} of {props.rollups().length} items shown
</span>
<Show when={props.rollupsSummary().stale > 0}>
<span class={getRecoveryRollupStatusPillClass('stale')}>
{props.rollupsSummary().stale} stale
</span>
</Show>
<Show when={props.rollupsSummary().neverSucceeded > 0}>
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
{props.rollupsSummary().neverSucceeded} never succeeded
</span>
</Show>
</div>
</div>
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border px-3 py-3">
<PageControls
search={
<SearchInput
value={props.queryFilter}
onChange={(value) => 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"
>
<LabeledFilterSelect
id="recovery-provider-filter"
label="Provider"
value={props.providerFilter()}
onChange={(event) =>
props.setProviderFilter(
normalizeSourcePlatformQueryValue(event.currentTarget.value),
)
}
selectClass="min-w-[10rem] max-w-[14rem]"
>
<For each={props.providerOptions()}>
{(provider) => (
<option value={provider}>
{provider === 'all' ? 'All Providers' : getSourcePlatformLabel(provider)}
</option>
)}
</For>
</LabeledFilterSelect>
<LabeledFilterSelect
id="recovery-protected-status-filter"
label="Latest status"
value={props.historyOutcomeFilter()}
onChange={(event) => {
const value = event.currentTarget.value as 'all' | RecoveryOutcome;
props.setHistoryOutcomeFilter(value);
if (value !== 'all') props.setVerificationFilter('all');
}}
selectClass="min-w-[9rem]"
>
<For each={availableOutcomes}>
{(outcome) => (
<option value={outcome}>
{outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome)}
</option>
)}
</For>
</LabeledFilterSelect>
<button
type="button"
aria-pressed={props.protectedStaleOnly()}
onClick={() => props.setProtectedStaleOnly((value) => !value)}
class={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${getRecoveryProtectedToggleClass(
props.protectedStaleOnly(),
)}`}
>
Stale only
</button>
</PageControls>
</div>
</Show>
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div class="px-6 py-6 text-sm text-muted">
{getRecoveryProtectedItemsLoadingState().text}
</div>
</Show>
<Show when={!props.loading() && props.error()}>
<div class="p-6">
<EmptyState
title={getRecoveryProtectedItemsFailureState().title}
description={String((props.error() as Error)?.message || props.error())}
/>
</div>
</Show>
<Show
when={!props.loading() && !props.error() && props.filteredRollups().length === 0}
>
<div class="p-6">
<EmptyState {...getRecoveryProtectedItemsEmptyState()} />
</div>
</Show>
<Show when={props.filteredRollups().length > 0}>
<div class="overflow-x-auto">
<Table
class="w-full border-collapse whitespace-nowrap"
style={{ 'table-layout': 'fixed', 'min-width': props.isMobile ? '100%' : '500px' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
{(
[
['subject', 'Subject'],
['source', 'Source'],
['lastBackup', 'Last Backup'],
['outcome', 'Outcome'],
] as const
).map(([column, label]) => (
<TableHead
class={`py-0.5 px-3 whitespace-nowrap text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer select-none hover:text-base-content transition-colors${
column === 'source'
? ' hidden md:table-cell w-[110px]'
: column === 'lastBackup'
? ' w-[120px]'
: column === 'outcome'
? ' w-[70px]'
: ''
}`}
onClick={() => toggleProtectedSort(column)}
>
<span class="inline-flex items-center gap-1">
{label}
<Show when={protectedSortCol() === column}>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
{protectedSortDir() === 'asc' ? (
<path d="M6 3l3.5 5h-7z" />
) : (
<path d="M6 9l3.5-5h-7z" />
)}
</svg>
</Show>
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border">
<For each={sortedRollups()}>
{(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 (
<TableRow
class="cursor-pointer border-b border-border hover:bg-surface-hover"
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<TableCell
class={`relative max-w-[420px] truncate whitespace-nowrap px-3 py-0.5 text-base-content ${
issueTone === 'rose' || issueTone === 'blue' ? 'font-medium' : ''
}`}
title={label}
>
<Show when={issueTone !== 'none'}>
<span class={`absolute inset-y-0 left-0 w-0.5 ${issueRailClass}`} />
</Show>
<div class="flex items-center gap-2">
<span class="truncate">{label}</span>
<Show when={neverSucceeded}>
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
{getRecoveryRollupStatusPillLabel('never-succeeded')}
</span>
</Show>
<Show when={!neverSucceeded && stale}>
<span class={getRecoveryRollupStatusPillClass('stale')}>
{getRecoveryRollupStatusPillLabel('stale')}
</span>
</Show>
</div>
</TableCell>
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-0.5">
<div class="flex flex-wrap gap-1.5">
<For each={providers}>
{(provider) => {
const badge = getSourcePlatformBadge(provider);
return (
<span class={badge?.classes || ''}>
{badge?.label || getSourcePlatformLabel(provider)}
</span>
);
}}
</For>
</div>
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-0.5 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
title={
successMs > 0
? formatAbsoluteTime(successMs)
: attemptMs > 0
? formatAbsoluteTime(attemptMs)
: undefined
}
>
{successMs > 0 ? (
formatRelativeTime(successMs)
) : neverSucceeded ? (
<span class={getRecoverySpecialOutcomeTextClass('never')}>never</span>
) : (
'—'
)}
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-0.5">
<span
class={`inline-flex rounded px-1.5 py-0.5 text-[10px] font-medium ${getRecoveryOutcomeBadgeClass(
outcome,
)}`}
>
{titleCaseDelimitedLabel(outcome)}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
</Show>
</Card>
);
};
@@ -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()');
@@ -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');