refactor(recovery): elevate workspace and simplify surfaces

This commit is contained in:
rcourtman
2026-03-27 17:53:02 +00:00
parent df05227877
commit a18bebaeb0
6 changed files with 367 additions and 276 deletions
@@ -440,6 +440,9 @@ The summary remains additive to the inventory-first workflow rather than a
replacement for it: Proxmox-heavy operators still land on protected items by
default, but the first visible framing now reflects the whole multi-platform
recovery system instead of a single backup table metaphor.
That top recovery frame must rely on solid elevated operator panels and border
hierarchy rather than decorative gradients so the page reads like a monitoring
workspace instead of a marketing-style dashboard shell.
That same summary contract now also includes platform- and item-coverage
framing through `frontend-modern/src/utils/recoverySummaryPresentation.ts`, so
the first visible recovery overview explicitly shows which protected item
@@ -595,11 +598,14 @@ same active recovery view. That same recovery filter contract now depends on
the canonical recovery index carrying a normalized `itemType` instead of
forcing each UI surface to re-derive protected item classes from raw
provider-native `subjectType` values.
That same recovery product surface keeps the activity timeline available even
when point-history loading fails: `frontend-modern/src/components/Recovery/Recovery.tsx`
must continue to render `RecoveryActivitySection` and the point-history error
card side by side so operators can still inspect backup cadence and active
filter context while recovery-point transport is degraded.
That same recovery product surface keeps the primary workspace visually ahead
of secondary analytics: once the summary frame ends,
`frontend-modern/src/components/Recovery/Recovery.tsx` must lead into the
route-backed recovery workspace and its protected-item or recovery-event table
surface before it drops into `RecoveryActivitySection`, so operators reach the
main control surface ahead of trend telemetry. The activity timeline remains
required even when point-history loading fails, but it is secondary to the
workspace rather than a competing top-of-page peer.
That shared unified-resource dependency now also includes policy-governed
resource metadata on the frontend decode path: storage and recovery surfaces
that route through `frontend-modern/src/hooks/useUnifiedResources.ts` must
@@ -484,57 +484,37 @@ const Recovery: Component = () => {
timeRange={summaryRange}
/>
<RecoveryActivitySection
activitySummary={activitySummary}
activeClusterLabel={activeClusterLabel}
activeItemTypeLabel={activeItemTypeLabel}
activeNamespaceLabel={activeNamespaceLabel}
activeNodeLabel={activeNodeLabel}
chartRangeDays={chartRangeDays}
clearClusterFilter={() => {
setClusterFilter('all');
setCurrentPage(1);
}}
clearFocusedRollup={() => setRollupId('')}
clearItemTypeFilter={() => {
setItemTypeFilter('all');
setCurrentPage(1);
}}
clearNamespaceFilter={() => {
setNamespaceFilter('all');
setCurrentPage(1);
}}
clearNodeFilter={() => {
setNodeFilter('all');
setCurrentPage(1);
}}
clearSelectedDate={() => {
setSelectedDateKey(null);
setCurrentPage(1);
}}
hasFocusedRollup={() => rollupId().trim().length > 0}
isMobile={isMobile()}
loading={() => recoverySeries.response.loading}
overallRollupsSummary={overallRollupsSummary}
selectedDateKey={selectedDateKey}
selectedDateLabel={selectedDateLabel}
selectedHistoryItemLabel={selectedHistoryItemLabel}
setChartRangeDays={(range) => {
setChartRangeDays(range);
setSelectedDateKey(null);
setCurrentPage(1);
}}
timeline={timeline}
toggleSelectedDate={(key) => {
setWorkspaceView('events');
setSelectedDateKey((previous) => (previous === key ? null : key));
setCurrentPage(1);
}}
/>
<div ref={historySectionRef} class="order-1 flex flex-col gap-4">
<Card padding="none" tone="card" class="overflow-hidden">
<div class="px-3 pt-1">
<div ref={historySectionRef} class="flex flex-col gap-4">
<Card
padding="none"
tone="card"
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
>
<div class="border-b border-border-subtle/80 bg-surface px-4 pb-4 pt-4">
<div class="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
Recovery Workspace
</div>
<div class="mt-1 max-w-2xl text-sm text-slate-200">
Run recovery operations from one shared surface: protected inventory first,
event drill-in second, with the same item-first filters and route state.
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-border-subtle bg-surface-hover/70 px-2.5 py-1 text-base-content">
{filteredRollups().length} protected items
</span>
<span class="rounded-full border border-border-subtle bg-surface-hover/70 px-2.5 py-1 text-base-content">
{recoveryPoints.meta().total} recovery events
</span>
<Show when={rollupId().trim().length > 0}>
<span class="rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-amber-200">
Focused drill-in
</span>
</Show>
</div>
</div>
<Subtabs
value={workspaceView()}
onChange={(value) => setWorkspaceView(value as RecoveryWorkspaceView)}
@@ -561,7 +541,7 @@ const Recovery: Component = () => {
]}
/>
</div>
<div class="border-b border-border px-3 pb-3 pt-2 text-xs text-muted">
<div class="bg-surface-hover px-4 py-3 text-xs text-muted">
<Show
when={workspaceView() === 'inventory'}
fallback="Recovery events grouped by day and filtered through one shared item-first recovery model."
@@ -659,6 +639,54 @@ const Recovery: Component = () => {
verificationFilter={verificationFilter}
/>
</Show>
<RecoveryActivitySection
activitySummary={activitySummary}
activeClusterLabel={activeClusterLabel}
activeItemTypeLabel={activeItemTypeLabel}
activeNamespaceLabel={activeNamespaceLabel}
activeNodeLabel={activeNodeLabel}
chartRangeDays={chartRangeDays}
clearClusterFilter={() => {
setClusterFilter('all');
setCurrentPage(1);
}}
clearFocusedRollup={() => setRollupId('')}
clearItemTypeFilter={() => {
setItemTypeFilter('all');
setCurrentPage(1);
}}
clearNamespaceFilter={() => {
setNamespaceFilter('all');
setCurrentPage(1);
}}
clearNodeFilter={() => {
setNodeFilter('all');
setCurrentPage(1);
}}
clearSelectedDate={() => {
setSelectedDateKey(null);
setCurrentPage(1);
}}
hasFocusedRollup={() => rollupId().trim().length > 0}
isMobile={isMobile()}
loading={() => recoverySeries.response.loading}
overallRollupsSummary={overallRollupsSummary}
selectedDateKey={selectedDateKey}
selectedDateLabel={selectedDateLabel}
selectedHistoryItemLabel={selectedHistoryItemLabel}
setChartRangeDays={(range) => {
setChartRangeDays(range);
setSelectedDateKey(null);
setCurrentPage(1);
}}
timeline={timeline}
toggleSelectedDate={(key) => {
setWorkspaceView('events');
setSelectedDateKey((previous) => (previous === key ? null : key));
setCurrentPage(1);
}}
/>
</div>
</div>
);
@@ -81,41 +81,46 @@ interface RecoveryActivitySectionProps {
const rangeOptions: Array<7 | 30 | 90 | 365> = [7, 30, 90, 365];
export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> = (props) => (
<Card padding="sm" class="h-full">
<Card
padding="sm"
class="h-full border-border bg-surface-hover shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
>
<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">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-border-subtle bg-surface/70 px-2.5 py-1 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">
<span class="rounded-full border border-emerald-500/25 bg-emerald-500/8 px-2.5 py-1 text-emerald-300">
{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>
<span class="rounded-full border border-amber-500/25 bg-amber-500/8 px-2.5 py-1 text-amber-200">
{props.overallRollupsSummary().stale} stale
</span>
</Show>
<Show when={props.overallRollupsSummary().neverSucceeded > 0}>
<span class="text-rose-500">
<span class="rounded-full border border-rose-500/25 bg-rose-500/8 px-2.5 py-1 text-rose-200">
{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">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
Recovery Activity
</div>
<div class="text-xs text-muted">
<div class="text-sm text-slate-300">
Daily recovery points across the selected history window.
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<Show when={props.selectedHistoryItemLabel()}>
<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 Item</span>
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface/70 px-2.5 py-1.5 text-xs">
<span class="font-semibold uppercase tracking-[0.14em] text-muted">Focused Item</span>
<span class="max-w-[18rem] truncate font-medium text-base-content">
{props.selectedHistoryItemLabel()}
</span>
@@ -242,8 +247,8 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
</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="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div class="rounded-xl border border-border-subtle 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
@@ -290,7 +295,7 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
</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">
<div class="flex h-28 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);
@@ -299,7 +304,7 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
</For>
</div>
<div class="relative h-44">
<div class="relative h-32">
<div
data-testid="recovery-activity-bars"
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch gap-[3px]"
@@ -159,28 +159,32 @@ export const RecoveryProtectedInventorySection: Component<
});
return (
<Card padding="none" tone="card" class="order-3 overflow-hidden">
<div class="border-b border-border bg-surface-hover px-3 py-2">
<Card
padding="none"
tone="card"
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
>
<div class="border-b border-border-subtle/80 bg-surface px-4 py-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
Protected Items
</div>
<div class="text-xs text-muted">
<div class="max-w-3xl text-sm text-slate-300">
Unified protection inventory across workloads, datasets, and other protected items, with platform mix carried as supporting context.
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-border-subtle bg-surface-hover/70 px-2.5 py-1 text-base-content">
{props.filteredRollups().length} of {props.rollups().length} items shown
</span>
<Show when={props.rollupsSummary().stale > 0}>
<span class={getRecoveryRollupStatusPillClass('stale')}>
<span class={`${getRecoveryRollupStatusPillClass('stale')} px-2.5 py-1`}>
{props.rollupsSummary().stale} stale
</span>
</Show>
<Show when={props.rollupsSummary().neverSucceeded > 0}>
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
<span class={`${getRecoveryRollupStatusPillClass('never-succeeded')} px-2.5 py-1`}>
{props.rollupsSummary().neverSucceeded} never succeeded
</span>
</Show>
@@ -189,7 +193,7 @@ export const RecoveryProtectedInventorySection: Component<
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border px-3 py-3">
<div class="border-b border-border-subtle/80 bg-surface-hover px-4 py-4">
<PageControls
search={
<SearchInput
@@ -312,13 +316,13 @@ export const RecoveryProtectedInventorySection: Component<
</Show>
<Show when={props.filteredRollups().length > 0}>
<div class="overflow-x-auto">
<div class="overflow-x-auto bg-surface">
<Table
class="w-full border-collapse whitespace-nowrap"
style={{ 'table-layout': 'fixed', 'min-width': props.isMobile ? '100%' : '640px' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<TableRow class="border-b border-border bg-surface-alt/95 text-muted">
{(
[
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
@@ -386,7 +390,7 @@ export const RecoveryProtectedInventorySection: Component<
return (
<TableRow
class="cursor-pointer border-b border-border hover:bg-surface-hover"
class="cursor-pointer border-b border-border/80 hover:bg-surface-hover/95"
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<TableCell
@@ -57,7 +57,7 @@ describe('RecoverySummary', () => {
expect(screen.getByText('Protected Footprint')).toBeInTheDocument();
expect(screen.getByText('Freshness')).toBeInTheDocument();
expect(screen.getByText('Recent History')).toBeInTheDocument();
expect(screen.getByText('Attention Queue')).toBeInTheDocument();
expect(screen.getAllByText('Attention Queue').length).toBeGreaterThan(0);
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
expect(screen.getAllByText('Item Types').length).toBeGreaterThan(0);
expect(screen.getByText('Primary Item')).toBeInTheDocument();
@@ -49,25 +49,41 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
return (
<Show when={hasRollups()}>
<Card padding="sm" class="overflow-hidden" data-testid="recovery-summary">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-border-subtle px-1 pb-2 text-[11px]">
<div class="flex flex-wrap items-center gap-3">
<span class="font-medium text-base-content">{summary().total} protected</span>
<span class="text-violet-300">
{itemCoverage().itemTypeCount} item type
{itemCoverage().itemTypeCount === 1 ? '' : 's'}
<Card
padding="none"
class="overflow-hidden border-border bg-surface-hover shadow-[0_14px_34px_rgba(2,6,23,0.14)]"
data-testid="recovery-summary"
>
<div class="flex flex-col gap-4 p-3 sm:p-4">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle/80 bg-surface pb-3 text-[11px]">
<div class="flex flex-wrap items-center gap-2.5">
<span class="inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface/70 px-2.5 py-1 font-medium text-base-content">
<span>{summary().total} protected</span>
</span>
<span class="text-sky-300">
{platformCoverage().platformCount} platform
{platformCoverage().platformCount === 1 ? '' : 's'}
<span class="inline-flex items-center gap-2 rounded-full border border-violet-500/25 bg-violet-500/8 px-2.5 py-1 text-violet-200">
<span>
{itemCoverage().itemTypeCount} item type
{itemCoverage().itemTypeCount === 1 ? '' : 's'}
</span>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-sky-500/25 bg-sky-500/8 px-2.5 py-1 text-sky-200">
<span>
{platformCoverage().platformCount} platform
{platformCoverage().platformCount === 1 ? '' : 's'}
</span>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-emerald-500/25 bg-emerald-500/8 px-2.5 py-1 text-emerald-300">
<span>{healthyCount()} healthy</span>
</span>
<span class="text-emerald-400">{healthyCount()} healthy</span>
<Show when={attentionCount() > 0}>
<span class="text-amber-300">{attentionCount()} attention</span>
<span class="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-amber-200">
<span>{attentionCount()} attention</span>
</span>
</Show>
<Show when={postureSummary().running > 0}>
<span class="text-blue-300">{postureSummary().running} running</span>
<span class="inline-flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-500/10 px-2.5 py-1 text-blue-200">
<span>{postureSummary().running} running</span>
</span>
</Show>
</div>
<Show when={props.onTimeRangeChange}>
@@ -87,27 +103,44 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
</Show>
</div>
<div class="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Recovery Posture
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.85fr)]">
<section class="rounded-xl border border-border bg-surface p-4 shadow-[inset_0_1px_0_rgba(148,163,184,0.04)]">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
Recovery Posture
</div>
<div class="mt-2 max-w-xl text-sm text-slate-300">
Recovery readiness across the current protected estate, with unhealthy
coverage and stale protection surfaced first.
</div>
</div>
<Show when={attentionItems().length > 0}>
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/8 px-3 py-1.5 text-xs text-amber-200">
<span class="font-semibold uppercase tracking-[0.16em]">Attention Queue</span>
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 tabular-nums">
{attentionCount()}
</span>
</div>
</Show>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-3">
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Protected</div>
<div class="mt-1 text-2xl font-semibold text-base-content">{summary().total}</div>
<div class="mt-1 text-3xl font-semibold text-base-content">{summary().total}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Healthy</div>
<div class="mt-1 text-2xl font-semibold text-emerald-400">{healthyCount()}</div>
<div class="mt-1 text-3xl font-semibold text-emerald-400">{healthyCount()}</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Attention</div>
<div class="mt-1 text-2xl font-semibold text-amber-300">{attentionCount()}</div>
<div class="mt-1 text-3xl font-semibold text-amber-300">{attentionCount()}</div>
</div>
</div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-surface-alt">
<div class="mt-5 h-3 overflow-hidden rounded-full bg-surface-alt/90">
<div class="flex h-full">
<For each={postureSegments()}>
{(segment) => (
@@ -121,10 +154,10 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
</div>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<For each={postureSegments()}>
{(segment) => (
<div class="flex items-center justify-between gap-2 rounded border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
<div class="flex items-center justify-between gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs">
<div class="flex items-center gap-2">
<span class={`h-2 w-2 rounded-full ${segment.color}`} />
<span class="text-base-content">{segment.label}</span>
@@ -134,49 +167,132 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
)}
</For>
</div>
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.78fr)_minmax(0,1fr)]">
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Freshness
</div>
<div class="mt-3 space-y-2.5">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="grid grid-cols-[44px_minmax(0,1fr)_28px] items-center gap-2 text-xs">
<span class="text-base-content">{bucket.label}</span>
<div class="h-2 overflow-hidden rounded-full bg-surface-alt">
<div
class={`h-full rounded-full ${bucket.color}`}
style={{ width: `${Math.max(bucket.percent, bucket.count > 0 ? 8 : 0)}%` }}
/>
</div>
<span class="text-right tabular-nums text-base-content">{bucket.count}</span>
</div>
)}
</For>
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Attention Queue
</div>
<Show
when={attentionItems().length > 0}
fallback={<div class="mt-3 text-sm text-emerald-300">No active recovery risks.</div>}
>
<div class="mt-3 grid gap-2 sm:grid-cols-2">
<For each={attentionItems().slice(0, 4)}>
{(item) => (
<div
class={`rounded-lg border px-3 py-2.5 ${getRecoveryAttentionChipClass(item.tone)}`}
>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<span
class={`h-2.5 w-2.5 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
/>
<span class="text-xs font-semibold uppercase tracking-[0.14em]">
{item.label}
</span>
</div>
<span class="text-sm font-semibold tabular-nums">{item.count}</span>
</div>
<div class="mt-1.5 text-xs leading-5 text-current/80">{item.detail}</div>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
</section>
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Protected Footprint
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Item Types</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{itemCoverage().itemTypeCount}
<div class="grid gap-4">
<section class="rounded-xl border border-border bg-surface p-4">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
Protected Footprint
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Item Types</div>
<div class="mt-1 text-3xl font-semibold text-base-content">
{itemCoverage().itemTypeCount}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Item</div>
<div class="mt-1 text-xl font-semibold text-base-content">
{itemCoverage().primaryItemLabel ?? 'n/a'}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Platforms</div>
<div class="mt-1 text-3xl font-semibold text-base-content">
{platformCoverage().platformCount}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Platform</div>
<div class="mt-1 text-xl font-semibold text-base-content">
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
</div>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Item</div>
<div class="mt-1 text-xl font-semibold text-base-content">
{itemCoverage().primaryItemLabel ?? 'n/a'}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Platforms</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{platformCoverage().platformCount}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Platform</div>
<div class="mt-1 text-xl font-semibold text-base-content">
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
</div>
</div>
</div>
<div class="mt-4 space-y-3">
<Show when={itemCoverage().items.length > 0}>
<div class="mt-4 space-y-4">
<Show when={itemCoverage().items.length > 0}>
<div>
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
Item Types
</div>
<div class="flex flex-wrap gap-2">
<For each={itemCoverage().items.slice(0, 6)}>
{(item) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface px-2.5 py-1.5 text-xs">
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
{item.label}
</span>
<span class="tabular-nums text-base-content">{item.count}</span>
<span class="text-muted">{item.percent}%</span>
</div>
)}
</For>
</div>
</div>
</Show>
<div>
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
Item Types
Platform Mix
</div>
<Show when={platformCoverage().multiPlatformCount > 0}>
<div class="mb-2 text-xs text-muted">
{platformCoverage().multiPlatformCount} protected item
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
platforms.
</div>
</Show>
<div class="flex flex-wrap gap-2">
<For each={itemCoverage().items.slice(0, 6)}>
<For each={platformCoverage().items.slice(0, 6)}>
{(item) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface px-2.5 py-1.5 text-xs">
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
{item.label}
</span>
@@ -187,148 +303,80 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
</For>
</div>
</div>
</Show>
<div>
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
Platform Mix
</div>
<Show when={platformCoverage().multiPlatformCount > 0}>
<div class="mb-2 text-xs text-muted">
{platformCoverage().multiPlatformCount} protected item
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
platforms.
</div>
</section>
<section class="rounded-xl border border-border bg-surface p-4">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
Recent History
</div>
</Show>
<div class="flex flex-wrap gap-2">
<For each={platformCoverage().items.slice(0, 6)}>
{(item) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
{item.label}
</span>
<span class="tabular-nums text-base-content">{item.count}</span>
<span class="text-muted">{item.percent}%</span>
<div class="mt-4 grid grid-cols-3 gap-3 text-sm">
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">
Recovery Points
</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().totalEvents}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Avg / Day</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().averagePerDay.toFixed(1)}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="text-[10px] uppercase tracking-wide text-muted">Days Active</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().activeDays}
</div>
</div>
</div>
</div>
</div>
<Show
when={activity().hasData}
fallback={
<div class="mt-6 text-sm text-muted">
{props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
</div>
}
>
<div class="mt-4 flex h-24 items-end gap-1 overflow-hidden rounded-lg border border-border-subtle bg-surface px-2 py-2">
<For each={activity().bars}>
{(bar) => (
<div class="flex h-full flex-1 items-end">
<div
class={`w-full rounded-t-sm ${
bar.isPeak
? 'bg-blue-500'
: bar.isLatest
? 'bg-blue-400'
: 'bg-blue-900/60'
}`}
style={{ height: `${bar.heightPct}%` }}
title={`${bar.day}: ${bar.total} recovery points`}
/>
</div>
)}
</For>
</div>
</div>
</div>
</section>
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Freshness
</div>
<div class="mt-3 space-y-2">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="grid grid-cols-[44px_minmax(0,1fr)_28px] items-center gap-2 text-xs">
<span class="text-base-content">{bucket.label}</span>
<div class="h-2 overflow-hidden rounded-full bg-surface-alt">
<div
class={`h-full rounded-full ${bucket.color}`}
style={{ width: `${Math.max(bucket.percent, bucket.count > 0 ? 8 : 0)}%` }}
/>
</div>
<span class="text-right tabular-nums text-base-content">{bucket.count}</span>
</div>
)}
</For>
</div>
</section>
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Recent History
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
<span>
Peak: {activity().busiestLabel ?? 'n/a'} ({activity().busiestCount})
</span>
<span>
Latest: {activity().latestLabel ?? 'n/a'} ({activity().latestCount})
</span>
</div>
<div class="mt-3 grid grid-cols-3 gap-3 text-sm">
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">
Recovery Points
</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().totalEvents}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Avg / Day</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().averagePerDay.toFixed(1)}
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wide text-muted">Days Active</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().activeDays}
</div>
</div>
</div>
</div>
</div>
<Show
when={activity().hasData}
fallback={
<div class="mt-6 text-sm text-muted">
{props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
</div>
}
>
<div class="mt-4 flex h-16 items-end gap-1 overflow-hidden rounded bg-surface-alt px-1.5 py-1">
<For each={activity().bars}>
{(bar) => (
<div class="flex h-full flex-1 items-end">
<div
class={`w-full rounded-t-sm ${
bar.isPeak
? 'bg-blue-500'
: bar.isLatest
? 'bg-blue-400'
: 'bg-blue-900/60'
}`}
style={{ height: `${bar.heightPct}%` }}
title={`${bar.day}: ${bar.total} recovery points`}
/>
</div>
)}
</For>
</div>
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
<span>
Peak: {activity().busiestLabel ?? 'n/a'} ({activity().busiestCount})
</span>
<span>
Latest: {activity().latestLabel ?? 'n/a'} ({activity().latestCount})
</span>
</div>
</Show>
</section>
</div>
<Show when={attentionItems().length > 0}>
<div class="flex flex-wrap items-center gap-2 border-t border-border-subtle px-1 pt-3">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Attention Queue
</div>
<For each={attentionItems().slice(0, 5)}>
{(item) => (
<div
class={`inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs ${getRecoveryAttentionChipClass(item.tone)}`}
>
<span
class={`h-2 w-2 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
/>
<span class="font-medium">{item.label}</span>
<span class="tabular-nums">{item.count}</span>
</div>
)}
</For>
</Show>
</section>
</div>
</Show>
</div>
</div>
</Card>
</Show>