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