refactor(recovery): realign summary and badge primitives

This commit is contained in:
rcourtman
2026-03-27 19:25:41 +00:00
parent a5bd932f6a
commit a71735b64f
7 changed files with 322 additions and 470 deletions
@@ -636,6 +636,13 @@ 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 same recovery product surface must also stay stylistically aligned with
established Pulse monitoring surfaces once shared primitives already exist.
`frontend-modern/src/components/Recovery/RecoverySummary.tsx` should compose
the shared `SummaryPanel` and `SummaryMetricCard` primitives the way
infrastructure and workloads do, and recovery item-type labels should render
through canonical workload/resource badge classes instead of adding
recovery-only wrapper chrome around VM, container, or other resource badges.
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,58 +485,40 @@ const Recovery: Component = () => {
/>
<div ref={historySectionRef} class="flex flex-col gap-4">
<Card
padding="none"
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="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
Recovery Workspace
</div>
<span class="text-xs text-muted">
Inventory first, event drill-in second, with shared item-first filters.
</span>
</div>
<Show when={rollupId().trim().length > 0}>
<span class="inline-flex w-fit items-center rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-xs text-amber-200">
Focused drill-in
</span>
</Show>
</div>
</div>
<div class="bg-surface-alt/25 px-4 py-1.5">
<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"
tabs={[
{
value: 'inventory',
label: (
<span class="inline-flex items-center gap-2">
<span>Protected items</span>
<span class="text-xs text-muted">{filteredRollups().length}</span>
</span>
),
},
{
value: 'events',
label: (
<span class="inline-flex items-center gap-2">
<span>Recovery events</span>
<span class="text-xs text-muted">{recoveryPoints.meta().total}</span>
</span>
),
},
]}
/>
</div>
</Card>
<div class="flex flex-col gap-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"
tabs={[
{
value: 'inventory',
label: (
<span class="inline-flex items-center gap-2">
<span>Protected items</span>
<span class="text-xs text-muted">{filteredRollups().length}</span>
</span>
),
},
{
value: 'events',
label: (
<span class="inline-flex items-center gap-2">
<span>Recovery events</span>
<span class="text-xs text-muted">{recoveryPoints.meta().total}</span>
</span>
),
},
]}
/>
<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">
Focused drill-in
</span>
</Show>
</div>
<Show when={workspaceView() === 'inventory'}>
<RecoveryProtectedInventorySection
@@ -177,23 +177,6 @@ export const RecoveryProtectedInventorySection: Component<
Math.min(protectedPage() * PROTECTED_ITEMS_PAGE_SIZE, sortedRollups().length),
);
const protectedSortLabel = createMemo(() => {
switch (protectedSortCol()) {
case 'item':
return 'Item';
case 'type':
return 'Item Type';
case 'platform':
return 'Platform';
case 'lastBackup':
return 'Latest Point';
case 'outcome':
return 'Outcome';
default:
return 'Latest Point';
}
});
createEffect(() => {
props.queryFilter();
props.platformFilter();
@@ -220,12 +203,7 @@ export const RecoveryProtectedInventorySection: Component<
>
<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="flex flex-wrap items-center gap-2">
<div class="text-sm font-semibold text-base-content">Protected inventory</div>
<div class="text-xs text-muted">
Item class and latest-point posture stay visible in the primary grid.
</div>
</div>
<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">
{sortedRollups().length} of {props.rollups().length} items shown
@@ -349,27 +327,6 @@ export const RecoveryProtectedInventorySection: Component<
</div>
</Show>
<Show when={sortedRollups().length > 0}>
<div class="border-b border-border-subtle/80 bg-surface-alt/20 px-4 py-3">
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
Showing {pageStart()} - {pageEnd()}
</span>
<span class="rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
Page {protectedPage()} / {protectedTotalPages()}
</span>
<span class="rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
Sorted by {protectedSortLabel()}
</span>
</div>
<div class="text-xs text-muted">
Open any item to drill directly into its recovery events.
</div>
</div>
</div>
</Show>
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div class="px-6 py-6 text-sm text-muted">
{getRecoveryProtectedItemsLoadingState().text}
@@ -514,11 +471,7 @@ export const RecoveryProtectedInventorySection: Component<
when={itemTypePresentation}
fallback={<span class="text-muted">—</span>}
>
<span
class={`inline-flex min-w-[4.75rem] justify-center rounded-full border px-2 py-1 text-[10px] font-medium ${
itemTypePresentation?.badgeClasses || 'bg-surface-alt text-base-content'
}`}
>
<span class={itemTypePresentation?.badgeClasses || 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-base-content'}>
{itemTypePresentation?.label}
</span>
</Show>
@@ -1,21 +1,21 @@
import { Component, For, Show, createMemo } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
import type { ProtectionRollup, RecoveryOutcome, RecoveryPointsSeriesBucket } from '@/types/recovery';
import {
buildRecoveryActivitySummary,
buildRecoveryAttentionItems,
buildRecoveryActivitySummary,
buildRecoveryFreshnessBuckets,
buildRecoveryItemCoverage,
buildRecoveryPlatformCoverage,
buildRecoveryPostureSegments,
buildRecoveryPostureSummary,
getRecoveryAttentionChipClass,
getRecoveryAttentionDotClass,
RECOVERY_SUMMARY_TIME_RANGES,
RECOVERY_SUMMARY_TIME_RANGE_LABELS,
type RecoverySummaryTimeRange,
} from '@/utils/recoverySummaryPresentation';
import { segmentedButtonClass } from '@/utils/segmentedButton';
export interface RecoverySummaryProps {
rollups: () => ProtectionRollup[];
@@ -46,131 +46,65 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
const healthyCount = createMemo(() => postureSummary().healthy);
const attentionCount = createMemo(() => postureSummary().attention);
const attentionSummary = createMemo(() => {
const parts: string[] = [];
if (postureSummary().failed > 0) {
parts.push(`${postureSummary().failed} failed`);
}
if (postureSummary().neverSucceeded > 0) {
parts.push(`${postureSummary().neverSucceeded} never succeeded`);
}
if (postureSummary().stale > 0) {
parts.push(`${postureSummary().stale} stale`);
}
if (postureSummary().warning > 0) {
parts.push(`${postureSummary().warning} warning`);
}
if (parts.length === 0 && postureSummary().running > 0) {
parts.push(`${postureSummary().running} running`);
}
return parts.slice(0, 3).join(' • ');
});
const recentWindowLabel = createMemo(() => {
const activitySummary = activity();
if (!activitySummary.startLabel || !activitySummary.endLabel) return null;
return `${activitySummary.startLabel} to ${activitySummary.endLabel}`;
});
const handleTimeRangeChange = (range: string) =>
props.onTimeRangeChange?.(range as RecoverySummaryTimeRange);
return (
<Show when={hasRollups()}>
<Card
padding="none"
class="overflow-hidden border-border bg-surface shadow-[0_8px_20px_rgba(15,23,42,0.08)]"
data-testid="recovery-summary"
>
<div class="flex flex-col gap-4 p-4 sm:p-5">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle/80 bg-surface pb-4 text-xs">
<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 px-2.5 py-1 font-medium text-base-content">
<span>{summary().total} protected</span>
<SummaryPanel
headerLeft={
<>
<span class="font-medium text-base-content">
{summary().total} protected
</span>
<span class="text-emerald-600 dark:text-emerald-400">
{healthyCount()} healthy
</span>
<Show when={attentionCount() > 0}>
<span class="text-amber-600 dark:text-amber-400">
{attentionCount()} attention
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
<span class="h-2 w-2 rounded-full bg-emerald-400" />
<span>{healthyCount()} healthy</span>
</span>
<Show when={attentionCount() > 0}>
<span class="inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
<span class="h-2 w-2 rounded-full bg-amber-400" />
<span>{attentionCount()} attention</span>
</span>
</Show>
<Show when={postureSummary().running > 0}>
<span class="inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface px-2.5 py-1 text-base-content">
<span class="h-2 w-2 rounded-full bg-blue-400" />
<span>{postureSummary().running} running</span>
</span>
</Show>
</div>
<Show when={props.onTimeRangeChange}>
<div class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5 text-xs">
<For each={RECOVERY_SUMMARY_TIME_RANGES}>
{(range) => (
<button
type="button"
onClick={() => props.onTimeRangeChange?.(range)}
class={segmentedButtonClass(props.timeRange() === range)}
>
{RECOVERY_SUMMARY_TIME_RANGE_LABELS[range]}
</button>
)}
</For>
</div>
</Show>
</div>
<div class="grid items-start gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(22rem,0.85fr)]">
<section class="rounded-xl border border-border-subtle bg-surface p-4">
<div class="flex flex-col gap-3 border-b border-border-subtle/80 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
Recovery Posture
</div>
<div class="mt-1.5 max-w-xl text-sm leading-6 text-muted">
Recovery readiness across the current protected estate, with the attention
queue surfaced before timeline detail.
</div>
</div>
<Show when={attentionItems().length > 0}>
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-border-subtle bg-surface-alt px-3 py-1.5 text-xs text-base-content">
<span class="font-semibold uppercase tracking-[0.16em]">Attention Queue</span>
<span class="rounded-full bg-amber-500/12 px-2 py-0.5 tabular-nums text-amber-300">
{attentionCount()}
</span>
</div>
</Show>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-border-subtle bg-surface-alt/30 px-3 py-3">
<div class="text-[11px] uppercase tracking-wide text-muted">Healthy</div>
<div class="mt-1 text-3xl font-semibold tracking-tight text-emerald-400">
{healthyCount()}
</div>
<div class="mt-1 text-sm text-muted">Recent successful protection within the current window.</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface-alt/30 px-3 py-3">
<div class="text-[11px] uppercase tracking-wide text-muted">Attention</div>
<div class="mt-1 text-3xl font-semibold tracking-tight text-amber-300">
{attentionCount()}
</div>
<div class="mt-1 text-sm text-muted">
{attentionSummary() || 'No active recovery risks.'}
</div>
</div>
<div class="rounded-lg border border-border-subtle bg-surface-alt/30 px-3 py-3">
<div class="text-[11px] uppercase tracking-wide text-muted">Protected</div>
<div class="mt-1 text-3xl font-semibold tracking-tight text-base-content">
{summary().total}
</div>
<div class="mt-1 text-sm text-muted">
{itemCoverage().itemTypeCount} item type
{itemCoverage().itemTypeCount === 1 ? '' : 's'} across {platformCoverage().platformCount}{' '}
platform{platformCoverage().platformCount === 1 ? '' : 's'}.
</div>
<Show when={postureSummary().running > 0}>
<span class="text-blue-600 dark:text-blue-400">
{postureSummary().running} running
</span>
</Show>
</>
}
timeRange={props.timeRange()}
onTimeRangeChange={props.onTimeRangeChange ? handleTimeRangeChange : undefined}
timeRanges={RECOVERY_SUMMARY_TIME_RANGES}
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>
</div>
<div class="mt-4 h-3 overflow-hidden rounded-full bg-surface-alt/90">
<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>
</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) => (
@@ -183,258 +117,214 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
</For>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<div class="space-y-2">
<For each={postureSegments()}>
{(segment) => (
<div class="inline-flex items-center gap-2 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-sm">
<div class="flex items-center gap-2">
<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 class="text-base-content">{segment.label}</span>
<span>{segment.label}</span>
</div>
<span class={segment.textColor}>{segment.count}</span>
<span class={`font-semibold tabular-nums ${segment.textColor}`}>
{segment.count}
</span>
</div>
)}
</For>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 p-3">
<div class="text-[11px] 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-[52px_minmax(0,1fr)_28px] items-center gap-2 text-sm">
<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-alt/25 p-3">
<div class="text-[11px] 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">
<For each={attentionItems().slice(0, 3)}>
{(item) => (
<div
class={`rounded-lg border px-3 py-3 ${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-[11px] font-semibold uppercase tracking-[0.14em]">
{item.label}
</span>
</div>
<span class="text-base font-semibold tabular-nums">{item.count}</span>
</div>
<div class="mt-1.5 text-sm leading-6 text-current/80">{item.detail}</div>
</div>
)}
</For>
</div>
</Show>
</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>
</section>
<section class="rounded-xl border border-border-subtle bg-surface-alt/20 p-4">
<div class="space-y-4">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">
Protected Footprint
</div>
<div class="mt-3 overflow-hidden rounded-lg border border-border-subtle bg-surface">
<dl class="divide-y divide-border-subtle/80">
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Item Types</dt>
<dd class="text-lg font-semibold text-base-content">
{itemCoverage().itemTypeCount}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Primary Item</dt>
<dd class="text-right text-base font-semibold text-base-content">
{itemCoverage().primaryItemLabel ?? 'n/a'}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Platforms</dt>
<dd class="text-lg font-semibold text-base-content">
{platformCoverage().platformCount}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">
Primary Platform
</dt>
<dd class="text-right text-base font-semibold text-base-content">
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
</dd>
</div>
</dl>
</div>
<div class="mt-3 space-y-3">
<Show when={itemCoverage().items.length > 0}>
<div>
<div class="mb-2 text-[11px] 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-full border border-border-subtle bg-surface px-2.5 py-1.5 text-sm">
<span
class={`rounded-full 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-[11px] font-semibold uppercase tracking-wide text-muted">
Platform Mix
</div>
<Show when={platformCoverage().multiPlatformCount > 0}>
<div class="mb-2 text-sm text-muted">
{platformCoverage().multiPlatformCount} protected item
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
platforms.
</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-full border border-border-subtle bg-surface px-2.5 py-1.5 text-sm">
<span
class={`rounded-full 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 class="space-y-2">
<For each={freshnessBuckets()}>
{(bucket) => (
<div class="flex items-center gap-3 text-sm">
<span class="w-10 shrink-0 text-base-content">{bucket.label}</span>
<div class="h-2 flex-1 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="w-7 text-right tabular-nums text-base-content">
{bucket.count}
</span>
</div>
</div>
</div>
<div class="border-t border-border-subtle/80 pt-4">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">
Recent History
</div>
<div class="mt-2 text-sm text-muted">
<Show when={recentWindowLabel()} fallback={'No recent activity window available.'}>
{recentWindowLabel()}
</Show>
</div>
<div class="mt-3 overflow-hidden rounded-lg border border-border-subtle bg-surface">
<dl class="divide-y divide-border-subtle/80">
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Recovery Points</dt>
<dd class="text-lg font-semibold text-base-content">
{activity().totalEvents}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Avg / Day</dt>
<dd class="text-base font-semibold text-base-content">
{activity().averagePerDay.toFixed(1)}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Days Active</dt>
<dd class="text-base font-semibold text-base-content">
{activity().activeDays}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Peak Day</dt>
<dd class="text-right text-base font-semibold text-base-content">
{activity().busiestLabel ?? 'n/a'}
</dd>
</div>
<div class="grid grid-cols-[minmax(0,0.95fr)_auto] items-center gap-3 px-3 py-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">
Latest Activity
</dt>
<dd class="text-right text-base font-semibold text-base-content">
{activity().latestLabel ?? 'n/a'}
</dd>
</div>
</dl>
</div>
<div class="mt-3 grid gap-3">
<Show
when={activity().hasData}
fallback={
<div class="rounded-lg border border-dashed border-border-subtle bg-surface p-3 text-sm text-muted">
{props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
</div>
}
>
<div class="rounded-lg border border-border-subtle bg-surface p-3">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">
Peak Throughput
</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().busiestCount}
</div>
<div class="mt-1 text-sm text-muted">
{activity().busiestLabel ?? 'n/a'}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">
Latest Throughput
</div>
<div class="mt-1 text-2xl font-semibold text-base-content">
{activity().latestCount}
</div>
<div class="mt-1 text-sm text-muted">
{activity().latestLabel ?? 'n/a'}
</div>
</div>
</div>
</div>
</Show>
</div>
</div>
)}
</For>
</div>
</section>
</div>
</div>
</div>
</Card>
</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 label="Protected Footprint" 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">Item Types</dt>
<dd class="font-semibold text-base-content">{itemCoverage().itemTypeCount}</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">Primary Item</dt>
<dd class="font-semibold text-base-content">
{itemCoverage().primaryItemLabel ?? 'n/a'}
</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">Platforms</dt>
<dd class="font-semibold text-base-content">{platformCoverage().platformCount}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Primary Platform</dt>
<dd class="font-semibold text-base-content">
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
</dd>
</div>
</dl>
<div class="text-sm text-muted">
{itemCoverage().itemTypeCount} item type
{itemCoverage().itemTypeCount === 1 ? '' : 's'} across {platformCoverage().platformCount}{' '}
platform{platformCoverage().platformCount === 1 ? '' : 's'}.
</div>
<Show when={itemCoverage().items.length > 0}>
<div>
<div class="mb-2 text-[11px] font-medium 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 text-[10px]">
<span class={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-[11px] font-medium uppercase tracking-wide text-muted">
Platform Mix
</div>
<Show when={platformCoverage().multiPlatformCount > 0}>
<div class="mb-2 text-sm text-muted">
{platformCoverage().multiPlatformCount} protected item
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple platforms.
</div>
</Show>
<div class="flex flex-wrap gap-2">
<For each={platformCoverage().items.slice(0, 6)}>
{(item) => {
const badge = getSourcePlatformBadge(item.key);
return (
<div class="inline-flex items-center gap-2 text-[10px]">
<span class={badge?.classes || 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-base-content'}>
{badge?.label || item.label}
</span>
<span class="tabular-nums text-base-content">{item.count}</span>
<span class="text-muted">{item.percent}%</span>
</div>
);
}}
</For>
</div>
</div>
</div>
</SummaryMetricCard>
<SummaryMetricCard
label="Recent History"
loaded={props.seriesLoaded()}
hasData={activity().hasData}
emptyMessage={props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
>
<div class="flex h-full flex-col gap-3">
<Show when={recentWindowLabel()}>
<div class="text-sm text-muted">{recentWindowLabel()}</div>
</Show>
<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">Recovery Points</dt>
<dd class="font-semibold text-base-content">{activity().totalEvents}</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">Avg / Day</dt>
<dd class="font-semibold text-base-content">{activity().averagePerDay.toFixed(1)}</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">Days Active</dt>
<dd class="font-semibold text-base-content">{activity().activeDays}</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">Peak Day</dt>
<dd class="font-semibold text-base-content">{activity().busiestLabel ?? 'n/a'}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-[11px] uppercase tracking-wide text-muted">Latest Activity</dt>
<dd class="font-semibold text-base-content">{activity().latestLabel ?? 'n/a'}</dd>
</div>
</dl>
<div class="grid grid-cols-2 gap-3 border-t border-border-subtle pt-3">
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Peak Throughput</div>
<div class="mt-1 text-2xl font-semibold text-base-content">{activity().busiestCount}</div>
<div class="mt-1 text-sm text-muted">{activity().busiestLabel ?? 'n/a'}</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-wide text-muted">Latest Throughput</div>
<div class="mt-1 text-2xl font-semibold text-base-content">{activity().latestCount}</div>
<div class="mt-1 text-sm text-muted">{activity().latestLabel ?? 'n/a'}</div>
</div>
</div>
</div>
</SummaryMetricCard>
</SummaryPanel>
</Show>
);
};
@@ -746,10 +746,6 @@ describe('Recovery', () => {
expect(await screen.findByText('Showing 1 - 24 of 26 protected items')).toBeInTheDocument();
expect(screen.getAllByText('Page 1 / 2').length).toBeGreaterThan(0);
expect(screen.getByText('Sorted by Latest Point')).toBeInTheDocument();
expect(
screen.getByText('Open any item to drill directly into its recovery events.'),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
@@ -41,7 +41,8 @@ describe('recoveryItemTypePresentation', () => {
it('falls back cleanly for unknown item types', () => {
expect(getRecoveryItemTypeLabel('custom-thing')).toBe('Custom Thing');
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toBe('bg-surface-alt text-base-content');
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('bg-surface-alt text-base-content');
expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('inline-flex items-center');
});
it('derives canonical item type keys from recovery rollups and points', () => {
@@ -9,7 +9,10 @@ export interface RecoveryItemTypePresentation {
badgeClasses: string;
}
const DEFAULT_BADGE_CLASSES = 'bg-surface-alt text-base-content';
const BADGE_BASE_CLASSES =
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap';
const DEFAULT_BADGE_TONE_CLASSES = 'bg-surface-alt text-base-content';
const DEFAULT_BADGE_CLASSES = `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`;
interface RecoveryItemTypeLike {
display?: {
@@ -87,33 +90,53 @@ export const getRecoveryItemTypePresentation = (
switch (key) {
case 'vm': {
const presentation = getWorkloadTypePresentation('vm');
return { key, label: presentation.label, badgeClasses: presentation.className };
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'system-container': {
const presentation = getWorkloadTypePresentation('system-container');
return { key, label: presentation.label, badgeClasses: presentation.className };
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'app-container': {
const presentation = getWorkloadTypePresentation('app-container', {
label: 'App Container',
title: 'Application Container',
});
return { key, label: presentation.label, badgeClasses: presentation.className };
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'oci-container': {
const presentation = getWorkloadTypePresentation('oci-container');
return { key, label: presentation.label, badgeClasses: presentation.className };
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'pod': {
const presentation = getWorkloadTypePresentation('pod');
return { key, label: presentation.label, badgeClasses: presentation.className };
return {
key,
label: presentation.label,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`,
};
}
case 'pvc': {
const presentation = getResourceTypePresentation('k8s-pvc');
return {
key,
label: presentation?.label || 'PVC',
badgeClasses: presentation?.badgeClasses || DEFAULT_BADGE_CLASSES,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`,
};
}
default: {
@@ -129,7 +152,7 @@ export const getRecoveryItemTypePresentation = (
return {
key,
label,
badgeClasses: presentation.badgeClasses,
badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.badgeClasses}`,
};
}
return {