mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
refactor(recovery): bound the primary workspace surface
This commit is contained in:
@@ -443,6 +443,11 @@ 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 framing must stay compact enough to keep the primary workspace in the
|
||||
first scroll window. `RecoverySummary.tsx` and the workspace shell in
|
||||
`Recovery.tsx` should compress posture, coverage, and mode framing into concise
|
||||
operator panels instead of stacking multiple dashboard-like slabs that push the
|
||||
main inventory or event table too far down the page.
|
||||
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
|
||||
@@ -458,6 +463,12 @@ first-class column in the main inventory grid rather than leaving platform as
|
||||
the only structural classifier beside the item label. Platform badges remain
|
||||
important supporting operator context, especially for Proxmox-heavy fleets,
|
||||
but the table frame itself must make protected item class explicit.
|
||||
That same inventory contract must keep the protected-items grid operationally
|
||||
bounded. The governed desktop recovery surface should not dump the entire
|
||||
protected estate into one endless slab; it should page or otherwise bound the
|
||||
primary inventory table so the workspace, filters, and adjacent activity panel
|
||||
remain readable as one monitoring surface instead of dissolving into a raw list
|
||||
dump.
|
||||
The same owned vocabulary applies to recovery events as well:
|
||||
`frontend-modern/src/utils/recoveryTablePresentation.ts` must keep the
|
||||
history-table `type` column labeled as `Item Type` within recovery surfaces so
|
||||
|
||||
@@ -488,17 +488,17 @@ const Recovery: Component = () => {
|
||||
<Card
|
||||
padding="none"
|
||||
tone="card"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
|
||||
class="overflow-hidden border-border bg-surface shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
|
||||
>
|
||||
<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 class="border-b border-border-subtle/80 bg-surface px-4 py-4">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<div class="text-xs 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 class="mt-1.5 max-w-2xl text-sm leading-6 text-muted">
|
||||
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">
|
||||
@@ -515,10 +515,14 @@ const Recovery: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-alt/35 px-4 py-2.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-10 px-2 py-2.5 text-sm"
|
||||
tabs={[
|
||||
{
|
||||
value: 'inventory',
|
||||
@@ -541,14 +545,6 @@ const Recovery: Component = () => {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<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."
|
||||
>
|
||||
Unified protection inventory across protected item types, with platform mix carried as supporting recovery context.
|
||||
</Show>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Show when={workspaceView() === 'inventory'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, createSignal } from 'solid-js';
|
||||
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import type { Accessor, Component } from 'solid-js';
|
||||
|
||||
import { Card } from '@/components/shared/Card';
|
||||
@@ -83,11 +83,13 @@ interface RecoveryProtectedInventorySectionProps {
|
||||
}
|
||||
|
||||
const availableOutcomes = ['all', 'success', 'warning', 'failed', 'running'] as const;
|
||||
const PROTECTED_ITEMS_PAGE_SIZE = 24;
|
||||
|
||||
export const RecoveryProtectedInventorySection: Component<
|
||||
RecoveryProtectedInventorySectionProps
|
||||
> = (props) => {
|
||||
const [protectedFiltersOpen, setProtectedFiltersOpen] = createSignal(false);
|
||||
const [protectedPage, setProtectedPage] = createSignal(1);
|
||||
const [protectedSortCol, setProtectedSortCol] = createSignal<ProtectedSortCol>('lastBackup');
|
||||
const [protectedSortDir, setProtectedSortDir] = createSignal<SortDir>('desc');
|
||||
|
||||
@@ -158,25 +160,51 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
return items;
|
||||
});
|
||||
|
||||
const protectedTotalPages = createMemo(() =>
|
||||
Math.max(1, Math.ceil(sortedRollups().length / PROTECTED_ITEMS_PAGE_SIZE)),
|
||||
);
|
||||
|
||||
const visibleRollups = createMemo<ProtectionRollup[]>(() => {
|
||||
const start = (protectedPage() - 1) * PROTECTED_ITEMS_PAGE_SIZE;
|
||||
return sortedRollups().slice(start, start + PROTECTED_ITEMS_PAGE_SIZE);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
props.queryFilter();
|
||||
props.platformFilter();
|
||||
props.itemTypeFilter();
|
||||
props.historyOutcomeFilter();
|
||||
props.protectedStaleOnly();
|
||||
protectedSortCol();
|
||||
protectedSortDir();
|
||||
setProtectedPage(1);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const totalPages = protectedTotalPages();
|
||||
if (protectedPage() > totalPages) {
|
||||
setProtectedPage(totalPages);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="none"
|
||||
tone="card"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_12px_28px_rgba(2,6,23,0.12)]"
|
||||
class="overflow-hidden border-border bg-surface shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
|
||||
>
|
||||
<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-[0.18em] text-slate-400">
|
||||
Protected Items
|
||||
</div>
|
||||
<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 class="text-sm font-semibold text-base-content">Protected inventory</div>
|
||||
<div class="max-w-3xl text-sm text-muted">
|
||||
Item-first recovery inventory, with item class and latest-point posture kept visible
|
||||
in the main grid instead of buried in drill-in.
|
||||
</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">
|
||||
{props.filteredRollups().length} of {props.rollups().length} items shown
|
||||
{sortedRollups().length} of {props.rollups().length} items shown
|
||||
</span>
|
||||
<Show when={props.rollupsSummary().stale > 0}>
|
||||
<span class={`${getRecoveryRollupStatusPillClass('stale')} px-2.5 py-1`}>
|
||||
@@ -193,7 +221,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</div>
|
||||
|
||||
<Show when={!props.kioskMode}>
|
||||
<div class="border-b border-border-subtle/80 bg-surface-hover px-4 py-4">
|
||||
<div class="border-b border-border-subtle/80 bg-surface-alt/35 px-4 py-4">
|
||||
<PageControls
|
||||
search={
|
||||
<SearchInput
|
||||
@@ -201,6 +229,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
onChange={(value) => props.setQueryFilter(value)}
|
||||
placeholder={getRecoveryProtectedSearchPlaceholder()}
|
||||
class="w-full"
|
||||
inputClass="py-2.5 text-sm"
|
||||
clearOnEscape
|
||||
history={{
|
||||
storageKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
|
||||
@@ -214,7 +243,8 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
count: protectedActiveFilterCount(),
|
||||
}}
|
||||
showFilters={!props.isMobile || protectedFiltersOpen()}
|
||||
toolbarClass="lg:flex-nowrap"
|
||||
searchRowClass="flex w-full items-center gap-3"
|
||||
toolbarClass="gap-3 lg:flex-nowrap"
|
||||
>
|
||||
<LabeledFilterSelect
|
||||
id="recovery-item-type-filter"
|
||||
@@ -225,7 +255,8 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
normalizeRecoveryItemTypeQueryValue(event.currentTarget.value) || 'all',
|
||||
)
|
||||
}
|
||||
selectClass="min-w-[10rem] max-w-[14rem]"
|
||||
groupClass="gap-2 px-1.5 py-1"
|
||||
selectClass="min-w-[10rem] max-w-[14rem] py-2 text-sm"
|
||||
>
|
||||
<For each={props.itemTypeOptions()}>
|
||||
{(itemType) => (
|
||||
@@ -247,7 +278,8 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
normalizeSourcePlatformQueryValue(event.currentTarget.value),
|
||||
)
|
||||
}
|
||||
selectClass="min-w-[10rem] max-w-[14rem]"
|
||||
groupClass="gap-2 px-1.5 py-1"
|
||||
selectClass="min-w-[10rem] max-w-[14rem] py-2 text-sm"
|
||||
>
|
||||
<For each={props.platformOptions()}>
|
||||
{(platform) => (
|
||||
@@ -267,7 +299,8 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
props.setHistoryOutcomeFilter(value);
|
||||
if (value !== 'all') props.setVerificationFilter('all');
|
||||
}}
|
||||
selectClass="min-w-[9rem]"
|
||||
groupClass="gap-2 px-1.5 py-1"
|
||||
selectClass="min-w-[9rem] py-2 text-sm"
|
||||
>
|
||||
<For each={availableOutcomes}>
|
||||
{(outcome) => (
|
||||
@@ -282,7 +315,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
type="button"
|
||||
aria-pressed={props.protectedStaleOnly()}
|
||||
onClick={() => props.setProtectedStaleOnly((value) => !value)}
|
||||
class={`rounded-md border px-2.5 py-1 text-xs font-medium transition-colors ${getRecoveryProtectedToggleClass(
|
||||
class={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${getRecoveryProtectedToggleClass(
|
||||
props.protectedStaleOnly(),
|
||||
)}`}
|
||||
>
|
||||
@@ -333,7 +366,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
] as const
|
||||
).map(([column, label]) => (
|
||||
<TableHead
|
||||
class={`py-0.5 px-3 whitespace-nowrap text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer select-none hover:text-base-content transition-colors${
|
||||
class={`px-3 py-2.5 whitespace-nowrap text-left text-[11px] sm:text-xs font-semibold uppercase tracking-wider cursor-pointer select-none hover:text-base-content transition-colors${
|
||||
column === 'type'
|
||||
? ' hidden md:table-cell w-[96px]'
|
||||
: column === 'platform'
|
||||
@@ -363,7 +396,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border">
|
||||
<For each={sortedRollups()}>
|
||||
<For each={visibleRollups()}>
|
||||
{(rollup) => {
|
||||
const resourceIndex = props.resourcesById();
|
||||
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
|
||||
@@ -390,11 +423,11 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
class="cursor-pointer border-b border-border/80 hover:bg-surface-hover/95"
|
||||
class="cursor-pointer border-b border-border/80 odd:bg-surface even:bg-surface-alt/20 hover:bg-surface-hover/95"
|
||||
onClick={() => props.onSelectRollup(rollup.rollupId)}
|
||||
>
|
||||
<TableCell
|
||||
class={`relative max-w-[420px] truncate whitespace-nowrap px-3 py-0.5 text-base-content ${
|
||||
class={`relative max-w-[420px] truncate whitespace-nowrap px-3 py-2.5 text-base-content ${
|
||||
issueTone === 'rose' || issueTone === 'blue' ? 'font-medium' : ''
|
||||
}`}
|
||||
title={label}
|
||||
@@ -417,7 +450,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-0.5">
|
||||
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-2.5">
|
||||
<Show
|
||||
when={itemTypePresentation}
|
||||
fallback={<span class="text-muted">—</span>}
|
||||
@@ -432,7 +465,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</Show>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-0.5">
|
||||
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-2.5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={platforms}>
|
||||
{(platform) => {
|
||||
@@ -448,7 +481,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</TableCell>
|
||||
|
||||
<TableCell
|
||||
class={`whitespace-nowrap px-3 py-0.5 ${getRecoveryRollupAgeTextClass(
|
||||
class={`whitespace-nowrap px-3 py-2.5 ${getRecoveryRollupAgeTextClass(
|
||||
rollup,
|
||||
nowMs,
|
||||
)}`}
|
||||
@@ -469,7 +502,7 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="whitespace-nowrap px-3 py-0.5">
|
||||
<TableCell class="whitespace-nowrap px-3 py-2.5">
|
||||
<span
|
||||
class={`inline-flex rounded px-1.5 py-0.5 text-[10px] font-medium ${getRecoveryOutcomeBadgeClass(
|
||||
outcome,
|
||||
@@ -485,6 +518,41 @@ export const RecoveryProtectedInventorySection: Component<
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3 text-xs text-muted">
|
||||
<div>
|
||||
<Show
|
||||
when={sortedRollups().length > 0}
|
||||
fallback={<span>Showing 0 of 0 protected items</span>}
|
||||
>
|
||||
<span>
|
||||
Showing {(protectedPage() - 1) * PROTECTED_ITEMS_PAGE_SIZE + 1} -{' '}
|
||||
{Math.min(protectedPage() * PROTECTED_ITEMS_PAGE_SIZE, sortedRollups().length)} of{' '}
|
||||
{sortedRollups().length} protected items
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={protectedPage() <= 1}
|
||||
onClick={() => setProtectedPage(Math.max(1, protectedPage() - 1))}
|
||||
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {protectedPage()} / {protectedTotalPages()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={protectedPage() >= protectedTotalPages()}
|
||||
onClick={() => setProtectedPage(Math.min(protectedTotalPages(), protectedPage() + 1))}
|
||||
class="rounded-md border border-border bg-surface px-2 py-1 text-xs font-medium text-base-content disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -51,11 +51,11 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
<Show when={hasRollups()}>
|
||||
<Card
|
||||
padding="none"
|
||||
class="overflow-hidden border-border bg-surface-hover shadow-[0_14px_34px_rgba(2,6,23,0.14)]"
|
||||
class="overflow-hidden border-border bg-surface shadow-[0_10px_24px_rgba(2,6,23,0.1)]"
|
||||
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-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/70 px-2.5 py-1 font-medium text-base-content">
|
||||
<span>{summary().total} protected</span>
|
||||
@@ -103,16 +103,16 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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="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-alt/35 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">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Recovery Posture
|
||||
</div>
|
||||
<div class="mt-2 max-w-xl text-sm text-slate-300">
|
||||
<div class="mt-1.5 max-w-xl text-sm leading-6 text-muted">
|
||||
Recovery readiness across the current protected estate, with unhealthy
|
||||
coverage and stale protection surfaced first.
|
||||
coverage surfaced before activity detail.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={attentionItems().length > 0}>
|
||||
@@ -125,22 +125,22 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<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-3xl font-semibold text-base-content">{summary().total}</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface 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>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Healthy</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-emerald-400">{healthyCount()}</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface 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>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted">Attention</div>
|
||||
<div class="mt-1 text-3xl font-semibold text-amber-300">{attentionCount()}</div>
|
||||
<div class="rounded-lg border border-border-subtle bg-surface 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>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 h-3 overflow-hidden rounded-full bg-surface-alt/90">
|
||||
<div class="mt-4 h-3 overflow-hidden rounded-full bg-surface-alt/90">
|
||||
<div class="flex h-full">
|
||||
<For each={postureSegments()}>
|
||||
{(segment) => (
|
||||
@@ -154,10 +154,10 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<For each={postureSegments()}>
|
||||
{(segment) => (
|
||||
<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="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">
|
||||
<span class={`h-2 w-2 rounded-full ${segment.color}`} />
|
||||
<span class="text-base-content">{segment.label}</span>
|
||||
@@ -167,15 +167,15 @@ 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="mt-4 grid gap-3 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
|
||||
<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-[44px_minmax(0,1fr)_28px] items-center gap-2 text-xs">
|
||||
<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
|
||||
@@ -191,31 +191,31 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</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">
|
||||
<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 sm:grid-cols-2">
|
||||
<For each={attentionItems().slice(0, 4)}>
|
||||
<div class="mt-3 grid gap-2">
|
||||
<For each={attentionItems().slice(0, 2)}>
|
||||
{(item) => (
|
||||
<div
|
||||
class={`rounded-lg border px-3 py-2.5 ${getRecoveryAttentionChipClass(item.tone)}`}
|
||||
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-xs font-semibold uppercase tracking-[0.14em]">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.14em]">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm font-semibold tabular-nums">{item.count}</span>
|
||||
<span class="text-base font-semibold tabular-nums">{item.count}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 text-xs leading-5 text-current/80">{item.detail}</div>
|
||||
<div class="mt-1.5 text-sm leading-6 text-current/80">{item.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
@@ -225,48 +225,48 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<div class="grid gap-3">
|
||||
<section class="rounded-xl border border-border-subtle bg-surface-alt/35 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Protected Footprint
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="mt-3 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">
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted">Item Types</div>
|
||||
<div class="mt-1 text-3xl font-semibold tracking-tight 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">
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted">Primary Item</div>
|
||||
<div class="mt-1 text-xl font-semibold leading-7 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">
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted">Platforms</div>
|
||||
<div class="mt-1 text-3xl font-semibold tracking-tight 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">
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted">Primary Platform</div>
|
||||
<div class="mt-1 text-xl font-semibold leading-7 text-base-content">
|
||||
{platformCoverage().primaryPlatformLabel ?? 'n/a'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="mt-3 space-y-3">
|
||||
<Show when={itemCoverage().items.length > 0}>
|
||||
<div>
|
||||
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
|
||||
<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-md border border-border-subtle bg-surface 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-sm">
|
||||
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -279,11 +279,11 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<div class="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted">
|
||||
<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-xs text-muted">
|
||||
<div class="mb-2 text-sm text-muted">
|
||||
{platformCoverage().multiPlatformCount} protected item
|
||||
{platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple
|
||||
platforms.
|
||||
@@ -292,7 +292,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
<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 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-sm">
|
||||
<span class={`rounded px-1.5 py-0.5 text-[10px] font-medium ${item.toneClass}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -306,74 +306,69 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
|
||||
</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>
|
||||
<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">
|
||||
<section class="rounded-xl border border-border-subtle bg-surface-alt/35 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">
|
||||
Recent History
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<div class="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-[11px] 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>
|
||||
<div class="text-[11px] 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>
|
||||
<div class="text-[11px] 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`}
|
||||
/>
|
||||
<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 Day</div>
|
||||
<div class="mt-1 text-base font-semibold text-base-content">
|
||||
{activity().busiestLabel ?? 'n/a'}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-muted">
|
||||
{activity().busiestCount} recovery point
|
||||
{activity().busiestCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</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>
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-wide text-muted">Latest Activity</div>
|
||||
<div class="mt-1 text-base font-semibold text-base-content">
|
||||
{activity().latestLabel ?? 'n/a'}
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-muted">
|
||||
{activity().latestCount} recovery point
|
||||
{activity().latestCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('Recovery', () => {
|
||||
|
||||
expect(await screen.findByTestId('recovery-summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Recovery Posture')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Protected Items')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: /protected items/i })).toBeInTheDocument();
|
||||
await screen.findByText('VM 123');
|
||||
expect(screen.queryByText('Recovery Events')).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
@@ -219,7 +219,11 @@ describe('Recovery', () => {
|
||||
fireEvent.click(await screen.findByText('VM 123'));
|
||||
|
||||
expect(await screen.findByText('Recovery Events')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Items')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /protected items/i })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('table')).toHaveLength(1);
|
||||
});
|
||||
@@ -245,7 +249,11 @@ describe('Recovery', () => {
|
||||
render(() => <Recovery />);
|
||||
|
||||
expect(await screen.findByText('Recovery Events')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Items')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /protected items/i })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('derives the recovery events workspace from focused route state when no explicit view is set', async () => {
|
||||
@@ -254,7 +262,11 @@ describe('Recovery', () => {
|
||||
render(() => <Recovery />);
|
||||
|
||||
expect(await screen.findByText('Recovery Events')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Items')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /protected items/i })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(screen.queryByText('Protected inventory')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders canonical rollup and history item labels when linked resources are unavailable', async () => {
|
||||
@@ -1053,7 +1065,7 @@ describe('Recovery', () => {
|
||||
it('persists the selected timeline range in the recovery URL', async () => {
|
||||
render(() => <Recovery />);
|
||||
|
||||
await screen.findByText('Protected Items');
|
||||
await screen.findByRole('tab', { name: /protected items/i });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '7d' }));
|
||||
|
||||
@@ -1067,7 +1079,7 @@ describe('Recovery', () => {
|
||||
|
||||
render(() => <Recovery />);
|
||||
|
||||
await screen.findByText('Protected Items');
|
||||
await screen.findByRole('tab', { name: /protected items/i });
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
Reference in New Issue
Block a user