mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 21:31:53 +00:00
Split recovery history section owners
This commit is contained in:
@@ -31,18 +31,20 @@ querying, and the operator-facing storage health presentation layer.
|
||||
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`
|
||||
9. `frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx`
|
||||
10. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts`
|
||||
11. `frontend-modern/src/components/Storage/Storage.tsx`
|
||||
12. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
|
||||
13. `frontend-modern/src/hooks/useRecoveryPoints.ts`
|
||||
14. `frontend-modern/src/hooks/useRecoveryRollups.ts`
|
||||
15. `frontend-modern/src/pages/RecoveryRoute.tsx`
|
||||
16. `frontend-modern/src/pages/Dashboard.tsx`
|
||||
17. `frontend-modern/src/pages/DashboardPanels/dashboardWidgets.ts`
|
||||
18. `frontend-modern/src/pages/DashboardPanels/RecoveryStatusPanel.tsx`
|
||||
19. `frontend-modern/src/pages/DashboardPanels/StoragePanel.tsx`
|
||||
20. `frontend-modern/src/types/recovery.ts`
|
||||
21. `frontend-modern/src/utils/recoveryTablePresentation.ts`
|
||||
22. `frontend-modern/src/utils/textPresentation.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -113,7 +115,10 @@ canonical route parsing, filter/query state, transport hook inputs, and URL
|
||||
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.
|
||||
protected inventory, activity, and history presentation layers. The history
|
||||
surface is further split so `RecoveryHistorySection.tsx` owns the toolbar and
|
||||
controller boundary, `useRecoveryHistorySectionState.ts` owns local section UI
|
||||
state, and `RecoveryHistoryTable.tsx` owns the row/detail renderer.
|
||||
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
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import {
|
||||
For,
|
||||
Show,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
} from 'solid-js';
|
||||
import { For, Show } 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,
|
||||
@@ -19,77 +12,31 @@ import {
|
||||
} 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 { RecoveryOutcome } from '@/types/recovery';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getRecoveryEmptyStateActionClass, getRecoveryFilterPanelClearClass, getRecoveryDrawerCloseButtonClass } from '@/utils/recoveryActionPresentation';
|
||||
import { getRecoveryFilterPanelClearClass } from '@/utils/recoveryActionPresentation';
|
||||
import { getRecoveryArtifactModePresentation, type RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
|
||||
import { normalizeRecoveryModeQueryValue } from '@/utils/recoveryRecordPresentation';
|
||||
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';
|
||||
import {
|
||||
RecoveryHistoryTable,
|
||||
type RecoveryPointGroup,
|
||||
type RecoveryPointsModel,
|
||||
} from '@/components/Recovery/RecoveryHistoryTable';
|
||||
import { useRecoveryHistorySectionState } from '@/components/Recovery/useRecoveryHistorySectionState';
|
||||
|
||||
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;
|
||||
@@ -144,58 +91,28 @@ interface RecoveryHistorySectionProps {
|
||||
}
|
||||
|
||||
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);
|
||||
const {
|
||||
advancedFiltersButtonRef,
|
||||
advancedFiltersPanelRef,
|
||||
clearSelectedPoint,
|
||||
historyActiveFilterCount,
|
||||
historyFiltersOpen,
|
||||
moreFiltersOpen,
|
||||
selectedPoint,
|
||||
setHistoryFiltersOpen,
|
||||
setMoreFiltersOpen,
|
||||
toggleSelectedPoint,
|
||||
} = useRecoveryHistorySectionState({
|
||||
clusterFilter: props.clusterFilter,
|
||||
currentPage: props.currentPage,
|
||||
historyOutcomeFilter: props.historyOutcomeFilter,
|
||||
modeFilter: props.modeFilter,
|
||||
namespaceFilter: props.namespaceFilter,
|
||||
nodeFilter: props.nodeFilter,
|
||||
providerFilter: props.providerFilter,
|
||||
queryFilter: props.queryFilter,
|
||||
scopeFilter: props.scopeFilter,
|
||||
verificationFilter: props.verificationFilter,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -449,414 +366,22 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
|
||||
</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>
|
||||
<RecoveryHistoryTable
|
||||
clearSelectedPoint={clearSelectedPoint}
|
||||
currentPage={props.currentPage}
|
||||
groupedByDay={props.groupedByDay}
|
||||
hasActiveArtifactFilters={props.hasActiveArtifactFilters}
|
||||
mobileVisibleArtifactColumns={props.mobileVisibleArtifactColumns}
|
||||
recoveryPoints={props.recoveryPoints}
|
||||
resetAllArtifactFilters={props.resetAllArtifactFilters}
|
||||
resourcesById={props.resourcesById}
|
||||
selectedPoint={selectedPoint}
|
||||
setCurrentPage={props.setCurrentPage}
|
||||
tableColumnCount={props.tableColumnCount}
|
||||
tableMinWidth={props.tableMinWidth}
|
||||
toggleSelectedPoint={toggleSelectedPoint}
|
||||
totalPages={props.totalPages}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
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 { 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 { getRecoveryArtifactModePresentation, type RecoveryArtifactMode } from '@/utils/recoveryArtifactModePresentation';
|
||||
import {
|
||||
getRecoveryHistoryEmptyState,
|
||||
getRecoveryPointsLoadingState,
|
||||
} from '@/utils/recoveryEmptyStatePresentation';
|
||||
import { getRecoveryOutcomeBadgeClass } from '@/utils/recoveryOutcomePresentation';
|
||||
import {
|
||||
getRecoveryPointDetailsSummary,
|
||||
getRecoveryPointRepositoryLabel,
|
||||
getRecoveryPointSubjectLabel,
|
||||
getRecoveryPointTimestampMs,
|
||||
normalizeRecoveryModeQueryValue,
|
||||
} from '@/utils/recoveryRecordPresentation';
|
||||
import {
|
||||
getRecoveryArtifactColumnHeaderClass,
|
||||
getRecoveryArtifactRowClass,
|
||||
getRecoveryEventTimeTextClass,
|
||||
getRecoverySubjectTypeBadgeClass,
|
||||
getRecoverySubjectTypeLabel,
|
||||
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';
|
||||
|
||||
type ArtifactMode = RecoveryArtifactMode;
|
||||
|
||||
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>;
|
||||
groupedByDay: Accessor<RecoveryPointGroup[]>;
|
||||
hasActiveArtifactFilters: Accessor<boolean>;
|
||||
mobileVisibleArtifactColumns: Accessor<ColumnDef[]>;
|
||||
recoveryPoints: RecoveryPointsModel;
|
||||
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) => (
|
||||
<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 = normalizeSourcePlatformQueryValue(
|
||||
String(point.provider || '').trim(),
|
||||
);
|
||||
const mode =
|
||||
normalizeRecoveryModeQueryValue(String(point.mode || '').toLowerCase()) ||
|
||||
'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(
|
||||
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-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 as ArtifactMode,
|
||||
).badgeClassName}`}
|
||||
>
|
||||
{getRecoveryArtifactModePresentation(mode as ArtifactMode).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={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} />
|
||||
</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>
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
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>;
|
||||
historyOutcomeFilter: Accessor<'all' | RecoveryOutcome>;
|
||||
modeFilter: Accessor<'all' | ArtifactMode>;
|
||||
namespaceFilter: Accessor<string>;
|
||||
nodeFilter: Accessor<string>;
|
||||
providerFilter: 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.queryFilter().trim() !== '') count += 1;
|
||||
if (params.providerFilter() !== '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.providerFilter();
|
||||
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)),
|
||||
};
|
||||
}
|
||||
+4
@@ -71,7 +71,9 @@ import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.t
|
||||
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 recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
|
||||
import problemResourcesTableSource from '@/pages/DashboardPanels/ProblemResourcesTable.tsx?raw';
|
||||
import workloadTypeBadgesSource from '@/components/shared/workloadTypeBadges.ts?raw';
|
||||
@@ -110,6 +112,8 @@ const recoverySource = [
|
||||
recoveryProtectedInventorySectionSource,
|
||||
recoveryActivitySectionSource,
|
||||
recoveryHistorySectionSource,
|
||||
recoveryHistoryTableSource,
|
||||
recoveryHistorySectionStateSource,
|
||||
].join('\n');
|
||||
|
||||
describe('monitored-system model guardrails', () => {
|
||||
|
||||
@@ -30,7 +30,9 @@ import recoverySummarySource from '@/components/Recovery/RecoverySummary.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 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 dashboardRecoverySource from '@/hooks/useDashboardRecovery.ts?raw';
|
||||
import recoveryOutcomePresentationSource from '@/utils/recoveryOutcomePresentation.ts?raw';
|
||||
@@ -267,6 +269,8 @@ const recoverySource = [
|
||||
recoveryProtectedInventorySectionSource,
|
||||
recoveryActivitySectionSource,
|
||||
recoveryHistorySectionSource,
|
||||
recoveryHistoryTableSource,
|
||||
recoveryHistorySectionStateSource,
|
||||
].join('\n');
|
||||
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
|
||||
import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw';
|
||||
@@ -429,6 +433,11 @@ describe('frontend resource type boundaries', () => {
|
||||
"import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';",
|
||||
);
|
||||
expect(recoveryComponentSource).toContain('useRecoverySurfaceState');
|
||||
expect(recoveryHistorySectionSource).toContain('useRecoveryHistorySectionState');
|
||||
expect(recoveryHistorySectionSource).toContain('RecoveryHistoryTable');
|
||||
expect(recoveryHistorySectionStateSource).toContain(
|
||||
'export function useRecoveryHistorySectionState',
|
||||
);
|
||||
expect(recoverySource).not.toContain('parseRecoveryLinkSearch');
|
||||
expect(recoverySource).not.toContain('buildRecoveryPath');
|
||||
expect(recoverySource).not.toContain('useRecoveryRollups');
|
||||
|
||||
Reference in New Issue
Block a user