mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 21:31:53 +00:00
Split recovery surface by product section
This commit is contained in:
@@ -28,18 +28,21 @@ querying, and the operator-facing storage health presentation layer.
|
||||
3. `internal/recovery/store/store.go`
|
||||
4. `frontend-modern/src/components/Recovery/Recovery.tsx`
|
||||
5. `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
|
||||
6. `frontend-modern/src/components/Storage/Storage.tsx`
|
||||
7. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
|
||||
8. `frontend-modern/src/hooks/useRecoveryPoints.ts`
|
||||
9. `frontend-modern/src/hooks/useRecoveryRollups.ts`
|
||||
10. `frontend-modern/src/pages/RecoveryRoute.tsx`
|
||||
11. `frontend-modern/src/pages/Dashboard.tsx`
|
||||
12. `frontend-modern/src/pages/DashboardPanels/dashboardWidgets.ts`
|
||||
13. `frontend-modern/src/pages/DashboardPanels/RecoveryStatusPanel.tsx`
|
||||
14. `frontend-modern/src/pages/DashboardPanels/StoragePanel.tsx`
|
||||
15. `frontend-modern/src/types/recovery.ts`
|
||||
16. `frontend-modern/src/utils/recoveryTablePresentation.ts`
|
||||
17. `frontend-modern/src/utils/textPresentation.ts`
|
||||
6. `frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx`
|
||||
7. `frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx`
|
||||
8. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx`
|
||||
9. `frontend-modern/src/components/Storage/Storage.tsx`
|
||||
10. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
|
||||
11. `frontend-modern/src/hooks/useRecoveryPoints.ts`
|
||||
12. `frontend-modern/src/hooks/useRecoveryRollups.ts`
|
||||
13. `frontend-modern/src/pages/RecoveryRoute.tsx`
|
||||
14. `frontend-modern/src/pages/Dashboard.tsx`
|
||||
15. `frontend-modern/src/pages/DashboardPanels/dashboardWidgets.ts`
|
||||
16. `frontend-modern/src/pages/DashboardPanels/RecoveryStatusPanel.tsx`
|
||||
17. `frontend-modern/src/pages/DashboardPanels/StoragePanel.tsx`
|
||||
18. `frontend-modern/src/types/recovery.ts`
|
||||
19. `frontend-modern/src/utils/recoveryTablePresentation.ts`
|
||||
20. `frontend-modern/src/utils/textPresentation.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -105,10 +108,12 @@ The recovery backend is a real product boundary, not just a helper package:
|
||||
query filtering, and recovery-point indexing for the `/api/recovery/*`
|
||||
surfaces.
|
||||
The recovery frontend now also separates that ownership more explicitly:
|
||||
`frontend-modern/src/components/Recovery/Recovery.tsx` is the product surface,
|
||||
while `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns
|
||||
`frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` owns
|
||||
canonical route parsing, filter/query state, transport hook inputs, and URL
|
||||
synchronization so those concerns do not drift back into the component shell.
|
||||
synchronization, while `frontend-modern/src/components/Recovery/Recovery.tsx`
|
||||
is the composition root for the operator-facing recovery surface and the split
|
||||
section owners under `frontend-modern/src/components/Recovery/` hold the
|
||||
protected inventory, activity, and history presentation layers.
|
||||
That same shared `internal/api/` dependency now also assumes tenant-scoped
|
||||
resource handlers seed registries from canonical unified resources only:
|
||||
recovery- and storage-adjacent API helpers may not fall back to raw tenant
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
import { For, Show } from 'solid-js';
|
||||
import type { Accessor, Component } from 'solid-js';
|
||||
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { hideTooltip, showTooltip } from '@/components/shared/Tooltip';
|
||||
import { segmentedButtonClass } from '@/utils/segmentedButton';
|
||||
import {
|
||||
getRecoveryBreadcrumbLinkClass,
|
||||
} from '@/utils/recoveryActionPresentation';
|
||||
import { getRecoveryFilterChipPresentation } from '@/utils/recoveryFilterChipPresentation';
|
||||
import {
|
||||
getRecoveryActivityEmptyState,
|
||||
getRecoveryActivityLoadingState,
|
||||
} from '@/utils/recoveryEmptyStatePresentation';
|
||||
import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation';
|
||||
import {
|
||||
getRecoveryPrettyDateLabel,
|
||||
getRecoveryCompactAxisLabel,
|
||||
} from '@/utils/recoveryDatePresentation';
|
||||
import {
|
||||
getRecoveryTimelineAxisLabelClass,
|
||||
getRecoveryTimelineBarMinWidthClass,
|
||||
getRecoveryTimelineLabelEvery,
|
||||
RECOVERY_TIMELINE_LEGEND_ITEM_CLASS,
|
||||
RECOVERY_TIMELINE_RANGE_GROUP_CLASS,
|
||||
} from '@/utils/recoveryTimelineChartPresentation';
|
||||
import { getRecoveryTimelineColumnButtonClass } from '@/utils/recoveryTimelinePresentation';
|
||||
|
||||
interface RecoveryRollupSummary {
|
||||
total: number;
|
||||
stale: number;
|
||||
neverSucceeded: number;
|
||||
}
|
||||
|
||||
interface ActivitySummary {
|
||||
totalPoints: number;
|
||||
activeDays: number;
|
||||
averagePerDay: number;
|
||||
}
|
||||
|
||||
interface TimelinePoint {
|
||||
key: string;
|
||||
label: string;
|
||||
total: number;
|
||||
snapshot: number;
|
||||
local: number;
|
||||
remote: number;
|
||||
}
|
||||
|
||||
interface TimelineModel {
|
||||
points: TimelinePoint[];
|
||||
axisMax: number;
|
||||
labelEvery: number;
|
||||
}
|
||||
|
||||
interface RecoveryActivitySectionProps {
|
||||
activitySummary: Accessor<ActivitySummary>;
|
||||
activeClusterLabel: Accessor<string>;
|
||||
activeNamespaceLabel: Accessor<string>;
|
||||
activeNodeLabel: Accessor<string>;
|
||||
chartRangeDays: Accessor<7 | 30 | 90 | 365>;
|
||||
clearClusterFilter: () => void;
|
||||
clearFocusedRollup: () => void;
|
||||
clearNamespaceFilter: () => void;
|
||||
clearNodeFilter: () => void;
|
||||
clearSelectedDate: () => void;
|
||||
hasFocusedRollup: Accessor<boolean>;
|
||||
isMobile: boolean;
|
||||
loading: Accessor<boolean>;
|
||||
overallRollupsSummary: Accessor<RecoveryRollupSummary>;
|
||||
selectedDateKey: Accessor<string | null>;
|
||||
selectedDateLabel: Accessor<string>;
|
||||
selectedHistorySubjectLabel: Accessor<string | null>;
|
||||
setChartRangeDays: (value: 7 | 30 | 90 | 365) => void;
|
||||
toggleSelectedDate: (key: string) => void;
|
||||
timeline: Accessor<TimelineModel>;
|
||||
}
|
||||
|
||||
const rangeOptions: Array<7 | 30 | 90 | 365> = [7, 30, 90, 365];
|
||||
|
||||
export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> = (props) => (
|
||||
<Card padding="sm" class="h-full">
|
||||
<div class="mb-3 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="font-medium text-base-content">
|
||||
{props.overallRollupsSummary().total} protected
|
||||
</span>
|
||||
<Show when={props.overallRollupsSummary().total - props.overallRollupsSummary().stale > 0}>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">
|
||||
{props.overallRollupsSummary().total - props.overallRollupsSummary().stale} healthy
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.overallRollupsSummary().stale > 0}>
|
||||
<span class="text-amber-500">{props.overallRollupsSummary().stale} stale</span>
|
||||
</Show>
|
||||
<Show when={props.overallRollupsSummary().neverSucceeded > 0}>
|
||||
<span class="text-rose-500">
|
||||
{props.overallRollupsSummary().neverSucceeded} never succeeded
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Recovery Activity
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
Daily recovery points across the selected history window.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Show when={props.selectedHistorySubjectLabel()}>
|
||||
<div class="inline-flex items-center gap-2 rounded-md border border-border bg-surface-alt px-2.5 py-1.5 text-xs">
|
||||
<span class="font-semibold uppercase tracking-wide text-muted">Focused</span>
|
||||
<span class="max-w-[18rem] truncate font-medium text-base-content">
|
||||
{props.selectedHistorySubjectLabel()}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.hasFocusedRollup()}
|
||||
fallback={<span class="text-xs text-muted">All protected items</span>}
|
||||
>
|
||||
<button type="button" onClick={props.clearFocusedRollup} class={getRecoveryBreadcrumbLinkClass()}>
|
||||
All history
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
|
||||
<span>{props.activitySummary().totalPoints} recovery points</span>
|
||||
<span>{props.activitySummary().averagePerDay.toFixed(1)} per day</span>
|
||||
<span>{props.activitySummary().activeDays} active days</span>
|
||||
<Show when={props.selectedDateKey()}>
|
||||
<span>{props.selectedDateLabel()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={
|
||||
props.selectedDateKey() ||
|
||||
props.activeClusterLabel() ||
|
||||
props.activeNodeLabel() ||
|
||||
props.activeNamespaceLabel()
|
||||
}
|
||||
>
|
||||
<div class="mb-1 flex flex-wrap items-center gap-1.5">
|
||||
<Show when={props.selectedDateKey()}>
|
||||
{(() => {
|
||||
const chip = getRecoveryFilterChipPresentation('day');
|
||||
return (
|
||||
<div class={chip.className}>
|
||||
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
|
||||
<span class="truncate font-mono text-[10px]" title={props.selectedDateLabel()}>
|
||||
{props.selectedDateLabel()}
|
||||
</span>
|
||||
<button type="button" onClick={props.clearSelectedDate} class={chip.clearButtonClass}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Show>
|
||||
<Show when={props.activeClusterLabel()}>
|
||||
{(() => {
|
||||
const chip = getRecoveryFilterChipPresentation('cluster');
|
||||
return (
|
||||
<div class={chip.className}>
|
||||
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
|
||||
<span class="truncate font-mono text-[10px]" title={props.activeClusterLabel()}>
|
||||
{props.activeClusterLabel()}
|
||||
</span>
|
||||
<button type="button" onClick={props.clearClusterFilter} class={chip.clearButtonClass}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Show>
|
||||
<Show when={props.activeNodeLabel()}>
|
||||
{(() => {
|
||||
const chip = getRecoveryFilterChipPresentation('node');
|
||||
return (
|
||||
<div class={chip.className}>
|
||||
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
|
||||
<span class="truncate font-mono text-[10px]" title={props.activeNodeLabel()}>
|
||||
{props.activeNodeLabel()}
|
||||
</span>
|
||||
<button type="button" onClick={props.clearNodeFilter} class={chip.clearButtonClass}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Show>
|
||||
<Show when={props.activeNamespaceLabel()}>
|
||||
{(() => {
|
||||
const chip = getRecoveryFilterChipPresentation('namespace');
|
||||
return (
|
||||
<div data-testid="active-namespace-chip" class={chip.className}>
|
||||
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
|
||||
<span class="truncate font-mono text-[10px]" title={props.activeNamespaceLabel()}>
|
||||
{props.activeNamespaceLabel()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.clearNamespaceFilter}
|
||||
class={chip.clearButtonClass}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="rounded-md border border-border bg-surface p-3">
|
||||
<div class={`mb-3 flex flex-wrap items-center justify-between gap-3 ${RECOVERY_TIMELINE_RANGE_GROUP_CLASS}`}>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Selected range
|
||||
</div>
|
||||
<div class={RECOVERY_TIMELINE_RANGE_GROUP_CLASS}>
|
||||
<For each={rangeOptions}>
|
||||
{(range) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.setChartRangeDays(range)}
|
||||
class={`px-2 py-1 ${segmentedButtonClass(props.chartRangeDays() === range, false, 'accent')}`}
|
||||
>
|
||||
{range}d
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={!props.loading() && props.timeline().points.length > 0}
|
||||
fallback={
|
||||
<div class="flex h-52 items-center justify-center text-sm text-muted">
|
||||
{props.loading()
|
||||
? getRecoveryActivityLoadingState().text
|
||||
: getRecoveryActivityEmptyState().text}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="mb-2 flex items-center justify-end gap-3 text-[10px] text-muted">
|
||||
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
|
||||
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`} />
|
||||
{getRecoveryArtifactModePresentation('snapshot').label}
|
||||
</div>
|
||||
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
|
||||
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('local').segmentClassName}`} />
|
||||
{getRecoveryArtifactModePresentation('local').label}
|
||||
</div>
|
||||
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
|
||||
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('remote').segmentClassName}`} />
|
||||
{getRecoveryArtifactModePresentation('remote').label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||
<div class="flex h-40 flex-col justify-between text-[10px] text-muted">
|
||||
<For each={[0, 1, 2, 3, 4]}>
|
||||
{(step) => {
|
||||
const value = Math.round((props.timeline().axisMax * (4 - step)) / 4);
|
||||
return <span>{value}</span>;
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="relative h-44">
|
||||
<div class="absolute inset-x-0 bottom-4 top-0 flex items-end gap-[3px]">
|
||||
<For each={props.timeline().points}>
|
||||
{(point) => {
|
||||
const total = point.total;
|
||||
const heightPct =
|
||||
props.timeline().axisMax > 0
|
||||
? (total / props.timeline().axisMax) * 100
|
||||
: 0;
|
||||
const columnHeight = Math.max(0, Math.min(100, heightPct));
|
||||
const snapshotHeight = total > 0 ? (point.snapshot / total) * 100 : 0;
|
||||
const localHeight = total > 0 ? (point.local / total) * 100 : 0;
|
||||
const remoteHeight = total > 0 ? (point.remote / total) * 100 : 0;
|
||||
const isSelected = props.selectedDateKey() === point.key;
|
||||
|
||||
return (
|
||||
<div class="flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class={`h-full w-full rounded-sm ${getRecoveryTimelineColumnButtonClass(isSelected)}`}
|
||||
aria-label={`${getRecoveryPrettyDateLabel(point.key)}: ${total} recovery points`}
|
||||
onClick={() => props.toggleSelectedDate(point.key)}
|
||||
onMouseEnter={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const breakdown: string[] = [];
|
||||
if (point.snapshot > 0) breakdown.push(`Snapshots: ${point.snapshot}`);
|
||||
if (point.local > 0) breakdown.push(`Local: ${point.local}`);
|
||||
if (point.remote > 0) breakdown.push(`Remote: ${point.remote}`);
|
||||
const tooltipText =
|
||||
point.total > 0
|
||||
? `${getRecoveryPrettyDateLabel(point.key)}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}\n${breakdown.join(' • ')}`
|
||||
: `${getRecoveryPrettyDateLabel(point.key)}\nNo recovery points available`;
|
||||
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
onFocus={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const tooltipText = `${getRecoveryPrettyDateLabel(point.key)}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}`;
|
||||
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
});
|
||||
}}
|
||||
onBlur={() => hideTooltip()}
|
||||
>
|
||||
<div class="relative h-full w-full">
|
||||
<Show when={total > 0}>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0"
|
||||
style={{ height: `${columnHeight}%` }}
|
||||
>
|
||||
<Show when={remoteHeight > 0}>
|
||||
<div
|
||||
class={`w-full ${getRecoveryArtifactModePresentation('remote').segmentClassName}`}
|
||||
style={{ height: `${remoteHeight}%` }}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={localHeight > 0}>
|
||||
<div
|
||||
class={`w-full ${getRecoveryArtifactModePresentation('local').segmentClassName}`}
|
||||
style={{ height: `${localHeight}%` }}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={snapshotHeight > 0}>
|
||||
<div
|
||||
class={`w-full ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`}
|
||||
style={{ height: `${snapshotHeight}%` }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-4 flex items-end gap-[3px]">
|
||||
<For each={props.timeline().points}>
|
||||
{(point, index) => {
|
||||
const showLabel =
|
||||
index() % getRecoveryTimelineLabelEvery(props.timeline().points.length) === 0 ||
|
||||
index() === props.timeline().points.length - 1;
|
||||
const isSelected = props.selectedDateKey() === point.key;
|
||||
const barMinWidth = getRecoveryTimelineBarMinWidthClass(
|
||||
props.isMobile,
|
||||
props.chartRangeDays(),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
class={`relative flex-1 ${props.isMobile ? '' : 'shrink-0'} ${barMinWidth}`}
|
||||
>
|
||||
<Show when={showLabel}>
|
||||
<span
|
||||
class={`absolute bottom-0 left-1/2 -translate-x-1/2 whitespace-nowrap text-[9px] ${
|
||||
isSelected
|
||||
? getRecoveryTimelineAxisLabelClass(true)
|
||||
: getRecoveryTimelineAxisLabelClass(false)
|
||||
}`}
|
||||
>
|
||||
{getRecoveryCompactAxisLabel(point.key, props.chartRangeDays())}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,862 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
} from 'solid-js';
|
||||
import type { Accessor, Component } from 'solid-js';
|
||||
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import {
|
||||
FilterActionButton,
|
||||
FilterToolbarPanel,
|
||||
LabeledFilterSelect,
|
||||
filterPanelDescriptionClass,
|
||||
filterPanelTitleClass,
|
||||
filterUtilityBadgeClass,
|
||||
} from '@/components/shared/FilterToolbar';
|
||||
import { PageControls } from '@/components/shared/PageControls';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/shared/Table';
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getRecoveryEmptyStateActionClass, getRecoveryFilterPanelClearClass, getRecoveryDrawerCloseButtonClass } from '@/utils/recoveryActionPresentation';
|
||||
import { getRecoveryArtifactModePresentation, type RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
|
||||
import {
|
||||
getRecoveryHistoryEmptyState,
|
||||
getRecoveryPointsLoadingState,
|
||||
} from '@/utils/recoveryEmptyStatePresentation';
|
||||
import {
|
||||
getRecoveryPointDetailsSummary,
|
||||
getRecoveryPointRepositoryLabel,
|
||||
getRecoveryPointSubjectLabel,
|
||||
getRecoveryPointTimestampMs,
|
||||
normalizeRecoveryModeQueryValue,
|
||||
} from '@/utils/recoveryRecordPresentation';
|
||||
import {
|
||||
getRecoveryArtifactColumnHeaderClass,
|
||||
getRecoveryArtifactRowClass,
|
||||
getRecoveryEventTimeTextClass,
|
||||
getRecoveryHistorySearchPlaceholder,
|
||||
getRecoverySearchHistoryEmptyMessage,
|
||||
getRecoverySubjectTypeBadgeClass,
|
||||
getRecoverySubjectTypeLabel,
|
||||
RECOVERY_ADVANCED_FILTER_FIELD_CLASS,
|
||||
RECOVERY_ADVANCED_FILTER_LABEL_CLASS,
|
||||
RECOVERY_GROUP_HEADER_ROW_CLASS,
|
||||
RECOVERY_GROUP_HEADER_TEXT_CLASS,
|
||||
} from '@/utils/recoveryTablePresentation';
|
||||
import { getRecoveryOutcomeBadgeClass } from '@/utils/recoveryOutcomePresentation';
|
||||
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { formatRecoveryTimeOnly } from '@/utils/recoveryDatePresentation';
|
||||
import { normalizeSourcePlatformQueryValue, getSourcePlatformLabel } from '@/utils/sourcePlatforms';
|
||||
import { RecoveryPointDetails } from '@/components/Recovery/RecoveryPointDetails';
|
||||
|
||||
type ArtifactMode = RecoveryArtifactMode;
|
||||
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
|
||||
|
||||
interface RecoveryPointGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
tone: 'recent' | 'default';
|
||||
items: RecoveryPoint[];
|
||||
}
|
||||
|
||||
interface RecoveryPointsMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface RecoveryPointsModel {
|
||||
meta: Accessor<RecoveryPointsMeta>;
|
||||
response: {
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface PageControlsColumnVisibility {
|
||||
availableToggles: () => ColumnDef[];
|
||||
isHiddenByUser: (id: string) => boolean;
|
||||
toggle: (id: string) => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
interface RecoveryHistorySectionProps {
|
||||
activeAdvancedFilterCount: Accessor<number>;
|
||||
artifactColumnVisibility: PageControlsColumnVisibility;
|
||||
availableOutcomes: readonly ('all' | 'success' | 'warning' | 'failed' | 'running')[];
|
||||
clusterFilter: Accessor<string>;
|
||||
clusterOptions: Accessor<string[]>;
|
||||
currentPage: Accessor<number>;
|
||||
groupedByDay: Accessor<RecoveryPointGroup[]>;
|
||||
hasActiveArtifactFilters: Accessor<boolean>;
|
||||
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
|
||||
isMobile: boolean;
|
||||
kioskMode: boolean;
|
||||
mobileVisibleArtifactColumns: Accessor<ColumnDef[]>;
|
||||
modeFilter: Accessor<'all' | ArtifactMode>;
|
||||
namespaceFilter: Accessor<string>;
|
||||
namespaceOptions: Accessor<string[]>;
|
||||
nodeFilter: Accessor<string>;
|
||||
nodeOptions: Accessor<string[]>;
|
||||
providerFilter: Accessor<string>;
|
||||
providerOptions: Accessor<string[]>;
|
||||
queryFilter: Accessor<string>;
|
||||
recoveryPoints: RecoveryPointsModel;
|
||||
resetAdvancedArtifactFilters: () => void;
|
||||
resetAllArtifactFilters: () => void;
|
||||
resourcesById: Accessor<Map<string, Resource>>;
|
||||
scopeFilter: Accessor<'all' | 'workload'>;
|
||||
setClusterFilter: (value: string) => void;
|
||||
setCurrentPage: (value: number) => void;
|
||||
setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void;
|
||||
setModeFilter: (value: 'all' | ArtifactMode) => void;
|
||||
setNamespaceFilter: (value: string) => void;
|
||||
setNodeFilter: (value: string) => void;
|
||||
setProviderFilter: (value: string) => void;
|
||||
setQueryFilter: (value: string) => void;
|
||||
setScopeFilter: (value: 'all' | 'workload') => void;
|
||||
setVerificationFilter: (value: VerificationFilter) => void;
|
||||
showClusterFilter: Accessor<boolean>;
|
||||
showNamespaceFilter: Accessor<boolean>;
|
||||
showNodeFilter: Accessor<boolean>;
|
||||
showVerificationFilter: Accessor<boolean>;
|
||||
tableColumnCount: Accessor<number>;
|
||||
tableMinWidth: Accessor<string>;
|
||||
totalPages: Accessor<number>;
|
||||
verificationFilter: Accessor<VerificationFilter>;
|
||||
}
|
||||
|
||||
export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (props) => {
|
||||
const [selectedPoint, setSelectedPoint] = createSignal<RecoveryPoint | null>(null);
|
||||
const [moreFiltersOpen, setMoreFiltersOpen] = createSignal(false);
|
||||
const [historyFiltersOpen, setHistoryFiltersOpen] = createSignal(false);
|
||||
let advancedFiltersPanelRef: HTMLDivElement | undefined;
|
||||
let advancedFiltersButtonRef: HTMLButtonElement | undefined;
|
||||
|
||||
const historyActiveFilterCount = () => {
|
||||
let count = 0;
|
||||
if (props.queryFilter().trim() !== '') count += 1;
|
||||
if (props.providerFilter() !== 'all') count += 1;
|
||||
if (props.historyOutcomeFilter() !== 'all') count += 1;
|
||||
if (props.scopeFilter() !== 'all') count += 1;
|
||||
if (props.modeFilter() !== 'all') count += 1;
|
||||
if (props.verificationFilter() !== 'all') count += 1;
|
||||
if (props.clusterFilter() !== 'all') count += 1;
|
||||
if (props.nodeFilter() !== 'all') count += 1;
|
||||
if (props.namespaceFilter() !== 'all') count += 1;
|
||||
return count;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
props.currentPage();
|
||||
props.providerFilter();
|
||||
props.historyOutcomeFilter();
|
||||
props.scopeFilter();
|
||||
props.modeFilter();
|
||||
props.verificationFilter();
|
||||
props.clusterFilter();
|
||||
props.nodeFilter();
|
||||
props.namespaceFilter();
|
||||
props.queryFilter();
|
||||
setSelectedPoint(null);
|
||||
});
|
||||
|
||||
const handleAdvancedFiltersClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (advancedFiltersPanelRef?.contains(target) || advancedFiltersButtonRef?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setMoreFiltersOpen(false);
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
if (moreFiltersOpen()) {
|
||||
document.addEventListener('mousedown', handleAdvancedFiltersClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('mousedown', handleAdvancedFiltersClickOutside);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="none" tone="card" class="mb-4 overflow-hidden">
|
||||
<div class="border-b border-border bg-surface-hover px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Backups By Date
|
||||
</div>
|
||||
<Show when={!props.kioskMode}>
|
||||
<div class="border-b border-border px-3 py-3">
|
||||
<PageControls
|
||||
search={
|
||||
<SearchInput
|
||||
value={props.queryFilter}
|
||||
onChange={(value) => {
|
||||
props.setQueryFilter(value);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
placeholder={getRecoveryHistorySearchPlaceholder()}
|
||||
class="w-full"
|
||||
clearOnEscape
|
||||
history={{
|
||||
storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
|
||||
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
mobileFilters={{
|
||||
enabled: props.isMobile,
|
||||
onToggle: () => setHistoryFiltersOpen((open) => !open),
|
||||
count: historyActiveFilterCount(),
|
||||
}}
|
||||
utilityActions={
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<FilterActionButton
|
||||
ref={advancedFiltersButtonRef}
|
||||
aria-label="Filter"
|
||||
aria-expanded={moreFiltersOpen()}
|
||||
aria-controls="recovery-filter-panel"
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setMoreFiltersOpen((open) => !open)}
|
||||
active={moreFiltersOpen() || props.activeAdvancedFilterCount() > 0}
|
||||
>
|
||||
<span>Filter</span>
|
||||
<Show when={props.activeAdvancedFilterCount() > 0}>
|
||||
<span class={filterUtilityBadgeClass}>
|
||||
{props.activeAdvancedFilterCount()}
|
||||
</span>
|
||||
</Show>
|
||||
</FilterActionButton>
|
||||
|
||||
<Show when={moreFiltersOpen()}>
|
||||
<FilterToolbarPanel ref={advancedFiltersPanelRef} id="recovery-filter-panel">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class={filterPanelTitleClass}>Filter results</div>
|
||||
<div class={filterPanelDescriptionClass}>
|
||||
Narrow by scope, method, verification, or location.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.activeAdvancedFilterCount() > 0}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.resetAdvancedArtifactFilters}
|
||||
class={getRecoveryFilterPanelClearClass()}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Scope</span>
|
||||
<select
|
||||
value={props.scopeFilter()}
|
||||
onChange={(event) => {
|
||||
props.setScopeFilter(
|
||||
event.currentTarget.value === 'workload' ? 'workload' : 'all',
|
||||
);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">All history</option>
|
||||
<option value="workload">Workloads only</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Method</span>
|
||||
<select
|
||||
value={props.modeFilter()}
|
||||
onChange={(event) => {
|
||||
props.setModeFilter(
|
||||
normalizeRecoveryModeQueryValue(event.currentTarget.value),
|
||||
);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">Any method</option>
|
||||
<option value="snapshot">
|
||||
{getRecoveryArtifactModePresentation('snapshot').label}
|
||||
</option>
|
||||
<option value="local">
|
||||
{getRecoveryArtifactModePresentation('local').label}
|
||||
</option>
|
||||
<option value="remote">
|
||||
{getRecoveryArtifactModePresentation('remote').label}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<Show when={props.showVerificationFilter()}>
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Verification</span>
|
||||
<select
|
||||
value={props.verificationFilter()}
|
||||
onChange={(event) => {
|
||||
props.setVerificationFilter(
|
||||
event.currentTarget.value as VerificationFilter,
|
||||
);
|
||||
if (event.currentTarget.value !== 'all') {
|
||||
props.setHistoryOutcomeFilter('all');
|
||||
}
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">Any verification</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="unverified">Unverified</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showClusterFilter()}>
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Cluster</span>
|
||||
<select
|
||||
value={props.clusterFilter()}
|
||||
onChange={(event) => {
|
||||
props.setClusterFilter(event.currentTarget.value);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">Any cluster</option>
|
||||
<For each={props.clusterOptions().filter((value) => value !== 'all')}>
|
||||
{(cluster) => <option value={cluster}>{cluster}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showNodeFilter()}>
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Node or agent</span>
|
||||
<select
|
||||
value={props.nodeFilter()}
|
||||
onChange={(event) => {
|
||||
props.setNodeFilter(event.currentTarget.value);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">Any node or agent</option>
|
||||
<For each={props.nodeOptions().filter((value) => value !== 'all')}>
|
||||
{(node) => <option value={node}>{node}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showNamespaceFilter()}>
|
||||
<label class="flex min-w-0 flex-col gap-1">
|
||||
<span class={RECOVERY_ADVANCED_FILTER_LABEL_CLASS}>Namespace</span>
|
||||
<select
|
||||
value={props.namespaceFilter()}
|
||||
onChange={(event) => {
|
||||
props.setNamespaceFilter(event.currentTarget.value);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
class={RECOVERY_ADVANCED_FILTER_FIELD_CLASS}
|
||||
>
|
||||
<option value="all">Any namespace</option>
|
||||
<For each={props.namespaceOptions().filter((value) => value !== 'all')}>
|
||||
{(namespace) => <option value={namespace}>{namespace}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
</Show>
|
||||
</div>
|
||||
</FilterToolbarPanel>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
columnVisibility={props.artifactColumnVisibility}
|
||||
resetAction={{
|
||||
show: props.hasActiveArtifactFilters(),
|
||||
onClick: props.resetAllArtifactFilters,
|
||||
label: 'Reset all',
|
||||
}}
|
||||
showFilters={!props.isMobile || historyFiltersOpen()}
|
||||
toolbarClass="lg:flex-nowrap"
|
||||
>
|
||||
<LabeledFilterSelect
|
||||
id="recovery-provider-filter-history"
|
||||
label="History provider"
|
||||
value={props.providerFilter()}
|
||||
onChange={(event) => {
|
||||
props.setProviderFilter(
|
||||
normalizeSourcePlatformQueryValue(event.currentTarget.value),
|
||||
);
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
selectClass="min-w-[10rem] max-w-[14rem]"
|
||||
>
|
||||
<For each={props.providerOptions()}>
|
||||
{(provider) => (
|
||||
<option value={provider}>
|
||||
{provider === 'all' ? 'All Providers' : getSourcePlatformLabel(provider)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</LabeledFilterSelect>
|
||||
|
||||
<LabeledFilterSelect
|
||||
id="recovery-status-filter"
|
||||
label="History status"
|
||||
value={props.historyOutcomeFilter()}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value as 'all' | RecoveryOutcome;
|
||||
props.setHistoryOutcomeFilter(value);
|
||||
if (value !== 'all') props.setVerificationFilter('all');
|
||||
props.setCurrentPage(1);
|
||||
}}
|
||||
selectClass="min-w-[7rem]"
|
||||
>
|
||||
<For each={props.availableOutcomes}>
|
||||
{(outcome) => (
|
||||
<option value={outcome}>
|
||||
{outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</LabeledFilterSelect>
|
||||
</PageControls>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={props.groupedByDay().length > 0}
|
||||
fallback={
|
||||
<div class="p-6">
|
||||
<Show
|
||||
when={props.recoveryPoints.response.loading}
|
||||
fallback={
|
||||
<EmptyState
|
||||
{...getRecoveryHistoryEmptyState()}
|
||||
actions={
|
||||
<Show when={props.hasActiveArtifactFilters()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.resetAllArtifactFilters}
|
||||
class={getRecoveryEmptyStateActionClass()}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="text-sm text-muted">{getRecoveryPointsLoadingState().text}</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<Table
|
||||
class="w-full border-collapse text-xs whitespace-nowrap"
|
||||
style={{ 'min-width': props.tableMinWidth(), 'table-layout': 'fixed' }}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-surface-alt text-muted border-b border-border">
|
||||
<For each={props.mobileVisibleArtifactColumns()}>
|
||||
{(column) => (
|
||||
<TableHead
|
||||
class={`py-0.5 px-3 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap ${getRecoveryArtifactColumnHeaderClass(
|
||||
column.id,
|
||||
)}`}
|
||||
>
|
||||
{column.label}
|
||||
</TableHead>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border">
|
||||
<For each={props.groupedByDay()}>
|
||||
{(group) => (
|
||||
<>
|
||||
<TableRow class={RECOVERY_GROUP_HEADER_ROW_CLASS}>
|
||||
<TableCell colSpan={props.tableColumnCount()} class={RECOVERY_GROUP_HEADER_TEXT_CLASS}>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate" title={group.label}>
|
||||
{group.label}
|
||||
</span>
|
||||
<Show when={group.tone === 'recent'}>
|
||||
<span class="inline-flex rounded px-1.5 py-px text-[9px] font-medium border border-border bg-surface">
|
||||
recent
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="shrink-0 font-mono text-[10px] tabular-nums text-muted">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<For each={group.items}>
|
||||
{(point) => {
|
||||
const resourceIndex = props.resourcesById();
|
||||
const subject = getRecoveryPointSubjectLabel(point, resourceIndex);
|
||||
const tsMs = getRecoveryPointTimestampMs(point);
|
||||
const timeOnly =
|
||||
point.completedAt && Number.isFinite(tsMs)
|
||||
? formatRecoveryTimeOnly(tsMs)
|
||||
: '—';
|
||||
const subjectType = getRecoverySubjectTypeLabel(point);
|
||||
const provider = String(point.provider || '').trim();
|
||||
const mode =
|
||||
(normalizeRecoveryModeQueryValue(String(point.mode || '').toLowerCase()) as ArtifactMode) ||
|
||||
'local';
|
||||
const outcome = (String(point.outcome || 'unknown').toLowerCase() as RecoveryOutcome) || 'unknown';
|
||||
const repoLabel = getRecoveryPointRepositoryLabel(point);
|
||||
const detailsSummary = getRecoveryPointDetailsSummary(point);
|
||||
const entityId = String(point.entityId || '').trim();
|
||||
const cluster = String(point.cluster || '').trim();
|
||||
const nodeAgent = String(point.node || '').trim();
|
||||
const namespace = String(point.namespace || '').trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
class={`cursor-pointer ${getRecoveryArtifactRowClass(selectedPoint()?.id === point.id)}`}
|
||||
onClick={() => setSelectedPoint(selectedPoint()?.id === point.id ? null : point)}
|
||||
>
|
||||
<For each={props.mobileVisibleArtifactColumns()}>
|
||||
{(column) => {
|
||||
switch (column.id) {
|
||||
case 'time':
|
||||
return (
|
||||
<TableCell
|
||||
class={`whitespace-nowrap px-3 py-0.5 text-right font-mono text-[11px] tabular-nums ${getRecoveryEventTimeTextClass(
|
||||
tsMs,
|
||||
)}`}
|
||||
>
|
||||
{timeOnly}
|
||||
</TableCell>
|
||||
);
|
||||
case 'type':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
|
||||
<Show when={subjectType} fallback={<span class="text-muted">—</span>}>
|
||||
<span
|
||||
class={`inline-flex min-w-[2.75rem] justify-center rounded px-1.5 py-px text-[9px] font-medium leading-none ${getRecoverySubjectTypeBadgeClass(
|
||||
point,
|
||||
)}`}
|
||||
>
|
||||
{subjectType}
|
||||
</span>
|
||||
</Show>
|
||||
</TableCell>
|
||||
);
|
||||
case 'subject':
|
||||
return (
|
||||
<TableCell
|
||||
class="max-w-[420px] whitespace-nowrap px-3 py-0.5 text-base-content"
|
||||
title={subject}
|
||||
>
|
||||
<div class="flex min-w-0 max-w-full items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate font-medium">
|
||||
{subject}
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 items-center gap-1">
|
||||
<Show when={point.immutable === true}>
|
||||
<svg
|
||||
class="h-3 w-3 text-emerald-500 dark:text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3l7 4v5c0 5-3.5 7.5-7 9-3.5-1.5-7-4-7-9V7l7-4z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<Show when={point.encrypted === true}>
|
||||
<svg
|
||||
class="h-3 w-3 text-amber-500 dark:text-amber-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
case 'entityId':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono tabular-nums">
|
||||
{entityId || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'cluster':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
|
||||
{cluster || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'nodeAgent':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
|
||||
{nodeAgent || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'namespace':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-[11px] text-muted font-mono">
|
||||
{namespace || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'source': {
|
||||
const badge = getSourcePlatformBadge(provider);
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
|
||||
<span
|
||||
class={`${badge?.classes || ''} inline-flex min-w-[3.25rem] justify-center px-1.5 py-px text-[9px] font-medium`}
|
||||
>
|
||||
{badge?.label || getSourcePlatformLabel(provider)}
|
||||
</span>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
case 'verified':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
|
||||
{typeof point.verified === 'boolean' ? (
|
||||
point.verified ? (
|
||||
<span
|
||||
class="inline-flex min-w-[1.25rem] items-center justify-center text-green-600 dark:text-green-400"
|
||||
title="Verified"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
class="inline-flex min-w-[1.25rem] items-center justify-center text-amber-500 dark:text-amber-400"
|
||||
title="Unverified"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.5"
|
||||
d="M12 9v2m0 4h.01M12 3a9 9 0 100 18 9 9 0 000-18z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span class="text-muted">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
case 'size':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-right font-mono text-[11px] tabular-nums text-muted">
|
||||
{point.sizeBytes && point.sizeBytes > 0
|
||||
? formatBytes(point.sizeBytes)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'method':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
|
||||
<span
|
||||
class={`inline-flex min-w-[3.5rem] justify-center rounded px-1.5 py-px text-[9px] font-medium ${getRecoveryArtifactModePresentation(
|
||||
mode,
|
||||
).badgeClassName}`}
|
||||
>
|
||||
{getRecoveryArtifactModePresentation(mode).label}
|
||||
</span>
|
||||
</TableCell>
|
||||
);
|
||||
case 'repository':
|
||||
return (
|
||||
<TableCell
|
||||
class="max-w-[220px] truncate whitespace-nowrap px-3 py-0.5 text-[11px] leading-4 text-base-content"
|
||||
title={repoLabel}
|
||||
>
|
||||
{repoLabel || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'details':
|
||||
return (
|
||||
<TableCell
|
||||
class="max-w-[280px] truncate whitespace-nowrap px-3 py-0.5 text-[10px] leading-4 text-muted"
|
||||
title={detailsSummary}
|
||||
>
|
||||
{detailsSummary || '—'}
|
||||
</TableCell>
|
||||
);
|
||||
case 'outcome':
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-center">
|
||||
<span
|
||||
class={`inline-flex min-w-[4.5rem] justify-center rounded px-1.5 py-px text-[9px] font-medium ${getRecoveryOutcomeBadgeClass(
|
||||
outcome,
|
||||
)}`}
|
||||
>
|
||||
{titleCaseDelimitedLabel(outcome)}
|
||||
</span>
|
||||
</TableCell>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5 text-muted">
|
||||
-
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</For>
|
||||
</TableRow>
|
||||
|
||||
<Show when={selectedPoint()?.id === point.id}>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={props.tableColumnCount()}
|
||||
class="bg-surface-alt px-0 sm:px-4 py-4 relative"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4 pb-2 mb-2 border-b border-border">
|
||||
<h2 class="text-sm font-semibold text-base-content">
|
||||
Recovery Point Details
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedPoint(null);
|
||||
}}
|
||||
class={getRecoveryDrawerCloseButtonClass()}
|
||||
aria-label="Close details"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<RecoveryPointDetails point={point} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 px-3 py-2 text-xs text-muted border-t border-border">
|
||||
<div>
|
||||
<Show
|
||||
when={(props.recoveryPoints.meta().total || 0) > 0}
|
||||
fallback={<span>Showing 0 of 0 recovery points</span>}
|
||||
>
|
||||
<span>
|
||||
Showing {(props.recoveryPoints.meta().page - 1) * props.recoveryPoints.meta().limit + 1} -{' '}
|
||||
{Math.min(
|
||||
props.recoveryPoints.meta().page * props.recoveryPoints.meta().limit,
|
||||
props.recoveryPoints.meta().total,
|
||||
)}{' '}
|
||||
of {props.recoveryPoints.meta().total} recovery points
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.currentPage() <= 1}
|
||||
onClick={() => props.setCurrentPage(Math.max(1, props.currentPage() - 1))}
|
||||
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {props.currentPage()} / {props.totalPages()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.currentPage() >= props.totalPages()}
|
||||
onClick={() => props.setCurrentPage(Math.min(props.totalPages(), props.currentPage() + 1))}
|
||||
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,425 @@
|
||||
import { For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import type { Accessor, Component } from 'solid-js';
|
||||
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { LabeledFilterSelect } from '@/components/shared/FilterToolbar';
|
||||
import { PageControls } from '@/components/shared/PageControls';
|
||||
import { SearchInput } from '@/components/shared/SearchInput';
|
||||
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
|
||||
import type { ProtectionRollup, RecoveryOutcome } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getRecoveryProtectedToggleClass,
|
||||
getRecoveryRollupStatusPillClass,
|
||||
getRecoveryRollupStatusPillLabel,
|
||||
getRecoverySpecialOutcomeTextClass,
|
||||
} from '@/utils/recoveryStatusPresentation';
|
||||
import {
|
||||
getRecoveryProtectedItemsEmptyState,
|
||||
getRecoveryProtectedItemsFailureState,
|
||||
getRecoveryProtectedItemsLoadingState,
|
||||
} from '@/utils/recoveryEmptyStatePresentation';
|
||||
import {
|
||||
getRecoveryRollupAgeTextClass,
|
||||
getRecoveryRollupIssueTone,
|
||||
getRecoveryProtectedSearchPlaceholder,
|
||||
getRecoverySearchHistoryEmptyMessage,
|
||||
isRecoveryRollupStale,
|
||||
} from '@/utils/recoveryTablePresentation';
|
||||
import {
|
||||
getRecoveryOutcomeBadgeClass,
|
||||
normalizeRecoveryOutcome,
|
||||
} from '@/utils/recoveryOutcomePresentation';
|
||||
import { getRecoveryIssueRailClass, type RecoveryIssueTone } from '@/utils/recoveryIssuePresentation';
|
||||
import { getRecoveryRollupSubjectLabel } from '@/utils/recoveryRecordPresentation';
|
||||
import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
|
||||
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
|
||||
|
||||
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
|
||||
type ProtectedSortCol = 'subject' | 'source' | 'lastBackup' | 'outcome';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface RecoveryRollupSummary {
|
||||
total: number;
|
||||
counts: Record<RecoveryOutcome, number>;
|
||||
stale: number;
|
||||
neverSucceeded: number;
|
||||
}
|
||||
|
||||
interface RecoveryProtectedInventorySectionProps {
|
||||
filteredRollups: Accessor<ProtectionRollup[]>;
|
||||
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
|
||||
isMobile: boolean;
|
||||
kioskMode: boolean;
|
||||
onSelectRollup: (rollupId: string) => void;
|
||||
protectedStaleOnly: Accessor<boolean>;
|
||||
providerFilter: Accessor<string>;
|
||||
providerOptions: Accessor<string[]>;
|
||||
queryFilter: Accessor<string>;
|
||||
resourcesById: Accessor<Map<string, Resource>>;
|
||||
rollups: Accessor<ProtectionRollup[]>;
|
||||
rollupsSummary: Accessor<RecoveryRollupSummary>;
|
||||
setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void;
|
||||
setProtectedStaleOnly: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setProviderFilter: (value: string) => void;
|
||||
setQueryFilter: (value: string) => void;
|
||||
setVerificationFilter: (value: VerificationFilter) => void;
|
||||
loading: Accessor<boolean>;
|
||||
error: Accessor<unknown>;
|
||||
}
|
||||
|
||||
const availableOutcomes = ['all', 'success', 'warning', 'failed', 'running'] as const;
|
||||
|
||||
export const RecoveryProtectedInventorySection: Component<
|
||||
RecoveryProtectedInventorySectionProps
|
||||
> = (props) => {
|
||||
const [protectedFiltersOpen, setProtectedFiltersOpen] = createSignal(false);
|
||||
const [protectedSortCol, setProtectedSortCol] = createSignal<ProtectedSortCol>('lastBackup');
|
||||
const [protectedSortDir, setProtectedSortDir] = createSignal<SortDir>('desc');
|
||||
|
||||
const toggleProtectedSort = (col: ProtectedSortCol) => {
|
||||
if (protectedSortCol() === col) {
|
||||
setProtectedSortDir((direction) => (direction === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setProtectedSortCol(col);
|
||||
setProtectedSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const protectedActiveFilterCount = createMemo(() => {
|
||||
let count = 0;
|
||||
if (props.queryFilter().trim() !== '') count += 1;
|
||||
if (props.providerFilter() !== 'all') count += 1;
|
||||
if (props.historyOutcomeFilter() !== 'all') count += 1;
|
||||
if (props.protectedStaleOnly()) count += 1;
|
||||
return count;
|
||||
});
|
||||
|
||||
const sortedRollups = createMemo<ProtectionRollup[]>(() => {
|
||||
const items = props.filteredRollups().slice();
|
||||
const sortColumn = protectedSortCol();
|
||||
const sortDirection = protectedSortDir();
|
||||
const resourceIndex = props.resourcesById();
|
||||
const multiplier = sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
items.sort((left, right) => {
|
||||
switch (sortColumn) {
|
||||
case 'subject': {
|
||||
const leftLabel = getRecoveryRollupSubjectLabel(left, resourceIndex).toLowerCase();
|
||||
const rightLabel = getRecoveryRollupSubjectLabel(right, resourceIndex).toLowerCase();
|
||||
return multiplier * leftLabel.localeCompare(rightLabel);
|
||||
}
|
||||
case 'source': {
|
||||
const leftSource = (left.providers || [])
|
||||
.map((provider) => getSourcePlatformLabel(String(provider)))
|
||||
.sort()
|
||||
.join(',');
|
||||
const rightSource = (right.providers || [])
|
||||
.map((provider) => getSourcePlatformLabel(String(provider)))
|
||||
.sort()
|
||||
.join(',');
|
||||
return multiplier * leftSource.localeCompare(rightSource);
|
||||
}
|
||||
case 'lastBackup': {
|
||||
const leftSuccess = left.lastSuccessAt ? Date.parse(left.lastSuccessAt) : 0;
|
||||
const rightSuccess = right.lastSuccessAt ? Date.parse(right.lastSuccessAt) : 0;
|
||||
return multiplier * (leftSuccess - rightSuccess);
|
||||
}
|
||||
case 'outcome': {
|
||||
const leftOutcome = normalizeRecoveryOutcome(left.lastOutcome);
|
||||
const rightOutcome = normalizeRecoveryOutcome(right.lastOutcome);
|
||||
return multiplier * leftOutcome.localeCompare(rightOutcome);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="none" tone="card" class="order-3 overflow-hidden">
|
||||
<div class="border-b border-border bg-surface-hover px-3 py-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Protected Inventory
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>
|
||||
{props.filteredRollups().length} of {props.rollups().length} items shown
|
||||
</span>
|
||||
<Show when={props.rollupsSummary().stale > 0}>
|
||||
<span class={getRecoveryRollupStatusPillClass('stale')}>
|
||||
{props.rollupsSummary().stale} stale
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.rollupsSummary().neverSucceeded > 0}>
|
||||
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
|
||||
{props.rollupsSummary().neverSucceeded} never succeeded
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={!props.kioskMode}>
|
||||
<div class="border-b border-border px-3 py-3">
|
||||
<PageControls
|
||||
search={
|
||||
<SearchInput
|
||||
value={props.queryFilter}
|
||||
onChange={(value) => props.setQueryFilter(value)}
|
||||
placeholder={getRecoveryProtectedSearchPlaceholder()}
|
||||
class="w-full"
|
||||
clearOnEscape
|
||||
history={{
|
||||
storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
|
||||
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
mobileFilters={{
|
||||
enabled: props.isMobile,
|
||||
onToggle: () => setProtectedFiltersOpen((open) => !open),
|
||||
count: protectedActiveFilterCount(),
|
||||
}}
|
||||
showFilters={!props.isMobile || protectedFiltersOpen()}
|
||||
toolbarClass="lg:flex-nowrap"
|
||||
>
|
||||
<LabeledFilterSelect
|
||||
id="recovery-provider-filter"
|
||||
label="Provider"
|
||||
value={props.providerFilter()}
|
||||
onChange={(event) =>
|
||||
props.setProviderFilter(
|
||||
normalizeSourcePlatformQueryValue(event.currentTarget.value),
|
||||
)
|
||||
}
|
||||
selectClass="min-w-[10rem] max-w-[14rem]"
|
||||
>
|
||||
<For each={props.providerOptions()}>
|
||||
{(provider) => (
|
||||
<option value={provider}>
|
||||
{provider === 'all' ? 'All Providers' : getSourcePlatformLabel(provider)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</LabeledFilterSelect>
|
||||
|
||||
<LabeledFilterSelect
|
||||
id="recovery-protected-status-filter"
|
||||
label="Latest status"
|
||||
value={props.historyOutcomeFilter()}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value as 'all' | RecoveryOutcome;
|
||||
props.setHistoryOutcomeFilter(value);
|
||||
if (value !== 'all') props.setVerificationFilter('all');
|
||||
}}
|
||||
selectClass="min-w-[9rem]"
|
||||
>
|
||||
<For each={availableOutcomes}>
|
||||
{(outcome) => (
|
||||
<option value={outcome}>
|
||||
{outcome === 'all' ? 'Any status' : titleCaseDelimitedLabel(outcome)}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</LabeledFilterSelect>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={props.protectedStaleOnly()}
|
||||
onClick={() => props.setProtectedStaleOnly((value) => !value)}
|
||||
class={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${getRecoveryProtectedToggleClass(
|
||||
props.protectedStaleOnly(),
|
||||
)}`}
|
||||
>
|
||||
Stale only
|
||||
</button>
|
||||
</PageControls>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading() && props.filteredRollups().length === 0}>
|
||||
<div class="px-6 py-6 text-sm text-muted">
|
||||
{getRecoveryProtectedItemsLoadingState().text}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.loading() && props.error()}>
|
||||
<div class="p-6">
|
||||
<EmptyState
|
||||
title={getRecoveryProtectedItemsFailureState().title}
|
||||
description={String((props.error() as Error)?.message || props.error())}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!props.loading() && !props.error() && props.filteredRollups().length === 0}
|
||||
>
|
||||
<div class="p-6">
|
||||
<EmptyState {...getRecoveryProtectedItemsEmptyState()} />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.filteredRollups().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<Table
|
||||
class="w-full border-collapse whitespace-nowrap"
|
||||
style={{ 'table-layout': 'fixed', 'min-width': props.isMobile ? '100%' : '500px' }}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-surface-alt text-muted border-b border-border">
|
||||
{(
|
||||
[
|
||||
['subject', 'Subject'],
|
||||
['source', 'Source'],
|
||||
['lastBackup', 'Last Backup'],
|
||||
['outcome', 'Outcome'],
|
||||
] as const
|
||||
).map(([column, label]) => (
|
||||
<TableHead
|
||||
class={`py-0.5 px-3 whitespace-nowrap text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer select-none hover:text-base-content transition-colors${
|
||||
column === 'source'
|
||||
? ' hidden md:table-cell w-[110px]'
|
||||
: column === 'lastBackup'
|
||||
? ' w-[120px]'
|
||||
: column === 'outcome'
|
||||
? ' w-[70px]'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => toggleProtectedSort(column)}
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<Show when={protectedSortCol() === column}>
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
|
||||
{protectedSortDir() === 'asc' ? (
|
||||
<path d="M6 3l3.5 5h-7z" />
|
||||
) : (
|
||||
<path d="M6 9l3.5-5h-7z" />
|
||||
)}
|
||||
</svg>
|
||||
</Show>
|
||||
</span>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border">
|
||||
<For each={sortedRollups()}>
|
||||
{(rollup) => {
|
||||
const resourceIndex = props.resourcesById();
|
||||
const label = getRecoveryRollupSubjectLabel(rollup, resourceIndex);
|
||||
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
|
||||
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
|
||||
const outcome = normalizeRecoveryOutcome(rollup.lastOutcome);
|
||||
const providers = (rollup.providers || [])
|
||||
.slice()
|
||||
.map((provider) => String(provider || '').trim())
|
||||
.filter(Boolean)
|
||||
.sort((left, right) =>
|
||||
getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)),
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const issueTone: RecoveryIssueTone = getRecoveryRollupIssueTone(rollup, nowMs);
|
||||
const issueRailClass =
|
||||
issueTone === 'none' ? '' : getRecoveryIssueRailClass(issueTone);
|
||||
const stale = isRecoveryRollupStale(rollup, nowMs);
|
||||
const neverSucceeded =
|
||||
(!Number.isFinite(successMs) || successMs <= 0) &&
|
||||
Number.isFinite(attemptMs) &&
|
||||
attemptMs > 0;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
class="cursor-pointer border-b border-border hover:bg-surface-hover"
|
||||
onClick={() => props.onSelectRollup(rollup.rollupId)}
|
||||
>
|
||||
<TableCell
|
||||
class={`relative max-w-[420px] truncate whitespace-nowrap px-3 py-0.5 text-base-content ${
|
||||
issueTone === 'rose' || issueTone === 'blue' ? 'font-medium' : ''
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<Show when={issueTone !== 'none'}>
|
||||
<span class={`absolute inset-y-0 left-0 w-0.5 ${issueRailClass}`} />
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate">{label}</span>
|
||||
<Show when={neverSucceeded}>
|
||||
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
|
||||
{getRecoveryRollupStatusPillLabel('never-succeeded')}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!neverSucceeded && stale}>
|
||||
<span class={getRecoveryRollupStatusPillClass('stale')}>
|
||||
{getRecoveryRollupStatusPillLabel('stale')}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-0.5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={providers}>
|
||||
{(provider) => {
|
||||
const badge = getSourcePlatformBadge(provider);
|
||||
return (
|
||||
<span class={badge?.classes || ''}>
|
||||
{badge?.label || getSourcePlatformLabel(provider)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
class={`whitespace-nowrap px-3 py-0.5 ${getRecoveryRollupAgeTextClass(
|
||||
rollup,
|
||||
nowMs,
|
||||
)}`}
|
||||
title={
|
||||
successMs > 0
|
||||
? formatAbsoluteTime(successMs)
|
||||
: attemptMs > 0
|
||||
? formatAbsoluteTime(attemptMs)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{successMs > 0 ? (
|
||||
formatRelativeTime(successMs)
|
||||
) : neverSucceeded ? (
|
||||
<span class={getRecoverySpecialOutcomeTextClass('never')}>never</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5">
|
||||
<span
|
||||
class={`inline-flex rounded px-1.5 py-0.5 text-[10px] font-medium ${getRecoveryOutcomeBadgeClass(
|
||||
outcome,
|
||||
)}`}
|
||||
>
|
||||
{titleCaseDelimitedLabel(outcome)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+11
-1
@@ -68,7 +68,10 @@ import resourceStateAdaptersSource from '@/utils/resourceStateAdapters.ts?raw';
|
||||
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
|
||||
import resourceBadgesSource from '@/components/Infrastructure/resourceBadges.ts?raw';
|
||||
import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw';
|
||||
import recoverySource from '@/components/Recovery/Recovery.tsx?raw';
|
||||
import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw';
|
||||
import recoveryActivitySectionSource from '@/components/Recovery/RecoveryActivitySection.tsx?raw';
|
||||
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
|
||||
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
|
||||
import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
|
||||
import problemResourcesTableSource from '@/pages/DashboardPanels/ProblemResourcesTable.tsx?raw';
|
||||
import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw';
|
||||
@@ -102,6 +105,13 @@ import apiTokenPresentationSource from '@/utils/apiTokenPresentation.ts?raw';
|
||||
import ssoProviderPresentationSource from '@/utils/ssoProviderPresentation.ts?raw';
|
||||
import systemSettingsPresentationSource from '@/utils/systemSettingsPresentation.ts?raw';
|
||||
|
||||
const recoverySource = [
|
||||
recoveryComponentSource,
|
||||
recoveryProtectedInventorySectionSource,
|
||||
recoveryActivitySectionSource,
|
||||
recoveryHistorySectionSource,
|
||||
].join('\n');
|
||||
|
||||
describe('monitored-system model guardrails', () => {
|
||||
it('keeps AgentProfilesPanel on unified resources (not host-only slices)', () => {
|
||||
expect(agentProfilesPanelSource).toContain('const { resources } = useResources()');
|
||||
|
||||
@@ -27,7 +27,10 @@ import infrastructurePageSource from '@/pages/Infrastructure.tsx?raw';
|
||||
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
|
||||
import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw';
|
||||
import recoverySummarySource from '@/components/Recovery/RecoverySummary.tsx?raw';
|
||||
import recoverySource from '@/components/Recovery/Recovery.tsx?raw';
|
||||
import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw';
|
||||
import recoveryActivitySectionSource from '@/components/Recovery/RecoveryActivitySection.tsx?raw';
|
||||
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
|
||||
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
|
||||
import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw';
|
||||
import dashboardRecoverySource from '@/hooks/useDashboardRecovery.ts?raw';
|
||||
import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw';
|
||||
@@ -258,6 +261,13 @@ import organizationOverviewPanelSource from '@/components/Settings/OrganizationO
|
||||
import organizationSharingPanelSource from '@/components/Settings/OrganizationSharingPanel.tsx?raw';
|
||||
import organizationRolePresentationSource from '@/utils/organizationRolePresentation.ts?raw';
|
||||
import organizationSettingsPresentationSource from '@/utils/organizationSettingsPresentation.ts?raw';
|
||||
|
||||
const recoverySource = [
|
||||
recoveryComponentSource,
|
||||
recoveryProtectedInventorySectionSource,
|
||||
recoveryActivitySectionSource,
|
||||
recoveryHistorySectionSource,
|
||||
].join('\n');
|
||||
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
|
||||
import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw';
|
||||
import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?raw';
|
||||
@@ -418,6 +428,7 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(recoverySource).toContain(
|
||||
"import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';",
|
||||
);
|
||||
expect(recoveryComponentSource).toContain('useRecoverySurfaceState');
|
||||
expect(recoverySource).not.toContain('parseRecoveryLinkSearch');
|
||||
expect(recoverySource).not.toContain('buildRecoveryPath');
|
||||
expect(recoverySource).not.toContain('useRecoveryRollups');
|
||||
@@ -431,7 +442,9 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(recoverySurfaceStateSource).toContain('useRecoveryPointsSeries');
|
||||
expect(recoverySource).toContain('getRecoveryFilterChipPresentation');
|
||||
expect(recoverySource).not.toContain('const titleize =');
|
||||
expect(recoverySource).toContain("segmentedButtonClass(chartRangeDays() === range, false, 'accent')");
|
||||
expect(recoverySource).toContain(
|
||||
"segmentedButtonClass(props.chartRangeDays() === range, false, 'accent')",
|
||||
);
|
||||
expect(recoverySource).not.toContain('border-blue-200 bg-blue-50 px-2 py-0.5');
|
||||
expect(recoverySource).not.toContain('border-cyan-200 bg-cyan-50 px-2 py-0.5');
|
||||
expect(recoverySource).not.toContain('border-emerald-200 bg-emerald-50 px-2 py-0.5');
|
||||
|
||||
Reference in New Issue
Block a user