Pulse/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx

535 lines
23 KiB
TypeScript

import { For, Show, createEffect, 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 { StatusDot } from '@/components/shared/StatusDot';
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,
getRecoveryRollupInventoryStatusLabel,
getRecoveryRollupInventoryStatusTextClass,
getRecoveryRollupInventoryStatusVariant,
getRecoverySpecialOutcomeTextClass,
} from '@/utils/recoveryStatusPresentation';
import {
getRecoveryProtectedItemsEmptyState,
getRecoveryProtectedItemsFailureState,
getRecoveryProtectedItemsLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import {
getRecoveryItemTypePresentation,
getRecoveryRollupItemTypeKey,
normalizeRecoveryItemTypeQueryValue,
} from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryRollupInventoryStatus,
getRecoveryRollupInventoryPriority,
getRecoveryRollupAgeTextClass,
getRecoveryProtectedSearchPlaceholder,
getRecoverySearchHistoryEmptyMessage,
getRecoveryRollupTimestampMs,
} from '@/utils/recoveryTablePresentation';
import {
normalizeRecoveryOutcome,
} from '@/utils/recoveryOutcomePresentation';
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
import {
getRecoveryRollupItemLabel,
getRecoveryRollupItemSecondaryLabel,
} from '@/utils/recoveryRecordPresentation';
import { getSourcePlatformLabel, normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
type VerificationFilter = 'all' | 'verified' | 'unverified' | 'unknown';
type ProtectedSortCol = 'item' | 'type' | 'platform' | '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>;
itemTypeFilter: Accessor<string>;
itemTypeOptions: Accessor<string[]>;
isMobile: boolean;
kioskMode: boolean;
onSelectRollup: (rollupId: string) => void;
protectedStaleOnly: Accessor<boolean>;
platformFilter: Accessor<string>;
platformOptions: Accessor<string[]>;
queryFilter: Accessor<string>;
resourcesById: Accessor<Map<string, Resource>>;
rollups: Accessor<ProtectionRollup[]>;
rollupsSummary: Accessor<RecoveryRollupSummary>;
setHistoryOutcomeFilter: (value: 'all' | RecoveryOutcome) => void;
setItemTypeFilter: (value: string) => void;
setProtectedStaleOnly: (value: boolean | ((prev: boolean) => boolean)) => void;
setPlatformFilter: (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>('outcome');
const [protectedSortDir, setProtectedSortDir] = createSignal<SortDir>('desc');
const toggleProtectedSort = (col: ProtectedSortCol) => {
if (protectedSortCol() === col) {
setProtectedSortDir((direction) => (direction === 'asc' ? 'desc' : 'asc'));
} else {
setProtectedSortCol(col);
setProtectedSortDir('asc');
}
};
const protectedActiveFilterCount = createMemo(() => {
let count = 0;
if (props.queryFilter().trim() !== '') count += 1;
if (props.platformFilter() !== 'all') count += 1;
if (props.itemTypeFilter() !== '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 'item': {
const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase();
const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase();
return multiplier * leftLabel.localeCompare(rightLabel);
}
case 'type': {
const leftType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(left))?.label.toLowerCase();
const rightType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(right))?.label.toLowerCase();
return multiplier * (leftType || '').localeCompare(rightType || '');
}
case 'platform': {
const leftSource = getRecoveryRollupPlatforms(left)
.map((platform) => getSourcePlatformLabel(String(platform)))
.sort()
.join(',');
const rightSource = getRecoveryRollupPlatforms(right)
.map((platform) => getSourcePlatformLabel(String(platform)))
.sort()
.join(',');
return multiplier * leftSource.localeCompare(rightSource);
}
case 'lastBackup': {
const leftSuccess = left.lastSuccessAt ? Date.parse(left.lastSuccessAt) : 0;
const rightSuccess = right.lastSuccessAt ? Date.parse(right.lastSuccessAt) : 0;
return multiplier * (leftSuccess - rightSuccess);
}
case 'outcome': {
const leftPriority = getRecoveryRollupInventoryPriority(left);
const rightPriority = getRecoveryRollupInventoryPriority(right);
if (leftPriority !== rightPriority) {
return multiplier * (leftPriority - rightPriority);
}
const leftTimestamp = getRecoveryRollupTimestampMs(left);
const rightTimestamp = getRecoveryRollupTimestampMs(right);
const naturalTieBreak =
leftPriority === 4
? leftTimestamp - rightTimestamp
: rightTimestamp - leftTimestamp;
if (naturalTieBreak !== 0) return multiplier * naturalTieBreak;
const leftOutcome = normalizeRecoveryOutcome(left.lastOutcome);
const rightOutcome = normalizeRecoveryOutcome(right.lastOutcome);
const outcomeCmp = leftOutcome.localeCompare(rightOutcome);
if (outcomeCmp !== 0) return multiplier * outcomeCmp;
const leftLabel = getRecoveryRollupItemLabel(left, resourceIndex).toLowerCase();
const rightLabel = getRecoveryRollupItemLabel(right, resourceIndex).toLowerCase();
return multiplier * leftLabel.localeCompare(rightLabel);
}
default:
return 0;
}
});
return items;
});
createEffect(() => {
props.queryFilter();
props.platformFilter();
props.itemTypeFilter();
props.historyOutcomeFilter();
props.protectedStaleOnly();
protectedSortCol();
protectedSortDir();
});
const resetProtectedFilters = () => {
props.setQueryFilter('');
props.setPlatformFilter('all');
props.setItemTypeFilter('all');
props.setHistoryOutcomeFilter('all');
props.setVerificationFilter('all');
props.setProtectedStaleOnly(false);
};
return (
<div class="flex flex-col gap-2">
<Show when={!props.kioskMode}>
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
<div class="px-4 py-3 sm:px-5">
<PageControls
role="group"
aria-label="Protected items controls"
search={
<SearchInput
value={props.queryFilter}
onChange={(value) => props.setQueryFilter(value)}
placeholder={getRecoveryProtectedSearchPlaceholder()}
inputClass="py-1.5 text-sm"
clearOnEscape
history={{
storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
}}
/>
}
mobileFilters={{
enabled: props.isMobile,
onToggle: () => setProtectedFiltersOpen((open) => !open),
count: protectedActiveFilterCount(),
}}
resetAction={{
show: protectedActiveFilterCount() > 0,
onClick: resetProtectedFilters,
label: 'Reset all',
title: 'Reset protected item filters',
}}
showFilters={!props.isMobile || protectedFiltersOpen()}
toolbarClass="gap-3 lg:flex-nowrap"
>
<LabeledFilterSelect
id="recovery-item-type-filter"
label="Item Type"
value={props.itemTypeFilter()}
onChange={(event) =>
props.setItemTypeFilter(
normalizeRecoveryItemTypeQueryValue(event.currentTarget.value) || 'all',
)
}
groupClass="gap-1.5 px-1.5 py-0.5"
selectClass="py-1 text-xs"
>
<For each={props.itemTypeOptions()}>
{(itemType) => (
<option value={itemType}>
{itemType === 'all'
? 'All Item Types'
: getRecoveryItemTypePresentation(itemType)?.label || itemType}
</option>
)}
</For>
</LabeledFilterSelect>
<LabeledFilterSelect
id="recovery-platform-filter"
label="Platform"
value={props.platformFilter()}
onChange={(event) =>
props.setPlatformFilter(
normalizeSourcePlatformQueryValue(event.currentTarget.value),
)
}
groupClass="gap-1.5 px-1.5 py-0.5"
selectClass="py-1 text-xs"
>
<For each={props.platformOptions()}>
{(platform) => (
<option value={platform}>
{platform === 'all' ? 'All Platforms' : getSourcePlatformLabel(platform)}
</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');
}}
groupClass="gap-1.5 px-1.5 py-0.5"
selectClass="py-1 text-xs"
>
<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>
</Card>
</Show>
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
<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 bg-surface">
<Table
class={`w-full border-collapse whitespace-nowrap table-fixed ${
props.isMobile ? 'min-w-full' : 'min-w-[640px]'
}`}
>
<TableHeader>
<TableRow class="bg-surface-alt/95 text-muted">
{(
[
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
['type', 'Item Type'],
['platform', getRecoveryArtifactColumnLabel('platform', 'Platform')],
['lastBackup', 'Latest Point'],
['outcome', 'Status'],
] as const
).map(([column, label]) => (
<TableHead
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-2 whitespace-nowrap text-left text-[11px] font-medium cursor-pointer select-none hover:text-base-content transition-colors${
column === 'type'
? ' hidden md:table-cell w-[96px]'
: column === 'platform'
? ' hidden lg:table-cell w-[110px]'
: column === 'lastBackup'
? ' w-[120px]'
: column === 'outcome'
? ' w-[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>
<For each={sortedRollups()}>
{(rollup) => {
const resourceIndex = props.resourcesById();
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
const secondaryLabel = getRecoveryRollupItemSecondaryLabel(rollup);
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const inventoryStatus = getRecoveryRollupInventoryStatus(rollup);
const inventoryStatusLabel = getRecoveryRollupInventoryStatusLabel(inventoryStatus);
const platforms = getRecoveryRollupPlatforms(rollup)
.map((platform) => String(platform || '').trim())
.filter(Boolean)
.sort((left, right) =>
getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)),
);
const itemTypePresentation =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null;
const nowMs = Date.now();
const neverSucceeded = inventoryStatus === 'never-succeeded';
return (
<TableRow
class="cursor-pointer odd:bg-surface even:bg-surface-alt/35 transition-colors hover:bg-surface-hover/95"
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<TableCell
class="max-w-[420px] px-3 py-1.5 text-base-content"
title={label}
>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<StatusDot
variant={getRecoveryRollupInventoryStatusVariant(inventoryStatus)}
size="xs"
pulse={inventoryStatus === 'running'}
title={inventoryStatusLabel}
ariaLabel={inventoryStatusLabel}
/>
<div class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate text-[13px] font-medium">{label}</span>
<Show when={secondaryLabel}>
<span class="shrink-0 text-[10px] font-mono tabular-nums text-muted">
{secondaryLabel}
</span>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5 text-[10px] md:hidden">
<Show when={itemTypePresentation?.label}>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
<Show when={platforms.length > 0}>
<For each={platforms.slice(0, 2)}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={`${badge?.classes || ''} lg:hidden`}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</Show>
</div>
</div>
</TableCell>
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-1.5">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted"></span>}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
</TableCell>
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-1.5">
<div class="flex flex-wrap gap-1.5">
<For each={platforms}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={badge?.classes || ''}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</div>
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-1.5 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
title={
successMs > 0
? formatAbsoluteTime(successMs)
: attemptMs > 0
? formatAbsoluteTime(attemptMs)
: undefined
}
>
{successMs > 0 ? (
formatRelativeTime(successMs)
) : neverSucceeded ? (
<span class={getRecoverySpecialOutcomeTextClass('never')}>never</span>
) : (
'—'
)}
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-1.5">
<span
class={`text-[11px] font-medium ${getRecoveryRollupInventoryStatusTextClass(
inventoryStatus,
)}`}
>
{inventoryStatusLabel}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3 text-xs text-muted">
<div>
<Show
when={sortedRollups().length > 0}
fallback={<span>Showing 0 of 0 protected items</span>}
>
<span>Showing {sortedRollups().length} protected items</span>
</Show>
</div>
</div>
</Show>
</Card>
</div>
);
};