refactor(recovery): compact summary and workspace handoff

This commit is contained in:
rcourtman
2026-03-27 22:58:28 +00:00
parent 06dc6adc3a
commit 66cf9b0e1f
5 changed files with 158 additions and 140 deletions
@@ -648,6 +648,11 @@ recovery-specific mini report. Recovery summary cards should prefer concise
counts, bars, and badge-backed coverage cues over tall explanatory copy blocks
so the protected-item table takes over the page at roughly the same visual
density as the infrastructure and workloads monitoring surfaces.
That same summary layout should also stay on the shared monitoring rhythm:
recovery should use the standard four-card `SummaryPanel` grid without letting
one oversized posture card dominate the full strip, so operators can scan
posture, freshness, footprint, and recent history at the same glance depth
they get on infrastructure and workloads.
That same summary rule also applies within individual cards: recovery posture,
freshness, attention, footprint, and history cards should favor compact rows
and metric lists over stacked prose callouts so the summary strip reads like
@@ -485,13 +485,13 @@ const Recovery: Component = () => {
/>
<div ref={historySectionRef} class="flex flex-col gap-4">
<div class="flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-col gap-2 rounded-md border border-border bg-surface px-3 py-1.5 sm:flex-row sm:items-center sm:justify-between">
<Subtabs
value={workspaceView()}
onChange={(value) => setWorkspaceView(value as RecoveryWorkspaceView)}
ariaLabel="Recovery data view"
listClass="flex flex-wrap items-center gap-2"
tabClass="min-h-8 px-2.5 py-1.5 text-sm"
tabClass="min-h-7 px-2.5 py-1 text-sm"
tabs={[
{
value: 'inventory',
@@ -514,7 +514,7 @@ const Recovery: Component = () => {
]}
/>
<Show when={rollupId().trim().length > 0}>
<span class="inline-flex w-fit items-center rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300">
<span class="inline-flex w-fit items-center rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-300">
Focused drill-in
</span>
</Show>
@@ -241,7 +241,7 @@ export const RecoveryProtectedInventorySection: Component<
tone="card"
class="overflow-hidden border-border-subtle bg-surface"
>
<div class="border-b border-border-subtle/80 bg-surface px-4 py-2.5">
<div class="border-b border-border-subtle/80 bg-surface px-4 py-2">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-3 text-[11px] text-muted">
<span class="font-medium text-base-content">
@@ -282,7 +282,7 @@ export const RecoveryProtectedInventorySection: Component<
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border-subtle/80 bg-surface px-4 py-3">
<div class="border-b border-border-subtle/80 bg-surface px-4 py-2.5">
<PageControls
search={
<SearchInput
@@ -58,7 +58,7 @@ describe('RecoverySummary', () => {
expect(screen.getByText('Freshness')).toBeInTheDocument();
expect(screen.getByText('Recent History')).toBeInTheDocument();
expect(screen.getAllByText('Attention Queue').length).toBeGreaterThan(0);
expect(screen.getByText('Stale')).toBeInTheDocument();
expect(screen.getAllByText('Stale').length).toBeGreaterThan(0);
expect(screen.getAllByText('Attention').length).toBeGreaterThan(0);
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
expect(screen.getAllByText('Item Types').length).toBeGreaterThan(0);
@@ -67,6 +67,6 @@ describe('RecoverySummary', () => {
expect(screen.getByText('Platform Mix')).toBeInTheDocument();
expect(screen.getByText('2 protected')).toBeInTheDocument();
expect(screen.getByText('1 healthy')).toBeInTheDocument();
expect(screen.getByText('Never succeeded')).toBeInTheDocument();
expect(screen.getAllByText('Never succeeded').length).toBeGreaterThan(0);
});
});
@@ -48,6 +48,30 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
if (!activitySummary.startLabel || !activitySummary.endLabel) return null;
return `${activitySummary.startLabel} to ${activitySummary.endLabel}`;
});
const attentionItems = createMemo(() =>
[
{
label: 'Stale',
count: summary().stale,
tone: 'amber',
},
{
label: 'Never succeeded',
count: summary().neverSucceeded,
tone: 'rose',
},
{
label: 'Attention',
count: attentionCount(),
tone: 'amber',
},
{
label: 'Running',
count: postureSummary().running,
tone: 'blue',
},
].filter((item) => item.count > 0),
);
const handleTimeRangeChange = (range: string) =>
props.onTimeRangeChange?.(range as RecoverySummaryTimeRange);
@@ -80,144 +104,130 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
timeRangeLabels={RECOVERY_SUMMARY_TIME_RANGE_LABELS}
testId="recovery-summary"
>
<div class="lg:col-span-2">
<SummaryMetricCard label="Recovery Posture" loaded={true} hasData={hasRollups()}>
<div class="grid h-full gap-3 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div class="flex h-full flex-col gap-2.5">
<div class="grid grid-cols-3 gap-2 border-b border-border-subtle pb-2.5 text-sm">
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Healthy</div>
<div class="mt-1 text-xl font-semibold text-emerald-600 dark:text-emerald-400">
{healthyCount()}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Attention</div>
<div class="mt-1 text-xl font-semibold text-amber-600 dark:text-amber-400">
{attentionCount()}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Protected</div>
<div class="mt-1 text-xl font-semibold text-base-content">{summary().total}</div>
</div>
</div>
<div class="space-y-2">
<div class="h-2 overflow-hidden rounded-full bg-surface-alt">
<div class="flex h-full">
<For each={postureSegments()}>
{(segment) => (
<div
class={segment.color}
style={{ width: `${Math.max(segment.percent, segment.count > 0 ? 4 : 0)}%` }}
title={`${segment.label}: ${segment.count}`}
/>
)}
</For>
</div>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-2">
<For each={postureSegments()}>
{(segment) => (
<div class="flex items-center justify-between gap-3 text-sm">
<div class="flex items-center gap-2 text-base-content">
<span class={`h-2 w-2 rounded-full ${segment.color}`} />
<span>{segment.label}</span>
</div>
<span class={`font-semibold tabular-nums ${segment.textColor}`}>
{segment.count}
</span>
</div>
)}
</For>
</div>
</div>
<div class="border-t border-border-subtle pt-2.5">
<div class="mb-2 flex items-center justify-between gap-3 text-[11px] font-medium uppercase tracking-wide text-muted">
<span>Freshness</span>
<Show when={summary().neverSucceeded > 0}>
<span class="text-amber-600 dark:text-amber-400">
{summary().neverSucceeded} never succeeded
</span>
</Show>
</div>
<div class="flex flex-wrap gap-1.5">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface-alt/35 px-2 py-1 text-xs">
<span class={`h-2 w-2 rounded-full ${bucket.color}`} />
<span class="text-base-content">{bucket.label}</span>
<span class="tabular-nums text-base-content">{bucket.count}</span>
</div>
)}
</For>
</div>
<SummaryMetricCard label="Recovery Posture" loaded={true} hasData={hasRollups()}>
<div class="flex h-full flex-col gap-3">
<div class="grid grid-cols-3 gap-2 border-b border-border-subtle pb-2.5 text-sm">
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Healthy</div>
<div class="mt-1 text-xl font-semibold text-emerald-600 dark:text-emerald-400">
{healthyCount()}
</div>
</div>
<div class="border-t border-border-subtle pt-2.5 lg:border-t-0 lg:border-l lg:pl-3 lg:pt-0">
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">
Attention Queue
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Attention</div>
<div class="mt-1 text-xl font-semibold text-amber-600 dark:text-amber-400">
{attentionCount()}
</div>
<div class="flex h-full flex-col gap-2">
<For
each={[
{
label: 'Stale',
count: summary().stale,
detail: 'No successful point inside the active freshness threshold.',
tone: 'amber',
},
{
label: 'Never succeeded',
count: summary().neverSucceeded,
detail: 'Attempts exist but no successful recovery point has landed.',
tone: 'rose',
},
{
label: 'Attention',
count: attentionCount(),
detail: 'Protected items currently outside healthy recovery posture.',
tone: 'amber',
},
{
label: 'Running',
count: postureSummary().running,
detail: 'Recovery jobs are still in progress and not yet final.',
tone: 'blue',
},
].filter((item) => item.count > 0)}
>
{(item) => (
<div class="border-b border-border-subtle pb-2 last:border-b-0 last:pb-0">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-medium text-base-content">
<span
class={`h-2.5 w-2.5 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
/>
<span>{item.label}</span>
</div>
<span
class={`text-sm font-semibold tabular-nums ${
item.tone === 'rose'
? 'text-rose-600 dark:text-rose-400'
: item.tone === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-amber-600 dark:text-amber-400'
}`}
>
{item.count}
</span>
</div>
<div class="mt-1 text-xs leading-5 text-muted">{item.detail}</div>
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Protected</div>
<div class="mt-1 text-xl font-semibold text-base-content">{summary().total}</div>
</div>
</div>
<div class="space-y-2">
<div class="h-2 overflow-hidden rounded-full bg-surface-alt">
<div class="flex h-full">
<For each={postureSegments()}>
{(segment) => (
<div
class={segment.color}
style={{ width: `${Math.max(segment.percent, segment.count > 0 ? 4 : 0)}%` }}
title={`${segment.label}: ${segment.count}`}
/>
)}
</For>
</div>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5">
<For each={postureSegments()}>
{(segment) => (
<div class="flex items-center justify-between gap-3 text-sm">
<div class="flex items-center gap-2 text-base-content">
<span class={`h-2 w-2 rounded-full ${segment.color}`} />
<span>{segment.label}</span>
</div>
<span class={`font-semibold tabular-nums ${segment.textColor}`}>
{segment.count}
</span>
</div>
)}
</For>
</div>
</div>
</SummaryMetricCard>
</div>
</div>
</SummaryMetricCard>
<SummaryMetricCard label="Freshness" loaded={true} hasData={hasRollups()}>
<div class="flex h-full flex-col gap-3">
<dl class="space-y-2 text-sm">
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
<dt class="text-[11px] uppercase tracking-wide text-muted">Stale</dt>
<dd class="font-semibold text-amber-600 dark:text-amber-400">{summary().stale}</dd>
</div>
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
<dt class="text-[11px] uppercase tracking-wide text-muted">Never succeeded</dt>
<dd class="font-semibold text-rose-600 dark:text-rose-400">
{summary().neverSucceeded}
</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Running</dt>
<dd class="font-semibold text-blue-600 dark:text-blue-400">
{postureSummary().running}
</dd>
</div>
</dl>
<div>
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">
Attention Queue
</div>
<div class="space-y-1.5">
<For each={attentionItems()}>
{(item) => (
<div class="flex items-center justify-between gap-3 text-sm">
<div class="flex items-center gap-2 text-base-content">
<span
class={`h-2.5 w-2.5 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
/>
<span>{item.label}</span>
</div>
<span
class={`font-semibold tabular-nums ${
item.tone === 'rose'
? 'text-rose-600 dark:text-rose-400'
: item.tone === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-amber-600 dark:text-amber-400'
}`}
>
{item.count}
</span>
</div>
)}
</For>
</div>
</div>
<div class="border-t border-border-subtle pt-2.5">
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">
Freshness Bands
</div>
<div class="flex flex-wrap gap-1.5">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface-alt/35 px-2 py-1 text-xs">
<span class={`h-2 w-2 rounded-full ${bucket.color}`} />
<span class="text-base-content">{bucket.label}</span>
<span class="tabular-nums text-base-content">{bucket.count}</span>
</div>
)}
</For>
</div>
</div>
</div>
</SummaryMetricCard>
<SummaryMetricCard label="Protected Footprint" loaded={true} hasData={hasRollups()}>
<div class="flex h-full flex-col gap-2.5">
@@ -300,10 +310,13 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
emptyMessage={props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
>
<div class="flex h-full flex-col gap-2.5">
<Show when={recentWindowLabel()}>
<div class="text-xs text-muted">{recentWindowLabel()}</div>
</Show>
<dl class="space-y-2 text-sm">
<Show when={recentWindowLabel()}>
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
<dt class="text-[11px] uppercase tracking-wide text-muted">Window</dt>
<dd class="text-right text-xs text-base-content">{recentWindowLabel()}</dd>
</div>
</Show>
<div class="flex items-center justify-between gap-3 border-b border-border-subtle pb-2">
<dt class="text-[11px] uppercase tracking-wide text-muted">Recovery Points</dt>
<dd class="font-semibold text-base-content">{activity().totalEvents}</dd>