mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
refactor(recovery): realign summary and badge primitives
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user