refactor(recovery): align summary and table hierarchy

This commit is contained in:
rcourtman
2026-03-27 22:39:45 +00:00
parent 9530be2c1b
commit 6718dd68e8
6 changed files with 192 additions and 126 deletions
@@ -648,6 +648,10 @@ 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 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
Pulse monitoring telemetry rather than a page-local narrative panel.
That same inventory surface should also follow the established monitoring-table
scan pattern in its first column. Protected-item rows should lead with a clear
status cue, the primary item name, and compact badge-backed item/platform
@@ -658,6 +662,11 @@ not just local row styling. When the protected-item page mixes healthy and
problematic coverage, the table body should section attention items ahead of
healthy coverage so operators can scan the recovery estate the way they scan
other grouped monitoring tables in Pulse.
That same table-shell contract must avoid duplicate framing once the view tabs
already establish the active workspace. `RecoveryProtectedInventorySection.tsx`
should keep page/count/sort orientation inside a slim table-shell status row
and let the filter strip lead directly into the grid instead of reintroducing a
second large inventory header card above the same table.
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
@@ -485,13 +485,13 @@ const Recovery: Component = () => {
/>
<div ref={historySectionRef} class="flex flex-col gap-4">
<div class="flex flex-col gap-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-2 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-3"
tabClass="min-h-9 px-2 py-2 text-sm"
listClass="flex flex-wrap items-center gap-2"
tabClass="min-h-8 px-2.5 py-1.5 text-sm"
tabs={[
{
value: 'inventory',
@@ -239,15 +239,34 @@ export const RecoveryProtectedInventorySection: Component<
<Card
padding="none"
tone="card"
class="overflow-hidden border-border-subtle bg-surface shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
class="overflow-hidden border-border-subtle bg-surface"
>
<div class="border-b border-border-subtle/80 bg-surface px-4 py-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-semibold text-base-content">Protected inventory</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">
<div class="border-b border-border-subtle/80 bg-surface px-4 py-2.5">
<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">
{sortedRollups().length} of {props.rollups().length} items shown
</span>
<span>
Page {protectedPage()} / {protectedTotalPages()}
</span>
<span>
Sort:{' '}
<span class="font-medium text-base-content">
{protectedSortCol() === 'lastBackup'
? 'Latest point'
: protectedSortCol() === 'item'
? 'Item'
: protectedSortCol() === 'type'
? 'Item type'
: protectedSortCol() === 'platform'
? 'Platform'
: 'Outcome'}
</span>{' '}
<span class="uppercase tracking-wide">{protectedSortDir()}</span>
</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<Show when={props.rollupsSummary().stale > 0}>
<span class={`${getRecoveryRollupStatusPillClass('stale')} px-2.5 py-1`}>
{props.rollupsSummary().stale} stale
@@ -263,7 +282,7 @@ export const RecoveryProtectedInventorySection: Component<
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border-subtle/80 bg-surface-alt/35 px-4 py-4">
<div class="border-b border-border-subtle/80 bg-surface px-4 py-3">
<PageControls
search={
<SearchInput
@@ -271,7 +290,7 @@ export const RecoveryProtectedInventorySection: Component<
onChange={(value) => props.setQueryFilter(value)}
placeholder={getRecoveryProtectedSearchPlaceholder()}
class="w-full"
inputClass="py-2.5 text-sm"
inputClass="py-2 text-sm"
clearOnEscape
history={{
storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
@@ -284,9 +303,9 @@ export const RecoveryProtectedInventorySection: Component<
onToggle: () => setProtectedFiltersOpen((open) => !open),
count: protectedActiveFilterCount(),
}}
showFilters={!props.isMobile || protectedFiltersOpen()}
searchRowClass="flex w-full items-center gap-3"
toolbarClass="gap-3 lg:flex-nowrap"
showFilters={!props.isMobile || protectedFiltersOpen()}
searchRowClass="flex w-full items-center gap-3"
toolbarClass="gap-3 lg:flex-nowrap"
>
<LabeledFilterSelect
id="recovery-item-type-filter"
@@ -408,7 +427,7 @@ export const RecoveryProtectedInventorySection: Component<
] as const
).map(([column, label]) => (
<TableHead
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-3 whitespace-nowrap text-left text-[11px] sm:text-xs font-semibold uppercase tracking-[0.18em] cursor-pointer select-none hover:text-base-content transition-colors${
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-2.5 whitespace-nowrap text-left text-[11px] sm:text-xs font-semibold uppercase tracking-[0.14em] cursor-pointer select-none hover:text-base-content transition-colors${
column === 'type'
? ' hidden md:table-cell w-[96px]'
: column === 'platform'
@@ -441,7 +460,7 @@ export const RecoveryProtectedInventorySection: Component<
<For each={visibleGroupedRollups()}>
{(group) => (
<>
<TableRow class="bg-surface-alt">
<TableRow class="bg-surface-alt/75">
<TableCell
colspan={5}
class="py-1 pr-3 pl-3 text-[12px] sm:text-sm font-semibold text-base-content"
@@ -494,7 +513,7 @@ export const RecoveryProtectedInventorySection: Component<
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<TableCell
class="max-w-[420px] px-3 py-2.5 text-base-content"
class="max-w-[420px] px-3 py-2 text-base-content"
title={label}
>
<div class="flex min-w-0 flex-col gap-1.5">
@@ -540,7 +559,7 @@ export const RecoveryProtectedInventorySection: Component<
</div>
</TableCell>
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-2.5">
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-2">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted">—</span>}
@@ -551,7 +570,7 @@ export const RecoveryProtectedInventorySection: Component<
</Show>
</TableCell>
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-2.5">
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-2">
<div class="flex flex-wrap gap-1.5">
<For each={platforms}>
{(platform) => {
@@ -567,7 +586,7 @@ export const RecoveryProtectedInventorySection: Component<
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-2.5 ${getRecoveryRollupAgeTextClass(
class={`whitespace-nowrap px-3 py-2 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
@@ -588,7 +607,7 @@ export const RecoveryProtectedInventorySection: Component<
)}
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-2.5">
<TableCell class="whitespace-nowrap px-3 py-2">
<span
class={`inline-flex rounded px-1.5 py-0.5 text-[10px] font-medium ${getRecoveryOutcomeBadgeClass(
outcome,
@@ -58,6 +58,8 @@ 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('Attention').length).toBeGreaterThan(0);
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
expect(screen.getAllByText('Item Types').length).toBeGreaterThan(0);
expect(screen.getByText('Primary Item')).toBeInTheDocument();
@@ -4,7 +4,6 @@ import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
import type { ProtectionRollup, RecoveryOutcome, RecoveryPointsSeriesBucket } from '@/types/recovery';
import {
buildRecoveryAttentionItems,
buildRecoveryActivitySummary,
buildRecoveryFreshnessBuckets,
buildRecoveryItemCoverage,
@@ -42,8 +41,6 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
const itemCoverage = createMemo(() => buildRecoveryItemCoverage(props.rollups()));
const platformCoverage = createMemo(() => buildRecoveryPlatformCoverage(props.rollups()));
const activity = createMemo(() => buildRecoveryActivitySummary(props.series()));
const attentionItems = createMemo(() => buildRecoveryAttentionItems(summary()));
const healthyCount = createMemo(() => postureSummary().healthy);
const attentionCount = createMemo(() => postureSummary().attention);
const recentWindowLabel = createMemo(() => {
@@ -83,115 +80,147 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
timeRangeLabels={RECOVERY_SUMMARY_TIME_RANGE_LABELS}
testId="recovery-summary"
>
<SummaryMetricCard label="Recovery Posture" loaded={true} hasData={hasRollups()}>
<div class="flex h-full flex-col gap-3">
<div class="grid grid-cols-3 gap-3 border-b border-border-subtle pb-3 text-sm">
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Healthy</div>
<div class="mt-1 text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
{healthyCount()}
<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>
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Attention</div>
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
{attentionCount()}
<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>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Protected</div>
<div class="mt-1 text-2xl font-semibold text-base-content">{summary().total}</div>
</div>
</div>
<div class="space-y-2">
<div class="h-2.5 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}`}
/>
<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>
)}
</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-3">
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">
Freshness
</div>
<div class="flex flex-wrap gap-2">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface-alt/35 px-2.5 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="Attention Queue"
loaded={true}
hasData={attentionItems().length > 0}
emptyMessage="No active recovery risks."
>
<div class="grid gap-2">
<For each={attentionItems().slice(0, 4)}>
{(item) => (
<div class="rounded-md border border-border-subtle bg-surface-alt/35 px-3 py-2">
<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-[11px] font-semibold uppercase tracking-[0.14em]">
{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.5 text-sm leading-6 text-muted">{item.detail}</div>
</div>
)}
</For>
</div>
</SummaryMetricCard>
</SummaryMetricCard>
</div>
<SummaryMetricCard label="Protected Footprint" loaded={true} hasData={hasRollups()}>
<div class="flex h-full flex-col gap-3">
<div class="flex h-full flex-col gap-2.5">
<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">Item Types</dt>
@@ -220,7 +249,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
<div class="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">
Item Types
</div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-1.5">
<For each={itemCoverage().items.slice(0, 6)}>
{(item) => (
<div class="inline-flex items-center gap-2 text-[10px]">
@@ -244,7 +273,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
{platformCoverage().multiPlatformCount === 1 ? '' : 's'}
</div>
</Show>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-1.5">
<For each={platformCoverage().items.slice(0, 6)}>
{(item) => {
const badge = getSourcePlatformBadge(item.key);
@@ -270,7 +299,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
hasData={activity().hasData}
emptyMessage={props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
>
<div class="flex h-full flex-col gap-3">
<div class="flex h-full flex-col gap-2.5">
<Show when={recentWindowLabel()}>
<div class="text-xs text-muted">{recentWindowLabel()}</div>
</Show>
@@ -206,6 +206,13 @@ describe('Recovery', () => {
expect(await screen.findByRole('tab', { name: /protected items/i })).toBeInTheDocument();
await screen.findByText('VM 123');
expect(screen.getByText('Needs Attention')).toBeInTheDocument();
expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument();
expect(screen.getAllByText(/Page 1 \/ 1/i).length).toBeGreaterThan(0);
expect(
screen.getAllByText((_, element) =>
(element?.textContent || '').replace(/\s+/g, ' ').includes('Sort: Latest point'),
).length,
).toBeGreaterThan(0);
expect(screen.queryByText('Recovery Events')).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByRole('table')).toHaveLength(1);