Delete orphaned Recovery aggregate surface

The platform-first IA migration retired /recovery on 2026-05-25
(commit 8b0b1b0c6) but left the Recovery surface components and their
guardrail tests in the tree, where they read like live code to anyone
grepping the codebase. Recovery.tsx, RecoveryActivitySection,
RecoveryHistorySection, RecoveryProtectedInventorySection,
RecoveryHistoryTable, RecoveryHistoryItemFilter, RecoveryPointDetails,
useRecoveryHistorySectionState, and features/recovery/useRecoverySurfaceState
have no live consumer post-migration — each was only reachable through
the retired /recovery route.

Drops the orphan files, their test files, and the guardrail assertions
in frontendResourceTypeBoundaries / PageControls / SharedPrimitives /
TypeColumn that exercised the dead surface. Recovery-related utility
modules (recoveryRecordPresentation, recoveryArtifactModePresentation,
recoveryTablePresentation, etc.) remain — they are still consumed by
the per-platform recovery integrations (Proxmox backups, etc.) and are
not part of the dead aggregate surface.

Updates a separate PageControls guardrail array to add the
AgentsMachinesTable ColumnPicker consumer that landed in the Standalone
rename series but wasn't reflected in the test's allowlist.
This commit is contained in:
rcourtman
2026-05-26 15:27:50 +01:00
parent 94757c7539
commit 66fb198cc6
19 changed files with 5 additions and 7907 deletions
@@ -1,771 +0,0 @@
import { Show, batch, createEffect, createMemo } from 'solid-js';
import type { Component } from 'solid-js';
import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection';
import { RecoveryHistorySection } from '@/components/Recovery/RecoveryHistorySection';
import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import { TableCard } from '@/components/shared/TableCard';
import type { FilterSelectOption } from '@/components/shared/FilterBar';
import {
FilterSegmentedControl,
type FilterSegmentOption,
} from '@/components/shared/FilterToolbar';
import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';
import type {
ProtectedStateFilter,
RecoveryWorkspaceView,
} from '@/features/recovery/useRecoverySurfaceState';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { useKioskMode } from '@/hooks/useKioskMode';
import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
getRecoveryFilterDateLabel,
getRecoveryFullDateLabel,
getRecoveryNiceAxisMax,
parseRecoveryDateKey,
recoveryDateKeyFromTimestamp,
resolveRecoveryDateSearchKey,
} from '@/utils/recoveryDatePresentation';
import { getRecoveryPointsFailureState } from '@/utils/recoveryEmptyStatePresentation';
import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation';
import {
getRecoveryPointItemLabel,
getRecoveryPointTimestampMs,
getRecoveryRollupItemLabel,
} from '@/utils/recoveryRecordPresentation';
import { getRecoveryRollupItemTypeKey } from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryGroupNoTimestampLabel,
getRecoveryArtifactTableMinWidth,
getRecoveryRollupInventoryStatus,
STALE_ISSUE_THRESHOLD_MS,
} from '@/utils/recoveryTablePresentation';
import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation';
import { getRecoveryTimelineDayFilterLabel } from '@/utils/recoveryTimelinePresentation';
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
const MOBILE_RECOVERY_COLUMNS = new Set(['time', 'item', 'outcome']);
const LEGACY_RECOVERY_COLUMN_ID_ALIASES = {
subject: 'item',
source: 'platform',
} as const;
type RecoveryActivityRangeDays = 7 | 30 | 90 | 365;
type RecoveryProps = {
embedded?: boolean;
tableOnly?: boolean;
forcedPlatformFilter?: string;
defaultWorkspaceView?: RecoveryWorkspaceView;
showEmbeddedWorkspaceSwitcher?: boolean;
};
const RECOVERY_WORKSPACE_OPTIONS: FilterSegmentOption[] = [
{ value: 'inventory', label: 'Protection', ariaLabel: 'Protection' },
{ value: 'events', label: 'Events', ariaLabel: 'Events' },
];
const Recovery: Component<RecoveryProps> = (props) => {
const kioskMode = useKioskMode();
const { isMobile } = useBreakpoint();
const {
chartRangeDays,
clusterFilter,
clusterOptions,
currentPage,
facets,
historyOutcomeFilter,
historyItemOptions,
itemTypeFilter,
itemTypeOptions,
modeFilter,
namespaceFilter,
namespaceOptions,
nodeFilter,
nodeOptions,
protectedStateFilter,
platformFilter,
platformOptions,
queryFilter,
recoveryPoints,
recoveryRollups,
recoverySeries,
resourcesById,
rollupId,
rollups,
scopeFilter,
selectedDateKey,
selectedHistoryItemLabel,
setChartRangeDays,
setClusterFilter,
setCurrentPage,
setHistoryOutcomeFilter,
setItemTypeFilter,
setModeFilter,
setNamespaceFilter,
setNodeFilter,
setProtectedStateFilter,
setPlatformFilter,
setQueryFilter,
setRollupId,
setScopeFilter,
setSelectedDateKey,
setVerificationFilter,
setWorkspaceView,
showClusterFilter,
showNamespaceFilter,
showNodeFilter,
showVerificationFilter,
tableWindow,
totalPages,
verificationFilter,
workspaceView,
} = useRecoverySurfaceState({
defaultWorkspaceView: props.defaultWorkspaceView,
});
createEffect(() => {
const forcedPlatform = props.forcedPlatformFilter?.trim();
if (!forcedPlatform) return;
if (platformFilter() !== forcedPlatform) {
setPlatformFilter(forcedPlatform);
}
});
const baseRollups = createMemo<ProtectionRollup[]>(() => {
const query = queryFilter().trim().toLowerCase();
const platform = platformFilter() === 'all' ? '' : platformFilter();
const itemType = itemTypeFilter() === 'all' ? '' : itemTypeFilter();
const resourceIndex = resourcesById();
const result = rollups().filter((rollup) => {
const platforms = getRecoveryRollupPlatforms(rollup)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
if (platform && !platforms.includes(platform)) return false;
const rollupItemType = getRecoveryRollupItemTypeKey(rollup);
if (itemType && rollupItemType !== itemType) return false;
if (!query) return true;
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
const haystack = [
rollup.rollupId,
rollup.itemResourceId || '',
label,
rollupItemType,
rollup.itemRef?.namespace || rollup.subjectRef?.namespace || '',
rollup.itemRef?.name || rollup.subjectRef?.name || '',
platforms.join(' '),
rollup.lastOutcome || '',
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
return [...result].sort((left, right) => {
const leftTimestamp = left.lastAttemptAt ? Date.parse(left.lastAttemptAt) : 0;
const rightTimestamp = right.lastAttemptAt ? Date.parse(right.lastAttemptAt) : 0;
if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp;
return left.rollupId.localeCompare(right.rollupId);
});
});
const summarizeRollups = (items: ProtectionRollup[]) => {
const nowMs = Date.now();
const staleThreshold = nowMs - STALE_ISSUE_THRESHOLD_MS;
const counts: Record<RecoveryOutcome, number> = {
success: 0,
warning: 0,
failed: 0,
running: 0,
unknown: 0,
};
let stale = 0;
let neverSucceeded = 0;
for (const rollup of items) {
counts[normalizeOutcome(rollup.lastOutcome)] += 1;
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
if (successMs > 0) {
if (successMs < staleThreshold) stale += 1;
} else if (attemptMs > 0) {
neverSucceeded += 1;
if (attemptMs < staleThreshold) stale += 1;
}
}
return { total: items.length, counts, stale, neverSucceeded };
};
const rollupsSummary = createMemo(() => summarizeRollups(baseRollups()));
const overallRollupsSummary = createMemo(() => summarizeRollups(rollups()));
const filteredRollups = createMemo<ProtectionRollup[]>(() => {
const selectedState = protectedStateFilter();
if (selectedState === 'all') return baseRollups();
const nowMs = Date.now();
return baseRollups().filter((rollup) => {
return getRecoveryRollupInventoryStatus(rollup, nowMs) === selectedState;
});
});
const filteredPoints = createMemo<RecoveryPoint[]>(() => {
const points = recoveryPoints.points() || [];
const selected = selectedDateKey();
if (!selected) return points;
const { from, to } = tableWindow();
const fromMs = from ? Date.parse(from) : -Infinity;
const toMs = to ? Date.parse(to) : Infinity;
return points.filter((point) => {
const timestamp = getRecoveryPointTimestampMs(point);
return timestamp >= fromMs && timestamp <= toMs;
});
});
const sortedPoints = createMemo<RecoveryPoint[]>(() => {
const resourceIndex = resourcesById();
return [...filteredPoints()].sort((left, right) => {
const leftTimestamp = getRecoveryPointTimestampMs(left);
const rightTimestamp = getRecoveryPointTimestampMs(right);
if (leftTimestamp !== rightTimestamp) return rightTimestamp - leftTimestamp;
const leftName = getRecoveryPointItemLabel(left, resourceIndex);
const rightName = getRecoveryPointItemLabel(right, resourceIndex);
return leftName.localeCompare(rightName);
});
});
const groupedByDay = createMemo(() => {
const groups: Array<{
key: string;
label: string;
tone: 'recent' | 'default';
items: RecoveryPoint[];
}> = [];
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterday = today - 24 * 60 * 60 * 1000;
const groupMap = new Map<
string,
{ key: string; label: string; tone: 'recent' | 'default'; items: RecoveryPoint[] }
>();
for (const point of sortedPoints()) {
const key = point.completedAt
? recoveryDateKeyFromTimestamp(Date.parse(point.completedAt))
: 'unknown';
if (!groupMap.has(key)) {
let label = getRecoveryGroupNoTimestampLabel();
let tone: 'recent' | 'default' = 'default';
if (key !== 'unknown') {
const date = parseRecoveryDateKey(key);
const dayTimestamp = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
).getTime();
if (dayTimestamp === today) {
label = `Today (${getRecoveryFullDateLabel(key)})`;
tone = 'recent';
} else if (dayTimestamp === yesterday) {
label = `Yesterday (${getRecoveryFullDateLabel(key)})`;
tone = 'recent';
} else {
label = getRecoveryFullDateLabel(key);
}
}
const group = { key, label, tone, items: [] as RecoveryPoint[] };
groupMap.set(key, group);
groups.push(group);
}
groupMap.get(key)!.items.push(point);
}
return groups;
});
const hasSizeData = createMemo(() => Boolean(facets().hasSize));
const hasVerificationData = createMemo(() => Boolean(facets().hasVerification));
const hasClusterData = createMemo(() => (facets().clusters || []).length > 0);
const hasNodeData = createMemo(() => (facets().nodesAgents || []).length > 0);
const hasNamespaceData = createMemo(() => (facets().namespaces || []).length > 0);
const hasEntityIDData = createMemo(() => Boolean(facets().hasEntityId));
const recoveryRollupsLoaded = createMemo(() => recoveryRollups.resolvedOnce());
const recoveryRollupsLoading = createMemo(
() => !recoveryRollupsLoaded() || recoveryRollups.rollups.loading,
);
const recoverySeriesLoaded = createMemo(() => recoverySeries.resolvedOnce());
const recoverySeriesLoading = createMemo(
() => !recoverySeriesLoaded() || recoverySeries.response.loading,
);
const recoveryPointsLoaded = createMemo(() => recoveryPoints.resolvedOnce());
const recoveryPointsLoading = createMemo(
() => !recoveryPointsLoaded() || recoveryPoints.response.loading,
);
const recoveryPointsModel = {
meta: recoveryPoints.meta,
response: {
get loading() {
return recoveryPointsLoading();
},
get error() {
return recoveryPoints.response.error;
},
},
};
const artifactColumns: ColumnDef[] = [
{ id: 'time', label: 'Time' },
{
...createVisibleCanonicalTypeColumn(),
label: getRecoveryArtifactColumnLabel('type', 'Type'),
},
{ id: 'item', label: getRecoveryArtifactColumnLabel('item', 'Item') },
{ id: 'entityId', label: 'ID', toggleable: true },
{
id: 'cluster',
label: getRecoveryArtifactColumnLabel('cluster', 'Cluster'),
toggleable: true,
},
{
id: 'nodeAgent',
label: getRecoveryArtifactColumnLabel('nodeAgent', 'Node/Agent'),
toggleable: true,
},
{
id: 'namespace',
label: getRecoveryArtifactColumnLabel('namespace', 'Namespace'),
toggleable: true,
},
{ id: 'platform', label: getRecoveryArtifactColumnLabel('platform', 'Platform') },
{ id: 'verified', label: 'Verified', toggleable: true },
{ id: 'size', label: 'Size', toggleable: true },
{ id: 'method', label: 'Method' },
{ id: 'repository', label: 'Target', toggleable: true },
{ id: 'details', label: 'Details', toggleable: true },
{ id: 'outcome', label: 'Outcome' },
];
const relevantArtifactColumnIDs = createMemo(() => {
const ids = new Set<string>([
'time',
'type',
'item',
'platform',
'method',
'repository',
'details',
'outcome',
]);
if (hasVerificationData()) ids.add('verified');
if (hasSizeData()) ids.add('size');
if (hasClusterData()) ids.add('cluster');
if (hasNodeData()) ids.add('nodeAgent');
if (hasNamespaceData()) ids.add('namespace');
if (hasEntityIDData()) ids.add('entityId');
return ids;
});
const artifactColumnVisibility = useColumnVisibility(
STORAGE_KEYS.RECOVERY_HIDDEN_COLUMNS,
artifactColumns,
['entityId', 'cluster', 'nodeAgent', 'namespace', 'size', 'details'],
relevantArtifactColumnIDs,
LEGACY_RECOVERY_COLUMN_ID_ALIASES,
);
const visibleArtifactColumns = createMemo(() => artifactColumnVisibility.visibleColumns());
const mobileVisibleArtifactColumns = createMemo(() =>
isMobile()
? visibleArtifactColumns().filter((column) => MOBILE_RECOVERY_COLUMNS.has(column.id))
: visibleArtifactColumns(),
);
const tableColumnCount = createMemo(() => mobileVisibleArtifactColumns().length);
const tableMinWidth = createMemo(() =>
isMobile()
? 'auto'
: getRecoveryArtifactTableMinWidth(mobileVisibleArtifactColumns().map((column) => column.id)),
);
createEffect(() => {
if (currentPage() > totalPages()) setCurrentPage(totalPages());
});
const timeline = createMemo(() => {
const points = (recoverySeries.series() || []).map((bucket) => ({
key: String(bucket.day || '').trim(),
label: parseRecoveryDateKey(String(bucket.day || '').trim()).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
}),
total: Number(bucket.total || 0),
snapshot: Number(bucket.snapshot || 0),
local: Number(bucket.local || 0),
remote: Number(bucket.remote || 0),
}));
const maxValue = points.reduce((maximum, point) => Math.max(maximum, point.total), 0);
const axisMax = getRecoveryNiceAxisMax(maxValue);
const labelEvery = getRecoveryTimelineLabelEvery(points.length, isMobile());
return { points, axisMax, labelEvery };
});
const activitySummary = createMemo(() => {
const points = timeline().points;
const totalPoints = points.reduce((sum, point) => sum + point.total, 0);
const activeDays = points.filter((point) => point.total > 0).length;
const averagePerDay = points.length > 0 ? totalPoints / points.length : 0;
return { totalPoints, activeDays, averagePerDay };
});
const selectedDateLabel = createMemo(() => {
const key = selectedDateKey();
return key ? getRecoveryFilterDateLabel(key) : '';
});
const dayFilterOptions = createMemo<FilterSelectOption[]>(() => {
const options = timeline().points.map((point) => ({
value: point.key,
label: getRecoveryTimelineDayFilterLabel(getRecoveryFilterDateLabel(point.key), point.total),
count: point.total,
}));
const selected = selectedDateKey();
if (selected && !options.some((option) => option.value === selected)) {
options.push({
value: selected,
label: getRecoveryTimelineDayFilterLabel(selectedDateLabel() || selected, 0),
count: 0,
});
}
return [{ value: 'all', label: 'Any day' }, ...options];
});
const setActivityRange = (range: RecoveryActivityRangeDays) => {
setChartRangeDays(range);
setSelectedDateKey(null);
setCurrentPage(1);
};
const setDayFilter = (key: string | null) => {
batch(() => {
setWorkspaceView('events');
setSelectedDateKey(key);
setCurrentPage(1);
});
};
const toggleDayFilter = (key: string) => {
setDayFilter(selectedDateKey() === key ? null : key);
};
const setHistorySearchFilter = (value: string) => {
const dayKey = resolveRecoveryDateSearchKey(
value,
timeline().points.map((point) => point.key),
);
if (dayKey) {
// Let the controlled search value observe the typed text before it is promoted to a filter.
setQueryFilter(value);
queueMicrotask(() => {
batch(() => {
setWorkspaceView('events');
setSelectedDateKey(dayKey);
setQueryFilter('');
setCurrentPage(1);
});
});
return;
}
batch(() => {
setQueryFilter(value);
setCurrentPage(1);
});
};
const hasActiveArtifactFilters = createMemo(
() =>
rollupId().trim() !== '' ||
queryFilter().trim() !== '' ||
platformFilter() !== 'all' ||
itemTypeFilter() !== 'all' ||
clusterFilter() !== 'all' ||
modeFilter() !== 'all' ||
historyOutcomeFilter() !== 'all' ||
scopeFilter() !== 'all' ||
nodeFilter() !== 'all' ||
namespaceFilter() !== 'all' ||
verificationFilter() !== 'all' ||
chartRangeDays() !== 30 ||
selectedDateKey() !== null,
);
const activeAdvancedFilterCount = createMemo(() => {
let count = 0;
if (scopeFilter() !== 'all') count += 1;
if (modeFilter() !== 'all') count += 1;
if (verificationFilter() !== 'all') count += 1;
if (clusterFilter() !== 'all') count += 1;
if (nodeFilter() !== 'all') count += 1;
if (namespaceFilter() !== 'all') count += 1;
return count;
});
const resetAdvancedArtifactFilters = () => {
batch(() => {
setScopeFilter('all');
setModeFilter('all');
setVerificationFilter('all');
setClusterFilter('all');
setNodeFilter('all');
setNamespaceFilter('all');
setCurrentPage(1);
});
};
const resetAllArtifactFilters = () => {
batch(() => {
setRollupId('');
setQueryFilter('');
setPlatformFilter('all');
setItemTypeFilter('all');
setClusterFilter('all');
setModeFilter('all');
setHistoryOutcomeFilter('all');
setScopeFilter('all');
setNodeFilter('all');
setNamespaceFilter('all');
setVerificationFilter('all');
setProtectedStateFilter('all');
setChartRangeDays(30);
setSelectedDateKey(null);
setCurrentPage(1);
});
};
const openEventsView = () => {
batch(() => {
setWorkspaceView('events');
setProtectedStateFilter('all');
setCurrentPage(1);
});
};
const openCoverageView = (state: ProtectedStateFilter = 'all') => {
batch(() => {
setWorkspaceView('inventory');
setRollupId('');
setSelectedDateKey(null);
setHistoryOutcomeFilter('all');
setProtectedStateFilter(state);
setCurrentPage(1);
});
};
const setEmbeddedWorkspaceView = (value: string) => {
if (value === 'inventory') {
openCoverageView('all');
return;
}
openEventsView();
};
const handleSelectRollup = (nextRollupId: string) => {
batch(() => {
setWorkspaceView('events');
setRollupId(nextRollupId);
setProtectedStateFilter('all');
setSelectedDateKey(null);
setCurrentPage(1);
});
};
const eventsActivity = () => (
<RecoveryActivitySection
activitySummary={activitySummary}
chartRangeDays={chartRangeDays}
isMobile={isMobile()}
loading={recoverySeriesLoading}
onRangeChange={setActivityRange}
overallRollupsSummary={overallRollupsSummary}
dayFilterKey={selectedDateKey}
timeline={timeline}
toggleDayFilter={toggleDayFilter}
/>
);
const headerActionClass =
'inline-flex items-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover';
return (
<div data-testid="recovery-page" class="flex flex-col gap-2">
<Show when={!props.embedded}>
<PageHeader
title="Recovery"
description="Review recovery activity, restore posture, and backup history across platforms."
actions={
<>
<Show when={workspaceView() === 'events'}>
<button
type="button"
class={headerActionClass}
onClick={() => {
openCoverageView('all');
}}
>
Protection coverage
</button>
</Show>
<Show when={workspaceView() === 'inventory'}>
<button type="button" class={headerActionClass} onClick={openEventsView}>
Back to recovery events
</button>
</Show>
</>
}
/>
</Show>
<div class="flex flex-col gap-2">
<Show when={props.embedded && props.showEmbeddedWorkspaceSwitcher}>
<div class="flex justify-end px-1">
<FilterSegmentedControl
aria-label="Recovery workspace"
value={workspaceView()}
onChange={setEmbeddedWorkspaceView}
options={RECOVERY_WORKSPACE_OPTIONS}
/>
</div>
</Show>
{(() => {
return (
<>
<Show when={workspaceView() === 'inventory'}>
<RecoveryProtectedInventorySection
filteredRollups={filteredRollups}
isMobile={isMobile()}
kioskMode={kioskMode()}
loading={recoveryRollupsLoading}
error={() => recoveryRollups.rollups.error}
onOpenEvents={openEventsView}
onSelectRollup={handleSelectRollup}
protectedStateFilter={protectedStateFilter}
itemTypeFilter={itemTypeFilter}
itemTypeOptions={itemTypeOptions}
platformFilter={platformFilter}
platformOptions={platformOptions}
queryFilter={queryFilter}
resourcesById={resourcesById}
rollups={rollups}
rollupsSummary={rollupsSummary}
setItemTypeFilter={setItemTypeFilter}
setProtectedStateFilter={(value: ProtectedStateFilter) =>
setProtectedStateFilter(value)
}
setPlatformFilter={setPlatformFilter}
setQueryFilter={setQueryFilter}
setVerificationFilter={setVerificationFilter}
/>
</Show>
<Show when={workspaceView() === 'events'}>
<Show when={!props.tableOnly}>{eventsActivity()}</Show>
<Show when={!recoveryPointsLoading() && recoveryPoints.response.error}>
<TableCard>
<div class="p-6">
<EmptyState
title={getRecoveryPointsFailureState().title}
description={String(
(recoveryPoints.response.error as Error)?.message ||
recoveryPoints.response.error,
)}
/>
</div>
</TableCard>
</Show>
<Show when={!recoveryPoints.response.error}>
<RecoveryHistorySection
activeAdvancedFilterCount={activeAdvancedFilterCount}
artifactColumnVisibility={artifactColumnVisibility}
availableOutcomes={['all', 'success', 'warning', 'failed', 'running']}
clusterFilter={clusterFilter}
clusterOptions={clusterOptions}
currentPage={currentPage}
dayFilterKey={selectedDateKey}
dayFilterLabel={selectedDateLabel}
dayFilterOptions={dayFilterOptions}
groupedByDay={groupedByDay}
hasActiveArtifactFilters={hasActiveArtifactFilters}
hasFocusedRollup={() => rollupId().trim().length > 0}
historyOutcomeFilter={historyOutcomeFilter}
historyItemOptions={historyItemOptions}
isMobile={isMobile()}
kioskMode={kioskMode()}
mobileVisibleArtifactColumns={mobileVisibleArtifactColumns}
modeFilter={modeFilter}
itemTypeFilter={itemTypeFilter}
itemTypeOptions={itemTypeOptions}
namespaceFilter={namespaceFilter}
namespaceOptions={namespaceOptions}
nodeFilter={nodeFilter}
nodeOptions={nodeOptions}
platformFilter={platformFilter}
platformOptions={platformOptions}
queryFilter={queryFilter}
recoveryPoints={recoveryPointsModel}
relatedPoints={sortedPoints}
resetAdvancedArtifactFilters={resetAdvancedArtifactFilters}
resetAllArtifactFilters={resetAllArtifactFilters}
resourcesById={resourcesById}
rollupId={rollupId}
scopeFilter={scopeFilter}
selectedHistoryItemLabel={selectedHistoryItemLabel}
setClusterFilter={setClusterFilter}
setCurrentPage={setCurrentPage}
setHistoryOutcomeFilter={setHistoryOutcomeFilter}
setItemTypeFilter={setItemTypeFilter}
setModeFilter={setModeFilter}
setNamespaceFilter={setNamespaceFilter}
setNodeFilter={setNodeFilter}
setPlatformFilter={setPlatformFilter}
setQueryFilter={setHistorySearchFilter}
setRollupId={setRollupId}
setScopeFilter={setScopeFilter}
setDayFilter={setDayFilter}
setVerificationFilter={setVerificationFilter}
showClusterFilter={showClusterFilter}
showNamespaceFilter={showNamespaceFilter}
showNodeFilter={showNodeFilter}
showVerificationFilter={showVerificationFilter}
tableColumnCount={tableColumnCount}
tableMinWidth={tableMinWidth}
totalPages={totalPages}
verificationFilter={verificationFilter}
/>
</Show>
</Show>
</>
);
})()}
</div>
</div>
);
};
export default Recovery;
@@ -1,412 +0,0 @@
import { createMemo, createSignal, For, Show } from 'solid-js';
import type { Accessor, Component, JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import {
SUMMARY_CHART_PLOT_AREA_CLASS,
SUMMARY_CHART_SLOT_CLASS,
} from '@/components/shared/summaryChartLayout';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
import {
getRecoveryActivityEmptyState,
getRecoveryActivityLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation';
import {
getRecoveryPrettyDateLabel,
getRecoveryCompactAxisLabel,
} from '@/utils/recoveryDatePresentation';
import {
getRecoveryTimelineAxisLabelClass,
getRecoveryTimelineAxisTicks,
getRecoveryTimelineChartGapPx,
getRecoveryTimelineChartMinWidthPx,
RECOVERY_TIMELINE_LEGEND_ITEM_CLASS,
} from '@/utils/recoveryTimelineChartPresentation';
import {
getRecoveryTimelineBarMarkerClass,
getRecoveryTimelineDayFilterStateLabel,
getRecoveryTimelineColumnAriaLabel,
getRecoveryTimelineColumnButtonClass,
getRecoveryTimelineEmptyMarkerClass,
getRecoveryTimelinePointTotalLabel,
getRecoveryTimelineTooltipRows,
} from '@/utils/recoveryTimelinePresentation';
interface RecoveryRollupSummary {
total: number;
stale: number;
neverSucceeded: number;
}
interface ActivitySummary {
totalPoints: number;
activeDays: number;
averagePerDay: number;
}
interface TimelinePoint {
key: string;
label: string;
total: number;
snapshot: number;
local: number;
remote: number;
}
interface TimelineModel {
points: TimelinePoint[];
axisMax: number;
labelEvery: number;
}
interface ActivityTooltipState {
dateLabel: string;
point: TimelinePoint;
x: number;
y: number;
}
const RECOVERY_ACTIVITY_RANGE_DAYS = [7, 30, 90, 365] as const;
interface RecoveryActivitySectionProps {
activitySummary: Accessor<ActivitySummary>;
chartRangeDays: Accessor<7 | 30 | 90 | 365>;
isMobile: boolean;
loading: Accessor<boolean>;
overallRollupsSummary: Accessor<RecoveryRollupSummary>;
onRangeChange: (days: (typeof RECOVERY_ACTIVITY_RANGE_DAYS)[number]) => void;
dayFilterKey: Accessor<string | null>;
toggleDayFilter: (key: string) => void;
timeline: Accessor<TimelineModel>;
}
function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JSX.Element {
const [activityTooltip, setActivityTooltip] = createSignal<ActivityTooltipState | null>(null);
const timelineAxisTicks = () =>
getRecoveryTimelineAxisTicks(
props.timeline().points.length,
props.isMobile,
props.timeline().labelEvery,
);
const chartMinWidthStyle = () => {
const minWidth = getRecoveryTimelineChartMinWidthPx(
props.isMobile,
props.chartRangeDays(),
props.timeline().points.length,
);
return minWidth > 0 ? `${minWidth}px` : '100%';
};
const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.chartRangeDays())}px`;
const filteredTimelinePoint = createMemo(() => {
const selected = props.dayFilterKey();
if (!selected) return null;
return props.timeline().points.find((point) => point.key === selected) ?? null;
});
const timelineHasDayFilter = () => filteredTimelinePoint() !== null;
const showActivityTooltip = (target: HTMLElement, point: TimelinePoint, dateLabel: string) => {
const rect = target.getBoundingClientRect();
setActivityTooltip({
dateLabel,
point,
x: rect.left + rect.width / 2,
y: rect.top,
});
};
const hideActivityTooltip = () => setActivityTooltip(null);
const formatActivityAverage = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return '0/day';
if (value >= 10) return `${Math.round(value)}/day`;
return `${Number(value.toFixed(1))}/day`;
};
return (
<>
<div class="mb-2 flex flex-col gap-1.5">
<div class="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<div class="font-semibold uppercase tracking-[0.18em] text-muted">
Recovery Activity
</div>
<span class="font-medium text-base-content">
{props.activitySummary().totalPoints} recovery points
</span>
<span>{props.activitySummary().activeDays} active days</span>
<Show when={props.overallRollupsSummary().stale > 0}>
<span class="font-medium text-amber-600 dark:text-amber-400">
{props.overallRollupsSummary().stale} stale
</span>
</Show>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<div
role="group"
aria-label="Recovery activity range"
class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5"
>
<For each={RECOVERY_ACTIVITY_RANGE_DAYS}>
{(days) => {
const selected = () => props.chartRangeDays() === days;
return (
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${
selected()
? 'bg-surface-hover text-base-content'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`}
aria-pressed={selected() ? 'true' : 'false'}
onClick={() => props.onRangeChange(days)}
>
{days}d
</button>
);
}}
</For>
</div>
<span class="rounded-md border border-border bg-surface-alt px-2 py-1 text-muted">
Avg {formatActivityAverage(props.activitySummary().averagePerDay)}
</span>
</div>
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 px-2 py-1.5">
<div class="mb-1 flex flex-wrap items-center justify-end gap-1.5 text-[9px] text-muted">
<div class="flex flex-wrap items-center gap-1.5 text-[9px] text-muted">
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('snapshot').aggregateLabel}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('local').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('local').aggregateLabel}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('remote').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('remote').aggregateLabel}
</div>
</div>
</div>
<Show
when={!props.loading() && props.timeline().points.length > 0}
fallback={
<div class="flex h-32 items-center justify-center text-sm text-muted">
{props.loading()
? getRecoveryActivityLoadingState().text
: getRecoveryActivityEmptyState().text}
</div>
}
>
<div class="relative">
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-2.5">
<div
class={`flex ${SUMMARY_CHART_PLOT_AREA_CLASS} flex-col justify-between text-[10px] text-muted`}
>
<For each={[0, 1, 2]}>
{(step) => {
const value = Math.round((props.timeline().axisMax * (2 - step)) / 2);
return <span>{value}</span>;
}}
</For>
</div>
<div
data-testid="recovery-activity-chart-scroll"
class="overflow-x-auto overscroll-x-contain pb-1"
>
<div
class={`relative ${SUMMARY_CHART_SLOT_CLASS}`}
style={{ 'min-width': chartMinWidthStyle() }}
>
<div
data-testid="recovery-activity-bars"
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch"
style={{ gap: chartGapStyle() }}
>
<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.dayFilterKey() === point.key;
const dateLabel = getRecoveryPrettyDateLabel(point.key);
return (
<div class="min-w-[3px] flex-1 self-stretch">
<button
type="button"
data-recovery-activity-bar={point.key}
class={`h-full w-full ${getRecoveryTimelineColumnButtonClass(
isSelected(),
timelineHasDayFilter(),
)}`}
aria-label={getRecoveryTimelineColumnAriaLabel(
dateLabel,
total,
isSelected(),
)}
aria-pressed={isSelected() ? 'true' : 'false'}
onClick={() => props.toggleDayFilter(point.key)}
onMouseEnter={(event) => {
showActivityTooltip(event.currentTarget, point, dateLabel);
}}
onMouseLeave={hideActivityTooltip}
onFocus={(event) => {
showActivityTooltip(event.currentTarget, point, dateLabel);
}}
onBlur={hideActivityTooltip}
>
<div class="relative h-full w-full overflow-hidden rounded-sm">
<Show
when={total > 0}
fallback={
<div
data-testid="recovery-activity-empty-marker"
class={getRecoveryTimelineEmptyMarkerClass(
isSelected(),
timelineHasDayFilter(),
)}
/>
}
>
<div
data-testid="recovery-activity-bar-stack"
class={getRecoveryTimelineBarMarkerClass(
isSelected(),
timelineHasDayFilter(),
)}
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">
<For each={timelineAxisTicks()}>
{(tick) => {
const point = props.timeline().points[tick.index];
const isSelected = () => props.dayFilterKey() === point.key;
const alignmentClass =
tick.align === 'start'
? 'left-0 text-left'
: tick.align === 'end'
? 'left-full -translate-x-full text-right'
: '-translate-x-1/2 text-center';
return (
<span
class={`absolute bottom-0 whitespace-nowrap text-[9px] ${
isSelected()
? getRecoveryTimelineAxisLabelClass(true)
: getRecoveryTimelineAxisLabelClass(false)
} ${alignmentClass}`}
style={{ left: `${tick.positionPct}%` }}
>
{getRecoveryCompactAxisLabel(point.key, props.chartRangeDays())}
</span>
);
}}
</For>
</div>
</div>
</div>
</div>
<TooltipPortal
when={activityTooltip() !== null}
x={activityTooltip()?.x ?? 0}
y={activityTooltip()?.y ?? 0}
align="center"
direction="up"
maxWidth={260}
>
<Show when={activityTooltip()}>
{(tooltip) => (
<div data-testid="recovery-activity-tooltip" class="min-w-[210px]">
<div class="flex items-start justify-between gap-3 border-b border-border pb-1">
<div class="min-w-0">
<div class="truncate text-[11px] font-semibold text-base-content">
{tooltip().dateLabel}
</div>
<div class="mt-0.5 text-[10px] text-muted">
{getRecoveryTimelinePointTotalLabel(tooltip().point.total)}
</div>
</div>
<div class="shrink-0 rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[9px] font-medium text-muted">
{getRecoveryTimelineDayFilterStateLabel(
props.dayFilterKey() === tooltip().point.key,
timelineHasDayFilter(),
)}
</div>
</div>
<ul class="mt-1.5 space-y-1">
<For each={getRecoveryTimelineTooltipRows(tooltip().point)}>
{(row) => (
<li
class={`flex items-center justify-between gap-4 ${
row.muted ? 'text-muted/70' : 'text-base-content'
}`}
>
<span class="flex min-w-0 items-center gap-1.5">
<span class={`h-2 w-2 shrink-0 rounded-sm ${row.segmentClassName}`} />
<span class="truncate">{row.label}</span>
</span>
<span class="shrink-0 font-mono tabular-nums">{row.value}</span>
</li>
)}
</For>
</ul>
</div>
)}
</Show>
</TooltipPortal>
</div>
</Show>
</div>
</>
);
}
export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> = (props) => (
<Card padding="sm" class="h-full border-border-subtle bg-surface">
<RecoveryActivitySectionContent {...props} />
</Card>
);
@@ -1,246 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { Accessor, Component } from 'solid-js';
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
import { SearchField } from '@/components/shared/SearchField';
import {
filterGroupClass,
filterLabelClass,
filterPanelDescriptionClass,
filterPanelTitleClass,
filterSelectClass,
} from '@/components/shared/FilterToolbar';
import { getRecoveryAnyItemLabel } from '@/utils/recoveryTablePresentation';
export interface RecoveryHistoryItemFilterOption {
rollupId: string;
label: string;
secondaryLabel?: string | null;
contextLabel?: string | null;
}
interface RecoveryHistoryItemFilterProps {
options: Accessor<RecoveryHistoryItemFilterOption[]>;
selectedRollupId: Accessor<string>;
selectedLabel: Accessor<string | null>;
onSelect: (rollupId: string) => void;
onClear: () => void;
}
const OPTION_LIMIT = 40;
export const RecoveryHistoryItemFilter: Component<RecoveryHistoryItemFilterProps> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
const [query, setQuery] = createSignal('');
const [activeIndex, setActiveIndex] = createSignal(0);
let rootRef: HTMLDivElement | undefined;
let searchInputRef: HTMLInputElement | undefined;
const filteredOptions = createMemo(() => {
const normalizedQuery = query().trim().toLowerCase();
const selected = props.selectedRollupId().trim();
const ranked = props
.options()
.filter((option) => {
if (!normalizedQuery) return true;
const haystack = [
option.label,
option.secondaryLabel || '',
option.contextLabel || '',
option.rollupId,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
})
.sort((left, right) => {
const leftSelected = left.rollupId === selected ? 1 : 0;
const rightSelected = right.rollupId === selected ? 1 : 0;
if (leftSelected !== rightSelected) return rightSelected - leftSelected;
return left.label.localeCompare(right.label);
});
return ranked.slice(0, OPTION_LIMIT);
});
const close = () => {
setIsOpen(false);
setQuery('');
setActiveIndex(0);
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (target && rootRef?.contains(target)) return;
close();
};
createEffect(() => {
if (!isOpen()) return;
document.addEventListener('mousedown', handleClickOutside);
queueMicrotask(() => searchInputRef?.focus());
onCleanup(() => document.removeEventListener('mousedown', handleClickOutside));
});
onCleanup(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
createEffect(() => {
filteredOptions();
setActiveIndex(0);
});
return (
<div ref={rootRef} class={`relative ${filterGroupClass}`}>
<span class={filterLabelClass}>Item</span>
<button
type="button"
class={`${filterSelectClass} flex min-w-[12rem] max-w-[18rem] items-center gap-2 pr-2 text-left`}
aria-haspopup="dialog"
aria-expanded={isOpen()}
aria-controls="recovery-history-item-filter-panel"
aria-label="Item filter"
data-testid="recovery-history-item-filter-trigger"
onClick={() => {
if (isOpen()) {
close();
return;
}
setIsOpen(true);
}}
>
<span class="truncate">{props.selectedLabel() || getRecoveryAnyItemLabel()}</span>
<ChevronDownIcon
class={`ml-auto h-3.5 w-3.5 shrink-0 transition-transform ${isOpen() ? 'rotate-180' : ''}`}
/>
</button>
<Show when={isOpen()}>
<div
id="recovery-history-item-filter-panel"
data-testid="recovery-history-item-filter-panel"
class="absolute left-0 top-[calc(100%+0.5rem)] z-20 w-[min(28rem,calc(100vw-2rem))] rounded-md border border-border bg-surface p-3 shadow-lg"
role="dialog"
aria-label="Choose recovery item"
>
<div class="mb-3 flex items-start justify-between gap-3">
<div>
<div class={filterPanelTitleClass}>Filter by item</div>
<div class={filterPanelDescriptionClass}>
Pick an exact protected item or clear the selection.
</div>
</div>
<Show when={props.selectedRollupId().trim() !== ''}>
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-base-content"
data-testid="recovery-history-item-filter-clear"
onClick={() => {
props.onClear();
close();
}}
>
Clear item
</button>
</Show>
</div>
<SearchField
value={query()}
onChange={setQuery}
inputRef={(element) => {
searchInputRef = element;
}}
onKeyDown={(event) => {
const options = filteredOptions();
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (!options.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActiveIndex((current) => Math.min(current + 1, options.length - 1));
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setActiveIndex((current) => Math.max(current - 1, 0));
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const option = options[activeIndex()];
if (!option) return;
props.onSelect(option.rollupId);
close();
}
}}
placeholder="Search covered items..."
inputClass="py-2 text-sm"
clearOnFocusedEscape={false}
/>
<div class="mt-3 max-h-[18rem] overflow-y-auto">
<Show
when={filteredOptions().length > 0}
fallback={
<div class="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted">
No matching covered items.
</div>
}
>
<div class="flex flex-col gap-1">
<For each={filteredOptions()}>
{(option, index) => {
const isSelected = () => option.rollupId === props.selectedRollupId().trim();
const isActive = () => index() === activeIndex();
return (
<button
type="button"
data-testid={`recovery-history-item-filter-option-${option.rollupId}`}
class={`flex w-full flex-col rounded-md px-3 py-2 text-left transition-colors ${
isSelected()
? 'bg-blue-50 text-base-content ring-1 ring-blue-200 dark:bg-blue-950/40 dark:ring-blue-900'
: isActive()
? 'bg-surface-hover text-base-content'
: 'text-base-content hover:bg-surface-hover'
}`}
onClick={() => {
props.onSelect(option.rollupId);
close();
}}
onMouseEnter={() => setActiveIndex(index())}
>
<div class="flex items-center gap-2">
<span class="truncate font-medium">{option.label}</span>
<Show when={isSelected()}>
<span class="shrink-0 rounded-full bg-surface-alt px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Selected
</span>
</Show>
</div>
<Show when={option.secondaryLabel || option.contextLabel}>
<div class="mt-0.5 flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted">
<Show when={option.secondaryLabel}>
<span>{option.secondaryLabel}</span>
</Show>
<Show when={option.contextLabel}>
<span>{option.contextLabel}</span>
</Show>
</div>
</Show>
</button>
);
}}
</For>
</div>
</Show>
</div>
</div>
</Show>
</div>
);
};
@@ -1,441 +0,0 @@
import { Show, createMemo } from 'solid-js';
import type { Accessor, Component } from 'solid-js';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import type { FilterSelectOption } from '@/components/shared/FilterBar';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { getRecoveryBreadcrumbLinkClass } from '@/utils/recoveryActionPresentation';
import {
getRecoveryArtifactModePresentation,
type RecoveryArtifactMode,
} from '@/utils/recoveryArtifactModePresentation';
import {
getRecoveryItemTypePresentation,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryLocationFacetAllLabel,
getRecoveryLocationFacetLabel,
} from '@/utils/recoveryLocationPresentation';
import { normalizeRecoveryModeQueryValue } from '@/utils/recoveryRecordPresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryAllHistoryLabel,
getRecoveryAllItemTypesLabel,
getRecoveryAllPlatformsLabel,
getRecoveryHistorySearchPlaceholder,
getRecoverySearchHistoryEmptyMessage,
} from '@/utils/recoveryTablePresentation';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms';
import {
RecoveryHistoryTable,
type RecoveryPointGroup,
type RecoveryPointsModel,
} from '@/components/Recovery/RecoveryHistoryTable';
import {
RecoveryHistoryItemFilter,
type RecoveryHistoryItemFilterOption,
} from '@/components/Recovery/RecoveryHistoryItemFilter';
import { useRecoveryHistorySectionState } from '@/components/Recovery/useRecoveryHistorySectionState';
type ArtifactMode = RecoveryArtifactMode;
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
interface ArtifactColumnVisibility {
availableToggles: () => ColumnDef[];
isHiddenByUser: (id: string) => boolean;
toggle: (id: string) => void;
resetToDefaults: () => void;
}
interface RecoveryHistorySectionProps {
activeAdvancedFilterCount: Accessor<number>;
artifactColumnVisibility: ArtifactColumnVisibility;
availableOutcomes: readonly ('all' | 'success' | 'warning' | 'failed' | 'running')[];
clusterFilter: Accessor<string>;
clusterOptions: Accessor<string[]>;
currentPage: Accessor<number>;
dayFilterKey: Accessor<string | null>;
dayFilterLabel: Accessor<string>;
dayFilterOptions: Accessor<FilterSelectOption[]>;
groupedByDay: Accessor<RecoveryPointGroup[]>;
hasActiveArtifactFilters: Accessor<boolean>;
hasFocusedRollup: Accessor<boolean>;
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
historyItemOptions: Accessor<RecoveryHistoryItemFilterOption[]>;
itemTypeFilter: Accessor<string>;
itemTypeOptions: Accessor<string[]>;
isMobile: boolean;
kioskMode: boolean;
mobileVisibleArtifactColumns: Accessor<ColumnDef[]>;
modeFilter: Accessor<'all' | ArtifactMode>;
namespaceFilter: Accessor<string>;
namespaceOptions: Accessor<string[]>;
nodeFilter: Accessor<string>;
nodeOptions: Accessor<string[]>;
platformFilter: Accessor<string>;
platformOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
recoveryPoints: RecoveryPointsModel;
relatedPoints: Accessor<RecoveryPoint[]>;
resetAdvancedArtifactFilters: () => void;
resetAllArtifactFilters: () => void;
resourcesById: Accessor<Map<string, Resource>>;
rollupId: Accessor<string>;
scopeFilter: Accessor<'all' | 'workload'>;
selectedHistoryItemLabel: Accessor<string | null>;
setClusterFilter: (value: string) => void;
setCurrentPage: (value: number) => void;
setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void;
setItemTypeFilter: (value: string) => void;
setModeFilter: (value: 'all' | ArtifactMode) => void;
setNamespaceFilter: (value: string) => void;
setNodeFilter: (value: string) => void;
setPlatformFilter: (value: string) => void;
setQueryFilter: (value: string) => void;
setRollupId: (value: string) => void;
setScopeFilter: (value: 'all' | 'workload') => void;
setDayFilter: (value: string | null) => void;
setVerificationFilter: (value: VerificationFilter) => void;
showClusterFilter: Accessor<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 { clearSelectedPoint, selectedPoint, toggleSelectedPoint } = useRecoveryHistorySectionState(
{
clusterFilter: props.clusterFilter,
currentPage: props.currentPage,
hasFocusedRollup: props.hasFocusedRollup,
historyOutcomeFilter: props.historyOutcomeFilter,
itemTypeFilter: props.itemTypeFilter,
modeFilter: props.modeFilter,
namespaceFilter: props.namespaceFilter,
nodeFilter: props.nodeFilter,
platformFilter: props.platformFilter,
queryFilter: props.queryFilter,
scopeFilter: props.scopeFilter,
verificationFilter: props.verificationFilter,
},
);
const isMobileAccessor = createMemo(() => props.isMobile);
const setItemType = (value: string) => {
props.setItemTypeFilter(normalizeRecoveryItemTypeQueryValue(value) || 'all');
props.setCurrentPage(1);
};
const setPlatform = (value: string) => {
props.setPlatformFilter(normalizeSourcePlatformQueryValue(value));
props.setCurrentPage(1);
};
const setOutcome = (value: string) => {
const outcome = value as 'all' | RecoveryOutcome;
props.setHistoryOutcomeFilter(outcome);
if (outcome !== 'all') props.setVerificationFilter('all');
props.setCurrentPage(1);
};
const setScope = (value: string) => {
props.setScopeFilter(value === 'workload' ? 'workload' : 'all');
props.setCurrentPage(1);
};
const setMode = (value: string) => {
props.setModeFilter(normalizeRecoveryModeQueryValue(value));
props.setCurrentPage(1);
};
const setVerification = (value: string) => {
props.setVerificationFilter(value as VerificationFilter);
if (value !== 'all') props.setHistoryOutcomeFilter('all');
props.setCurrentPage(1);
};
const setCluster = (value: string) => {
props.setClusterFilter(value);
props.setCurrentPage(1);
};
const setNode = (value: string) => {
props.setNodeFilter(value);
props.setCurrentPage(1);
};
const setNamespace = (value: string) => {
props.setNamespaceFilter(value);
props.setCurrentPage(1);
};
const selectedDayFilterValue = () => props.dayFilterKey() ?? 'all';
const setDay = (value: string) => {
props.setDayFilter(value === 'all' ? null : value);
};
const buildFilters = (): FilterDef[] => {
const filters: FilterDef[] = [
{
id: 'day',
label: 'Day',
group: 'scope',
value: selectedDayFilterValue,
setValue: setDay,
defaultValue: 'all',
options: props.dayFilterOptions,
formatChipValue: (value, options) =>
options.find((option) => option.value === value)?.label ||
props.dayFilterLabel() ||
value,
},
{
id: 'item-type',
label: getRecoveryArtifactColumnLabel('type', 'Item Type'),
group: 'properties',
value: props.itemTypeFilter,
setValue: setItemType,
defaultValue: 'all',
options: () =>
props.itemTypeOptions().map((itemType) => ({
value: itemType,
label:
itemType === 'all'
? getRecoveryAllItemTypesLabel()
: getRecoveryItemTypePresentation(itemType)?.label || itemType,
})),
},
{
id: 'platform',
label: 'Platform',
group: 'scope',
value: props.platformFilter,
setValue: setPlatform,
defaultValue: 'all',
options: () =>
props.platformOptions().map((platform) => ({
value: platform,
label:
platform === 'all'
? getRecoveryAllPlatformsLabel()
: getSourcePlatformLabel(platform),
})),
},
{
id: 'outcome',
label: 'Status',
group: 'status',
value: props.historyOutcomeFilter,
setValue: setOutcome,
defaultValue: 'all',
options: () =>
props.availableOutcomes.map((outcome) => ({
value: outcome,
label: outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome),
})),
},
{
id: 'scope',
label: 'Scope',
group: 'properties',
value: props.scopeFilter,
setValue: setScope,
defaultValue: 'all',
options: () => [
{ value: 'all', label: getRecoveryAllHistoryLabel() },
{ value: 'workload', label: 'Workloads only' },
],
},
{
id: 'method',
label: 'Method',
group: 'properties',
value: props.modeFilter,
setValue: setMode,
defaultValue: 'all',
options: () => [
{ value: 'all', label: 'Any method' },
{ value: 'snapshot', label: getRecoveryArtifactModePresentation('snapshot').label },
{ value: 'local', label: getRecoveryArtifactModePresentation('local').label },
{ value: 'remote', label: getRecoveryArtifactModePresentation('remote').label },
],
},
];
if (props.showVerificationFilter()) {
filters.push({
id: 'verification',
label: 'Verification',
group: 'status',
value: props.verificationFilter,
setValue: setVerification,
defaultValue: 'all',
options: () => [
{ value: 'all', label: 'Any verification' },
{ value: 'verified', label: 'Verified' },
{ value: 'unverified', label: 'Unverified' },
{ value: 'unknown', label: 'Unknown' },
],
});
}
if (props.showClusterFilter()) {
filters.push({
id: 'cluster',
label: getRecoveryLocationFacetLabel('cluster'),
group: 'scope',
value: props.clusterFilter,
setValue: setCluster,
defaultValue: 'all',
options: () => [
{ value: 'all', label: getRecoveryLocationFacetAllLabel('cluster') },
...props
.clusterOptions()
.filter((value) => value !== 'all')
.map((cluster) => ({ value: cluster, label: cluster })),
],
});
}
if (props.showNodeFilter()) {
filters.push({
id: 'node',
label: getRecoveryLocationFacetLabel('node'),
group: 'scope',
value: props.nodeFilter,
setValue: setNode,
defaultValue: 'all',
options: () => [
{ value: 'all', label: getRecoveryLocationFacetAllLabel('node') },
...props
.nodeOptions()
.filter((value) => value !== 'all')
.map((node) => ({ value: node, label: node })),
],
});
}
if (props.showNamespaceFilter()) {
filters.push({
id: 'namespace',
label: getRecoveryLocationFacetLabel('namespace'),
group: 'scope',
value: props.namespaceFilter,
setValue: setNamespace,
defaultValue: 'all',
options: () => [
{ value: 'all', label: getRecoveryLocationFacetAllLabel('namespace') },
...props
.namespaceOptions()
.filter((value) => value !== 'all')
.map((namespace) => ({ value: namespace, label: namespace })),
],
});
}
return filters;
};
return (
<div class="flex flex-col gap-2">
<Show when={!props.kioskMode}>
<Show when={props.hasFocusedRollup() && props.selectedHistoryItemLabel()}>
<div class="flex min-w-0 flex-wrap items-center gap-2 px-1 text-sm">
<button
type="button"
class={getRecoveryBreadcrumbLinkClass()}
onClick={() => {
props.setRollupId('');
props.setCurrentPage(1);
}}
>
All events
</button>
<span class="text-muted">/</span>
<span
class="min-w-0 truncate font-medium text-base-content"
title={props.selectedHistoryItemLabel() ?? ''}
>
{props.selectedHistoryItemLabel()}
</span>
</div>
</Show>
<FilterBar
role="group"
ariaLabel="Recovery events controls"
isMobile={isMobileAccessor}
savedViewsKey="recovery-events"
search={{
value: props.queryFilter,
setValue: (value: string) => {
props.setQueryFilter(value);
props.setCurrentPage(1);
},
placeholder: getRecoveryHistorySearchPlaceholder(),
historyKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
clearOnEscape: true,
}}
searchTrailing={
<RecoveryHistoryItemFilter
options={props.historyItemOptions}
selectedRollupId={props.rollupId}
selectedLabel={props.selectedHistoryItemLabel}
onSelect={(value) => {
props.setRollupId(value);
props.setCurrentPage(1);
}}
onClear={() => {
props.setRollupId('');
props.setCurrentPage(1);
}}
/>
}
filters={buildFilters()}
viewOptionsTrailing={
<ColumnPicker
columns={props.artifactColumnVisibility.availableToggles()}
isHidden={props.artifactColumnVisibility.isHiddenByUser}
onToggle={props.artifactColumnVisibility.toggle}
onReset={props.artifactColumnVisibility.resetToDefaults}
/>
}
onClearAll={() => {
if (props.hasActiveArtifactFilters()) {
props.resetAllArtifactFilters();
}
}}
showClearAll={props.hasActiveArtifactFilters}
/>
</Show>
<TableCard>
<TableCardHeader title="Recovery events" />
<RecoveryHistoryTable
clearSelectedPoint={clearSelectedPoint}
currentPage={props.currentPage}
dayFilterKey={props.dayFilterKey}
groupedByDay={props.groupedByDay}
hasActiveArtifactFilters={props.hasActiveArtifactFilters}
isMobile={props.isMobile}
mobileVisibleArtifactColumns={props.mobileVisibleArtifactColumns}
recoveryPoints={props.recoveryPoints}
relatedPoints={props.relatedPoints}
resetAllArtifactFilters={props.resetAllArtifactFilters}
resourcesById={props.resourcesById}
selectedPoint={selectedPoint}
setCurrentPage={props.setCurrentPage}
tableColumnCount={props.tableColumnCount}
tableMinWidth={props.tableMinWidth}
toggleSelectedPoint={toggleSelectedPoint}
totalPages={props.totalPages}
/>
</TableCard>
</div>
);
};
@@ -1,531 +0,0 @@
import { For, Show } from 'solid-js';
import type { Accessor, Component } from 'solid-js';
import { RecoveryPointDetails } from '@/components/Recovery/RecoveryPointDetails';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { formatBytes } from '@/utils/format';
import {
getRecoveryDrawerCloseButtonClass,
getRecoveryEmptyStateActionClass,
} from '@/utils/recoveryActionPresentation';
import {
getRecoveryHistoryEmptyState,
getRecoveryPointsLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import { getRecoveryOutcomeLabel } from '@/utils/recoveryOutcomePresentation';
import {
getRecoveryPointDetailsSummary,
getRecoveryPointItemLabel,
getRecoveryPointItemSecondaryLabel,
getRecoveryPointModeLabel,
getRecoveryPointRepositoryLabel,
getRecoveryPointTimestampMs,
} from '@/utils/recoveryRecordPresentation';
import { getRecoveryPointPlatform } from '@/utils/recoveryPlatformModel';
import {
getRecoveryItemTypePresentation,
getRecoveryPointItemTypeKey,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryArtifactColumnHeaderClass,
getRecoveryArtifactMetadataTextClass,
getRecoveryArtifactOutcomeTextClass,
getRecoveryArtifactRowClass,
getRecoveryEventTimeTextClass,
RECOVERY_GROUP_HEADER_ROW_CLASS,
RECOVERY_GROUP_HEADER_TEXT_CLASS,
} from '@/utils/recoveryTablePresentation';
import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { formatRecoveryTimeOnly } from '@/utils/recoveryDatePresentation';
export interface RecoveryPointGroup {
key: string;
label: string;
tone: 'recent' | 'default';
items: RecoveryPoint[];
}
interface RecoveryPointsMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface RecoveryPointsModel {
meta: Accessor<RecoveryPointsMeta>;
response: {
loading: boolean;
error: unknown;
};
}
interface RecoveryHistoryTableProps {
currentPage: Accessor<number>;
dayFilterKey: Accessor<string | null>;
groupedByDay: Accessor<RecoveryPointGroup[]>;
hasActiveArtifactFilters: Accessor<boolean>;
isMobile: boolean;
mobileVisibleArtifactColumns: Accessor<ColumnDef[]>;
recoveryPoints: RecoveryPointsModel;
relatedPoints: Accessor<RecoveryPoint[]>;
resetAllArtifactFilters: () => void;
resourcesById: Accessor<Map<string, Resource>>;
selectedPoint: Accessor<RecoveryPoint | null>;
setCurrentPage: (value: number) => void;
tableColumnCount: Accessor<number>;
tableMinWidth: Accessor<string>;
toggleSelectedPoint: (point: RecoveryPoint) => void;
totalPages: Accessor<number>;
clearSelectedPoint: () => void;
}
export const RecoveryHistoryTable: Component<RecoveryHistoryTableProps> = (props) => {
const visiblePointCount = () =>
props.groupedByDay().reduce((total, group) => total + group.items.length, 0);
const useVisibleFooterCount = () => Boolean(props.dayFilterKey());
const footerTotal = () =>
useVisibleFooterCount() ? visiblePointCount() : props.recoveryPoints.meta().total || 0;
const footerPage = () => (useVisibleFooterCount() ? 1 : props.recoveryPoints.meta().page);
const footerLimit = () =>
useVisibleFooterCount()
? Math.max(visiblePointCount(), 1)
: props.recoveryPoints.meta().limit || 1;
const footerStart = () =>
footerTotal() > 0 ? (footerPage() - 1) * footerLimit() + 1 : 0;
const footerEnd = () => Math.min(footerPage() * footerLimit(), footerTotal());
return (
<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>
}
>
<Table
class="w-full border-collapse 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={`px-3 py-1.5 text-left text-[11px] font-medium 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>
</div>
<span class="shrink-0 text-[10px] tabular-nums text-muted">
{group.items.length} event{group.items.length === 1 ? '' : 's'}
</span>
</div>
</TableCell>
</TableRow>
<For each={group.items}>
{(point) => {
const resourceIndex = props.resourcesById();
const item = getRecoveryPointItemLabel(point, resourceIndex);
const itemSecondary = getRecoveryPointItemSecondaryLabel(point);
const tsMs = getRecoveryPointTimestampMs(point);
const timeOnly =
point.completedAt && Number.isFinite(tsMs) ? formatRecoveryTimeOnly(tsMs) : '—';
const itemTypePresentation = getRecoveryItemTypePresentation(
getRecoveryPointItemTypeKey(point),
);
const platform = normalizeSourcePlatformQueryValue(
getRecoveryPointPlatform(point),
);
const outcome =
(String(point.outcome || 'unknown').toLowerCase() as RecoveryOutcome) ||
'unknown';
const outcomeVariant = (() => {
switch (outcome) {
case 'success':
return 'success' as const;
case 'warning':
return 'warning' as const;
case 'failed':
return 'danger' as const;
default:
return 'muted' as const;
}
})();
const repoLabel = getRecoveryPointRepositoryLabel(point);
const detailsSummary = getRecoveryPointDetailsSummary(point);
const verificationLabel =
point.verified === true
? 'Verified'
: point.verified === false
? 'Verification failed'
: 'Verification unknown';
const entityId = String(point.entityId || '').trim();
const cluster = String(point.display?.clusterLabel || point.cluster || '').trim();
const nodeAgent = String(
point.display?.nodeHostLabel ||
point.display?.nodeAgentLabel ||
point.node ||
'',
).trim();
const namespace = String(
point.display?.namespaceLabel || point.namespace || '',
).trim();
return (
<>
<TableRow
class={`cursor-pointer ${getRecoveryArtifactRowClass(
props.selectedPoint()?.id === point.id,
)}`}
onClick={() => props.toggleSelectedPoint(point)}
>
<For each={props.mobileVisibleArtifactColumns()}>
{(column) => {
switch (column.id) {
case 'time':
return (
<TableCell
class={`whitespace-nowrap px-3 py-1 text-right font-mono text-[11px] tabular-nums ${getRecoveryEventTimeTextClass(
tsMs,
)}`}
>
{timeOnly}
</TableCell>
);
case 'type':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-center">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted" aria-hidden="true">—</span>}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
</TableCell>
);
case 'item':
return (
<TableCell
class="max-w-[420px] whitespace-nowrap px-3 py-1 text-base-content"
title={item}
>
<div class="flex min-w-0 max-w-full items-center gap-2">
<StatusDot
variant={outcomeVariant}
size="xs"
pulse={outcome === 'running'}
title={getRecoveryOutcomeLabel(outcome)}
ariaLabel={getRecoveryOutcomeLabel(outcome)}
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-baseline gap-1.5">
<span class="min-w-0 truncate text-[12px] font-medium">
{item}
</span>
<Show when={itemSecondary}>
<span class="shrink-0 text-[10px] font-mono tabular-nums text-muted">
{itemSecondary}
</span>
</Show>
</div>
<Show when={props.isMobile}>
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[10px] leading-4 text-muted">
<span>{getRecoveryPointModeLabel(point.mode)}</span>
<span>{getSourcePlatformLabel(platform)}</span>
<Show when={repoLabel}>
<span class="max-w-full truncate">{repoLabel}</span>
</Show>
<span>{verificationLabel}</span>
</div>
</Show>
</div>
</div>
</TableCell>
);
case 'entityId':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-[11px] text-muted font-mono tabular-nums">
{entityId || '—'}
</TableCell>
);
case 'cluster':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-[11px] text-muted font-mono">
{cluster || '—'}
</TableCell>
);
case 'nodeAgent':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-[11px] text-muted font-mono">
{nodeAgent || '—'}
</TableCell>
);
case 'namespace':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-[11px] text-muted font-mono">
{namespace || '—'}
</TableCell>
);
case 'platform': {
const badge = getSourcePlatformBadge(platform);
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-center">
<span
class={`${badge?.classes || ''} inline-flex justify-center px-1.5 py-px text-[9px] font-medium`}
>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
</TableCell>
);
}
case 'verified':
return (
<TableCell class="whitespace-nowrap px-3 py-1 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" aria-hidden="true">—</span>
)}
</TableCell>
);
case 'size':
return (
<TableCell class="whitespace-nowrap px-3 py-1 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-1 text-center">
<span class={getRecoveryArtifactMetadataTextClass()}>
{getRecoveryPointModeLabel(point.mode)}
</span>
</TableCell>
);
case 'repository':
return (
<TableCell
class="max-w-[220px] truncate whitespace-nowrap px-3 py-1 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-1 text-[10px] leading-4 text-muted"
title={detailsSummary}
>
{detailsSummary || '—'}
</TableCell>
);
case 'outcome':
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-center">
<span
class={getRecoveryArtifactOutcomeTextClass(outcome)}
title={getRecoveryOutcomeLabel(outcome)}
>
{titleCaseDelimitedLabel(outcome)}
</span>
</TableCell>
);
default:
return (
<TableCell class="whitespace-nowrap px-3 py-1 text-muted">
-
</TableCell>
);
}
}}
</For>
</TableRow>
<Show when={props.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();
props.clearSelectedPoint();
}}
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}
relatedPoints={props.relatedPoints()}
/>
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>
</>
)}
</For>
</TableBody>
</Table>
<div class="flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] text-muted border-t border-border">
<div>
<Show when={footerTotal() > 0} fallback={<span>Showing 0 of 0 recovery points</span>}>
<span>
Showing {footerStart()} - {footerEnd()} of {footerTotal()} 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>
);
};
@@ -1,306 +0,0 @@
import { render, screen } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { RecoveryPointDetails } from './RecoveryPointDetails';
const wsState = vi.hoisted(() => ({ resources: [] as Resource[] }));
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({
state: wsState,
}),
}));
describe('RecoveryPointDetails', () => {
beforeEach(() => {
wsState.resources = [];
});
it('renders platform-neutral details framing while preserving PBS-specific metadata', () => {
wsState.resources = [
{
id: 'pbs-resource-1',
type: 'pbs',
name: 'pbs-main',
displayName: 'pbs-main',
platformId: 'pbs-main',
platformType: 'proxmox-pbs',
sourceType: 'api',
status: 'online',
lastSeen: Date.parse('2026-03-10T10:00:00Z'),
platformData: {
pbs: {
instanceId: 'pbs-main',
datastores: [
{
name: 'fast-store',
used: 500,
total: 1000,
usage: 50,
status: 'ok',
deduplicationFactor: 2.25,
},
],
},
},
} as Resource,
];
render(() => (
<RecoveryPointDetails
point={{
id: 'point-1',
platform: 'proxmox-pbs',
kind: 'backup',
mode: 'remote',
outcome: 'success',
startedAt: '2026-03-10T09:58:00Z',
completedAt: '2026-03-10T10:00:00Z',
verified: true,
immutable: true,
display: {
clusterLabel: 'Lab Cluster',
nodeHostLabel: 'pve-01',
namespaceLabel: 'Finance',
},
itemRef: {
type: 'proxmox-vm',
name: '100',
},
repositoryRef: {
type: 'pbs-datastore',
namespace: 'pbs-main',
name: 'fast-store',
},
details: {
comment: 'Nightly retention protected copy',
owner: 'root@pam',
files: ['vm/100/2026-03-10T10:00:00Z'],
verificationState: 'ok',
vmid: '100',
datastore: 'fast-store',
namespace: 'Finance',
},
}}
/>
));
expect(screen.getByText('Target Details')).toBeInTheDocument();
expect(screen.queryByText('Platform Details')).not.toBeInTheDocument();
expect(screen.queryByText('PBS Details')).not.toBeInTheDocument();
expect(
screen.getByText(
'Repository owner, target capacity, and file inventory for this recovery point.',
),
).toBeInTheDocument();
expect(screen.queryByText('Restore action path')).not.toBeInTheDocument();
expect(screen.getByText('Restore readiness')).toBeInTheDocument();
expect(screen.getByText('Verified candidate')).toBeInTheDocument();
expect(screen.getByText('Verification provenance')).toBeInTheDocument();
expect(screen.getByText('PBS catalog verification')).toBeInTheDocument();
expect(screen.getAllByText('High confidence').length).toBeGreaterThan(0);
expect(screen.getByText('Catalog check passed')).toBeInTheDocument();
expect(screen.queryByText('State: ok')).not.toBeInTheDocument();
expect(screen.getByText('Target Health')).toBeInTheDocument();
expect(screen.queryByText('UPID:')).not.toBeInTheDocument();
expect(screen.getByText('Recovery metadata')).toBeInTheDocument();
expect(screen.getByText('Item Type')).toBeInTheDocument();
expect(screen.getByText('Cluster / Site')).toBeInTheDocument();
expect(screen.getByText('Host / Agent')).toBeInTheDocument();
expect(screen.getByText('Namespace / Group')).toBeInTheDocument();
expect(screen.getAllByText('Outcome').length).toBeGreaterThan(0);
expect(screen.getByText('VM')).toBeInTheDocument();
expect(screen.getByText('Lab Cluster')).toBeInTheDocument();
expect(screen.getByText('pve-01')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Backup / Remote Copy')).toBeInTheDocument();
expect(screen.getAllByText('Success').length).toBeGreaterThan(0);
expect(screen.queryByText('Target Ref')).not.toBeInTheDocument();
expect(screen.queryByText('Item Ref')).not.toBeInTheDocument();
expect(screen.getByText('VMID')).toBeInTheDocument();
expect(screen.getByText('Datastore: fast-store')).toBeInTheDocument();
expect(screen.queryByText('Namespace')).not.toBeInTheDocument();
expect(screen.queryByText('details.vmid')).not.toBeInTheDocument();
expect(screen.getAllByText('PBS').length).toBeGreaterThan(0);
});
it('surfaces restore readiness when a successful point has not been verified', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-unverified',
platform: 'truenas',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'success',
completedAt: '2026-03-10T10:00:00Z',
display: {
itemLabel: 'tank/apps',
itemType: 'dataset',
nodeHostLabel: 'tn-scale-01',
},
}}
/>
));
expect(screen.queryByText('Restore action path')).not.toBeInTheDocument();
expect(screen.getByText('Restore readiness')).toBeInTheDocument();
expect(screen.getByText('Available candidate')).toBeInTheDocument();
expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument();
expect(screen.queryByText('Needs verification')).not.toBeInTheDocument();
expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument();
});
it('keeps PBS container identifiers and empty verification details operator-safe', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-pbs-container',
platform: 'proxmox-pbs',
kind: 'backup',
mode: 'remote',
outcome: 'success',
completedAt: '2026-03-10T10:00:00Z',
verified: true,
display: {
clusterLabel: 'delly',
nodeHostLabel: 'minipc',
namespaceLabel: 'minipc',
itemType: 'lxc',
},
repositoryRef: {
type: 'pbs-datastore',
namespace: 'minipc',
name: 'main',
},
details: {
verificationState: 'ok',
vmid: '113',
datastore: 'main',
},
}}
/>
));
expect(screen.getByText('Verification provenance')).toBeInTheDocument();
expect(screen.getByText('PBS catalog verification')).toBeInTheDocument();
expect(screen.getByText('Catalog check passed')).toBeInTheDocument();
expect(screen.queryByText('State: ok')).not.toBeInTheDocument();
expect(screen.queryByText('No verification timestamp recorded')).not.toBeInTheDocument();
expect(screen.getByText('CTID')).toBeInTheDocument();
expect(screen.queryByText('VMID')).not.toBeInTheDocument();
expect(screen.queryByText('Namespace / Group')).not.toBeInTheDocument();
});
it('uses canonical platform labels without forcing provider detail panels for other platforms', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-2',
platform: 'truenas',
display: {
nodeHostLabel: 'tn-scale-01',
},
itemRef: {
type: 'truenas-dataset',
name: 'tank/apps',
},
kind: 'snapshot',
mode: 'snapshot',
outcome: 'failed',
completedAt: '2026-03-10T10:00:00Z',
}}
/>
));
expect(screen.queryByText('Target Details')).not.toBeInTheDocument();
expect(screen.queryByText('Platform Details')).not.toBeInTheDocument();
expect(screen.queryByText('PBS Details')).not.toBeInTheDocument();
expect(screen.getByText('Item Type')).toBeInTheDocument();
expect(screen.getByText('Host / Agent')).toBeInTheDocument();
expect(screen.getByText('Dataset')).toBeInTheDocument();
expect(screen.getByText('tn-scale-01')).toBeInTheDocument();
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
expect(screen.queryByText('Repository Ref')).not.toBeInTheDocument();
expect(screen.getAllByText('TrueNAS').length).toBeGreaterThan(0);
});
it('uses platform-aware PVE wording and suppresses meaningless task details', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-3',
platform: 'proxmox-pve',
itemResourceId: 'res:vm-105',
kind: 'snapshot',
mode: 'snapshot',
outcome: 'success',
completedAt: '2026-03-31T10:00:00Z',
details: {
instance: 'delly',
node: 'delly',
snapshotName: 'vzdump',
vmid: '105',
},
}}
relatedPoints={[
{
id: 'point-4',
platform: 'proxmox-pbs',
itemResourceId: 'res:vm-105',
kind: 'backup',
mode: 'remote',
outcome: 'success',
verified: true,
completedAt: '2026-03-31T10:10:00Z',
repositoryRef: {
type: 'pbs-datastore',
namespace: 'pbs-main',
name: 'fast-store',
},
},
]}
/>
));
expect(screen.queryByText('Snapshot / Snapshot')).not.toBeInTheDocument();
expect(screen.getAllByText('Snapshot').length).toBeGreaterThan(0);
expect(screen.getByText('Local snapshot on delly')).toBeInTheDocument();
expect(screen.getByText('Protection chain')).toBeInTheDocument();
expect(screen.getByText('Remote copy')).toBeInTheDocument();
expect(screen.getByText(/Verified/)).toBeInTheDocument();
});
it('turns generic PVE job errors into actionable failure copy and hides VMID zero', () => {
render(() => (
<RecoveryPointDetails
point={{
id: 'point-5',
platform: 'proxmox-pve',
kind: 'backup',
mode: 'local',
outcome: 'failed',
completedAt: '2026-03-31T10:00:00Z',
details: {
status: 'job errors',
taskName: 'vzdump',
vmid: '0',
},
}}
/>
));
expect(screen.getByText('Failure detail')).toBeInTheDocument();
expect(
screen.getAllByText(/Inspect the platform task log for the failing step/i).length,
).toBeGreaterThan(0);
expect(screen.getByText('Not restorable')).toBeInTheDocument();
expect(screen.queryByText('Investigate source task')).not.toBeInTheDocument();
expect(screen.queryByText('Verification provenance')).not.toBeInTheDocument();
expect(screen.queryByText('VMID')).not.toBeInTheDocument();
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,594 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import type { Accessor, Component } from 'solid-js';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import type { ProtectionRollup } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import {
getRecoveryRollupInventoryStatusLabel,
getRecoveryRollupInventoryStatusTextClass,
getRecoveryRollupInventoryStatusVariant,
getRecoveryRollupStatusPillClass,
getRecoverySpecialOutcomeTextClass,
} from '@/utils/recoveryStatusPresentation';
import {
getRecoveryProtectedItemsEmptyState,
getRecoveryProtectedItemsFailureState,
getRecoveryProtectedItemsLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import {
getRecoveryItemTypePresentation,
getRecoveryRollupItemTypeKey,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryRollupInventoryStatus,
getRecoveryRollupInventoryPriority,
getRecoveryRollupAgeTextClass,
getRecoveryProtectedSearchPlaceholder,
getRecoverySearchHistoryEmptyMessage,
getRecoveryRollupTimestampMs,
getRecoveryAllItemTypesLabel,
getRecoveryAllPlatformsLabel,
STALE_ISSUE_THRESHOLD_MS,
type RecoveryRollupInventoryStatus,
} from '@/utils/recoveryTablePresentation';
import { normalizeRecoveryOutcome } from '@/utils/recoveryOutcomePresentation';
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
import {
getRecoveryRollupItemLabel,
getRecoveryRollupItemSecondaryLabel,
} from '@/utils/recoveryRecordPresentation';
import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
type ProtectedStateFilter = 'all' | RecoveryRollupInventoryStatus;
type ProtectedSortCol = 'item' | 'type' | 'platform' | 'lastBackup' | 'outcome';
type SortDir = 'asc' | 'desc';
interface RecoveryRollupSummary {
total: number;
counts: Record<string, number>;
stale: number;
neverSucceeded: number;
}
interface RecoveryProtectedInventorySectionProps {
filteredRollups: Accessor<ProtectionRollup[]>;
itemTypeFilter: Accessor<string>;
itemTypeOptions: Accessor<string[]>;
isMobile: boolean;
kioskMode: boolean;
onOpenEvents?: () => void;
onSelectRollup: (rollupId: string) => void;
protectedStateFilter: Accessor<ProtectedStateFilter>;
platformFilter: Accessor<string>;
platformOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
resourcesById: Accessor<Map<string, Resource>>;
rollups: Accessor<ProtectionRollup[]>;
rollupsSummary: Accessor<RecoveryRollupSummary>;
setItemTypeFilter: (value: string) => void;
setProtectedStateFilter: (value: ProtectedStateFilter) => void;
setPlatformFilter: (value: string) => void;
setQueryFilter: (value: string) => void;
setVerificationFilter: (value: VerificationFilter) => void;
loading: Accessor<boolean>;
error: Accessor<unknown>;
}
const availableProtectionStates = [
'all',
'healthy',
'stale',
'never-succeeded',
'failed',
'warning',
'running',
'unknown',
] as const satisfies readonly ProtectedStateFilter[];
const STALE_THRESHOLD_DAYS = Math.max(
1,
Math.round(STALE_ISSUE_THRESHOLD_MS / (24 * 60 * 60 * 1000)),
);
const getProtectionInsight = (
rollup: ProtectionRollup,
inventoryStatus: ReturnType<typeof getRecoveryRollupInventoryStatus>,
nowMs: number,
): string => {
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
if (inventoryStatus === 'never-succeeded') {
return attemptMs > 0
? `No successful point recorded after ${formatRelativeTime(attemptMs)}`
: 'No successful point recorded yet';
}
if (inventoryStatus === 'stale') {
if (successMs > 0 && Number.isFinite(successMs)) {
const ageDays = Math.max(1, Math.floor((nowMs - successMs) / (24 * 60 * 60 * 1000)));
return `Last success is ${ageDays} days old`;
}
return `No successful point within ${STALE_THRESHOLD_DAYS} days`;
}
if (inventoryStatus === 'failed') return 'Latest protection event failed';
if (inventoryStatus === 'warning') return 'Latest event completed with warnings';
if (inventoryStatus === 'running') return 'Protection event in progress';
return '';
};
const normalizeProtectedStateFilter = (value: string): ProtectedStateFilter => {
const normalized = value.trim().toLowerCase();
if (normalized === 'success') return 'healthy';
return availableProtectionStates.includes(normalized as ProtectedStateFilter)
? (normalized as ProtectedStateFilter)
: 'all';
};
export const RecoveryProtectedInventorySection: Component<
RecoveryProtectedInventorySectionProps
> = (props) => {
const [protectedSortCol, setProtectedSortCol] = createSignal<ProtectedSortCol>('outcome');
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 isMobileAccessor = createMemo(() => props.isMobile);
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 'item': {
const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase();
const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase();
return multiplier * leftLabel.localeCompare(rightLabel);
}
case 'type': {
const leftType = getRecoveryItemTypePresentation(
getRecoveryRollupItemTypeKey(left),
)?.label.toLowerCase();
const rightType = getRecoveryItemTypePresentation(
getRecoveryRollupItemTypeKey(right),
)?.label.toLowerCase();
return multiplier * (leftType || '').localeCompare(rightType || '');
}
case 'platform': {
const leftSource = getRecoveryRollupPlatforms(left)
.map((platform) => getSourcePlatformLabel(String(platform)))
.sort()
.join(',');
const rightSource = getRecoveryRollupPlatforms(right)
.map((platform) => getSourcePlatformLabel(String(platform)))
.sort()
.join(',');
return multiplier * leftSource.localeCompare(rightSource);
}
case 'lastBackup': {
const leftSuccess = left.lastSuccessAt ? Date.parse(left.lastSuccessAt) : 0;
const rightSuccess = right.lastSuccessAt ? Date.parse(right.lastSuccessAt) : 0;
return multiplier * (leftSuccess - rightSuccess);
}
case 'outcome': {
const leftPriority = getRecoveryRollupInventoryPriority(left);
const rightPriority = getRecoveryRollupInventoryPriority(right);
if (leftPriority !== rightPriority) {
return multiplier * (leftPriority - rightPriority);
}
const leftTimestamp = getRecoveryRollupTimestampMs(left);
const rightTimestamp = getRecoveryRollupTimestampMs(right);
const naturalTieBreak =
leftPriority === 4 ? leftTimestamp - rightTimestamp : rightTimestamp - leftTimestamp;
if (naturalTieBreak !== 0) return multiplier * naturalTieBreak;
const leftOutcome = normalizeRecoveryOutcome(left.lastOutcome);
const rightOutcome = normalizeRecoveryOutcome(right.lastOutcome);
const outcomeCmp = leftOutcome.localeCompare(rightOutcome);
if (outcomeCmp !== 0) return multiplier * outcomeCmp;
const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase();
const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase();
return multiplier * leftLabel.localeCompare(rightLabel);
}
default:
return 0;
}
});
return items;
});
createEffect(() => {
props.queryFilter();
props.platformFilter();
props.itemTypeFilter();
props.protectedStateFilter();
protectedSortCol();
protectedSortDir();
});
return (
<div class="flex flex-col gap-2">
<Show when={!props.kioskMode}>
<FilterBar
role="group"
ariaLabel="Protection coverage controls"
isMobile={isMobileAccessor}
savedViewsKey="recovery-protected"
search={{
value: props.queryFilter,
setValue: props.setQueryFilter,
placeholder: getRecoveryProtectedSearchPlaceholder(),
historyKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
clearOnEscape: true,
}}
filters={
[
{
id: 'item-type',
label: getRecoveryArtifactColumnLabel('type', 'Item Type'),
group: 'properties',
value: props.itemTypeFilter,
setValue: (value: string) =>
props.setItemTypeFilter(normalizeRecoveryItemTypeQueryValue(value) || 'all'),
defaultValue: 'all',
options: () =>
props.itemTypeOptions().map((itemType) => ({
value: itemType,
label:
itemType === 'all'
? getRecoveryAllItemTypesLabel()
: getRecoveryItemTypePresentation(itemType)?.label || itemType,
})),
},
{
id: 'platform',
label: 'Platform',
group: 'scope',
value: props.platformFilter,
setValue: (value: string) =>
props.setPlatformFilter(normalizeSourcePlatformQueryValue(value)),
defaultValue: 'all',
options: () =>
props.platformOptions().map((platform) => ({
value: platform,
label:
platform === 'all'
? getRecoveryAllPlatformsLabel()
: getSourcePlatformLabel(platform),
})),
},
{
id: 'protected-state',
label: 'Protection state',
group: 'status',
value: props.protectedStateFilter,
setValue: (value: string) => {
const normalized = normalizeProtectedStateFilter(value);
props.setProtectedStateFilter(normalized);
props.setVerificationFilter('all');
},
defaultValue: 'all',
options: () =>
availableProtectionStates.map((state) => ({
value: state,
label:
state === 'all' ? 'Any state' : getRecoveryRollupInventoryStatusLabel(state),
})),
},
] as FilterDef[]
}
/>
</Show>
<TableCard>
<TableCardHeader
title="Protection coverage"
showClearAction={Boolean(props.onOpenEvents)}
clearLabel="Recovery events"
clearAriaLabel="Return to recovery events"
onClear={props.onOpenEvents}
/>
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div
data-testid="recovery-protected-loading"
class="animate-pulse pointer-events-none select-none"
>
<div class="px-4 py-3 text-sm text-muted">
{getRecoveryProtectedItemsLoadingState().text}
</div>
<div class="space-y-3 px-4 py-4">
<div class="flex items-center gap-3">
<div class="h-4 w-32 rounded bg-surface-hover" />
<div class="h-4 w-20 rounded bg-surface-hover" />
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="ml-auto h-4 w-16 rounded bg-surface-hover" />
</div>
<For each={[1, 2, 3, 4]}>
{() => (
<div class="grid grid-cols-[minmax(0,1.5fr)_110px_110px_120px_90px] gap-3 border-t border-border-subtle pt-3">
<div class="space-y-2">
<div class="h-4 w-3/4 rounded bg-surface-hover" />
<div class="h-3 w-1/2 rounded bg-surface-hover" />
</div>
<div class="h-4 w-16 rounded bg-surface-hover" />
<div class="h-4 w-20 rounded bg-surface-hover" />
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="h-6 w-16 rounded bg-surface-hover" />
</div>
)}
</For>
</div>
</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}>
<Table
wrapperClass="bg-surface"
class={`w-full border-collapse whitespace-nowrap table-fixed ${
props.isMobile ? 'min-w-full' : 'min-w-[640px]'
}`}
>
<TableHeader>
<TableRow class="bg-surface-alt/95 text-muted">
{(
[
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
['type', getRecoveryArtifactColumnLabel('type', 'Item Type')],
['platform', getRecoveryArtifactColumnLabel('platform', 'Platform')],
['lastBackup', 'Latest Point'],
['outcome', 'Status'],
] as const
).map(([column, label]) => (
<TableHead
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-2 whitespace-nowrap text-left text-[11px] font-medium cursor-pointer select-none hover:text-base-content transition-colors${
column === 'type'
? ' hidden md:table-cell w-[96px]'
: column === 'platform'
? ' hidden lg:table-cell w-[110px]'
: column === 'lastBackup'
? ' w-[120px]'
: column === 'outcome'
? ' w-[116px]'
: ''
}`}
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>
<For each={sortedRollups()}>
{(rollup) => {
const resourceIndex = props.resourcesById();
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
const secondaryLabel = getRecoveryRollupItemSecondaryLabel(rollup);
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const inventoryStatus = getRecoveryRollupInventoryStatus(rollup);
const inventoryStatusLabel =
getRecoveryRollupInventoryStatusLabel(inventoryStatus);
const platforms = getRecoveryRollupPlatforms(rollup)
.map((platform) => String(platform || '').trim())
.filter(Boolean)
.sort((left, right) =>
getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)),
);
const itemTypePresentation =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null;
const nowMs = Date.now();
const neverSucceeded = inventoryStatus === 'never-succeeded';
const protectionInsight = getProtectionInsight(rollup, inventoryStatus, nowMs);
return (
<TableRow
class="cursor-pointer odd:bg-surface even:bg-surface-alt/35 transition-colors hover:bg-surface-hover/95"
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<TableCell class="max-w-[420px] px-3 py-1.5 text-base-content" title={label}>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<StatusDot
variant={getRecoveryRollupInventoryStatusVariant(inventoryStatus)}
size="xs"
pulse={inventoryStatus === 'running'}
title={inventoryStatusLabel}
ariaLabel={inventoryStatusLabel}
/>
<div class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate text-[13px] font-medium">{label}</span>
<Show when={secondaryLabel}>
<span class="shrink-0 text-[10px] font-mono tabular-nums text-muted">
{secondaryLabel}
</span>
</Show>
<Show when={rollup.verifyIntent === 'stale'}>
<span
class={`${getRecoveryRollupStatusPillClass('stale')} shrink-0`}
data-testid="recovery-protected-verify-due-badge"
title="Latest backup has not been verified within the staleness window"
>
Verify due
</span>
</Show>
</div>
</div>
<Show when={protectionInsight}>
<div class="pl-4 text-[10px] leading-4 text-muted">
{protectionInsight}
</div>
</Show>
<div class="flex flex-wrap items-center gap-1.5 text-[10px] md:hidden">
<Show when={itemTypePresentation?.label}>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
<Show when={platforms.length > 0}>
<For each={platforms.slice(0, 2)}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={`${badge?.classes || ''} lg:hidden`}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</Show>
</div>
</div>
</TableCell>
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-1.5">
<Show
when={itemTypePresentation}
fallback={
<span class="text-muted" aria-hidden="true">
—
</span>
}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
</TableCell>
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-1.5">
<div class="flex flex-wrap gap-1.5">
<For each={platforms}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={badge?.classes || ''}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</div>
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-1.5 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
title={
successMs > 0
? formatAbsoluteTime(successMs)
: attemptMs > 0
? formatAbsoluteTime(attemptMs)
: undefined
}
>
<div class="flex flex-col">
<span>
{successMs > 0 ? (
formatRelativeTime(successMs)
) : neverSucceeded ? (
<span class={getRecoverySpecialOutcomeTextClass('never')}>never</span>
) : (
'—'
)}
</span>
<Show when={inventoryStatus !== 'healthy' && attemptMs > 0}>
<span class="text-[10px] font-normal text-muted">
Attempt {formatRelativeTime(attemptMs)}
</span>
</Show>
</div>
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-1.5">
<span
class={`text-[11px] font-medium ${getRecoveryRollupInventoryStatusTextClass(
inventoryStatus,
)}`}
>
{inventoryStatusLabel}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
<div class="flex items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3 text-xs text-muted">
<div>
<Show
when={sortedRollups().length > 0}
fallback={<span>Showing 0 of 0 coverage items</span>}
>
<span>Showing {sortedRollups().length} coverage items</span>
</Show>
</div>
</div>
</Show>
</TableCard>
</div>
);
};
@@ -1,174 +0,0 @@
import { createSignal } from 'solid-js';
import { render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let mockRecoverySurfaceState: any;
vi.mock('@/features/recovery/useRecoverySurfaceState', () => ({
useRecoverySurfaceState: () => mockRecoverySurfaceState,
}));
vi.mock('@/hooks/useBreakpoint', () => ({
useBreakpoint: () => ({ isMobile: () => false }),
}));
vi.mock('@/hooks/useKioskMode', () => ({
useKioskMode: () => () => false,
}));
vi.mock('@/hooks/useColumnVisibility', () => ({
useColumnVisibility: () => ({
availableToggles: () => [],
isHiddenByUser: () => false,
resetToDefaults: vi.fn(),
toggle: vi.fn(),
visibleColumns: () => [],
}),
}));
vi.mock('@/components/Recovery/RecoveryProtectedInventorySection', () => ({
RecoveryProtectedInventorySection: () => <div data-testid="protected-inventory" />,
}));
vi.mock('@/components/Recovery/RecoveryActivitySection', () => ({
RecoveryActivitySection: () => <div data-testid="activity-chart" />,
}));
vi.mock('@/components/Recovery/RecoveryHistorySection', () => ({
RecoveryHistorySection: () => <div data-testid="history-section" />,
}));
import Recovery from '@/components/Recovery/Recovery';
describe('Recovery layout guards', () => {
beforeEach(() => {
const [workspaceView, setWorkspaceView] = createSignal<'inventory' | 'events'>('events');
mockRecoverySurfaceState = {
activitySummary: () => ({ totalPoints: 2, activeDays: 2, averagePerDay: 1 }),
activeAdvancedFilterCount: () => 0,
activeClusterLabel: () => '',
activeNamespaceLabel: () => '',
activeNodeLabel: () => '',
artifactColumnVisibility: undefined,
chartRangeDays: () => 30,
clearClusterFilter: vi.fn(),
clearFocusedRollup: vi.fn(),
clearNamespaceFilter: vi.fn(),
clearNodeFilter: vi.fn(),
clearSelectedDate: vi.fn(),
clusterFilter: () => 'all',
clusterOptions: () => ['all'],
currentPage: () => 1,
facets: () => ({}),
hasActiveArtifactFilters: () => false,
hasFocusedRollup: () => false,
historyOutcomeFilter: () => 'all',
itemTypeFilter: () => 'all',
itemTypeOptions: () => ['all'],
isMobile: false,
loading: () => false,
modeFilter: () => 'all',
namespaceFilter: () => 'all',
namespaceOptions: () => ['all'],
nodeFilter: () => 'all',
nodeOptions: () => ['all'],
overallRollupsSummary: () => ({ total: 2, stale: 0, neverSucceeded: 0 }),
protectedStateFilter: () => 'all',
platformFilter: () => 'all',
platformOptions: () => ['all'],
queryFilter: () => '',
recoveryPoints: {
meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }),
points: () => [],
resolvedOnce: () => true,
response: {
error: new Error('history points unavailable'),
loading: false,
},
},
recoveryRollups: {
rollups: () => [],
resolvedOnce: () => true,
response: { error: undefined, loading: false },
},
recoverySeries: {
resolvedOnce: () => true,
response: { error: undefined, loading: false },
series: () => [
{ day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ day: '2026-02-14', total: 1, snapshot: 0, local: 1, remote: 0 },
],
},
resourcesById: () => new Map(),
rollupId: () => '',
rollups: () => [],
scopeFilter: () => 'all',
selectedDateKey: () => null,
selectedDateLabel: () => '',
selectedHistoryItemLabel: () => null,
setChartRangeDays: vi.fn(),
setClusterFilter: vi.fn(),
setCurrentPage: vi.fn(),
setHistoryOutcomeFilter: vi.fn(),
setItemTypeFilter: vi.fn(),
setModeFilter: vi.fn(),
setNamespaceFilter: vi.fn(),
setNodeFilter: vi.fn(),
setProtectedStateFilter: vi.fn(),
setPlatformFilter: vi.fn(),
setQueryFilter: vi.fn(),
setRollupId: vi.fn(),
setScopeFilter: vi.fn(),
setSelectedDateKey: vi.fn(),
setVerificationFilter: vi.fn(),
showClusterFilter: () => false,
showNamespaceFilter: () => false,
showNodeFilter: () => false,
showVerificationFilter: () => false,
tableWindow: () => ({ from: null, to: null }),
tableColumnCount: () => 0,
tableMinWidth: () => 'auto',
totalPages: () => 1,
verificationFilter: () => 'all',
workspaceView,
setWorkspaceView,
};
});
afterEach(() => {
vi.clearAllMocks();
});
it('mounts the activity chart only in the recovery events workspace and keeps it there when events fail', () => {
render(() => <Recovery />);
expect(screen.getByTestId('activity-chart')).toBeInTheDocument();
expect(screen.queryByTestId('protected-inventory')).not.toBeInTheDocument();
expect(screen.queryByTestId('history-section')).not.toBeInTheDocument();
expect(screen.getByText('Failed to load recovery points')).toBeInTheDocument();
});
it('keeps the event workspace mounted without a decorative summary shell while data hydrates', () => {
mockRecoverySurfaceState.recoveryRollups = {
rollups: Object.assign(() => [], { loading: false }),
resolvedOnce: () => false,
response: { error: undefined, loading: false },
};
mockRecoverySurfaceState.recoverySeries = {
resolvedOnce: () => false,
response: { error: undefined, loading: false },
series: () => [],
};
mockRecoverySurfaceState.recoveryPoints = {
meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }),
points: () => [],
resolvedOnce: () => false,
response: { error: undefined, loading: false },
};
render(() => <Recovery />);
expect(screen.queryByTestId('recovery-summary')).not.toBeInTheDocument();
expect(screen.getByTestId('activity-chart')).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,151 +0,0 @@
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection';
describe('RecoveryActivitySection', () => {
it('renders the bar row with stretch sizing so bars can take visible height', () => {
const timeline = () => ({
points: [
{ key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 },
],
axisMax: 2,
labelEvery: 1,
});
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
chartRangeDays={() => 30}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
dayFilterKey={() => null}
timeline={timeline}
toggleDayFilter={() => undefined}
/>
));
const bars = screen.getByTestId('recovery-activity-bars');
expect(bars.className).toContain('items-stretch');
expect(bars.style.gap).toBe('3px');
expect(bars.parentElement?.className).toContain('h-[136px] sm:h-[150px]');
expect(bars.closest('.grid')?.firstElementChild?.className).toContain('h-[120px] sm:h-[134px]');
expect(screen.getByTestId('recovery-activity-chart-scroll').className).toContain(
'overflow-x-auto',
);
expect(
screen.queryByText('Daily recovery points across the selected history window.'),
).not.toBeInTheDocument();
expect(screen.queryByText(/^Range$/)).not.toBeInTheDocument();
expect(screen.queryByText(/1.5 \/ day/i)).not.toBeInTheDocument();
expect(screen.queryByText('Lowest active day')).not.toBeInTheDocument();
expect(screen.queryByText('Below normal')).not.toBeInTheDocument();
expect(within(bars).getAllByRole('button', { name: /recovery point/i })).toHaveLength(2);
expect(within(bars).getAllByRole('button', { pressed: false })).toHaveLength(2);
});
it('uses day-filter styling and a structured activity tooltip', () => {
const timeline = () => ({
points: [
{ key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 },
],
axisMax: 2,
labelEvery: 1,
});
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
chartRangeDays={() => 30}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
dayFilterKey={() => '2026-02-14'}
timeline={timeline}
toggleDayFilter={() => undefined}
/>
));
expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument();
const bars = screen.getByTestId('recovery-activity-bars');
const buttons = within(bars).getAllByRole('button', { name: /recovery point/i });
expect(buttons[0].className).not.toContain('opacity-40');
expect(buttons[1].className).not.toContain('ring-blue-500');
expect(within(buttons[0]).getByTestId('recovery-activity-bar-stack').className).toContain(
'opacity-40',
);
expect(within(buttons[1]).getByTestId('recovery-activity-bar-stack').className).toContain(
'ring-blue-500',
);
expect(within(bars).getByRole('button', { pressed: true })).toBe(buttons[1]);
fireEvent.mouseEnter(buttons[1]);
const tooltip = screen.getByTestId('recovery-activity-tooltip');
expect(within(tooltip).getByText('Day filter')).toBeInTheDocument();
expect(within(tooltip).getByText('2 recovery points')).toBeInTheDocument();
expect(within(tooltip).getByText('Snapshots')).toBeInTheDocument();
expect(within(tooltip).getByText('Local Copies')).toBeInTheDocument();
expect(within(tooltip).getByText('Remote Copies')).toBeInTheDocument();
expect(within(tooltip).getByText('2 (100%)')).toBeInTheDocument();
expect(tooltip.textContent).not.toContain(' • ');
fireEvent.mouseLeave(buttons[1]);
expect(screen.queryByTestId('recovery-activity-tooltip')).not.toBeInTheDocument();
});
it('updates an open tooltip when the hovered day becomes selected', () => {
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
const timeline = () => ({
points: [
{ key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 },
],
axisMax: 2,
labelEvery: 1,
});
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
chartRangeDays={() => 30}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
dayFilterKey={selectedDateKey}
timeline={timeline}
toggleDayFilter={(key) => setSelectedDateKey((previous) => (previous === key ? null : key))}
/>
));
const bars = screen.getByTestId('recovery-activity-bars');
const buttons = within(bars).getAllByRole('button', { name: /recovery point/i });
fireEvent.mouseEnter(buttons[1]);
const tooltip = screen.getByTestId('recovery-activity-tooltip');
expect(within(tooltip).getByText('Timeline day')).toBeInTheDocument();
fireEvent.click(buttons[1]);
expect(within(tooltip).getByText('Day filter')).toBeInTheDocument();
expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument();
const selectedButton = within(bars).getByRole('button', { pressed: true });
const dimmedButton = within(bars).getByRole('button', { pressed: false });
expect(selectedButton.className).not.toContain('ring-blue-500');
expect(dimmedButton.className).not.toContain('opacity-40');
expect(within(selectedButton).getByTestId('recovery-activity-bar-stack').className).toContain(
'ring-blue-500',
);
expect(within(dimmedButton).getByTestId('recovery-activity-bar-stack').className).toContain(
'opacity-40',
);
});
});
@@ -1,80 +0,0 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, expect, it } from 'vitest';
import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection';
import type { ProtectionRollup, VerifyIntent } from '@/types/recovery';
import type { Resource } from '@/types/resource';
const baseRollup: ProtectionRollup = {
rollupId: 'res:vm-100',
itemResourceId: 'vm-100',
display: {
subjectLabel: 'web-prod',
subjectType: 'proxmox-vm',
},
lastAttemptAt: '2026-05-10T12:00:00Z',
lastSuccessAt: '2026-05-10T12:00:00Z',
lastOutcome: 'success',
platforms: ['proxmox-pbs'],
};
const renderSection = (rollup: ProtectionRollup) => {
const rollups = [rollup];
return render(() => (
<RecoveryProtectedInventorySection
filteredRollups={() => rollups}
itemTypeFilter={() => 'all'}
itemTypeOptions={() => ['all']}
isMobile={false}
kioskMode
onSelectRollup={() => undefined}
protectedStateFilter={() => 'all'}
platformFilter={() => 'all'}
platformOptions={() => ['all']}
queryFilter={() => ''}
resourcesById={() => new Map<string, Resource>()}
rollups={() => rollups}
rollupsSummary={() => ({
total: rollups.length,
counts: {},
stale: 0,
neverSucceeded: 0,
})}
setItemTypeFilter={() => undefined}
setProtectedStateFilter={() => undefined}
setPlatformFilter={() => undefined}
setQueryFilter={() => undefined}
setVerificationFilter={() => undefined}
loading={() => false}
error={() => undefined}
/>
));
};
describe('RecoveryProtectedInventorySection — Verify due badge', () => {
it('renders the Verify due badge when verifyIntent is stale', () => {
renderSection({ ...baseRollup, verifyIntent: 'stale' });
const badge = screen.getByTestId('recovery-protected-verify-due-badge');
expect(badge).toBeInTheDocument();
expect(badge.textContent).toMatch(/verify due/i);
});
it('does not render the Verify due badge when verifyIntent is verified', () => {
renderSection({ ...baseRollup, verifyIntent: 'verified' });
expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull();
});
it('does not render the Verify due badge when verifyIntent is unknown', () => {
renderSection({ ...baseRollup, verifyIntent: 'unknown' as VerifyIntent });
expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull();
});
it('does not render the Verify due badge when verifyIntent is omitted (legacy backend)', () => {
renderSection({ ...baseRollup });
expect(screen.queryByTestId('recovery-protected-verify-due-badge')).toBeNull();
});
});
@@ -1,103 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import type { Accessor } from 'solid-js';
import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
import type { RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
type ArtifactMode = RecoveryArtifactMode;
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
interface UseRecoveryHistorySectionStateParams {
clusterFilter: Accessor<string>;
currentPage: Accessor<number>;
hasFocusedRollup: Accessor<boolean>;
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
itemTypeFilter: Accessor<string>;
modeFilter: Accessor<'all' | ArtifactMode>;
namespaceFilter: Accessor<string>;
nodeFilter: Accessor<string>;
platformFilter: Accessor<string>;
queryFilter: Accessor<string>;
scopeFilter: Accessor<'all' | 'workload'>;
verificationFilter: Accessor<VerificationFilter>;
}
export function useRecoveryHistorySectionState(
params: UseRecoveryHistorySectionStateParams,
) {
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 = createMemo(() => {
let count = 0;
if (params.hasFocusedRollup()) count += 1;
if (params.queryFilter().trim() !== '') count += 1;
if (params.platformFilter() !== 'all') count += 1;
if (params.itemTypeFilter() !== 'all') count += 1;
if (params.historyOutcomeFilter() !== 'all') count += 1;
if (params.scopeFilter() !== 'all') count += 1;
if (params.modeFilter() !== 'all') count += 1;
if (params.verificationFilter() !== 'all') count += 1;
if (params.clusterFilter() !== 'all') count += 1;
if (params.nodeFilter() !== 'all') count += 1;
if (params.namespaceFilter() !== 'all') count += 1;
return count;
});
createEffect(() => {
params.currentPage();
params.hasFocusedRollup();
params.platformFilter();
params.itemTypeFilter();
params.historyOutcomeFilter();
params.scopeFilter();
params.modeFilter();
params.verificationFilter();
params.clusterFilter();
params.nodeFilter();
params.namespaceFilter();
params.queryFilter();
setSelectedPoint(null);
});
const handleAdvancedFiltersClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (advancedFiltersPanelRef?.contains(target) || advancedFiltersButtonRef?.contains(target)) {
return;
}
setMoreFiltersOpen(false);
};
createEffect(() => {
if (moreFiltersOpen()) {
document.addEventListener('mousedown', handleAdvancedFiltersClickOutside);
} else {
document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside);
});
return {
advancedFiltersButtonRef: (element: HTMLButtonElement) => {
advancedFiltersButtonRef = element;
},
advancedFiltersPanelRef: (element: HTMLDivElement) => {
advancedFiltersPanelRef = element;
},
clearSelectedPoint: () => setSelectedPoint(null),
historyActiveFilterCount,
historyFiltersOpen,
moreFiltersOpen,
selectedPoint,
setHistoryFiltersOpen,
setMoreFiltersOpen,
toggleSelectedPoint: (point: RecoveryPoint) =>
setSelectedPoint((current) => (current?.id === point.id ? null : point)),
};
}
@@ -2,9 +2,6 @@ import { describe, expect, it } from 'vitest';
import pageControlsSource from '@/components/shared/PageControls.tsx?raw';
import filterToolbarSource from '@/components/shared/FilterToolbar.tsx?raw';
import workloadsFilterSource from '@/components/Workloads/WorkloadsFilter.tsx?raw';
import recoveryPageSource from '@/components/Recovery/Recovery.tsx?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
import subtabsSource from '@/components/shared/Subtabs.tsx?raw';
const tsxSources = import.meta.glob('../../**/*.tsx', {
@@ -51,11 +48,8 @@ describe('page controls guardrails', () => {
// RecoveryProtectedInventorySection migrated to FilterBar; defensive
// assertions remain to catch any regression that reintroduces FilterHeader
// / ColumnPicker forks here.
expect(recoveryProtectedInventorySectionSource).not.toContain('<FilterHeader');
expect(recoveryProtectedInventorySectionSource).not.toContain('<ColumnPicker');
// RecoveryHistorySection migrated to FilterBar; defensive guards remain.
expect(recoveryHistorySectionSource).not.toContain('<FilterHeader');
});
@@ -102,17 +96,17 @@ describe('page controls guardrails', () => {
'./PageControls.tsx',
]);
// RecoveryHistorySection and WorkloadsFilter compose ColumnPicker via
// FilterBar's viewOptionsTrailing slot; PageControls plus those two
// surfaces are the canonical ColumnPicker consumers.
// WorkloadsFilter and the Standalone AgentsMachinesTable compose
// ColumnPicker via FilterBar's viewOptionsTrailing slot; PageControls
// plus those two surfaces are the canonical ColumnPicker consumers.
expect(columnPickerUsers).toEqual([
'../Recovery/RecoveryHistorySection.tsx',
'../../features/standalone/AgentsMachinesTable.tsx',
'../Workloads/WorkloadsFilter.tsx',
'./PageControls.tsx',
]);
expect(columnPickerImportUsers).toEqual([
'../Recovery/RecoveryHistorySection.tsx',
'../../features/standalone/AgentsMachinesTable.tsx',
'../Workloads/WorkloadsFilter.tsx',
'./PageControls.tsx',
]);
@@ -127,12 +121,6 @@ describe('page controls guardrails', () => {
expect(pageControlsSource).toContain('searchLeading?: JSX.Element');
expect(pageControlsSource).toContain('searchLeading={local.searchLeading}');
expect(pageControlsSource).toContain('{...divProps}');
expect(recoveryProtectedInventorySectionSource).not.toContain('<FilterHeader');
expect(recoveryHistorySectionSource).not.toContain('<FilterHeader');
expect(recoveryProtectedInventorySectionSource).not.toContain('searchRowClass=');
expect(recoveryHistorySectionSource).not.toContain('searchRowClass=');
expect(recoveryProtectedInventorySectionSource).not.toContain('!w-auto');
expect(recoveryHistorySectionSource).not.toContain('!w-auto');
});
it('keeps display controls and utility actions on the shared toolbar rail', () => {
@@ -157,8 +145,6 @@ describe('page controls guardrails', () => {
'shrink-0 flex-wrap items-center justify-end gap-2 self-start',
);
expect(pageControlsSource).not.toContain('2xl:ml-auto');
expect(recoveryHistorySectionSource).not.toContain('toolbarClass="lg:flex-nowrap"');
expect(recoveryHistorySectionSource).not.toContain('ml-auto flex items-center gap-2');
});
it('keeps compact stable filters on the shared labeled toggle primitive', () => {
@@ -180,17 +166,9 @@ describe('page controls guardrails', () => {
expect(workloadsFilterSource).not.toContain('xl:flex-col xl:items-start');
// RecoveryHistorySection migrated to FilterBar; LabeledFilterToggleGroup
// / LabeledFilterSelect no longer rendered.
expect(recoveryHistorySectionSource).not.toContain('LabeledFilterToggleGroup');
expect(recoveryHistorySectionSource).not.toContain('LabeledFilterSelect');
});
it('keeps embedded workspace tabs on the canonical shared segmented-control pattern', () => {
expect(subtabsSource).not.toContain("variant?: 'default' | 'control'");
expect(recoveryPageSource).not.toContain('variant="control"');
expect(recoveryPageSource).not.toContain('listClass=');
expect(recoveryPageSource).not.toContain('tabClass=');
expect(recoveryPageSource).toContain('FilterSegmentedControl');
expect(recoveryPageSource).toContain('RECOVERY_WORKSPACE_OPTIONS');
expect(recoveryPageSource).not.toContain('Focused drill-in');
});
});
@@ -144,10 +144,6 @@ import storagePoolsTableSource from '@/components/Storage/StoragePoolsTable.tsx?
import diskListSource from '@/components/Storage/DiskList.tsx?raw';
import storageSummarySource from '@/components/Storage/StorageSummary.tsx?raw';
import workloadsSummarySource from '@/components/Workloads/WorkloadsSummary.tsx?raw';
import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryHistoryTableSource from '@/components/Recovery/RecoveryHistoryTable.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
import alertHistoryTableSectionSource from '@/features/alerts/AlertHistoryTableSection.tsx?raw';
import alertHistoryTableGroupRowSource from '@/features/alerts/AlertHistoryTableGroupRow.tsx?raw';
@@ -351,8 +347,6 @@ describe('shared primitive guardrails', () => {
unifiedResourceHostTableCardSource,
unifiedResourceServiceInfrastructureCardSource,
storageContentCardSource,
recoveryHistorySectionSource,
recoveryProtectedInventorySectionSource,
]) {
expect(source).toContain('TableCardHeader');
expect(source).not.toContain('SummaryTableCardHeader');
@@ -374,9 +368,6 @@ describe('shared primitive guardrails', () => {
unifiedResourceHostTableCardSource,
unifiedResourceServiceInfrastructureCardSource,
storageContentCardSource,
recoveryComponentSource,
recoveryHistorySectionSource,
recoveryProtectedInventorySectionSource,
]) {
expect(source).toContain('TableCard');
expect(source).not.toContain('overflow-hidden border-border-subtle bg-surface');
@@ -567,8 +558,6 @@ describe('shared primitive guardrails', () => {
unifiedResourceHostTableCardSource,
unifiedResourcePBSTableSectionSource,
unifiedResourcePMGTableSectionSource,
recoveryHistoryTableSource,
recoveryProtectedInventorySectionSource,
alertHistoryTableSectionSource,
workloadsTableSource,
storagePoolsTableSource,
@@ -597,8 +586,6 @@ describe('shared primitive guardrails', () => {
for (const source of [
unifiedResourceHostTableCardSource,
recoveryHistorySectionSource,
recoveryProtectedInventorySectionSource,
alertHistoryTableSectionSource,
workloadsTableSource,
storageContentCardSource,
@@ -608,8 +595,6 @@ describe('shared primitive guardrails', () => {
for (const source of [
unifiedResourceHostTableCardSource,
recoveryHistorySectionSource,
recoveryProtectedInventorySectionSource,
storageContentCardSource,
]) {
expect(source).toContain('TableCardHeader');
@@ -621,7 +606,6 @@ describe('shared primitive guardrails', () => {
expect(storagePoolsTableSource).not.toContain('STORAGE_POOLS_SCROLL_WRAP_CLASS');
expect(diskListSource).not.toContain("from '@/components/shared/Card'");
expect(diskListSource).not.toContain('PHYSICAL_DISK_TABLE_SCROLL_CLASS');
expect(recoveryProtectedInventorySectionSource).toContain('wrapperClass="bg-surface"');
expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"');
expect(storageContentCardSource).toContain('<StoragePoolsTable');
expect(pulseDataGridSource).toContain('wrapperClass="scrollbar-hide"');
@@ -1568,49 +1552,11 @@ describe('shared primitive guardrails', () => {
// FilterDef entries rather than rendering the labelled-select row from
// PageControls. Guards against regression to the old layout-per-page
// pattern.
expect(recoveryProtectedInventorySectionSource).toContain(
"import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';",
);
expect(recoveryProtectedInventorySectionSource).toContain('<FilterBar');
expect(recoveryProtectedInventorySectionSource).toContain("id: 'item-type'");
expect(recoveryProtectedInventorySectionSource).toContain("id: 'platform'");
expect(recoveryProtectedInventorySectionSource).toContain("id: 'protected-state'");
expect(recoveryProtectedInventorySectionSource).toContain('role="group"');
expect(recoveryProtectedInventorySectionSource).toContain(
'ariaLabel="Protection coverage controls"',
);
expect(recoveryProtectedInventorySectionSource).not.toContain('PageControls');
expect(recoveryProtectedInventorySectionSource).not.toContain('LabeledFilterSelect');
expect(recoveryProtectedInventorySectionSource).not.toContain('protectedFiltersOpen');
// Recovery events surface — every advanced filter folds into the catalog
// (scope, method, verification, cluster, node, namespace) so the
// dedicated "Filter" popover panel and FilterToolbarPanel-based advanced
// panel are no longer rendered.
expect(recoveryHistorySectionSource).toContain(
"import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';",
);
expect(recoveryHistorySectionSource).toContain('<FilterBar');
expect(recoveryHistorySectionSource).toContain('ariaLabel="Recovery events controls"');
expect(recoveryHistorySectionSource).toContain("id: 'item-type'");
expect(recoveryHistorySectionSource).toContain("id: 'platform'");
expect(recoveryHistorySectionSource).toContain("id: 'outcome'");
expect(recoveryHistorySectionSource).toContain("id: 'scope'");
expect(recoveryHistorySectionSource).toContain("id: 'method'");
expect(recoveryHistorySectionSource).toContain("id: 'verification'");
expect(recoveryHistorySectionSource).toContain("id: 'cluster'");
expect(recoveryHistorySectionSource).toContain("id: 'node'");
expect(recoveryHistorySectionSource).toContain("id: 'namespace'");
expect(recoveryHistorySectionSource).toContain('searchTrailing={');
expect(recoveryHistorySectionSource).toContain('<RecoveryHistoryItemFilter');
expect(recoveryHistorySectionSource).toContain('<ColumnPicker');
expect(recoveryHistorySectionSource).toContain('onClearAll={');
expect(recoveryHistorySectionSource).toContain('showClearAll={props.hasActiveArtifactFilters}');
expect(recoveryHistorySectionSource).not.toContain('PageControls');
expect(recoveryHistorySectionSource).not.toContain('FilterActionButton');
expect(recoveryHistorySectionSource).not.toContain('FilterToolbarPanel');
expect(recoveryHistorySectionSource).not.toContain('moreFiltersOpen');
expect(recoveryHistorySectionSource).not.toContain('historyFiltersOpen');
// WorkloadsFilter — Type/Status stay in the shared FilterBar catalog but
// render as inline primary controls; Node/Platform/Namespace/Runtime
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import guestRowModelSource from '@/components/Workloads/guestRowModel.tsx?raw';
import recoverySource from '@/components/Recovery/Recovery.tsx?raw';
import responsiveSource from '@/types/responsive.ts?raw';
import typeColumnDefinitionSource from '@/utils/typeColumnDefinition.ts?raw';
import typeColumnContractSource from '@/utils/typeColumnContract.ts?raw';
@@ -64,11 +63,6 @@ describe('type column guardrails', () => {
expect(guestRowModelSource).not.toContain('createCanonicalTypeColumn');
expect(guestRowModelSource).not.toMatch(INLINE_TYPE_COLUMN_PATTERN);
expect(guestRowModelSource).not.toContain("defaultVisibility:");
expect(recoverySource).toContain('createVisibleCanonicalTypeColumn()');
expect(recoverySource).not.toContain('createCanonicalTypeColumn');
expect(recoverySource).not.toMatch(INLINE_TYPE_COLUMN_PATTERN);
expect(recoverySource).not.toContain("defaultVisibility:");
});
it('limits runtime Type columns to the known allowlist', () => {
@@ -92,7 +86,6 @@ describe('type column guardrails', () => {
.sort();
expect(typeColumnUsers).toEqual([
'../Recovery/Recovery.tsx',
'../Workloads/guestRowModel.tsx',
]);
@@ -127,7 +120,6 @@ describe('type column guardrails', () => {
.sort();
expect(directHelperImportUsers).toEqual([
'../Recovery/Recovery.tsx',
'../Workloads/guestRowModel.tsx',
]);
});
@@ -1,612 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { useStorageRecoveryResources } from '@/hooks/useUnifiedResources';
import { useRecoveryRollups, type RecoveryRollupsQuery } from '@/hooks/useRecoveryRollups';
import { useRecoveryPoints } from '@/hooks/useRecoveryPoints';
import { useRecoveryPointsFacets } from '@/hooks/useRecoveryPointsFacets';
import { useRecoveryPointsSeries } from '@/hooks/useRecoveryPointsSeries';
import { buildRecoveryRouteSearch, parseRecoveryLinkSearch } from '@/routing/resourceLinks';
import type { ProtectionRollup, RecoveryOutcome } from '@/types/recovery';
import type { Resource } from '@/types/resource';
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
import {
getSourcePlatformLabel,
normalizeSourcePlatformKey,
normalizeSourcePlatformQueryValue,
} from '@/utils/sourcePlatforms';
import { buildSourcePlatformOptions } from '@/utils/sourcePlatformOptions';
import {
getRecoveryPointPlatform,
getRecoveryRollupPlatforms,
} from '@/utils/recoveryPlatformModel';
import {
getRecoveryRollupItemLabel,
getRecoveryRollupItemSecondaryLabel,
normalizeRecoveryModeQueryValue,
} from '@/utils/recoveryRecordPresentation';
import {
getRecoveryPointItemTypeKey,
getRecoveryRollupItemTypeKey,
getRecoveryItemTypePresentation,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryRollupInventoryStatus,
type RecoveryRollupInventoryStatus,
} from '@/utils/recoveryTablePresentation';
import { getRecoveryRollupInventoryStatusLabel } from '@/utils/recoveryStatusPresentation';
import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation';
import type { RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
import { parseRecoveryDateKey } from '@/utils/recoveryDatePresentation';
import { formatRelativeTime } from '@/utils/format';
type ArtifactMode = RecoveryArtifactMode;
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
export type RecoveryWorkspaceView = 'inventory' | 'events';
export type ProtectedStateFilter = 'all' | RecoveryRollupInventoryStatus;
type RecoverySurfaceStateOptions = {
defaultWorkspaceView?: RecoveryWorkspaceView;
};
const isRecoveryDateKey = (value: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(value);
const isRecoveryRangeDays = (value: string): value is '7' | '30' | '90' | '365' =>
value === '7' || value === '30' || value === '90' || value === '365';
const normalizeRecoveryRouteValue = (value: string | null | undefined): string =>
String(value || '').trim();
const normalizeRecoveryWorkspaceViewValue = (
value: string | null | undefined,
): RecoveryWorkspaceView | '' => {
const normalized = normalizeRecoveryRouteValue(value).toLowerCase();
if (normalized === 'inventory' || normalized === 'events') {
return normalized;
}
return '';
};
const normalizeRecoveryRouteSelection = (value: string | null | undefined): string => {
const normalized = normalizeRecoveryRouteValue(value);
return normalized.toLowerCase() === 'all' ? 'all' : normalized;
};
const normalizeRecoveryBooleanFlag = (value: string | null | undefined): boolean => {
const normalized = normalizeRecoveryRouteValue(value).toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
};
const normalizeRecoveryPlatformSelection = (value: string | null | undefined): string => {
const normalized = normalizeSourcePlatformQueryValue(value);
if (!normalized || normalized === 'all') return 'all';
return normalizeSourcePlatformKey(normalized) || 'all';
};
const normalizeRecoveryItemTypeSelection = (value: string | null | undefined): string => {
const normalized = normalizeRecoveryItemTypeQueryValue(value);
return normalized || 'all';
};
const normalizeProtectedStateFilter = (value: string | null | undefined): ProtectedStateFilter => {
const normalized = normalizeRecoveryRouteValue(value).toLowerCase();
if (normalized === 'success') return 'healthy';
if (
normalized === 'healthy' ||
normalized === 'warning' ||
normalized === 'failed' ||
normalized === 'running' ||
normalized === 'stale' ||
normalized === 'never-succeeded' ||
normalized === 'unknown'
) {
return normalized as ProtectedStateFilter;
}
return 'all';
};
const getRollupFreshnessLabel = (rollup: ProtectionRollup): string => {
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
if (Number.isFinite(successMs) && successMs > 0) {
return `Last success ${formatRelativeTime(successMs)}`;
}
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
if (Number.isFinite(attemptMs) && attemptMs > 0) {
return `Last attempt ${formatRelativeTime(attemptMs)}`;
}
return 'No attempts recorded';
};
export function useRecoverySurfaceState(options: RecoverySurfaceStateOptions = {}) {
const navigate = useNavigate();
const location = useLocation();
const defaultWorkspaceView = options.defaultWorkspaceView ?? 'events';
const routeStateNavigate = createRouteStateNavigateScheduler(
navigate,
() => `${untrack(() => location.pathname)}${untrack(() => location.search || '')}`,
);
const storageRecoveryResources = useStorageRecoveryResources();
const [rollupId, setRollupId] = createSignal('');
const [workspaceView, setWorkspaceView] = createSignal<RecoveryWorkspaceView>('events');
const [queryFilter, setQueryFilter] = createSignal('');
const [platformFilter, setPlatformFilter] = createSignal('all');
const [itemTypeFilter, setItemTypeFilter] = createSignal('all');
const [clusterFilter, setClusterFilter] = createSignal('all');
const [modeFilter, setModeFilter] = createSignal<'all' | ArtifactMode>('all');
const [historyOutcomeFilter, setHistoryOutcomeFilter] = createSignal<'all' | RecoveryOutcome>(
'all',
);
const [verificationFilter, setVerificationFilter] = createSignal<VerificationFilter>('all');
const [scopeFilter, setScopeFilter] = createSignal<'all' | 'workload'>('all');
const [nodeFilter, setNodeFilter] = createSignal('all');
const [namespaceFilter, setNamespaceFilter] = createSignal('all');
const [protectedStateFilter, setProtectedStateFilter] = createSignal<ProtectedStateFilter>('all');
const [chartRangeDays, setChartRangeDays] = createSignal<7 | 30 | 90 | 365>(30);
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
const [currentPage, setCurrentPage] = createSignal(1);
const [hydratedLocationKey, setHydratedLocationKey] = createSignal('');
const tzOffsetMinutes = createMemo(() => -new Date().getTimezoneOffset());
const chartWindow = createMemo(() => {
const days = chartRangeDays();
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - (days - 1));
start.setHours(0, 0, 0, 0);
return { from: start.toISOString(), to: end.toISOString() };
});
const tableWindow = createMemo(() => {
const selected = selectedDateKey();
if (selected) {
const start = parseRecoveryDateKey(selected);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
return { from: start.toISOString(), to: end.toISOString() };
}
return chartWindow();
});
const buildRecoveryTransportQuery = (
window: { from: string; to: string },
includeRollupId = true,
): RecoveryRollupsQuery => {
const rid = rollupId().trim();
const mode = modeFilter();
const outcome = historyOutcomeFilter();
const scope: 'workload' | null = scopeFilter() === 'workload' ? 'workload' : null;
const vf = verificationFilter();
return {
rollupId: includeRollupId && rid ? rid : null,
platform: platformFilter() === 'all' ? null : platformFilter(),
itemType: itemTypeFilter() === 'all' ? null : itemTypeFilter(),
mode: mode === 'all' ? null : mode,
outcome: outcome === 'all' ? null : outcome,
q: queryFilter().trim() || null,
cluster: clusterFilter() === 'all' ? null : clusterFilter(),
node: nodeFilter() === 'all' ? null : nodeFilter(),
namespace: namespaceFilter() === 'all' ? null : namespaceFilter(),
scope,
verification: vf === 'all' ? null : vf,
from: window.from,
to: window.to,
};
};
const chartTransportQuery = createMemo(() => buildRecoveryTransportQuery(chartWindow()));
const tableTransportQuery = createMemo(() => buildRecoveryTransportQuery(tableWindow()));
const recoverySeriesTransportQuery = createMemo(() => ({
...chartTransportQuery(),
tzOffsetMinutes: tzOffsetMinutes(),
}));
const recoveryRollups = useRecoveryRollups(() => chartTransportQuery());
const rollups = createMemo<ProtectionRollup[]>(() => recoveryRollups.rollups() || []);
const selectedRollup = createMemo<ProtectionRollup | null>(() => {
const selected = rollupId().trim();
if (!selected) return null;
return rollups().find((rollup) => rollup.rollupId === selected) || null;
});
const recoveryHistoryItemCatalog = useRecoveryRollups(() => {
if (!rollupId().trim()) return null;
return buildRecoveryTransportQuery(chartWindow(), false);
});
const selectableRollups = createMemo<ProtectionRollup[]>(() => {
const selected = rollupId().trim();
if (!selected) return rollups();
const catalog = recoveryHistoryItemCatalog.rollups() || [];
if (catalog.length > 0) return catalog;
const focused = selectedRollup();
return focused ? [focused] : [];
});
const selectedHistoryRollup = createMemo<ProtectionRollup | null>(() => {
const selected = rollupId().trim();
if (!selected) return null;
return (
selectableRollups().find((rollup) => rollup.rollupId === selected) || selectedRollup() || null
);
});
const resourcesById = createMemo(() => {
const map = new Map<string, Resource>();
for (const resource of storageRecoveryResources.resources() || []) {
if (resource?.id) map.set(resource.id, resource);
}
return map;
});
const selectedHistoryItemLabel = createMemo(() => {
const rollup = selectedHistoryRollup();
return rollup ? getRecoveryRollupItemLabel(rollup, resourcesById()) : null;
});
const historyItemOptions = createMemo(() => {
const resourceIndex = resourcesById();
const options = selectableRollups().map((rollup) => {
const itemTypeLabel =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup))?.label || null;
const platformLabels = getRecoveryRollupPlatforms(rollup)
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
const stateLabel = getRecoveryRollupInventoryStatusLabel(
getRecoveryRollupInventoryStatus(rollup),
);
return {
rollupId: rollup.rollupId,
label: getRecoveryRollupItemLabel(rollup, resourceIndex),
secondaryLabel: getRecoveryRollupItemSecondaryLabel(rollup),
contextLabel: [
stateLabel,
getRollupFreshnessLabel(rollup),
itemTypeLabel,
platformLabels.join(', '),
]
.filter(Boolean)
.join(' · '),
};
});
const selected = selectedHistoryRollup();
if (!selected) return options;
if (options.some((option) => option.rollupId === selected.rollupId)) return options;
const itemTypeLabel =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(selected))?.label || null;
const platformLabels = getRecoveryRollupPlatforms(selected)
.map((platform) => getSourcePlatformLabel(String(platform || '').trim()))
.filter(Boolean)
.sort((left, right) => left.localeCompare(right));
const stateLabel = getRecoveryRollupInventoryStatusLabel(
getRecoveryRollupInventoryStatus(selected),
);
return [
{
rollupId: selected.rollupId,
label: getRecoveryRollupItemLabel(selected, resourceIndex),
secondaryLabel: getRecoveryRollupItemSecondaryLabel(selected),
contextLabel: [
stateLabel,
getRollupFreshnessLabel(selected),
itemTypeLabel,
platformLabels.join(', '),
]
.filter(Boolean)
.join(' · '),
},
...options,
];
});
const recoveryPoints = useRecoveryPoints(() => {
return {
...tableTransportQuery(),
page: currentPage(),
limit: 200,
};
});
const recoveryFacets = useRecoveryPointsFacets(() => tableTransportQuery());
const recoverySeries = useRecoveryPointsSeries(() => recoverySeriesTransportQuery());
createEffect(() => {
const currentPath = `${location.pathname}${location.search || ''}`;
const parsed = parseRecoveryLinkSearch(location.search);
const nextRollup = normalizeRecoveryRouteValue(parsed.rollupId);
const nextView = normalizeRecoveryWorkspaceViewValue(parsed.view);
const nextQuery = normalizeRecoveryRouteValue(parsed.query);
const nextPlatform = normalizeRecoveryPlatformSelection(parsed.platform || '');
const nextItemType = normalizeRecoveryItemTypeSelection(parsed.itemType || '');
const normalizedRange = normalizeRecoveryRouteValue(parsed.range);
const nextRange = isRecoveryRangeDays(normalizedRange) ? Number(normalizedRange) : 30;
const nextCluster = normalizeRecoveryRouteSelection(parsed.cluster || 'all') || 'all';
const normalizedDay = normalizeRecoveryRouteValue(parsed.day);
const nextDay = isRecoveryDateKey(normalizedDay) ? normalizedDay : '';
const legacyStaleState = normalizeRecoveryBooleanFlag(parsed.stale) ? 'stale' : '';
const hasInventoryState = Boolean(
normalizeRecoveryRouteValue(parsed.state) || legacyStaleState,
);
const derivedDefaultView: RecoveryWorkspaceView = hasInventoryState
? 'inventory'
: defaultWorkspaceView;
const resolvedView = (nextView || derivedDefaultView) as RecoveryWorkspaceView;
const visibleRollup = resolvedView === 'events' ? nextRollup : '';
const visibleDay = resolvedView === 'events' ? nextDay : '';
const nextMode = normalizeRecoveryModeQueryValue(parsed.mode);
const rawScope = normalizeRecoveryRouteValue(parsed.scope).toLowerCase();
const nextScope: 'all' | 'workload' = rawScope === 'workload' ? 'workload' : 'all';
const nextNode = normalizeRecoveryRouteSelection(parsed.node || 'all') || 'all';
const nextNamespace = normalizeRecoveryRouteSelection(parsed.namespace || 'all') || 'all';
const verificationValue = normalizeRecoveryRouteValue(parsed.verification).toLowerCase();
const statusValue = normalizeRecoveryRouteValue(parsed.status).toLowerCase();
const nextProtectedState =
resolvedView === 'inventory'
? normalizeProtectedStateFilter(parsed.state || legacyStaleState || statusValue)
: 'all';
if (visibleRollup !== untrack(rollupId)) setRollupId(visibleRollup);
if (resolvedView !== untrack(workspaceView)) setWorkspaceView(resolvedView);
if (nextQuery !== untrack(queryFilter)) setQueryFilter(nextQuery);
if (nextPlatform !== untrack(platformFilter)) setPlatformFilter(nextPlatform);
if (nextItemType !== untrack(itemTypeFilter)) setItemTypeFilter(nextItemType);
if (nextProtectedState !== untrack(protectedStateFilter)) {
setProtectedStateFilter(nextProtectedState);
}
if (nextRange !== untrack(chartRangeDays)) setChartRangeDays(nextRange as 7 | 30 | 90 | 365);
if (nextCluster !== untrack(clusterFilter)) setClusterFilter(nextCluster);
if ((visibleDay || null) !== untrack(selectedDateKey)) setSelectedDateKey(visibleDay || null);
if (nextMode !== untrack(modeFilter)) setModeFilter(nextMode);
if (nextScope !== untrack(scopeFilter)) setScopeFilter(nextScope);
if (nextNode !== untrack(nodeFilter)) setNodeFilter(nextNode);
if (nextNamespace !== untrack(namespaceFilter)) setNamespaceFilter(nextNamespace);
if (resolvedView === 'inventory') {
if (untrack(verificationFilter) !== 'all') setVerificationFilter('all');
if (untrack(historyOutcomeFilter) !== 'all') setHistoryOutcomeFilter('all');
} else if (
verificationValue === 'verified' ||
verificationValue === 'unverified' ||
verificationValue === 'unknown'
) {
if (verificationValue !== untrack(verificationFilter)) {
setVerificationFilter(verificationValue as VerificationFilter);
}
if (untrack(historyOutcomeFilter) !== 'all') setHistoryOutcomeFilter('all');
} else {
if (untrack(verificationFilter) !== 'all') setVerificationFilter('all');
const normalizedOutcome = normalizeOutcome(statusValue);
if (statusValue && normalizedOutcome !== 'unknown') {
if (normalizedOutcome !== untrack(historyOutcomeFilter)) {
setHistoryOutcomeFilter(normalizedOutcome);
}
} else if (untrack(historyOutcomeFilter) !== 'all') {
setHistoryOutcomeFilter('all');
}
}
if (hydratedLocationKey() !== currentPath) {
setHydratedLocationKey(currentPath);
}
});
createEffect(() => {
rollupId();
workspaceView();
queryFilter();
platformFilter();
itemTypeFilter();
clusterFilter();
modeFilter();
historyOutcomeFilter();
verificationFilter();
nodeFilter();
namespaceFilter();
scopeFilter();
protectedStateFilter();
chartRangeDays();
selectedDateKey();
if (untrack(currentPage) !== 1) setCurrentPage(1);
});
createEffect(() => {
const rid = rollupId().trim();
const defaultView: RecoveryWorkspaceView = 'events';
const status = historyOutcomeFilter() !== 'all' ? historyOutcomeFilter() : null;
const verification = verificationFilter() !== 'all' ? verificationFilter() : null;
const currentPath = `${location.pathname}${location.search || ''}`;
if (hydratedLocationKey() !== currentPath) return;
const nextSearch = buildRecoveryRouteSearch({
rollupId: rid || null,
view: workspaceView() !== defaultView ? workspaceView() : null,
query: queryFilter().trim() || null,
platform: platformFilter() !== 'all' ? platformFilter() : null,
itemType: itemTypeFilter() !== 'all' ? itemTypeFilter() : null,
state:
workspaceView() === 'inventory' && protectedStateFilter() !== 'all'
? protectedStateFilter()
: null,
stale: null,
range: chartRangeDays() !== 30 ? String(chartRangeDays()) : null,
cluster: clusterFilter() !== 'all' ? clusterFilter() : null,
day: selectedDateKey(),
mode: modeFilter() !== 'all' ? modeFilter() : null,
status,
verification,
scope: scopeFilter() === 'workload' ? 'workload' : null,
node: nodeFilter() !== 'all' ? nodeFilter() : null,
namespace: namespaceFilter() !== 'all' ? namespaceFilter() : null,
});
const nextPath = `${location.pathname}${nextSearch}`;
if (nextPath !== currentPath) {
routeStateNavigate.schedule(nextPath);
}
});
onCleanup(() => {
routeStateNavigate.cleanup();
});
const facets = createMemo(() => recoveryFacets.facets() || {});
const platformOptions = createMemo(() => {
const platforms = new Set<string>();
for (const rollup of rollups()) {
for (const platform of getRecoveryRollupPlatforms(rollup)) {
const normalized = normalizeSourcePlatformQueryValue(String(platform || '').trim());
if (normalized) platforms.add(normalized);
}
}
for (const point of recoveryPoints.points() || []) {
const normalized = normalizeSourcePlatformQueryValue(getRecoveryPointPlatform(point));
if (normalized) platforms.add(normalized);
}
const selected = normalizeRecoveryPlatformSelection(platformFilter());
if (selected !== 'all') platforms.add(selected);
return ['all', ...buildSourcePlatformOptions(platforms).map((option) => option.key)];
});
const itemTypeOptions = createMemo(() => {
const values = new Set<string>();
for (const value of facets().itemTypes || []) {
const normalized = normalizeRecoveryItemTypeQueryValue(value);
if (normalized) values.add(normalized);
}
for (const rollup of rollups()) {
const normalized = getRecoveryRollupItemTypeKey(rollup);
if (normalized) values.add(normalized);
}
for (const point of recoveryPoints.points() || []) {
const normalized = getRecoveryPointItemTypeKey(point);
if (normalized) values.add(normalized);
}
const sorted = [...values].sort((left, right) => {
const leftLabel = getRecoveryItemTypePresentation(left)?.label || left;
const rightLabel = getRecoveryItemTypePresentation(right)?.label || right;
return leftLabel.localeCompare(rightLabel);
});
const selected = itemTypeFilter().trim();
if (selected && selected !== 'all' && !sorted.includes(selected)) sorted.unshift(selected);
return ['all', ...sorted];
});
const clusterOptions = createMemo(() => {
const values = (facets().clusters || [])
.slice()
.map((value) => String(value || '').trim())
.filter(Boolean)
.sort();
const selected = clusterFilter().trim();
if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected);
return ['all', ...values];
});
const nodeOptions = createMemo(() => {
const values = (facets().nodesAgents || [])
.slice()
.map((value) => String(value || '').trim())
.filter(Boolean)
.sort();
const selected = nodeFilter().trim();
if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected);
return ['all', ...values];
});
const namespaceOptions = createMemo(() => {
const values = (facets().namespaces || [])
.slice()
.map((value) => String(value || '').trim())
.filter(Boolean)
.sort();
const selected = namespaceFilter().trim();
if (selected && selected !== 'all' && !values.includes(selected)) values.unshift(selected);
return ['all', ...values];
});
const showClusterFilter = createMemo(
() => clusterOptions().length > 1 || clusterFilter() !== 'all',
);
const showNodeFilter = createMemo(() => nodeOptions().length > 1 || nodeFilter() !== 'all');
const showNamespaceFilter = createMemo(
() => namespaceOptions().length > 1 || namespaceFilter() !== 'all',
);
const showVerificationFilter = createMemo(
() => Boolean(facets().hasVerification) || verificationFilter() !== 'all',
);
const totalPages = createMemo(() => Math.max(1, recoveryPoints.meta().totalPages || 1));
return {
chartRangeDays,
clusterFilter,
clusterOptions,
currentPage,
facets,
historyOutcomeFilter,
historyItemOptions,
itemTypeFilter,
itemTypeOptions,
modeFilter,
namespaceFilter,
namespaceOptions,
nodeFilter,
nodeOptions,
protectedStateFilter,
platformFilter,
platformOptions,
queryFilter,
recoveryFacets,
recoveryPoints,
recoveryRollups,
recoverySeries,
resourcesById,
rollupId,
rollups,
scopeFilter,
selectedDateKey,
selectedHistoryItemLabel,
setChartRangeDays,
setClusterFilter,
setCurrentPage,
setHistoryOutcomeFilter,
setItemTypeFilter,
setModeFilter,
setNamespaceFilter,
setNodeFilter,
setProtectedStateFilter,
setPlatformFilter,
setQueryFilter,
setRollupId,
setScopeFilter,
setSelectedDateKey,
setVerificationFilter,
setWorkspaceView,
showClusterFilter,
showNamespaceFilter,
showNodeFilter,
showVerificationFilter,
storageRecoveryResources,
tableWindow,
totalPages,
verificationFilter,
workspaceView,
};
}
@@ -142,13 +142,6 @@ import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge
import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw';
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw';
import recoveryActivitySectionSource from '@/components/Recovery/RecoveryActivitySection.tsx?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryHistoryTableSource from '@/components/Recovery/RecoveryHistoryTable.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
import recoveryHistorySectionStateSource from '@/components/Recovery/useRecoveryHistorySectionState.ts?raw';
import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw';
import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw';
import recoveryArtifactModePresentationSource from '@/utils/recoveryArtifactModePresentation.ts?raw';
import recoveryFilterChipPresentationSource from '@/utils/recoveryFilterChipPresentation.ts?raw';
@@ -510,14 +503,6 @@ import organizationSettingsPresentationSource from '@/utils/organizationSettings
import rbacFeatureGateSectionSource from '@/components/Settings/RBACFeatureGateSection.tsx?raw';
import rbacFeatureGateStateSource from '@/components/Settings/useRBACFeatureGateState.ts?raw';
const recoverySource = [
recoveryComponentSource,
recoveryProtectedInventorySectionSource,
recoveryActivitySectionSource,
recoveryHistorySectionSource,
recoveryHistoryTableSource,
recoveryHistorySectionStateSource,
].join('\n');
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import rolesPanelStateSource from '@/components/Settings/useRolesPanelState.ts?raw';
import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw';
@@ -1039,196 +1024,11 @@ describe('frontend resource type boundaries', () => {
expect(emptyStatePresentationSource).toContain('export function getEmptyStatePresentation');
expect(discoveryTargetSource).toContain('canonicalizeFrontendResourceType');
expect(recoveryOutcomePresentationSource).toContain('import type { RecoveryOutcome }');
expect(recoveryComponentSource).not.toContain('RecoverySummary');
expect(recoveryComponentSource).not.toContain('recovery-summary');
expect(recoveryActivitySectionSource).toContain('RECOVERY_ACTIVITY_RANGE_DAYS');
expect(recoveryActivitySectionSource).toContain('Recovery activity range');
expect(recoverySource).not.toContain(
"const RECOVERY_TIME_RANGES: readonly string[] = ['7d', '30d', '90d']",
);
expect(recoverySource).not.toContain(
'const RECOVERY_TIME_RANGE_LABELS: Record<string, string>',
);
expect(recoverySource).not.toContain('const FRESHNESS_LABELS:');
expect(recoverySource).toContain('getRecoveryArtifactModePresentation');
expect(recoverySource).not.toContain('const MODE_LABELS: Record<ArtifactMode, string>');
expect(recoverySource).not.toContain('const MODE_BADGE_CLASS: Record<ArtifactMode, string>');
expect(recoverySource).not.toContain('const CHART_SEGMENT_CLASS: Record<ArtifactMode, string>');
expect(recoverySource).not.toContain(
"const ISSUE_RAIL_CLASS: Record<Exclude<IssueTone, 'none'>, string>",
);
expect(recoverySource).not.toContain('const normalizeOutcome =');
expect(recoveryArtifactModePresentationSource).toContain(
'export function getRecoveryArtifactModePresentation',
);
expect(recoveryIssuePresentationSource).toContain('export function getRecoveryIssueRailClass');
expect(recoverySource).toContain(
"import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';",
);
expect(recoveryComponentSource).toContain('useRecoverySurfaceState');
expect(recoveryComponentSource).toContain('TableCard');
expect(recoveryHistorySectionSource).toContain('useRecoveryHistorySectionState');
expect(recoveryHistorySectionSource).toContain('RecoveryHistoryTable');
expect(recoveryHistorySectionSource).toContain('TableCard');
expect(recoveryHistorySectionSource).toContain('getRecoveryAllHistoryLabel');
expect(recoveryHistoryTableSource).toMatch(/<Table(?:\s|>)/);
expect(recoveryHistoryTableSource).not.toContain('<div class="overflow-x-auto">');
expect(recoveryHistoryTableSource).not.toContain('<div class="overflow-x-auto bg-surface">');
expect(recoveryTablePresentationSource).toContain('getAllFilterOptionLabel');
expect(recoveryHistorySectionSource).not.toContain(
'overflow-hidden border-border-subtle bg-surface',
);
expect(recoveryProtectedInventorySectionSource).toContain('TableCard');
expect(recoveryProtectedInventorySectionSource).toMatch(/<Table(?:\s|>)/);
expect(recoveryProtectedInventorySectionSource).toContain('wrapperClass="bg-surface"');
expect(recoveryProtectedInventorySectionSource).not.toContain('<div class="overflow-x-auto">');
expect(recoveryProtectedInventorySectionSource).not.toContain(
'<div class="overflow-x-auto bg-surface">',
);
expect(recoveryProtectedInventorySectionSource).not.toContain(
'overflow-hidden border-border-subtle bg-surface',
);
expect(recoveryHistorySectionStateSource).toContain(
'export function useRecoveryHistorySectionState',
);
expect(recoverySource).not.toContain('parseRecoveryLinkSearch');
expect(recoverySource).not.toContain('buildRecoveryRouteSearch');
expect(recoverySource).not.toContain('useRecoveryRollups');
expect(recoverySource).not.toContain('useRecoveryPointsFacets');
expect(recoverySurfaceStateSource).toContain('export function useRecoverySurfaceState');
expect(recoverySurfaceStateSource).toContain('parseRecoveryLinkSearch');
expect(recoverySurfaceStateSource).toContain('buildRecoveryRouteSearch');
expect(recoverySurfaceStateSource).toContain('useRecoveryRollups');
expect(recoverySurfaceStateSource).toContain('useRecoveryPoints');
expect(recoverySurfaceStateSource).toContain('useRecoveryPointsFacets');
expect(recoverySurfaceStateSource).toContain('useRecoveryPointsSeries');
expect(recoveryComponentSource.indexOf('<RecoveryActivitySection')).toBeGreaterThan(-1);
expect(recoveryComponentSource.indexOf('{eventsActivity()}')).toBeGreaterThan(
recoveryComponentSource.indexOf("<Show when={workspaceView() === 'events'}>"),
);
expect(recoverySource).not.toContain('getRecoveryFilterChipPresentation');
expect(recoverySource).not.toContain('const titleize =');
expect(recoverySource).not.toContain('<SummaryPanel');
expect(recoverySource).not.toContain('SummaryMetricCard');
expect(recoveryActivitySectionSource).toContain('onRangeChange');
expect(recoveryActivitySectionSource).not.toContain('segmentedButtonClass(');
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');
expect(recoverySource).not.toContain('border-violet-200 bg-violet-50 px-2 py-0.5');
expect(recoverySource).not.toContain(
"chartRangeDays() === range\n ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'",
);
expect(recoveryActivitySectionSource).toContain('getRecoveryTimelineColumnButtonClass');
expect(recoveryHistoryTableSource).toContain('getRecoveryArtifactColumnHeaderClass');
expect(recoveryHistoryTableSource).toContain('getRecoveryArtifactRowClass');
expect(recoverySource).toContain('recoveryDateKeyFromTimestamp');
expect(recoverySource).toContain('parseRecoveryDateKey');
expect(recoverySource).toContain('getRecoveryPrettyDateLabel');
expect(recoverySource).toContain('getRecoveryFullDateLabel');
expect(recoverySource).toContain('getRecoveryCompactAxisLabel');
expect(recoverySource).toContain('formatRecoveryTimeOnly');
expect(recoverySource).toContain('getRecoveryNiceAxisMax');
expect(recoveryProtectedInventorySectionSource).toContain(
'getRecoveryRollupInventoryStatusTextClass',
);
expect(recoveryProtectedInventorySectionSource).toContain('getRecoverySpecialOutcomeTextClass');
expect(recoveryHistorySectionSource).toContain('getRecoveryBreadcrumbLinkClass');
expect(recoveryHistoryTableSource).toContain('getRecoveryEmptyStateActionClass');
expect(recoverySource).toContain('getRecoveryProtectedItemsLoadingState');
expect(recoverySource).toContain('getRecoveryProtectedItemsFailureState');
expect(recoverySource).toContain('getRecoveryActivityLoadingState');
expect(recoverySource).toContain('getRecoveryActivityEmptyState');
expect(recoverySource).toContain('getRecoveryPointsLoadingState');
expect(recoverySource).toContain('getRecoveryPointsFailureState');
expect(recoverySource).toContain('getRecoveryProtectedItemsEmptyState');
expect(recoverySource).toContain('getRecoveryHistoryEmptyState');
expect(recoveryHistoryTableSource).toContain('getRecoveryDrawerCloseButtonClass');
expect(recoverySource).toContain('getRecoveryRollupItemLabel');
expect(recoverySource).toContain('getRecoveryPointItemLabel');
expect(recoverySource).toContain('getRecoveryPointRepositoryLabel');
expect(recoverySource).toContain('getRecoveryPointDetailsSummary');
expect(recoverySource).toContain('getRecoveryPointTimestampMs');
expect(recoverySource).toContain('normalizeRecoveryModeQueryValue');
expect(recoveryActivitySectionSource).toContain('getRecoveryTimelineAxisLabelClass');
expect(recoverySource).toContain('getRecoveryTimelineLabelEvery');
expect(recoverySource).toContain('getRecoveryGroupNoTimestampLabel');
expect(recoveryProtectedInventorySectionSource).toContain(
'getRecoveryProtectedSearchPlaceholder',
);
expect(recoveryHistorySectionSource).toContain('getRecoveryHistorySearchPlaceholder');
expect(recoverySource).toContain('getRecoverySearchHistoryEmptyMessage');
expect(recoverySource).not.toContain('Search protected items...');
expect(recoverySource).not.toContain('Search recovery history...');
expect(recoverySource).not.toContain('Recent searches appear here.');
expect(recoveryActivitySectionSource).toContain('RECOVERY_TIMELINE_LEGEND_ITEM_CLASS');
expect(recoveryHistoryTableSource).toContain('getRecoveryEventTimeTextClass');
expect(recoverySource).not.toContain('getRecoverySubjectTypeBadgeClass');
expect(recoverySource).not.toContain('getRecoverySubjectTypeLabel');
expect(recoveryProtectedInventorySectionSource).toContain('getRecoveryRollupAgeTextClass');
expect(recoveryHistoryTableSource).toContain('RECOVERY_GROUP_HEADER_ROW_CLASS');
expect(recoveryHistoryTableSource).toContain('RECOVERY_GROUP_HEADER_TEXT_CLASS');
expect(recoverySource).not.toContain(
"isSelected\n ? 'bg-blue-100 dark:bg-blue-900'\n : 'hover:bg-surface-hover'",
);
expect(recoverySource).not.toContain('const groupHeaderRowClass =');
expect(recoverySource).not.toContain('const groupHeaderTextClass =');
expect(recoverySource).not.toContain('const dateKeyFromTimestamp =');
expect(recoverySource).not.toContain('const parseDateKey =');
expect(recoverySource).not.toContain('const prettyDateLabel =');
expect(recoverySource).not.toContain('const fullDateLabel =');
expect(recoverySource).not.toContain('const compactAxisLabel =');
expect(recoverySource).not.toContain('const formatTimeOnly =');
expect(recoverySource).not.toContain('const niceAxisMax =');
expect(recoverySource).not.toContain(
"protectedStaleOnly() ? 'border-amber-300 bg-amber-50 text-amber-800",
);
expect(recoverySource).not.toContain('rounded-full bg-blue-100/80 px-1.5 py-px');
expect(recoverySource).not.toContain(
'text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors',
);
expect(recoverySource).not.toContain(
'inline-flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover',
);
expect(recoverySource).not.toContain(
'rounded-md p-1 hover:text-base-content hover:bg-surface-hover',
);
expect(recoverySource).not.toContain(
'const labelEvery = dayCount <= 7 ? 1 : dayCount <= 30 ? 3 : 10',
);
expect(recoverySource).not.toContain('class="flex items-center gap-1"');
expect(recoverySource).not.toContain(
'class="inline-flex rounded border border-border bg-surface p-0.5 text-xs"',
);
expect(recoverySource).not.toContain("'font-semibold text-blue-700 dark:text-blue-300'");
expect(recoverySource).not.toContain('const rollupSubjectLabel =');
expect(recoverySource).not.toContain('const pointTimestampMs =');
expect(recoverySource).not.toContain('const buildSubjectLabelForPoint =');
expect(recoverySource).not.toContain('const buildRepositoryLabelForPoint =');
expect(recoverySource).not.toContain('const buildDetailsSummaryForPoint =');
expect(recoverySource).not.toContain('const normalizeModeFromQuery =');
expect(recoverySource).not.toContain('No protected items yet');
expect(recoverySource).not.toContain('Loading protected items...');
expect(recoverySource).not.toContain('Loading recovery activity...');
expect(recoverySource).not.toContain('No recovery activity in the selected window.');
expect(recoverySource).not.toContain('Loading recovery points...');
expect(recoverySource).not.toContain('Failed to load protected items');
expect(recoverySource).not.toContain('Failed to load recovery points');
expect(recoverySource).not.toContain(
'Pulse hasn’t observed any protected items for this org yet.',
);
expect(recoverySource).not.toContain('No recovery history matches your filters');
expect(recoverySource).not.toContain(
'Adjust your search, platform, method, status, or verification filters.',
);
expect(recoverySource).not.toContain('const eventTimeTextClass =');
expect(recoverySource).not.toContain('const subjectTypeBadgeClass =');
expect(recoverySource).not.toContain('const artifactColumnHeaderClass =');
expect(recoverySource).not.toContain('const artifactRowClass =');
expect(recoverySource).not.toContain('const advancedFilterLabelClass =');
expect(recoverySource).not.toContain('const advancedFilterFieldClass =');
expect(recoverySource).not.toContain('const deriveRollupIssueTone =');
expect(recoverySource).not.toContain('const rollupAgeTextClass =');
expect(recoveryFilterChipPresentationSource).toContain(
'export function getRecoveryFilterChipPresentation',
);
@@ -1355,10 +1155,6 @@ describe('frontend resource type boundaries', () => {
expect(recoveryTimelinePresentationSource).toContain(
'export function getRecoveryTimelineColumnButtonClass',
);
expect(recoverySource).not.toContain('RECOVERY_SUMMARY_TIME_RANGES');
expect(recoverySource).not.toContain('RECOVERY_FRESHNESS_BUCKETS');
expect(recoverySource).not.toContain('function getAttentionChipClass(');
expect(recoverySource).not.toContain('function getAttentionDotClass(');
expect(systemSettingsPresentationSource).toContain('export const PVE_POLLING_PRESETS');
expect(systemSettingsPresentationSource).toContain('export const BACKUP_INTERVAL_OPTIONS');
expect(systemSettingsPresentationSource).toContain(
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import recoveryPointDetailsSource from '@/components/Recovery/RecoveryPointDetails.tsx?raw';
import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
describe('recovery canonical vocabulary', () => {
it('keeps recovery platform filter iterators platform-first', () => {
// The intent of this guardrail is the canonical "platform" naming over
// legacy "provider", not a specific callback shape. Both files now use
// block-body callbacks (`(platform) => { const badge = ...; return ... }`)
// because they need a local for the badge resolution, so the original
// expression-body pattern `(platform) => (` no longer matches.
expect(recoveryProtectedInventorySectionSource).toContain('(platform) =>');
expect(recoveryHistorySectionSource).toContain('(platform) =>');
expect(recoveryProtectedInventorySectionSource).not.toContain('(provider) =>');
expect(recoveryHistorySectionSource).not.toContain('(provider) =>');
});
it('keeps recovery activity focus labels item-first', () => {
expect(recoverySurfaceStateSource).toContain(
'const selectedHistoryItemLabel = createMemo(() => {',
);
expect(recoverySurfaceStateSource).not.toContain(
'const selectedHistorySubjectLabel = createMemo(() => {',
);
expect(recoveryHistorySectionSource).toContain(
'selectedHistoryItemLabel: Accessor<string | null>;',
);
expect(recoveryHistorySectionSource).not.toContain(
'selectedHistorySubjectLabel: Accessor<string | null>;',
);
});
it('keeps recovery detail platform helpers platform-first', () => {
expect(recoveryPointDetailsSource).toContain(
"const isPbsPlatform = createMemo(() => platformKey() === 'proxmox-pbs');",
);
expect(recoveryPointDetailsSource).not.toContain(
"const isPbsProvider = createMemo(() => platformKey() === 'proxmox-pbs');",
);
});
});