mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
refactor(recovery): elevate workspace and simplify surfaces
This commit is contained in:
@@ -440,6 +440,9 @@ The summary remains additive to the inventory-first workflow rather than a
|
||||
replacement for it: Proxmox-heavy operators still land on protected items by
|
||||
default, but the first visible framing now reflects the whole multi-platform
|
||||
recovery system instead of a single backup table metaphor.
|
||||
That top recovery frame must rely on solid elevated operator panels and border
|
||||
hierarchy rather than decorative gradients so the page reads like a monitoring
|
||||
workspace instead of a marketing-style dashboard shell.
|
||||
That same summary contract now also includes platform- and item-coverage
|
||||
framing through `frontend-modern/src/utils/recoverySummaryPresentation.ts`, so
|
||||
the first visible recovery overview explicitly shows which protected item
|
||||
@@ -595,11 +598,14 @@ same active recovery view. That same recovery filter contract now depends on
|
||||
the canonical recovery index carrying a normalized `itemType` instead of
|
||||
forcing each UI surface to re-derive protected item classes from raw
|
||||
provider-native `subjectType` values.
|
||||
That same recovery product surface keeps the activity timeline available even
|
||||
when point-history loading fails: `frontend-modern/src/components/Recovery/Recovery.tsx`
|
||||
must continue to render `RecoveryActivitySection` and the point-history error
|
||||
card side by side so operators can still inspect backup cadence and active
|
||||
filter context while recovery-point transport is degraded.
|
||||
That same recovery product surface keeps the primary workspace visually ahead
|
||||
of secondary analytics: once the summary frame ends,
|
||||
`frontend-modern/src/components/Recovery/Recovery.tsx` must lead into the
|
||||
route-backed recovery workspace and its protected-item or recovery-event table
|
||||
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 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
|
||||
|
||||
@@ -484,57 +484,37 @@ const Recovery: Component = () => {
|
||||
timeRange={summaryRange}
|
||||
/>
|
||||
|
||||
<RecoveryActivitySection
|
||||
activitySummary={activitySummary}
|
||||
activeClusterLabel={activeClusterLabel}
|
||||
activeItemTypeLabel={activeItemTypeLabel}
|
||||
activeNamespaceLabel={activeNamespaceLabel}
|
||||
activeNodeLabel={activeNodeLabel}
|
||||
chartRangeDays={chartRangeDays}
|
||||
clearClusterFilter={() => {
|
||||
setClusterFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearFocusedRollup={() => setRollupId('')}
|
||||
clearItemTypeFilter={() => {
|
||||
setItemTypeFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearNamespaceFilter={() => {
|
||||
setNamespaceFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearNodeFilter={() => {
|
||||
setNodeFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearSelectedDate={() => {
|
||||
setSelectedDateKey(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
hasFocusedRollup={() => rollupId().trim().length > 0}
|
||||
isMobile={isMobile()}
|
||||
loading={() => recoverySeries.response.loading}
|
||||
overallRollupsSummary={overallRollupsSummary}
|
||||
selectedDateKey={selectedDateKey}
|
||||
selectedDateLabel={selectedDateLabel}
|
||||
selectedHistoryItemLabel={selectedHistoryItemLabel}
|
||||
setChartRangeDays={(range) => {
|
||||
setChartRangeDays(range);
|
||||
setSelectedDateKey(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
timeline={timeline}
|
||||
toggleSelectedDate={(key) => {
|
||||
setWorkspaceView('events');
|
||||
setSelectedDateKey((previous) => (previous === key ? null : key));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div ref={historySectionRef} class="order-1 flex flex-col gap-4">
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<div class="px-3 pt-1">
|
||||
<div ref={historySectionRef} class="flex flex-col gap-4">
|
||||
<Card
|
||||
padding="none"
|
||||
tone="card"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
|
||||
>
|
||||
<div class="border-b border-border-subtle/80 bg-surface px-4 pb-4 pt-4">
|
||||
<div class="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Recovery Workspace
|
||||
</div>
|
||||
<div class="mt-1 max-w-2xl text-sm text-slate-200">
|
||||
Run recovery operations from one shared surface: protected inventory first,
|
||||
event drill-in second, with the same item-first filters and route state.
|
||||
</div>
|
||||
</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">
|
||||
{filteredRollups().length} protected items
|
||||
</span>
|
||||
<span class="rounded-full border border-border-subtle bg-surface-hover/70 px-2.5 py-1 text-base-content">
|
||||
{recoveryPoints.meta().total} recovery events
|
||||
</span>
|
||||
<Show when={rollupId().trim().length > 0}>
|
||||
<span class="rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-amber-200">
|
||||
Focused drill-in
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Subtabs
|
||||
value={workspaceView()}
|
||||
onChange={(value) => setWorkspaceView(value as RecoveryWorkspaceView)}
|
||||
@@ -561,7 +541,7 @@ const Recovery: Component = () => {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div class="border-b border-border px-3 pb-3 pt-2 text-xs text-muted">
|
||||
<div class="bg-surface-hover px-4 py-3 text-xs text-muted">
|
||||
<Show
|
||||
when={workspaceView() === 'inventory'}
|
||||
fallback="Recovery events grouped by day and filtered through one shared item-first recovery model."
|
||||
@@ -659,6 +639,54 @@ const Recovery: Component = () => {
|
||||
verificationFilter={verificationFilter}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<RecoveryActivitySection
|
||||
activitySummary={activitySummary}
|
||||
activeClusterLabel={activeClusterLabel}
|
||||
activeItemTypeLabel={activeItemTypeLabel}
|
||||
activeNamespaceLabel={activeNamespaceLabel}
|
||||
activeNodeLabel={activeNodeLabel}
|
||||
chartRangeDays={chartRangeDays}
|
||||
clearClusterFilter={() => {
|
||||
setClusterFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearFocusedRollup={() => setRollupId('')}
|
||||
clearItemTypeFilter={() => {
|
||||
setItemTypeFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearNamespaceFilter={() => {
|
||||
setNamespaceFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearNodeFilter={() => {
|
||||
setNodeFilter('all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
clearSelectedDate={() => {
|
||||
setSelectedDateKey(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
hasFocusedRollup={() => rollupId().trim().length > 0}
|
||||
isMobile={isMobile()}
|
||||
loading={() => recoverySeries.response.loading}
|
||||
overallRollupsSummary={overallRollupsSummary}
|
||||
selectedDateKey={selectedDateKey}
|
||||
selectedDateLabel={selectedDateLabel}
|
||||
selectedHistoryItemLabel={selectedHistoryItemLabel}
|
||||
setChartRangeDays={(range) => {
|
||||
setChartRangeDays(range);
|
||||
setSelectedDateKey(null);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
timeline={timeline}
|
||||
toggleSelectedDate={(key) => {
|
||||
setWorkspaceView('events');
|
||||
setSelectedDateKey((previous) => (previous === key ? null : key));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,41 +81,46 @@ interface RecoveryActivitySectionProps {
|
||||
const rangeOptions: Array<7 | 30 | 90 | 365> = [7, 30, 90, 365];
|
||||
|
||||
export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> = (props) => (
|
||||
<Card padding="sm" class="h-full">
|
||||
<Card
|
||||
padding="sm"
|
||||
class="h-full border-border bg-surface-hover shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
|
||||
>
|
||||
<div class="mb-3 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="font-medium text-base-content">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-border-subtle bg-surface/70 px-2.5 py-1 font-medium text-base-content">
|
||||
{props.overallRollupsSummary().total} protected
|
||||
</span>
|
||||
<Show when={props.overallRollupsSummary().total - props.overallRollupsSummary().stale > 0}>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">
|
||||
<span class="rounded-full border border-emerald-500/25 bg-emerald-500/8 px-2.5 py-1 text-emerald-300">
|
||||
{props.overallRollupsSummary().total - props.overallRollupsSummary().stale} healthy
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.overallRollupsSummary().stale > 0}>
|
||||
<span class="text-amber-500">{props.overallRollupsSummary().stale} stale</span>
|
||||
<span class="rounded-full border border-amber-500/25 bg-amber-500/8 px-2.5 py-1 text-amber-200">
|
||||
{props.overallRollupsSummary().stale} stale
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.overallRollupsSummary().neverSucceeded > 0}>
|
||||
<span class="text-rose-500">
|
||||
<span class="rounded-full border border-rose-500/25 bg-rose-500/8 px-2.5 py-1 text-rose-200">
|
||||
{props.overallRollupsSummary().neverSucceeded} never succeeded
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Recovery Activity
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
<div class="text-sm text-slate-300">
|
||||
Daily recovery points across the selected history window.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Show when={props.selectedHistoryItemLabel()}>
|
||||
<div class="inline-flex items-center gap-2 rounded-md border border-border bg-surface-alt px-2.5 py-1.5 text-xs">
|
||||
<span class="font-semibold uppercase tracking-wide text-muted">Focused Item</span>
|
||||
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface/70 px-2.5 py-1.5 text-xs">
|
||||
<span class="font-semibold uppercase tracking-[0.14em] text-muted">Focused Item</span>
|
||||
<span class="max-w-[18rem] truncate font-medium text-base-content">
|
||||
{props.selectedHistoryItemLabel()}
|
||||
</span>
|
||||
@@ -242,8 +247,8 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="rounded-md border border-border bg-surface p-3">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="rounded-xl border border-border-subtle bg-surface p-3">
|
||||
<div class={`mb-3 flex flex-wrap items-center justify-between gap-3 ${RECOVERY_TIMELINE_RANGE_GROUP_CLASS}`}>
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Selected range
|
||||
@@ -290,7 +295,7 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||
<div class="flex h-40 flex-col justify-between text-[10px] text-muted">
|
||||
<div class="flex h-28 flex-col justify-between text-[10px] text-muted">
|
||||
<For each={[0, 1, 2, 3, 4]}>
|
||||
{(step) => {
|
||||
const value = Math.round((props.timeline().axisMax * (4 - step)) / 4);
|
||||
@@ -299,7 +304,7 @@ export const RecoveryActivitySection: Component<RecoveryActivitySectionProps> =
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="relative h-44">
|
||||
<div class="relative h-32">
|
||||
<div
|
||||
data-testid="recovery-activity-bars"
|
||||
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch gap-[3px]"
|
||||
|
||||
@@ -159,28 +159,32 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
});
|
||||
|
||||
return (
|
||||
<Card padding="none" tone="card" class="order-3 overflow-hidden">
|
||||
<div class="border-b border-border bg-surface-hover px-3 py-2">
|
||||
<Card
|
||||
padding="none"
|
||||
tone="card"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
|
||||
>
|
||||
<div class="border-b border-border-subtle/80 bg-surface px-4 py-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Protected Items
|
||||
</div>
|
||||
<div class="text-xs text-muted">
|
||||
<div class="max-w-3xl text-sm text-slate-300">
|
||||
Unified protection inventory across workloads, datasets, and other protected items, with platform mix carried as supporting context.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>
|
||||
<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">
|
||||
{props.filteredRollups().length} of {props.rollups().length} items shown
|
||||
</span>
|
||||
<Show when={props.rollupsSummary().stale > 0}>
|
||||
<span class={getRecoveryRollupStatusPillClass('stale')}>
|
||||
<span class={`${getRecoveryRollupStatusPillClass('stale')} px-2.5 py-1`}>
|
||||
{props.rollupsSummary().stale} stale
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.rollupsSummary().neverSucceeded > 0}>
|
||||
<span class={getRecoveryRollupStatusPillClass('never-succeeded')}>
|
||||
<span class={`${getRecoveryRollupStatusPillClass('never-succeeded')} px-2.5 py-1`}>
|
||||
{props.rollupsSummary().neverSucceeded} never succeeded
|
||||
</span>
|
||||
</Show>
|
||||
@@ -189,7 +193,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</div>
|
||||
|
||||
<Show when={!props.kioskMode}>
|
||||
<div class="border-b border-border px-3 py-3">
|
||||
<div class="border-b border-border-subtle/80 bg-surface-hover px-4 py-4">
|
||||
<PageControls
|
||||
search={
|
||||
<SearchInput
|
||||
@@ -312,13 +316,13 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</Show>
|
||||
|
||||
<Show when={props.filteredRollups().length > 0}>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto bg-surface">
|
||||
<Table
|
||||
class="w-full border-collapse whitespace-nowrap"
|
||||
style={{ 'table-layout': 'fixed', 'min-width': props.isMobile ? '100%' : '640px' }}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="border-b border-border bg-surface-alt/95 text-muted">
|
||||
{(
|
||||
[
|
||||
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
|
||||
@@ -386,7 +390,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
class="cursor-pointer border-b border-border hover:bg-surface-hover"
|
||||
class="cursor-pointer border-b border-border/80 hover:bg-surface-hover/95"
|
||||
onClick={() => props.onSelectRollup(rollup.rollupId)}
|
||||
>
|
||||
<TableCell
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('RecoverySummary', () => {
|
||||
expect(screen.getByText('Protected Footprint')).toBeInTheDocument();
|
||||
expect(screen.getByText('Freshness')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recent History')).toBeInTheDocument();
|
||||
expect(screen.getByText('Attention Queue')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Attention Queue').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Recovery Points')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Item Types').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Primary Item')).toBeInTheDocument();
|
||||
|
||||
@@ -49,25 +49,41 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Show when={hasRollups()}>
|
||||
<Card padding="sm" class="overflow-hidden" data-testid="recovery-summary">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-border-subtle px-1 pb-2 text-[11px]">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="font-medium text-base-content">{summary().total} protected</span>
|
||||
<span class="text-violet-300">
|
||||
{itemCoverage().itemTypeCount} item type
|
||||
{itemCoverage().itemTypeCount === 1 ? '' : 's'}
|
||||
<Card
|
||||
padding="none"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_14px_34px_rgba(2,6,23,0.14)]"
|
||||
data-testid="recovery-summary"
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-3 sm:p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle/80 bg-surface pb-3 text-[11px]">
|
||||
<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/70 px-2.5 py-1 font-medium text-base-content">
|
||||
<span>{summary().total} protected</span>
|
||||
</span>
|
||||
<span class="text-sky-300">
|
||||
{platformCoverage().platformCount} platform
|
||||
{platformCoverage().platformCount === 1 ? '' : 's'}
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-violet-500/25 bg-violet-500/8 px-2.5 py-1 text-violet-200">
|
||||
<span>
|
||||
{itemCoverage().itemTypeCount} item type
|
||||
{itemCoverage().itemTypeCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-sky-500/25 bg-sky-500/8 px-2.5 py-1 text-sky-200">
|
||||
<span>
|
||||
{platformCoverage().platformCount} platform
|
||||
{platformCoverage().platformCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-emerald-500/25 bg-emerald-500/8 px-2.5 py-1 text-emerald-300">
|
||||
<span>{healthyCount()} healthy</span>
|
||||
</span>
|
||||
<span class="text-emerald-400">{healthyCount()} healthy</span>
|
||||
<Show when={attentionCount() > 0}>
|
||||
<span class="text-amber-300">{attentionCount()} attention</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-500/10 px-2.5 py-1 text-amber-200">
|
||||
<span>{attentionCount()} attention</span>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={postureSummary().running > 0}>
|
||||
<span class="text-blue-300">{postureSummary().running} running</span>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-500/10 px-2.5 py-1 text-blue-200">
|
||||
<span>{postureSummary().running} running</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.onTimeRangeChange}>
|
||||
@@ -87,27 +103,44 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Recovery Posture
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.85fr)]">
|
||||
<section class="rounded-xl border border-border bg-surface p-4 shadow-[inset_0_1px_0_rgba(148,163,184,0.04)]">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Recovery Posture
|
||||
</div>
|
||||
<div class="mt-2 max-w-xl text-sm text-slate-300">
|
||||
Recovery readiness across the current protected estate, with unhealthy
|
||||
coverage and stale protection surfaced first.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={attentionItems().length > 0}>
|
||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/8 px-3 py-1.5 text-xs text-amber-200">
|
||||
<span class="font-semibold uppercase tracking-[0.16em]">Attention Queue</span>
|
||||
<span class="rounded-full bg-amber-500/15 px-2 py-0.5 tabular-nums">
|
||||
{attentionCount()}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Protected</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">{summary().total}</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-base-content">{summary().total}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Healthy</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-emerald-400">{healthyCount()}</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-emerald-400">{healthyCount()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Attention</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-amber-300">{attentionCount()}</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-amber-300">{attentionCount()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 h-3 overflow-hidden rounded-full bg-surface-alt">
|
||||
<div class="mt-5 h-3 overflow-hidden rounded-full bg-surface-alt/90">
|
||||
<div class="flex h-full">
|
||||
<For each={postureSegments()}>
|
||||
{(segment) => (
|
||||
@@ -121,10 +154,10 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<For each={postureSegments()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center justify-between gap-2 rounded border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-2 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={`h-2 w-2 rounded-full ${segment.color}`} />
|
||||
<span class="text-base-content">{segment.label}</span>
|
||||
@@ -134,49 +167,132 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,0.78fr)_minmax(0,1fr)]">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] 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-[44px_minmax(0,1fr)_28px] items-center gap-2 text-xs">
|
||||
<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 p-3">
|
||||
<div class="text-[10px] 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 sm:grid-cols-2">
|
||||
<For each={attentionItems().slice(0, 4)}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`rounded-lg border px-3 py-2.5 ${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-xs font-semibold uppercase tracking-[0.14em]">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold tabular-nums">{item.count}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 text-xs leading-5 text-current/80">{item.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Protected Footprint
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Item Types</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{itemCoverage().itemTypeCount}
|
||||
<div class="grid gap-4">
|
||||
<section class="rounded-xl border border-border bg-surface p-4">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Protected Footprint
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Item Types</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-base-content">
|
||||
{itemCoverage().itemTypeCount}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Item</div>
|
||||
<div class="mt-1 text-xl font-semibold text-base-content">
|
||||
{itemCoverage().primaryItemLabel ?? 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Platforms</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-base-content">
|
||||
{platformCoverage().platformCount}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Platform</div>
|
||||
<div class="mt-1 text-xl font-semibold text-base-content">
|
||||
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Item</div>
|
||||
<div class="mt-1 text-xl font-semibold text-base-content">
|
||||
{itemCoverage().primaryItemLabel ?? 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Platforms</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{platformCoverage().platformCount}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Primary Platform</div>
|
||||
<div class="mt-1 text-xl font-semibold text-base-content">
|
||||
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<Show when={itemCoverage().items.length > 0}>
|
||||
<div class="mt-4 space-y-4">
|
||||
<Show when={itemCoverage().items.length > 0}>
|
||||
<div>
|
||||
<div class="mb-2 text-[10px] 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-md border border-border-subtle bg-surface px-2.5 py-1.5 text-xs">
|
||||
<span class={`rounded 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-[10px] font-semibold uppercase tracking-wide text-muted">
|
||||
Item Types
|
||||
Platform Mix
|
||||
</div>
|
||||
<Show when={platformCoverage().multiPlatformCount > 0}>
|
||||
<div class="mb-2 text-xs text-muted">
|
||||
{platformCoverage().multiPlatformCount} protected item
|
||||
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
|
||||
platforms.
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={itemCoverage().items.slice(0, 6)}>
|
||||
<For each={platformCoverage().items.slice(0, 6)}>
|
||||
{(item) => (
|
||||
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
|
||||
<div class="inline-flex items-center gap-2 rounded-md border border-border-subtle bg-surface px-2.5 py-1.5 text-xs">
|
||||
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -187,148 +303,80 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
|
||||
Platform Mix
|
||||
</div>
|
||||
<Show when={platformCoverage().multiPlatformCount > 0}>
|
||||
<div class="mb-2 text-xs text-muted">
|
||||
{platformCoverage().multiPlatformCount} protected item
|
||||
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
|
||||
platforms.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border border-border bg-surface p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Recent History
|
||||
</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-md border border-border-subtle bg-surface/60 px-2.5 py-1.5 text-xs">
|
||||
<span class={`rounded 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 class="mt-4 grid grid-cols-3 gap-3 text-sm">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">
|
||||
Recovery Points
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().totalEvents}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Avg / Day</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().averagePerDay.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Days Active</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().activeDays}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activity().hasData}
|
||||
fallback={
|
||||
<div class="mt-6 text-sm text-muted">
|
||||
{props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mt-4 flex h-24 items-end gap-1 overflow-hidden rounded-lg border border-border-subtle bg-surface px-2 py-2">
|
||||
<For each={activity().bars}>
|
||||
{(bar) => (
|
||||
<div class="flex h-full flex-1 items-end">
|
||||
<div
|
||||
class={`w-full rounded-t-sm ${
|
||||
bar.isPeak
|
||||
? 'bg-blue-500'
|
||||
: bar.isLatest
|
||||
? 'bg-blue-400'
|
||||
: 'bg-blue-900/60'
|
||||
}`}
|
||||
style={{ height: `${bar.heightPct}%` }}
|
||||
title={`${bar.day}: ${bar.total} recovery points`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Freshness
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<For each={freshnessBuckets()}>
|
||||
{(bucket) => (
|
||||
<div class="grid grid-cols-[44px_minmax(0,1fr)_28px] items-center gap-2 text-xs">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="rounded-md border border-border bg-surface-hover/40 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Recent History
|
||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>
|
||||
Peak: {activity().busiestLabel ?? 'n/a'} ({activity().busiestCount})
|
||||
</span>
|
||||
<span>
|
||||
Latest: {activity().latestLabel ?? 'n/a'} ({activity().latestCount})
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">
|
||||
Recovery Points
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().totalEvents}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Avg / Day</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().averagePerDay.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Days Active</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-base-content">
|
||||
{activity().activeDays}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={activity().hasData}
|
||||
fallback={
|
||||
<div class="mt-6 text-sm text-muted">
|
||||
{props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mt-4 flex h-16 items-end gap-1 overflow-hidden rounded bg-surface-alt px-1.5 py-1">
|
||||
<For each={activity().bars}>
|
||||
{(bar) => (
|
||||
<div class="flex h-full flex-1 items-end">
|
||||
<div
|
||||
class={`w-full rounded-t-sm ${
|
||||
bar.isPeak
|
||||
? 'bg-blue-500'
|
||||
: bar.isLatest
|
||||
? 'bg-blue-400'
|
||||
: 'bg-blue-900/60'
|
||||
}`}
|
||||
style={{ height: `${bar.heightPct}%` }}
|
||||
title={`${bar.day}: ${bar.total} recovery points`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>
|
||||
Peak: {activity().busiestLabel ?? 'n/a'} ({activity().busiestCount})
|
||||
</span>
|
||||
<span>
|
||||
Latest: {activity().latestLabel ?? 'n/a'} ({activity().latestCount})
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Show when={attentionItems().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-border-subtle px-1 pt-3">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Attention Queue
|
||||
</div>
|
||||
<For each={attentionItems().slice(0, 5)}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs ${getRecoveryAttentionChipClass(item.tone)}`}
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${getRecoveryAttentionDotClass(item.tone)}`}
|
||||
/>
|
||||
<span class="font-medium">{item.label}</span>
|
||||
<span class="tabular-nums">{item.count}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user