Simplify recovery presentation and type contracts

This commit is contained in:
rcourtman
2026-05-14 21:08:54 +01:00
parent 3580a5ed6d
commit 7d6b447c59
34 changed files with 1458 additions and 2114 deletions
+20 -20
View File
@@ -4,10 +4,10 @@ Pulse v6 includes a **provider-neutral recovery view** that aggregates backup, s
## Overview
Recovery answers two questions:
Recovery is event-first and answers two questions:
1. **"What is protected?"** → The **Protected Items** table shows a rollup per subject with its latest backup/snapshot status.
2. **"What happened?"** → The **Events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes.
1. **"What happened?"** → The **Recovery events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes.
2. **"What is covered?"** -> The **Protection coverage** action opens a rollup review for healthy, stale, failed, warning, running, unknown, and never-succeeded items.
## Supported Providers
@@ -49,19 +49,7 @@ A rollup groups recovery points for a subject to show:
## Navigating Recovery
### Protected Items Tab
Shows one row per protected subject (or per subject + method when multiple backup methods exist). Key columns:
| Column | Description |
|---|---|
| Subject | The protected resource (VM name, dataset path, etc.) |
| Method | Backup method (PBS backup, local dump, ZFS snapshot, replication) |
| Last Point | Most recent recovery point timestamp |
| Outcome | Success / Warning / Failed |
| Source | Which provider created this point (pve, pbs, truenas, k8s) |
### Events Tab
### Recovery Events
Shows individual recovery points. Key columns:
@@ -74,21 +62,33 @@ Shows individual recovery points. Key columns:
| Size | Size of the artifact (when available) |
| Verified | Whether the backup has been verified (tri-state) |
### Protection Coverage
Available from the Recovery header action and compatibility routes when posture or freshness needs review. It shows one row per protected item (or per item + method when multiple backup methods exist). Key columns:
| Column | Description |
|---|---|
| Item | The protected resource (VM name, dataset path, etc.) |
| Item Type | Canonical resource category |
| Platform | Which provider created the latest point (PVE, PBS, TrueNAS, Kubernetes) |
| Latest Point | Most recent successful recovery point timestamp |
| Status | Healthy, stale, failed, warning, running, unknown, or never succeeded |
### Filtering
Both tabs support:
Both workspaces support:
- **Source filter** — show only points from a specific provider
- **Platform filter** — show only points from a specific platform
- **Outcome filter** — show only failed, successful, or running points
- **Time range** — filter to a specific time window
- **Search** — full-text search across subjects and details
- **Search** — full-text search across items and details
## API Reference
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/recovery/points` | List individual recovery points |
| `GET` | `/api/recovery/rollups` | List subject rollups (protected items) |
| `GET` | `/api/recovery/rollups` | List subject rollups (protection coverage) |
| `GET` | `/api/recovery/series` | Time-series data for recovery charts |
| `GET` | `/api/recovery/facets` | Available filter facets (providers, kinds, outcomes) |
+1 -15
View File
@@ -5788,21 +5788,7 @@
],
"coverage_gaps": [],
"candidate_lanes": [],
"work_claims": [
{
"id": "codex-storage-native-topology-lane-l15",
"agent_id": "codex-storage-native-topology",
"summary": "Make Storage show platform-native storage topology clearly",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-05-14T15:57:59Z",
"heartbeat_at": "2026-05-14T15:57:59Z",
"expires_at": "2026-05-14T17:57:59Z",
"work_item": {
"kind": "lane",
"id": "L15"
}
}
],
"work_claims": [],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [
@@ -720,7 +720,7 @@ prompt explain the same operator-facing priority.
system badge names a platform with its version, source/method context may
still add collection labels such as Pulse Agent, but it must not repeat the
same platform again as an unversioned source badge.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders. Shared chart slot geometry belongs in `frontend-modern/src/components/shared/summaryChartLayout.ts` so `SummaryMetricCard` and governed non-summary chart sections can consume the same normal and compact chart heights instead of re-declaring page-local `h-*` sizing.
Storage's top-of-page summary scope is limited to the stable chart grid and
shared row/group focus affordances. A rolling-history capacity planner must
not be bolted onto the sticky summary shell as an extra frontend primitive;
@@ -2033,16 +2033,17 @@ through the shared `searchLeading` slot instead of recreating a second local
header strip above the control bar.
Pages that filter a list-of-resources surface (Infrastructure, Workloads,
Storage, Recovery Protected items, Recovery events) compose the chip-based
Storage, Recovery Protection coverage, Recovery events) compose the chip-based
`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx` shell instead
of `PageControls`. Each page declares a `FilterDef[]` catalog (label, options,
value, defaultValue, group); `FilterBar` renders chips for active filters and
exposes the rest behind a "+ Filter" menu, with type-ahead at both the menu
and chip popovers (`AddFilterMenu` and `FilterChip`). View options
(grouping segmented control, charts toggle, columns picker, sort key) sit in
the `viewOptionsTrailing` slot and are not chips. Subtabs that switch the
rendered dataset (Recovery's Protected items vs Recovery events, Storage's
Pools vs Physical Disks) sit above the bar; they are navigation, not filters.
the `viewOptionsTrailing` slot and are not chips. Recovery is event-first and
does not use equal workspace subtabs for protected rollups versus event
history; Storage subtabs (Pools / Physical Disks) sit above the bar as
navigation, not filters.
Pages that have not yet migrated (the alert-history filter card,
Kubernetes deployments drawer) keep using `PageControls` and
`LabeledFilterSelect`, but new resource-list filter surfaces should reach for
@@ -36,27 +36,31 @@ state.
7. `frontend-modern/src/components/Recovery/RecoveryActivitySection.tsx`
8. `frontend-modern/src/components/Recovery/RecoveryHistorySection.tsx`
9. `frontend-modern/src/components/Recovery/RecoveryHistoryTable.tsx`
10. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`
11. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts`
12. `frontend-modern/src/pages/Storage.tsx`
13. `frontend-modern/src/pages/Ceph.tsx`
14. `frontend-modern/src/components/Storage/Storage.tsx`
15. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
16. `frontend-modern/src/utils/storageSources.ts`
17. `frontend-modern/src/hooks/useRecoveryPoints.ts`
18. `frontend-modern/src/hooks/useRecoveryRollups.ts`
19. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts`
20. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts`
21. `frontend-modern/src/pages/Recovery.tsx`
22. `frontend-modern/src/routing/resourceLinks.ts`
23. `frontend-modern/src/types/recovery.ts`
24. `frontend-modern/src/utils/recoveryTablePresentation.ts`
25. `frontend-modern/src/utils/recoveryItemTypePresentation.ts`
26. `frontend-modern/src/utils/textPresentation.ts`
27. `frontend-modern/src/components/Storage/StorageSummary.tsx`
28. `frontend-modern/src/utils/storageSummaryCache.ts`
29. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
30. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
10. `frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx`
11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`
12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts`
13. `frontend-modern/src/pages/Storage.tsx`
14. `frontend-modern/src/pages/Ceph.tsx`
15. `frontend-modern/src/components/Storage/Storage.tsx`
16. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
17. `frontend-modern/src/utils/storageSources.ts`
18. `frontend-modern/src/hooks/useRecoveryPoints.ts`
19. `frontend-modern/src/hooks/useRecoveryRollups.ts`
20. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts`
21. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts`
22. `frontend-modern/src/pages/Recovery.tsx`
23. `frontend-modern/src/routing/resourceLinks.ts`
24. `frontend-modern/src/types/recovery.ts`
25. `frontend-modern/src/utils/recoveryDatePresentation.ts`
26. `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts`
27. `frontend-modern/src/utils/recoveryTablePresentation.ts`
28. `frontend-modern/src/utils/recoveryTimelinePresentation.ts`
29. `frontend-modern/src/utils/recoveryItemTypePresentation.ts`
30. `frontend-modern/src/utils/textPresentation.ts`
31. `frontend-modern/src/components/Storage/StorageSummary.tsx`
32. `frontend-modern/src/utils/storageSummaryCache.ts`
33. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
34. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
## Shared Boundaries
@@ -149,6 +153,11 @@ bypass the API fail-closed execution gate.
`"recovery-events"`) so operators can save and recall named filter combos
through the shared `SavedViewsMenu`. The same pattern applies to Storage
(`savedViewsKey="storage"`).
Recovery must not restore the retired top-level RecoverySummary metric card
strip. Aggregate recovery counts, success/failure mix, and selected-day
context belong inside the Recovery activity and event-history surfaces that
already own the chart and table filters, rather than as a separate summary
helper that competes with the event-first page hierarchy.
Recovery event filters now compose the shared chip-based `FilterBar`
(`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a
`FilterDef[]` catalog. The legacy "advanced filter popover" retired: scope,
@@ -189,6 +198,9 @@ bypass the API fail-closed execution gate.
shared chart slot height and matching plot-area height so it keeps visual
parity with the Workloads, Storage, and Infrastructure summary charts
without copying page-local chart sizing.
A selected timeline day is an active event-history filter: table totals,
footer ranges, empty states, and search/date matching must describe the
visible filtered recovery points, not the unfiltered API page metadata.
3. Add or change storage page UX through `frontend-modern/src/pages/Storage.tsx`, `frontend-modern/src/pages/Ceph.tsx`, `frontend-modern/src/components/Storage/`, `frontend-modern/src/features/storageBackups/`, and the shared storage-source contract in `frontend-modern/src/utils/storageSources.ts`
The retired dashboard route must not reintroduce storage or recovery
widgets as compatibility panels. Storage capacity, storage health,
@@ -155,6 +155,13 @@ cross-source deduplication.
executor adapters to make stale-approval decisions.
2. Add typed accessors and views in `internal/unifiedresources/views.go`
3. Add source ingestion/adaptation in the adapter layer only
Frontend resource platform contracts in
`frontend-modern/src/types/resource.ts` must include platform ids that the
canonical source/platform adapters can emit. Agent-native storage sources
such as Unraid are first-class platform ids for typing, filtering, tests,
and storage/recovery presentation; consumers must not hide them behind
Proxmox-only or generic agent-only frontend unions when unified resources
has already published explicit platform evidence.
Infrastructure table platform presentation extends through
`frontend-modern/src/utils/resourceBadgePresentation.ts`,
`frontend-modern/src/components/Infrastructure/resourceBadges.ts`, and the
@@ -4,11 +4,10 @@ import type { Component } from 'solid-js';
import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection';
import { RecoveryHistorySection } from '@/components/Recovery/RecoveryHistorySection';
import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection';
import { RecoverySummary } from '@/components/Recovery/RecoverySummary';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import { Subtabs } from '@/components/shared/Subtabs';
import { TableCard } from '@/components/shared/TableCard';
import type { FilterSelectOption } from '@/components/shared/FilterBar';
import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';
import type { ProtectedStateFilter } from '@/features/recovery/useRecoverySurfaceState';
import { useBreakpoint } from '@/hooks/useBreakpoint';
@@ -18,10 +17,12 @@ import { useKioskMode } from '@/hooks/useKioskMode';
import type { ProtectionRollup, RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
getRecoveryFilterDateLabel,
getRecoveryFullDateLabel,
getRecoveryNiceAxisMax,
parseRecoveryDateKey,
recoveryDateKeyFromTimestamp,
resolveRecoveryDateSearchKey,
} from '@/utils/recoveryDatePresentation';
import { getRecoveryPointsFailureState } from '@/utils/recoveryEmptyStatePresentation';
import { normalizeRecoveryOutcome as normalizeOutcome } from '@/utils/recoveryOutcomePresentation';
@@ -30,10 +31,7 @@ import {
getRecoveryPointTimestampMs,
getRecoveryRollupItemLabel,
} from '@/utils/recoveryRecordPresentation';
import {
getRecoveryRollupItemTypeKey,
getRecoveryItemTypePresentation,
} from '@/utils/recoveryItemTypePresentation';
import { getRecoveryRollupItemTypeKey } from '@/utils/recoveryItemTypePresentation';
import {
getRecoveryArtifactColumnLabel,
getRecoveryGroupNoTimestampLabel,
@@ -42,6 +40,7 @@ import {
STALE_ISSUE_THRESHOLD_MS,
} from '@/utils/recoveryTablePresentation';
import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPresentation';
import { getRecoveryTimelineDayFilterLabel } from '@/utils/recoveryTimelinePresentation';
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
@@ -50,8 +49,7 @@ const LEGACY_RECOVERY_COLUMN_ID_ALIASES = {
subject: 'item',
source: 'platform',
} as const;
type RecoveryWorkspaceView = 'inventory' | 'events';
type RecoverySummaryRange = '7d' | '30d' | '90d' | '365d';
type RecoveryActivityRangeDays = 7 | 30 | 90 | 365;
const Recovery: Component = () => {
const kioskMode = useKioskMode();
@@ -403,39 +401,71 @@ const Recovery: Component = () => {
const selectedDateLabel = createMemo(() => {
const key = selectedDateKey();
if (!key) return '';
const [year, month, day] = key.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return key;
return new Date(year, month - 1, day).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return key ? getRecoveryFilterDateLabel(key) : '';
});
const activeClusterLabel = createMemo(() => (clusterFilter() === 'all' ? '' : clusterFilter()));
const activeNodeLabel = createMemo(() => (nodeFilter() === 'all' ? '' : nodeFilter()));
const activeNamespaceLabel = createMemo(() =>
namespaceFilter() === 'all' ? '' : namespaceFilter(),
);
const activeItemTypeLabel = createMemo(() => {
if (itemTypeFilter() === 'all') return '';
return getRecoveryItemTypePresentation(itemTypeFilter())?.label || itemTypeFilter();
const dayFilterOptions = createMemo<FilterSelectOption[]>(() => {
const options = timeline().points.map((point) => ({
value: point.key,
label: getRecoveryTimelineDayFilterLabel(getRecoveryFilterDateLabel(point.key), point.total),
count: point.total,
}));
const selected = selectedDateKey();
if (selected && !options.some((option) => option.value === selected)) {
options.push({
value: selected,
label: getRecoveryTimelineDayFilterLabel(selectedDateLabel() || selected, 0),
count: 0,
});
}
return [{ value: 'all', label: 'Any day' }, ...options];
});
const summaryRange = createMemo<RecoverySummaryRange>(() => {
const range = chartRangeDays();
if (range === 7) return '7d';
if (range === 90) return '90d';
if (range === 365) return '365d';
return '30d';
});
const setSummaryRange = (range: RecoverySummaryRange) => {
const nextRange = range === '7d' ? 7 : range === '90d' ? 90 : range === '365d' ? 365 : 30;
setChartRangeDays(nextRange);
const setActivityRange = (range: RecoveryActivityRangeDays) => {
setChartRangeDays(range);
setSelectedDateKey(null);
setCurrentPage(1);
};
const setDayFilter = (key: string | null) => {
batch(() => {
setWorkspaceView('events');
setSelectedDateKey(key);
setCurrentPage(1);
});
};
const toggleDayFilter = (key: string) => {
setDayFilter(selectedDateKey() === key ? null : key);
};
const setHistorySearchFilter = (value: string) => {
const dayKey = resolveRecoveryDateSearchKey(
value,
timeline().points.map((point) => point.key),
);
if (dayKey) {
// Let the controlled search value observe the typed text before it is promoted to a filter.
setQueryFilter(value);
queueMicrotask(() => {
batch(() => {
setWorkspaceView('events');
setSelectedDateKey(dayKey);
setQueryFilter('');
setCurrentPage(1);
});
});
return;
}
batch(() => {
setQueryFilter(value);
setCurrentPage(1);
});
};
const hasActiveArtifactFilters = createMemo(
() =>
rollupId().trim() !== '' ||
@@ -496,6 +526,25 @@ const Recovery: Component = () => {
});
};
const openEventsView = () => {
batch(() => {
setWorkspaceView('events');
setProtectedStateFilter('all');
setCurrentPage(1);
});
};
const openCoverageView = (state: ProtectedStateFilter = 'all') => {
batch(() => {
setWorkspaceView('inventory');
setRollupId('');
setSelectedDateKey(null);
setHistoryOutcomeFilter('all');
setProtectedStateFilter(state);
setCurrentPage(1);
});
};
const handleSelectRollup = (nextRollupId: string) => {
batch(() => {
setWorkspaceView('events');
@@ -506,134 +555,50 @@ const Recovery: Component = () => {
});
};
const handleWorkspaceViewChange = (value: string) => {
const nextView = value as RecoveryWorkspaceView;
batch(() => {
setWorkspaceView(nextView);
setCurrentPage(1);
if (nextView === 'inventory') {
setRollupId('');
setSelectedDateKey(null);
} else {
setProtectedStateFilter('all');
}
});
};
const workspaceControls = () => (
<Subtabs
value={workspaceView()}
onChange={handleWorkspaceViewChange}
ariaLabel="Recovery data view"
class="px-4 sm:px-5"
tabs={[
{
value: 'inventory',
label: 'Protected items',
},
{
value: 'events',
label: 'Recovery events',
},
]}
/>
);
const eventsActivity = () => (
<RecoveryActivitySection
activitySummary={activitySummary}
activeClusterLabel={activeClusterLabel}
activeItemTypeLabel={activeItemTypeLabel}
activeNamespaceLabel={activeNamespaceLabel}
activeNodeLabel={activeNodeLabel}
chartRangeDays={chartRangeDays}
clearClusterFilter={() => {
setClusterFilter('all');
setCurrentPage(1);
}}
clearItemTypeFilter={() => {
setItemTypeFilter('all');
setCurrentPage(1);
}}
clearNamespaceFilter={() => {
setNamespaceFilter('all');
setCurrentPage(1);
}}
clearNodeFilter={() => {
setNodeFilter('all');
setCurrentPage(1);
}}
clearSelectedDate={() => {
setSelectedDateKey(null);
setCurrentPage(1);
}}
isMobile={isMobile()}
loading={recoverySeriesLoading}
onRangeChange={setActivityRange}
overallRollupsSummary={overallRollupsSummary}
selectedDateKey={selectedDateKey}
selectedDateLabel={selectedDateLabel}
dayFilterKey={selectedDateKey}
timeline={timeline}
toggleSelectedDate={(key) => {
batch(() => {
setWorkspaceView('events');
setSelectedDateKey((previous) => (previous === key ? null : key));
setCurrentPage(1);
});
}}
toggleDayFilter={toggleDayFilter}
/>
);
const headerActionClass =
'inline-flex items-center rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover';
return (
<div data-testid="recovery-page" class="flex flex-col gap-2">
<PageHeader
title="Recovery"
description="Review protected inventory, recent recovery activity, and restore posture across platforms."
description="Review recovery activity, restore posture, and backup history across platforms."
actions={
<>
<Show when={workspaceView() === 'events'}>
<button
type="button"
class={headerActionClass}
onClick={() => {
openCoverageView('all');
}}
>
Protection coverage
</button>
</Show>
<Show when={workspaceView() === 'inventory'}>
<button type="button" class={headerActionClass} onClick={openEventsView}>
Back to recovery events
</button>
</Show>
</>
}
/>
<RecoverySummary
loaded={recoveryRollupsLoaded}
rollups={rollups}
series={() => recoverySeries.series() || []}
seriesLoaded={recoverySeriesLoaded}
seriesFailed={() => Boolean(recoverySeries.response.error)}
summary={overallRollupsSummary}
timeRange={summaryRange}
onTimeRangeChange={setSummaryRange}
onOpenEvents={() => {
batch(() => {
setWorkspaceView('events');
setProtectedStateFilter('all');
setCurrentPage(1);
});
}}
onShowAttention={() => {
batch(() => {
setWorkspaceView('inventory');
setRollupId('');
setSelectedDateKey(null);
setHistoryOutcomeFilter('all');
if ((overallRollupsSummary().counts.failed || 0) > 0) {
setProtectedStateFilter('failed');
} else {
setProtectedStateFilter('stale');
}
setCurrentPage(1);
});
}}
onShowStale={() => {
batch(() => {
setWorkspaceView('inventory');
setRollupId('');
setSelectedDateKey(null);
setHistoryOutcomeFilter('all');
setProtectedStateFilter('stale');
setCurrentPage(1);
});
}}
/>
{workspaceControls()}
<div class="flex flex-col gap-2">
{(() => {
return (
@@ -645,6 +610,7 @@ const Recovery: Component = () => {
kioskMode={kioskMode()}
loading={recoveryRollupsLoading}
error={() => recoveryRollups.rollups.error}
onOpenEvents={openEventsView}
onSelectRollup={handleSelectRollup}
protectedStateFilter={protectedStateFilter}
itemTypeFilter={itemTypeFilter}
@@ -690,6 +656,9 @@ const Recovery: Component = () => {
clusterFilter={clusterFilter}
clusterOptions={clusterOptions}
currentPage={currentPage}
dayFilterKey={selectedDateKey}
dayFilterLabel={selectedDateLabel}
dayFilterOptions={dayFilterOptions}
groupedByDay={groupedByDay}
hasActiveArtifactFilters={hasActiveArtifactFilters}
hasFocusedRollup={() => rollupId().trim().length > 0}
@@ -724,9 +693,10 @@ const Recovery: Component = () => {
setNamespaceFilter={setNamespaceFilter}
setNodeFilter={setNodeFilter}
setPlatformFilter={setPlatformFilter}
setQueryFilter={setQueryFilter}
setQueryFilter={setHistorySearchFilter}
setRollupId={setRollupId}
setScopeFilter={setScopeFilter}
setDayFilter={setDayFilter}
setVerificationFilter={setVerificationFilter}
showClusterFilter={showClusterFilter}
showNamespaceFilter={showNamespaceFilter}
@@ -1,13 +1,16 @@
import { For, Show, createMemo } from 'solid-js';
import { createMemo, createSignal, For, Show } from 'solid-js';
import type { Accessor, Component, JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { hideTooltip, showTooltip } from '@/components/shared/Tooltip';
import {
SUMMARY_CHART_PLOT_AREA_CLASS,
SUMMARY_CHART_SLOT_CLASS,
} from '@/components/shared/summaryChartLayout';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
import {
getRecoveryActivityEmptyState,
getRecoveryActivityLoadingState,
} from '@/utils/recoveryEmptyStatePresentation';
import { getRecoveryFilterChipPresentation } from '@/utils/recoveryFilterChipPresentation';
import { getRecoveryArtifactModePresentation } from '@/utils/recoveryArtifactModePresentation';
import {
getRecoveryPrettyDateLabel,
@@ -21,8 +24,13 @@ import {
RECOVERY_TIMELINE_LEGEND_ITEM_CLASS,
} from '@/utils/recoveryTimelineChartPresentation';
import {
getRecoveryTimelineBarMarkerClass,
getRecoveryTimelineDayFilterStateLabel,
getRecoveryTimelineColumnAriaLabel,
getRecoveryTimelineColumnButtonClass,
getRecoveryTimelineEmptyMarkerClass,
getRecoveryTimelinePointTotalLabel,
getRecoveryTimelineTooltipRows,
} from '@/utils/recoveryTimelinePresentation';
interface RecoveryRollupSummary {
@@ -52,28 +60,29 @@ interface TimelineModel {
labelEvery: number;
}
interface ActivityTooltipState {
dateLabel: string;
point: TimelinePoint;
x: number;
y: number;
}
const RECOVERY_ACTIVITY_RANGE_DAYS = [7, 30, 90, 365] as const;
interface RecoveryActivitySectionProps {
activitySummary: Accessor<ActivitySummary>;
activeClusterLabel: Accessor<string>;
activeItemTypeLabel: Accessor<string>;
activeNamespaceLabel: Accessor<string>;
activeNodeLabel: Accessor<string>;
chartRangeDays: Accessor<7 | 30 | 90 | 365>;
clearClusterFilter: () => void;
clearItemTypeFilter: () => void;
clearNamespaceFilter: () => void;
clearNodeFilter: () => void;
clearSelectedDate: () => void;
isMobile: boolean;
loading: Accessor<boolean>;
overallRollupsSummary: Accessor<RecoveryRollupSummary>;
selectedDateKey: Accessor<string | null>;
selectedDateLabel: Accessor<string>;
toggleSelectedDate: (key: string) => void;
onRangeChange: (days: (typeof RECOVERY_ACTIVITY_RANGE_DAYS)[number]) => void;
dayFilterKey: Accessor<string | null>;
toggleDayFilter: (key: string) => void;
timeline: Accessor<TimelineModel>;
}
function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JSX.Element {
const [activityTooltip, setActivityTooltip] = createSignal<ActivityTooltipState | null>(null);
const timelineAxisTicks = () =>
getRecoveryTimelineAxisTicks(
props.timeline().points.length,
@@ -89,59 +98,27 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
return minWidth > 0 ? `${minWidth}px` : '100%';
};
const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.chartRangeDays())}px`;
const filteredTimelinePoint = createMemo(() => {
const selected = props.dayFilterKey();
if (!selected) return null;
return props.timeline().points.find((point) => point.key === selected) ?? null;
});
const timelineHasDayFilter = () => filteredTimelinePoint() !== null;
const showActivityTooltip = (target: HTMLElement, point: TimelinePoint, dateLabel: string) => {
const rect = target.getBoundingClientRect();
setActivityTooltip({
dateLabel,
point,
x: rect.left + rect.width / 2,
y: rect.top,
});
};
const hideActivityTooltip = () => setActivityTooltip(null);
const formatActivityAverage = (value: number): string => {
if (!Number.isFinite(value) || value <= 0) return '0/day';
if (value >= 10) return `${Math.round(value)}/day`;
return `${Number(value.toFixed(1))}/day`;
};
const selectedTimelinePoint = createMemo(() => {
const selected = props.selectedDateKey();
if (!selected) return null;
return props.timeline().points.find((point) => point.key === selected) || null;
});
const lowestActivePoint = createMemo(() => {
const active = props.timeline().points.filter((point) => point.total > 0);
if (active.length === 0) return null;
return active.reduce((lowest, point) => (point.total < lowest.total ? point : lowest), active[0]);
});
const activitySignal = createMemo(() => {
const selected = selectedTimelinePoint();
const average = props.activitySummary().averagePerDay;
if (selected) {
if (average > 0 && selected.total < average * 0.5) {
return {
tone: 'amber',
label: 'Below normal',
detail: `${selected.total} points vs ${formatActivityAverage(average)} average`,
};
}
return {
tone: 'base',
label: 'Selected day',
detail: `${selected.total} points`,
};
}
const lowest = lowestActivePoint();
if (lowest && average > 0 && lowest.total < average * 0.5) {
return {
tone: 'amber',
label: 'Lowest active day',
detail: `${getRecoveryPrettyDateLabel(lowest.key)}: ${lowest.total} points`,
};
}
return {
tone: 'base',
label: 'Average volume',
detail: formatActivityAverage(average),
};
});
const signalClass = () =>
activitySignal().tone === 'amber'
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
: 'border-border bg-surface-alt text-muted';
return (
<>
<div class="mb-2 flex flex-col gap-1.5">
@@ -161,131 +138,57 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
</Show>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px]">
<div
role="group"
aria-label="Recovery activity range"
class="inline-flex shrink-0 rounded border border-border bg-surface p-0.5"
>
<For each={RECOVERY_ACTIVITY_RANGE_DAYS}>
{(days) => {
const selected = () => props.chartRangeDays() === days;
return (
<button
type="button"
class={`rounded px-2 py-1 font-medium transition-colors ${
selected()
? 'bg-surface-hover text-base-content'
: 'text-muted hover:bg-surface-hover hover:text-base-content'
}`}
aria-pressed={selected() ? 'true' : 'false'}
onClick={() => props.onRangeChange(days)}
>
{days}d
</button>
);
}}
</For>
</div>
<span class="rounded-md border border-border bg-surface-alt px-2 py-1 text-muted">
Avg {formatActivityAverage(props.activitySummary().averagePerDay)}
</span>
<span class={`rounded-md border px-2 py-1 ${signalClass()}`}>
<span class="font-medium">{activitySignal().label}</span>
<span class="ml-1"> {activitySignal().detail}</span>
</span>
</div>
</div>
</div>
<Show
when={
props.selectedDateKey() ||
props.activeItemTypeLabel() ||
props.activeClusterLabel() ||
props.activeNodeLabel() ||
props.activeNamespaceLabel()
}
>
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<Show when={props.selectedDateKey()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('day');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.selectedDateLabel()}>
{props.selectedDateLabel()}
</span>
<button type="button" onClick={props.clearSelectedDate} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeClusterLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('cluster');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeClusterLabel()}>
{props.activeClusterLabel()}
</span>
<button type="button" onClick={props.clearClusterFilter} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeItemTypeLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('item-type');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeItemTypeLabel()}>
{props.activeItemTypeLabel()}
</span>
<button
type="button"
onClick={props.clearItemTypeFilter}
class={chip.clearButtonClass}
>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeNodeLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('node');
return (
<div class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeNodeLabel()}>
{props.activeNodeLabel()}
</span>
<button type="button" onClick={props.clearNodeFilter} class={chip.clearButtonClass}>
Clear
</button>
</div>
);
})()}
</Show>
<Show when={props.activeNamespaceLabel()}>
{(() => {
const chip = getRecoveryFilterChipPresentation('namespace');
return (
<div data-testid="active-namespace-chip" class={chip.className}>
<span class="font-medium uppercase tracking-wide">{chip.label}</span>
<span class="truncate font-mono text-[10px]" title={props.activeNamespaceLabel()}>
{props.activeNamespaceLabel()}
</span>
<button
type="button"
onClick={props.clearNamespaceFilter}
class={chip.clearButtonClass}
>
Clear
</button>
</div>
);
})()}
</Show>
</div>
</Show>
<div class="rounded-lg border border-border-subtle bg-surface-alt/25 px-2 py-1.5">
<div class="mb-1 flex flex-wrap items-center justify-end gap-1.5 text-[9px] text-muted">
<div class="flex flex-wrap items-center gap-1.5 text-[9px] text-muted">
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`} />
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('snapshot').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('snapshot').aggregateLabel}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('local').segmentClassName}`} />
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('local').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('local').aggregateLabel}
</div>
<div class={RECOVERY_TIMELINE_LEGEND_ITEM_CLASS}>
<span class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('remote').segmentClassName}`} />
<span
class={`h-2.5 w-2.5 rounded ${getRecoveryArtifactModePresentation('remote').segmentClassName}`}
/>
{getRecoveryArtifactModePresentation('remote').aggregateLabel}
</div>
</div>
@@ -303,7 +206,9 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
>
<div class="relative">
<div class="grid grid-cols-[auto_minmax(0,1fr)] gap-2.5">
<div class="flex h-16 flex-col justify-between text-[10px] text-muted">
<div
class={`flex ${SUMMARY_CHART_PLOT_AREA_CLASS} flex-col justify-between text-[10px] text-muted`}
>
<For each={[0, 1, 2]}>
{(step) => {
const value = Math.round((props.timeline().axisMax * (2 - step)) / 2);
@@ -316,7 +221,10 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
data-testid="recovery-activity-chart-scroll"
class="overflow-x-auto overscroll-x-contain pb-1"
>
<div class="relative h-20" style={{ 'min-width': chartMinWidthStyle() }}>
<div
class={`relative ${SUMMARY_CHART_SLOT_CLASS}`}
style={{ 'min-width': chartMinWidthStyle() }}
>
<div
data-testid="recovery-activity-bars"
class="absolute inset-x-0 bottom-4 top-0 flex items-stretch"
@@ -333,63 +241,53 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
const snapshotHeight = total > 0 ? (point.snapshot / total) * 100 : 0;
const localHeight = total > 0 ? (point.local / total) * 100 : 0;
const remoteHeight = total > 0 ? (point.remote / total) * 100 : 0;
const isSelected = props.selectedDateKey() === point.key;
const isSelected = () => props.dayFilterKey() === point.key;
const dateLabel = getRecoveryPrettyDateLabel(point.key);
return (
<div class="min-w-[3px] flex-1 self-stretch">
<button
type="button"
class={`h-full w-full ${getRecoveryTimelineColumnButtonClass(isSelected)}`}
data-recovery-activity-bar={point.key}
class={`h-full w-full ${getRecoveryTimelineColumnButtonClass(
isSelected(),
timelineHasDayFilter(),
)}`}
aria-label={getRecoveryTimelineColumnAriaLabel(
dateLabel,
total,
isSelected,
isSelected(),
)}
aria-pressed={isSelected ? 'true' : 'false'}
onClick={() => props.toggleSelectedDate(point.key)}
aria-pressed={isSelected() ? 'true' : 'false'}
onClick={() => props.toggleDayFilter(point.key)}
onMouseEnter={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const breakdown: string[] = [];
if (point.snapshot > 0) {
breakdown.push(
`${getRecoveryArtifactModePresentation('snapshot').aggregateLabel}: ${point.snapshot}`,
);
}
if (point.local > 0) {
breakdown.push(
`${getRecoveryArtifactModePresentation('local').aggregateLabel}: ${point.local}`,
);
}
if (point.remote > 0) {
breakdown.push(
`${getRecoveryArtifactModePresentation('remote').aggregateLabel}: ${point.remote}`,
);
}
const tooltipText =
point.total > 0
? `${dateLabel}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}\n${breakdown.join(' • ')}`
: `${dateLabel}\nNo recovery points available`;
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
showActivityTooltip(event.currentTarget, point, dateLabel);
}}
onMouseLeave={() => hideTooltip()}
onMouseLeave={hideActivityTooltip}
onFocus={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const tooltipText = `${dateLabel}\nAvailable: ${point.total} recovery point${point.total > 1 ? 's' : ''}`;
showTooltip(tooltipText, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
showActivityTooltip(event.currentTarget, point, dateLabel);
}}
onBlur={() => hideTooltip()}
onBlur={hideActivityTooltip}
>
<div class="relative h-full w-full overflow-hidden rounded-sm">
<Show when={total > 0}>
<Show
when={total > 0}
fallback={
<div
data-testid="recovery-activity-empty-marker"
class={getRecoveryTimelineEmptyMarkerClass(
isSelected(),
timelineHasDayFilter(),
)}
/>
}
>
<div
class="absolute inset-x-0 bottom-0"
data-testid="recovery-activity-bar-stack"
class={getRecoveryTimelineBarMarkerClass(
isSelected(),
timelineHasDayFilter(),
)}
style={{ height: `${columnHeight}%` }}
>
<Show when={remoteHeight > 0}>
@@ -424,7 +322,7 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
<For each={timelineAxisTicks()}>
{(tick) => {
const point = props.timeline().points[tick.index];
const isSelected = props.selectedDateKey() === point.key;
const isSelected = () => props.dayFilterKey() === point.key;
const alignmentClass =
tick.align === 'start'
? 'left-0 text-left'
@@ -435,7 +333,7 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
return (
<span
class={`absolute bottom-0 whitespace-nowrap text-[9px] ${
isSelected
isSelected()
? getRecoveryTimelineAxisLabelClass(true)
: getRecoveryTimelineAxisLabelClass(false)
} ${alignmentClass}`}
@@ -450,6 +348,56 @@ function RecoveryActivitySectionContent(props: RecoveryActivitySectionProps): JS
</div>
</div>
</div>
<TooltipPortal
when={activityTooltip() !== null}
x={activityTooltip()?.x ?? 0}
y={activityTooltip()?.y ?? 0}
align="center"
direction="up"
maxWidth={260}
>
<Show when={activityTooltip()}>
{(tooltip) => (
<div data-testid="recovery-activity-tooltip" class="min-w-[210px]">
<div class="flex items-start justify-between gap-3 border-b border-border pb-1">
<div class="min-w-0">
<div class="truncate text-[11px] font-semibold text-base-content">
{tooltip().dateLabel}
</div>
<div class="mt-0.5 text-[10px] text-muted">
{getRecoveryTimelinePointTotalLabel(tooltip().point.total)}
</div>
</div>
<div class="shrink-0 rounded border border-border bg-surface-alt px-1.5 py-0.5 text-[9px] font-medium text-muted">
{getRecoveryTimelineDayFilterStateLabel(
props.dayFilterKey() === tooltip().point.key,
timelineHasDayFilter(),
)}
</div>
</div>
<ul class="mt-1.5 space-y-1">
<For each={getRecoveryTimelineTooltipRows(tooltip().point)}>
{(row) => (
<li
class={`flex items-center justify-between gap-4 ${
row.muted ? 'text-muted/70' : 'text-base-content'
}`}
>
<span class="flex min-w-0 items-center gap-1.5">
<span class={`h-2 w-2 shrink-0 rounded-sm ${row.segmentClassName}`} />
<span class="truncate">{row.label}</span>
</span>
<span class="shrink-0 font-mono tabular-nums">{row.value}</span>
</li>
)}
</For>
</ul>
</div>
)}
</Show>
</TooltipPortal>
</div>
</Show>
</div>
@@ -178,7 +178,7 @@ export const RecoveryHistoryItemFilter: Component<RecoveryHistoryItemFilterProps
close();
}
}}
placeholder="Search protected items..."
placeholder="Search covered items..."
inputClass="py-2 text-sm"
clearOnFocusedEscape={false}
/>
@@ -188,7 +188,7 @@ export const RecoveryHistoryItemFilter: Component<RecoveryHistoryItemFilterProps
when={filteredOptions().length > 0}
fallback={
<div class="rounded-md border border-dashed border-border px-3 py-4 text-sm text-muted">
No matching protected items.
No matching covered items.
</div>
}
>
@@ -3,6 +3,7 @@ import type { Accessor, Component } from 'solid-js';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import type { FilterSelectOption } from '@/components/shared/FilterBar';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
@@ -61,6 +62,9 @@ interface RecoveryHistorySectionProps {
clusterFilter: Accessor<string>;
clusterOptions: Accessor<string[]>;
currentPage: Accessor<number>;
dayFilterKey: Accessor<string | null>;
dayFilterLabel: Accessor<string>;
dayFilterOptions: Accessor<FilterSelectOption[]>;
groupedByDay: Accessor<RecoveryPointGroup[]>;
hasActiveArtifactFilters: Accessor<boolean>;
hasFocusedRollup: Accessor<boolean>;
@@ -98,6 +102,7 @@ interface RecoveryHistorySectionProps {
setQueryFilter: (value: string) => void;
setRollupId: (value: string) => void;
setScopeFilter: (value: 'all' | 'workload') => void;
setDayFilter: (value: string | null) => void;
setVerificationFilter: (value: VerificationFilter) => void;
showClusterFilter: Accessor<boolean>;
showNamespaceFilter: Accessor<boolean>;
@@ -110,20 +115,22 @@ interface RecoveryHistorySectionProps {
}
export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (props) => {
const { clearSelectedPoint, selectedPoint, toggleSelectedPoint } = useRecoveryHistorySectionState({
clusterFilter: props.clusterFilter,
currentPage: props.currentPage,
hasFocusedRollup: props.hasFocusedRollup,
historyOutcomeFilter: props.historyOutcomeFilter,
itemTypeFilter: props.itemTypeFilter,
modeFilter: props.modeFilter,
namespaceFilter: props.namespaceFilter,
nodeFilter: props.nodeFilter,
platformFilter: props.platformFilter,
queryFilter: props.queryFilter,
scopeFilter: props.scopeFilter,
verificationFilter: props.verificationFilter,
});
const { clearSelectedPoint, selectedPoint, toggleSelectedPoint } = useRecoveryHistorySectionState(
{
clusterFilter: props.clusterFilter,
currentPage: props.currentPage,
hasFocusedRollup: props.hasFocusedRollup,
historyOutcomeFilter: props.historyOutcomeFilter,
itemTypeFilter: props.itemTypeFilter,
modeFilter: props.modeFilter,
namespaceFilter: props.namespaceFilter,
nodeFilter: props.nodeFilter,
platformFilter: props.platformFilter,
queryFilter: props.queryFilter,
scopeFilter: props.scopeFilter,
verificationFilter: props.verificationFilter,
},
);
const isMobileAccessor = createMemo(() => props.isMobile);
@@ -166,9 +173,26 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
props.setNamespaceFilter(value);
props.setCurrentPage(1);
};
const selectedDayFilterValue = () => props.dayFilterKey() ?? 'all';
const setDay = (value: string) => {
props.setDayFilter(value === 'all' ? null : value);
};
const buildFilters = (): FilterDef[] => {
const filters: FilterDef[] = [
{
id: 'day',
label: 'Day',
group: 'scope',
value: selectedDayFilterValue,
setValue: setDay,
defaultValue: 'all',
options: props.dayFilterOptions,
formatChipValue: (value, options) =>
options.find((option) => option.value === value)?.label ||
props.dayFilterLabel() ||
value,
},
{
id: 'item-type',
label: getRecoveryArtifactColumnLabel('type', 'Item Type'),
@@ -395,6 +419,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
<RecoveryHistoryTable
clearSelectedPoint={clearSelectedPoint}
currentPage={props.currentPage}
dayFilterKey={props.dayFilterKey}
groupedByDay={props.groupedByDay}
hasActiveArtifactFilters={props.hasActiveArtifactFilters}
isMobile={props.isMobile}
@@ -76,6 +76,7 @@ export interface RecoveryPointsModel {
interface RecoveryHistoryTableProps {
currentPage: Accessor<number>;
dayFilterKey: Accessor<string | null>;
groupedByDay: Accessor<RecoveryPointGroup[]>;
hasActiveArtifactFilters: Accessor<boolean>;
isMobile: boolean;
@@ -93,40 +94,55 @@ interface RecoveryHistoryTableProps {
clearSelectedPoint: () => void;
}
export const RecoveryHistoryTable: Component<RecoveryHistoryTableProps> = (props) => (
<Show
when={props.groupedByDay().length > 0}
fallback={
<div class="p-6">
<Show
when={props.recoveryPoints.response.loading}
fallback={
<EmptyState
{...getRecoveryHistoryEmptyState()}
actions={
<Show when={props.hasActiveArtifactFilters()}>
<button
type="button"
onClick={props.resetAllArtifactFilters}
class={getRecoveryEmptyStateActionClass()}
>
Clear filters
</button>
</Show>
}
/>
}
>
<div class="text-sm text-muted">{getRecoveryPointsLoadingState().text}</div>
</Show>
</div>
}
>
<Table
class="w-full border-collapse whitespace-nowrap"
style={{ 'min-width': props.tableMinWidth(), 'table-layout': 'fixed' }}
export const RecoveryHistoryTable: Component<RecoveryHistoryTableProps> = (props) => {
const visiblePointCount = () =>
props.groupedByDay().reduce((total, group) => total + group.items.length, 0);
const useVisibleFooterCount = () => Boolean(props.dayFilterKey());
const footerTotal = () =>
useVisibleFooterCount() ? visiblePointCount() : props.recoveryPoints.meta().total || 0;
const footerPage = () => (useVisibleFooterCount() ? 1 : props.recoveryPoints.meta().page);
const footerLimit = () =>
useVisibleFooterCount()
? Math.max(visiblePointCount(), 1)
: props.recoveryPoints.meta().limit || 1;
const footerStart = () =>
footerTotal() > 0 ? (footerPage() - 1) * footerLimit() + 1 : 0;
const footerEnd = () => Math.min(footerPage() * footerLimit(), footerTotal());
return (
<Show
when={props.groupedByDay().length > 0}
fallback={
<div class="p-6">
<Show
when={props.recoveryPoints.response.loading}
fallback={
<EmptyState
{...getRecoveryHistoryEmptyState()}
actions={
<Show when={props.hasActiveArtifactFilters()}>
<button
type="button"
onClick={props.resetAllArtifactFilters}
class={getRecoveryEmptyStateActionClass()}
>
Clear filters
</button>
</Show>
}
/>
}
>
<div class="text-sm text-muted">{getRecoveryPointsLoadingState().text}</div>
</Show>
</div>
}
>
<TableHeader>
<Table
class="w-full border-collapse whitespace-nowrap"
style={{ 'min-width': props.tableMinWidth(), 'table-layout': 'fixed' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<For each={props.mobileVisibleArtifactColumns()}>
{(column) => (
@@ -476,48 +492,40 @@ export const RecoveryHistoryTable: Component<RecoveryHistoryTableProps> = (props
)}
</For>
</TableBody>
</Table>
</Table>
<div class="flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] text-muted border-t border-border">
<div>
<Show
when={(props.recoveryPoints.meta().total || 0) > 0}
fallback={<span>Showing 0 of 0 recovery points</span>}
>
<div class="flex items-center justify-between gap-2 px-3 py-1.5 text-[11px] text-muted border-t border-border">
<div>
<Show when={footerTotal() > 0} fallback={<span>Showing 0 of 0 recovery points</span>}>
<span>
Showing {footerStart()} - {footerEnd()} of {footerTotal()} recovery points
</span>
</Show>
</div>
<div class="flex items-center gap-2">
<button
type="button"
disabled={props.currentPage() <= 1}
onClick={() => props.setCurrentPage(Math.max(1, props.currentPage() - 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>
Showing {(props.recoveryPoints.meta().page - 1) * props.recoveryPoints.meta().limit + 1}{' '}
-{' '}
{Math.min(
props.recoveryPoints.meta().page * props.recoveryPoints.meta().limit,
props.recoveryPoints.meta().total,
)}{' '}
of {props.recoveryPoints.meta().total} recovery points
Page {props.currentPage()} / {props.totalPages()}
</span>
</Show>
<button
type="button"
disabled={props.currentPage() >= props.totalPages()}
onClick={() =>
props.setCurrentPage(Math.min(props.totalPages(), props.currentPage() + 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>
<div class="flex items-center gap-2">
<button
type="button"
disabled={props.currentPage() <= 1}
onClick={() => props.setCurrentPage(Math.max(1, props.currentPage() - 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 {props.currentPage()} / {props.totalPages()}
</span>
<button
type="button"
disabled={props.currentPage() >= props.totalPages()}
onClick={() =>
props.setCurrentPage(Math.min(props.totalPages(), props.currentPage() + 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>
);
</Show>
);
};
@@ -75,6 +75,7 @@ interface RecoveryProtectedInventorySectionProps {
itemTypeOptions: Accessor<string[]>;
isMobile: boolean;
kioskMode: boolean;
onOpenEvents?: () => void;
onSelectRollup: (rollupId: string) => void;
protectedStateFilter: Accessor<ProtectedStateFilter>;
platformFilter: Accessor<string>;
@@ -117,22 +118,21 @@ const getProtectionInsight = (
if (inventoryStatus === 'never-succeeded') {
return attemptMs > 0
? `No successful point recorded after ${formatRelativeTime(attemptMs)}; open events to inspect attempts`
: 'No successful point recorded yet; open events to confirm the first attempt';
? `No successful point recorded after ${formatRelativeTime(attemptMs)}`
: 'No successful point recorded yet';
}
if (inventoryStatus === 'stale') {
if (successMs > 0 && Number.isFinite(successMs)) {
const ageDays = Math.max(1, Math.floor((nowMs - successMs) / (24 * 60 * 60 * 1000)));
return `Last success is ${ageDays} days old; open events to inspect the latest point`;
return `Last success is ${ageDays} days old`;
}
return `No successful point within ${STALE_THRESHOLD_DAYS} days; open events to inspect attempts`;
return `No successful point within ${STALE_THRESHOLD_DAYS} days`;
}
if (inventoryStatus === 'failed') return 'Latest protection event failed; open event details';
if (inventoryStatus === 'warning') return 'Latest event completed with warnings; review details';
if (inventoryStatus === 'running')
return 'Protection event in progress; check events for completion';
if (inventoryStatus === 'failed') return 'Latest protection event failed';
if (inventoryStatus === 'warning') return 'Latest event completed with warnings';
if (inventoryStatus === 'running') return 'Protection event in progress';
return '';
};
@@ -243,83 +243,85 @@ export const RecoveryProtectedInventorySection: Component<
<div class="flex flex-col gap-2">
<Show when={!props.kioskMode}>
<FilterBar
role="group"
ariaLabel="Protected items controls"
isMobile={isMobileAccessor}
savedViewsKey="recovery-protected"
search={{
value: props.queryFilter,
setValue: props.setQueryFilter,
placeholder: getRecoveryProtectedSearchPlaceholder(),
historyKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
clearOnEscape: true,
}}
filters={
[
{
id: 'item-type',
label: getRecoveryArtifactColumnLabel('type', 'Item Type'),
group: 'properties',
value: props.itemTypeFilter,
setValue: (value: string) =>
props.setItemTypeFilter(
normalizeRecoveryItemTypeQueryValue(value) || 'all',
),
defaultValue: 'all',
options: () =>
props.itemTypeOptions().map((itemType) => ({
value: itemType,
label:
itemType === 'all'
? getRecoveryAllItemTypesLabel()
: getRecoveryItemTypePresentation(itemType)?.label || itemType,
})),
role="group"
ariaLabel="Protection coverage controls"
isMobile={isMobileAccessor}
savedViewsKey="recovery-protected"
search={{
value: props.queryFilter,
setValue: props.setQueryFilter,
placeholder: getRecoveryProtectedSearchPlaceholder(),
historyKey: STORAGE_KEYS.RECOVERY_SEARCH_HISTORY,
emptyMessage: getRecoverySearchHistoryEmptyMessage(),
clearOnEscape: true,
}}
filters={
[
{
id: 'item-type',
label: getRecoveryArtifactColumnLabel('type', 'Item Type'),
group: 'properties',
value: props.itemTypeFilter,
setValue: (value: string) =>
props.setItemTypeFilter(normalizeRecoveryItemTypeQueryValue(value) || 'all'),
defaultValue: 'all',
options: () =>
props.itemTypeOptions().map((itemType) => ({
value: itemType,
label:
itemType === 'all'
? getRecoveryAllItemTypesLabel()
: getRecoveryItemTypePresentation(itemType)?.label || itemType,
})),
},
{
id: 'platform',
label: 'Platform',
group: 'scope',
value: props.platformFilter,
setValue: (value: string) =>
props.setPlatformFilter(normalizeSourcePlatformQueryValue(value)),
defaultValue: 'all',
options: () =>
props.platformOptions().map((platform) => ({
value: platform,
label:
platform === 'all'
? getRecoveryAllPlatformsLabel()
: getSourcePlatformLabel(platform),
})),
},
{
id: 'protected-state',
label: 'Protection state',
group: 'status',
value: props.protectedStateFilter,
setValue: (value: string) => {
const normalized = normalizeProtectedStateFilter(value);
props.setProtectedStateFilter(normalized);
props.setVerificationFilter('all');
},
{
id: 'platform',
label: 'Platform',
group: 'scope',
value: props.platformFilter,
setValue: (value: string) =>
props.setPlatformFilter(normalizeSourcePlatformQueryValue(value)),
defaultValue: 'all',
options: () =>
props.platformOptions().map((platform) => ({
value: platform,
label:
platform === 'all'
? getRecoveryAllPlatformsLabel()
: getSourcePlatformLabel(platform),
})),
},
{
id: 'protected-state',
label: 'Protection state',
group: 'status',
value: props.protectedStateFilter,
setValue: (value: string) => {
const normalized = normalizeProtectedStateFilter(value);
props.setProtectedStateFilter(normalized);
props.setVerificationFilter('all');
},
defaultValue: 'all',
options: () =>
availableProtectionStates.map((state) => ({
value: state,
label:
state === 'all'
? 'Any state'
: getRecoveryRollupInventoryStatusLabel(state),
})),
},
] as FilterDef[]
}
/>
defaultValue: 'all',
options: () =>
availableProtectionStates.map((state) => ({
value: state,
label:
state === 'all' ? 'Any state' : getRecoveryRollupInventoryStatusLabel(state),
})),
},
] as FilterDef[]
}
/>
</Show>
<TableCard>
<TableCardHeader title="Protected items" />
<TableCardHeader
title="Protection coverage"
showClearAction={Boolean(props.onOpenEvents)}
clearLabel="Recovery events"
clearAriaLabel="Return to recovery events"
onClear={props.onOpenEvents}
/>
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div
data-testid="recovery-protected-loading"
@@ -502,7 +504,11 @@ export const RecoveryProtectedInventorySection: Component<
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-1.5">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted" aria-hidden="true">—</span>}
fallback={
<span class="text-muted" aria-hidden="true">
—
</span>
}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
@@ -575,9 +581,9 @@ export const RecoveryProtectedInventorySection: Component<
<div>
<Show
when={sortedRollups().length > 0}
fallback={<span>Showing 0 of 0 protected items</span>}
fallback={<span>Showing 0 of 0 coverage items</span>}
>
<span>Showing {sortedRollups().length} protected items</span>
<span>Showing {sortedRollups().length} coverage items</span>
</Show>
</div>
</div>
@@ -1,141 +0,0 @@
import { render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RecoverySummary } from './RecoverySummary';
import recoverySummarySource from './RecoverySummary.tsx?raw';
describe('RecoverySummary', () => {
afterEach(() => {
vi.useRealTimers();
});
it('renders the recovery overview inside the shared summary panel style', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-09T12:00:00Z'));
render(() => (
<RecoverySummary
loaded={() => true}
rollups={() => [
{
rollupId: 'alpha',
lastOutcome: 'success',
lastSuccessAt: '2026-03-09T11:30:00Z',
platforms: ['proxmox-pbs'],
display: { subjectType: 'proxmox-vm' },
},
{
rollupId: 'beta',
lastOutcome: 'failed',
lastAttemptAt: '2026-03-08T11:00:00Z',
lastSuccessAt: null,
platforms: ['proxmox-pve'],
subjectRef: { type: 'truenas-dataset' },
},
]}
series={() => [
{ day: '2026-03-07', total: 1, snapshot: 1, local: 0, remote: 0 },
{ day: '2026-03-08', total: 3, snapshot: 1, local: 1, remote: 1 },
{ day: '2026-03-09', total: 2, snapshot: 1, local: 0, remote: 1 },
]}
seriesLoaded={() => true}
summary={() => ({
total: 2,
counts: {
success: 1,
warning: 0,
failed: 1,
running: 0,
unknown: 0,
},
stale: 1,
neverSucceeded: 1,
})}
timeRange={() => '30d'}
/>
));
expect(screen.getByTestId('recovery-summary')).toBeInTheDocument();
expect(screen.getByText('Posture')).toBeInTheDocument();
expect(screen.getByText('Coverage')).toBeInTheDocument();
expect(screen.getByText('Freshness')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
expect(screen.getAllByText(/attention/i).length).toBeGreaterThan(0);
expect(screen.getByText(/^points$/i)).toBeInTheDocument();
expect(screen.getByText(/^types$/i)).toBeInTheDocument();
expect(screen.getByText('2 platforms')).toBeInTheDocument();
expect(screen.getAllByText('1 healthy')).toHaveLength(1);
expect(screen.getAllByText('1 stale').length).toBeGreaterThan(0);
expect(screen.getByText('1 never succeeded')).toBeInTheDocument();
expect(screen.getByText('fresh in 24h')).toBeInTheDocument();
expect(screen.getByText(/^(Mar 9|9 Mar)$/)).toBeInTheDocument();
expect(screen.getByText('2 protected items')).toBeInTheDocument();
expect(screen.getByText('attention')).toBeInTheDocument();
expect(screen.queryByText('need attention')).not.toBeInTheDocument();
expect(screen.queryByText('1 attention')).not.toBeInTheDocument();
expect(screen.queryByText('Failed 1')).not.toBeInTheDocument();
expect(screen.queryByText('>7d 1 · Never Succeeded 1')).not.toBeInTheDocument();
expect(screen.queryByText('Healthy 1 · Failed 1')).not.toBeInTheDocument();
expect(screen.queryByText('Running')).not.toBeInTheDocument();
expect(screen.queryByText('<7d')).not.toBeInTheDocument();
expect(screen.queryByText('<1h')).not.toBeInTheDocument();
expect(screen.queryByText('<24h')).not.toBeInTheDocument();
expect(screen.queryByText('Days Active')).not.toBeInTheDocument();
expect(screen.queryByText('Multi-platform')).not.toBeInTheDocument();
expect(screen.queryByText('Primary Platform')).not.toBeInTheDocument();
expect(screen.queryByText('Primary Item')).not.toBeInTheDocument();
expect(screen.queryByText('Avg / Day')).not.toBeInTheDocument();
expect(screen.queryByText('Peak Throughput')).not.toBeInTheDocument();
expect(screen.queryByText('Peak Day')).not.toBeInTheDocument();
expect(screen.queryByText('stale items')).not.toBeInTheDocument();
expect(screen.queryByText('Platforms 2')).not.toBeInTheDocument();
expect(screen.queryByText(/^Latest Activity (Mar 9|9 Mar)$/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Platforms$/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Healthy$/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Failed$/)).not.toBeInTheDocument();
});
it('uses the shared default summary density rather than a recovery-only compact override', () => {
expect(recoverySummarySource).not.toContain('density="compact"');
expect(recoverySummarySource).not.toContain('!p-1.5 sm:!p-2');
expect(recoverySummarySource.match(/bodyLayout="auto"/g) ?? []).toHaveLength(4);
});
it('renders a stable summary shell while the first recovery rollups request is still pending', () => {
render(() => (
<RecoverySummary
loaded={() => false}
rollups={() => []}
series={() => []}
seriesLoaded={() => false}
summary={() => ({
total: 0,
counts: {
success: 0,
warning: 0,
failed: 0,
running: 0,
unknown: 0,
},
stale: 0,
neverSucceeded: 0,
})}
timeRange={() => '30d'}
/>
));
expect(screen.getByTestId('recovery-summary')).toBeInTheDocument();
expect(screen.queryByText('0 protected items')).not.toBeInTheDocument();
expect(screen.queryByText('No recovery activity yet')).not.toBeInTheDocument();
});
it('keeps recovery summary outside the interactive page-group-entity summary contract', () => {
expect(recoverySummarySource).not.toContain('useSummaryPageInteractionState');
expect(recoverySummarySource).not.toContain('hoveredGroupScope');
expect(recoverySummarySource).not.toContain('focusedGroupScope');
expect(recoverySummarySource).not.toContain('hoveredResourceId');
expect(recoverySummarySource).not.toContain('focusedResourceId');
expect(recoverySummarySource).not.toContain('chartHoverSync');
expect(recoverySummarySource).not.toContain('InteractiveSparkline');
expect(recoverySummarySource).not.toContain('DensityMap');
});
});
@@ -1,246 +0,0 @@
import { Component, Show, createMemo } from 'solid-js';
import { SummaryPanel } from '@/components/shared/SummaryPanel';
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
import type { ProtectionRollup, RecoveryOutcome, RecoveryPointsSeriesBucket } from '@/types/recovery';
import {
buildRecoveryActivitySummary,
buildRecoveryFreshnessBuckets,
buildRecoveryItemCoverage,
buildRecoveryPlatformCoverage,
buildRecoveryPostureSummary,
RECOVERY_SUMMARY_TIME_RANGES,
RECOVERY_SUMMARY_TIME_RANGE_LABELS,
type RecoverySummaryTimeRange,
} from '@/utils/recoverySummaryPresentation';
export interface RecoverySummaryProps {
loaded: () => boolean;
rollups: () => ProtectionRollup[];
series: () => RecoveryPointsSeriesBucket[];
seriesLoaded: () => boolean;
seriesFailed?: () => boolean;
summary: () => {
total: number;
counts: Record<RecoveryOutcome, number>;
stale: number;
neverSucceeded: number;
};
timeRange: () => RecoverySummaryTimeRange;
onTimeRangeChange?: (range: RecoverySummaryTimeRange) => void;
onOpenEvents?: () => void;
onShowAttention?: () => void;
onShowStale?: () => void;
}
export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
const loaded = () => props.loaded();
const summary = () => props.summary();
const hasRollups = () => summary().total > 0;
const postureSummary = createMemo(() => buildRecoveryPostureSummary(props.rollups()));
const freshnessBuckets = createMemo(() => buildRecoveryFreshnessBuckets(props.rollups()));
const itemCoverage = createMemo(() => buildRecoveryItemCoverage(props.rollups()));
const platformCoverage = createMemo(() => buildRecoveryPlatformCoverage(props.rollups()));
const activity = createMemo(() => buildRecoveryActivitySummary(props.series()));
const healthyCount = createMemo(() => postureSummary().healthy);
const attentionCount = createMemo(() => postureSummary().attention);
const primaryPostureMetric = createMemo(() => {
if (attentionCount() > 0) {
return {
value: attentionCount(),
label: 'attention',
valueClass: 'text-amber-600 dark:text-amber-400',
};
}
if (postureSummary().running > 0) {
return {
value: postureSummary().running,
label: 'running',
valueClass: 'text-blue-600 dark:text-blue-400',
};
}
return {
value: healthyCount(),
label: 'healthy',
valueClass: 'text-emerald-600 dark:text-emerald-400',
};
});
const handleTimeRangeChange = (range: string) =>
props.onTimeRangeChange?.(range as RecoverySummaryTimeRange);
const postureSecondaryLabel = createMemo(() => {
if (healthyCount() <= 0) return undefined;
return (
<span class="ml-auto truncate text-xs text-emerald-600 dark:text-emerald-400">
{healthyCount()} healthy
</span>
);
});
const freshWithin24hCount = createMemo(
() =>
(freshnessBuckets().find((bucket) => bucket.key === 'under1h')?.count ?? 0) +
(freshnessBuckets().find((bucket) => bucket.key === 'under24h')?.count ?? 0),
);
const freshnessSecondaryLabel = createMemo(() => {
if (summary().stale > 0) {
return (
<span class="ml-auto truncate text-xs text-amber-600 dark:text-amber-400">
{summary().stale} stale
</span>
);
}
if (summary().neverSucceeded > 0) {
return (
<span class="ml-auto truncate text-xs text-red-600 dark:text-red-400">
{summary().neverSucceeded} never succeeded
</span>
);
}
return undefined;
});
const coverageSecondaryLabel = createMemo(() => {
const platformCount = platformCoverage().platformCount;
if (platformCount <= 0) return undefined;
return (
<span class="ml-auto truncate text-xs text-muted">
{platformCount} {platformCount === 1 ? 'platform' : 'platforms'}
</span>
);
});
const activitySecondaryLabel = createMemo(() => {
const latestLabel = activity().latestLabel;
if (!latestLabel) return undefined;
return <span class="ml-auto truncate text-xs text-muted">{latestLabel}</span>;
});
const summaryActionClass =
'mt-auto inline-flex w-fit rounded border border-border bg-surface px-2 py-1 text-[11px] font-medium text-base-content hover:bg-surface-hover';
return (
<Show when={!loaded() || hasRollups()}>
<SummaryPanel
headerLeft={
<Show
when={loaded()}
fallback={<div class="h-4 w-32 rounded bg-surface-hover animate-pulse" />}
>
<span class="font-medium text-base-content">{summary().total} protected items</span>
<Show
when={summary().stale > 0 || summary().neverSucceeded > 0}
fallback={
<Show when={summary().total > 0}>
<span class="text-emerald-600 dark:text-emerald-400">all fresh</span>
</Show>
}
>
<Show when={summary().neverSucceeded > 0}>
<span class="text-red-600 dark:text-red-400">
{summary().neverSucceeded} never succeeded
</span>
</Show>
<Show when={summary().stale > 0}>
<span class="text-amber-600 dark:text-amber-400">
{summary().stale} stale
</span>
</Show>
</Show>
</Show>
}
timeRange={props.timeRange()}
onTimeRangeChange={props.onTimeRangeChange ? handleTimeRangeChange : undefined}
timeRanges={RECOVERY_SUMMARY_TIME_RANGES}
timeRangeLabels={RECOVERY_SUMMARY_TIME_RANGE_LABELS}
testId="recovery-summary"
class="overflow-hidden"
>
<SummaryMetricCard
label="Posture"
secondaryLabel={loaded() ? postureSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
<div>
<div class={`text-xl font-semibold tabular-nums ${primaryPostureMetric().valueClass}`}>
{primaryPostureMetric().value}
</div>
<div class="text-[11px] text-muted">{primaryPostureMetric().label}</div>
</div>
<Show when={attentionCount() > 0 && props.onShowAttention}>
<button type="button" class={summaryActionClass} onClick={() => props.onShowAttention?.()}>
Show attention
</button>
</Show>
</div>
</SummaryMetricCard>
<SummaryMetricCard
label="Freshness"
secondaryLabel={loaded() ? freshnessSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
<div>
<div class="text-xl font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">
{freshWithin24hCount()}
</div>
<div class="text-[11px] text-muted">fresh in 24h</div>
</div>
<Show when={summary().stale > 0 && props.onShowStale}>
<button type="button" class={summaryActionClass} onClick={() => props.onShowStale?.()}>
Show stale
</button>
</Show>
</div>
</SummaryMetricCard>
<SummaryMetricCard
label="Coverage"
secondaryLabel={loaded() ? coverageSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
<div>
<div class="text-xl font-semibold tabular-nums text-base-content">
{itemCoverage().itemTypeCount}
</div>
<div class="text-[11px] text-muted">types</div>
</div>
<Show when={itemCoverage().primaryItemLabel}>
<div class="text-[11px] text-muted">
Primary: {itemCoverage().primaryItemLabel}
</div>
</Show>
</div>
</SummaryMetricCard>
<SummaryMetricCard
label="Activity"
secondaryLabel={props.seriesLoaded() ? activitySecondaryLabel() : undefined}
bodyLayout="auto"
loaded={props.seriesLoaded()}
hasData={activity().hasData}
emptyMessage={props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'}
>
<div class="flex h-full flex-col gap-1.5">
<div>
<div class="text-xl font-semibold tabular-nums text-base-content">
{activity().totalEvents}
</div>
<div class="text-[11px] text-muted">points</div>
</div>
<Show when={props.onOpenEvents}>
<button type="button" class={summaryActionClass} onClick={() => props.onOpenEvents?.()}>
Open events
</button>
</Show>
</div>
</SummaryMetricCard>
</SummaryPanel>
</Show>
);
};
@@ -1,5 +1,5 @@
import { createSignal } from 'solid-js';
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let mockRecoverySurfaceState: any;
@@ -42,7 +42,7 @@ import Recovery from '@/components/Recovery/Recovery';
describe('Recovery layout guards', () => {
beforeEach(() => {
const [workspaceView, setWorkspaceView] = createSignal<'inventory' | 'events'>('inventory');
const [workspaceView, setWorkspaceView] = createSignal<'inventory' | 'events'>('events');
mockRecoverySurfaceState = {
activitySummary: () => ({ totalPoints: 2, activeDays: 2, averagePerDay: 1 }),
activeAdvancedFilterCount: () => 0,
@@ -142,16 +142,13 @@ describe('Recovery layout guards', () => {
it('mounts the activity chart only in the recovery events workspace and keeps it there when events fail', () => {
render(() => <Recovery />);
expect(screen.getByTestId('protected-inventory')).toBeInTheDocument();
expect(screen.queryByTestId('activity-chart')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /recovery events/i }));
expect(screen.getByTestId('activity-chart')).toBeInTheDocument();
expect(screen.queryByTestId('protected-inventory')).not.toBeInTheDocument();
expect(screen.queryByTestId('history-section')).not.toBeInTheDocument();
expect(screen.getByText('Failed to load recovery points')).toBeInTheDocument();
});
it('keeps the summary shell mounted while recovery data is still on its first load', () => {
it('keeps the event workspace mounted without a decorative summary shell while data hydrates', () => {
mockRecoverySurfaceState.recoveryRollups = {
rollups: Object.assign(() => [], { loading: false }),
resolvedOnce: () => false,
@@ -171,7 +168,7 @@ describe('Recovery layout guards', () => {
render(() => <Recovery />);
expect(screen.getByTestId('recovery-summary')).toBeInTheDocument();
expect(screen.getByTestId('protected-inventory')).toBeInTheDocument();
expect(screen.queryByTestId('recovery-summary')).not.toBeInTheDocument();
expect(screen.getByTestId('activity-chart')).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import { render, screen } from '@solidjs/testing-library';
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection';
@@ -17,30 +18,22 @@ describe('RecoveryActivitySection', () => {
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
activeClusterLabel={() => ''}
activeItemTypeLabel={() => ''}
activeNamespaceLabel={() => ''}
activeNodeLabel={() => ''}
chartRangeDays={() => 30}
clearClusterFilter={() => undefined}
clearItemTypeFilter={() => undefined}
clearNamespaceFilter={() => undefined}
clearNodeFilter={() => undefined}
clearSelectedDate={() => undefined}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
selectedDateKey={() => null}
selectedDateLabel={() => ''}
dayFilterKey={() => null}
timeline={timeline}
toggleSelectedDate={() => undefined}
toggleDayFilter={() => undefined}
/>
));
const bars = screen.getByTestId('recovery-activity-bars');
expect(bars.className).toContain('items-stretch');
expect(bars.style.gap).toBe('3px');
expect(bars.parentElement?.className).toContain('h-20');
expect(bars.parentElement?.className).toContain('h-[136px] sm:h-[150px]');
expect(bars.closest('.grid')?.firstElementChild?.className).toContain('h-[120px] sm:h-[134px]');
expect(screen.getByTestId('recovery-activity-chart-scroll').className).toContain(
'overflow-x-auto',
);
@@ -49,7 +42,110 @@ describe('RecoveryActivitySection', () => {
).not.toBeInTheDocument();
expect(screen.queryByText(/^Range$/)).not.toBeInTheDocument();
expect(screen.queryByText(/1.5 \/ day/i)).not.toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /recovery point/i })).toHaveLength(2);
expect(screen.getAllByRole('button', { pressed: false })).toHaveLength(2);
expect(screen.queryByText('Lowest active day')).not.toBeInTheDocument();
expect(screen.queryByText('Below normal')).not.toBeInTheDocument();
expect(within(bars).getAllByRole('button', { name: /recovery point/i })).toHaveLength(2);
expect(within(bars).getAllByRole('button', { pressed: false })).toHaveLength(2);
});
it('uses day-filter styling and a structured activity tooltip', () => {
const timeline = () => ({
points: [
{ key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 },
],
axisMax: 2,
labelEvery: 1,
});
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
chartRangeDays={() => 30}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
dayFilterKey={() => '2026-02-14'}
timeline={timeline}
toggleDayFilter={() => undefined}
/>
));
expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument();
const bars = screen.getByTestId('recovery-activity-bars');
const buttons = within(bars).getAllByRole('button', { name: /recovery point/i });
expect(buttons[0].className).not.toContain('opacity-40');
expect(buttons[1].className).not.toContain('ring-blue-500');
expect(within(buttons[0]).getByTestId('recovery-activity-bar-stack').className).toContain(
'opacity-40',
);
expect(within(buttons[1]).getByTestId('recovery-activity-bar-stack').className).toContain(
'ring-blue-500',
);
expect(within(bars).getByRole('button', { pressed: true })).toBe(buttons[1]);
fireEvent.mouseEnter(buttons[1]);
const tooltip = screen.getByTestId('recovery-activity-tooltip');
expect(within(tooltip).getByText('Day filter')).toBeInTheDocument();
expect(within(tooltip).getByText('2 recovery points')).toBeInTheDocument();
expect(within(tooltip).getByText('Snapshots')).toBeInTheDocument();
expect(within(tooltip).getByText('Local Copies')).toBeInTheDocument();
expect(within(tooltip).getByText('Remote Copies')).toBeInTheDocument();
expect(within(tooltip).getByText('2 (100%)')).toBeInTheDocument();
expect(tooltip.textContent).not.toContain(' • ');
fireEvent.mouseLeave(buttons[1]);
expect(screen.queryByTestId('recovery-activity-tooltip')).not.toBeInTheDocument();
});
it('updates an open tooltip when the hovered day becomes selected', () => {
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
const timeline = () => ({
points: [
{ key: '2026-02-13', label: 'Feb 13', total: 1, snapshot: 1, local: 0, remote: 0 },
{ key: '2026-02-14', label: 'Feb 14', total: 2, snapshot: 0, local: 2, remote: 0 },
],
axisMax: 2,
labelEvery: 1,
});
render(() => (
<RecoveryActivitySection
activitySummary={() => ({ totalPoints: 3, activeDays: 2, averagePerDay: 1.5 })}
chartRangeDays={() => 30}
isMobile={false}
loading={() => false}
onRangeChange={() => undefined}
overallRollupsSummary={() => ({ total: 2, stale: 0, neverSucceeded: 0 })}
dayFilterKey={selectedDateKey}
timeline={timeline}
toggleDayFilter={(key) => setSelectedDateKey((previous) => (previous === key ? null : key))}
/>
));
const bars = screen.getByTestId('recovery-activity-bars');
const buttons = within(bars).getAllByRole('button', { name: /recovery point/i });
fireEvent.mouseEnter(buttons[1]);
const tooltip = screen.getByTestId('recovery-activity-tooltip');
expect(within(tooltip).getByText('Timeline day')).toBeInTheDocument();
fireEvent.click(buttons[1]);
expect(within(tooltip).getByText('Day filter')).toBeInTheDocument();
expect(screen.queryByTestId('recovery-activity-selected-summary')).not.toBeInTheDocument();
const selectedButton = within(bars).getByRole('button', { pressed: true });
const dimmedButton = within(bars).getByRole('button', { pressed: false });
expect(selectedButton.className).not.toContain('ring-blue-500');
expect(dimmedButton.className).not.toContain('opacity-40');
expect(within(selectedButton).getByTestId('recovery-activity-bar-stack').className).toContain(
'ring-blue-500',
);
expect(within(dimmedButton).getByTestId('recovery-activity-bar-stack').className).toContain(
'opacity-40',
);
});
});
@@ -86,6 +86,7 @@ import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx
import selectionCardGroupModelSource from '@/components/shared/selectionCardGroupModel.ts?raw';
import summaryMetricCardSource from '@/components/shared/SummaryMetricCard.tsx?raw';
import summaryPanelSource from '@/components/shared/SummaryPanel.tsx?raw';
import summaryChartLayoutSource from '@/components/shared/summaryChartLayout.ts?raw';
import summarySynchronizedReadoutSource from '@/components/shared/SummarySynchronizedReadout.tsx?raw';
import tagBadgesSource from '@/components/shared/TagBadges.tsx?raw';
import tlsVerificationWarningBannerSource from '@/components/shared/TlsVerificationWarningBanner.tsx?raw';
@@ -1348,8 +1349,16 @@ describe('shared primitive guardrails', () => {
expect(summaryMetricCardSource).toContain(
"isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'",
);
expect(summaryMetricCardSource).toContain(
"isCompact() ? 'h-[108px] sm:h-[120px]' : 'h-[136px] sm:h-[150px]'",
expect(summaryMetricCardSource).toContain('SUMMARY_CHART_SLOT_COMPACT_CLASS');
expect(summaryMetricCardSource).toContain('SUMMARY_CHART_SLOT_CLASS');
expect(summaryChartLayoutSource).toContain(
"export const SUMMARY_CHART_SLOT_CLASS = 'h-[136px] sm:h-[150px]'",
);
expect(summaryChartLayoutSource).toContain(
"export const SUMMARY_CHART_SLOT_COMPACT_CLASS = 'h-[108px] sm:h-[120px]'",
);
expect(summaryChartLayoutSource).toContain(
"export const SUMMARY_CHART_PLOT_AREA_CLASS = 'h-[120px] sm:h-[134px]'",
);
expect(summaryMetricCardSource).toContain('!p-1.5 sm:!p-2');
expect(summaryMetricCardSource).not.toContain('Recovery Posture');
@@ -1545,13 +1554,13 @@ describe('shared primitive guardrails', () => {
expect(recoveryProtectedInventorySectionSource).toContain("id: 'protected-state'");
expect(recoveryProtectedInventorySectionSource).toContain('role="group"');
expect(recoveryProtectedInventorySectionSource).toContain(
'ariaLabel="Protected items controls"',
'ariaLabel="Protection coverage controls"',
);
expect(recoveryProtectedInventorySectionSource).not.toContain('PageControls');
expect(recoveryProtectedInventorySectionSource).not.toContain('LabeledFilterSelect');
expect(recoveryProtectedInventorySectionSource).not.toContain('protectedFiltersOpen');
// Recovery events sub-tab — every advanced filter folds into the catalog
// Recovery events surface — every advanced filter folds into the catalog
// (scope, method, verification, cluster, node, namespace) so the
// dedicated "Filter" popover panel and FilterToolbarPanel-based advanced
// panel are no longer rendered.
@@ -1,6 +1,7 @@
import { Show, type JSX } from 'solid-js';
import { Card } from './Card';
import { SparklineSkeleton } from './SparklineSkeleton';
import { SUMMARY_CHART_SLOT_CLASS, SUMMARY_CHART_SLOT_COMPACT_CLASS } from './summaryChartLayout';
import type { SummaryCardInteractionState } from './summaryCardInteraction';
export interface SummaryMetricCardProps {
@@ -31,7 +32,7 @@ export function SummaryMetricCard(props: SummaryMetricCardProps) {
const bodyLayout = () => props.bodyLayout ?? 'chart';
const interactionState = () => props.interactionState ?? 'default';
const chartSlotClass = () =>
isCompact() ? 'h-[108px] sm:h-[120px]' : 'h-[136px] sm:h-[150px]';
isCompact() ? SUMMARY_CHART_SLOT_COMPACT_CLASS : SUMMARY_CHART_SLOT_CLASS;
const interactionClass = () => {
switch (interactionState()) {
case 'active':
@@ -48,10 +49,7 @@ export function SummaryMetricCard(props: SummaryMetricCardProps) {
data-summary-card-state={interactionState()}
class={`h-full rounded-md transition-all duration-150 ease-out ${interactionClass()}`.trim()}
>
<Card
padding="sm"
class={`h-full ${isCompact() ? '!p-1.5 sm:!p-2' : ''}`.trim()}
>
<Card padding="sm" class={`h-full ${isCompact() ? '!p-1.5 sm:!p-2' : ''}`.trim()}>
<div class="flex flex-col h-full">
<div
class={`flex min-w-0 items-center gap-2 ${isCompact() ? 'mb-1 min-h-[20px]' : 'mb-1.5 min-h-[24px]'}`.trim()}
@@ -0,0 +1,3 @@
export const SUMMARY_CHART_SLOT_CLASS = 'h-[136px] sm:h-[150px]';
export const SUMMARY_CHART_SLOT_COMPACT_CLASS = 'h-[108px] sm:h-[120px]';
export const SUMMARY_CHART_PLOT_AREA_CLASS = 'h-[120px] sm:h-[134px]';
@@ -84,9 +84,7 @@ const normalizeRecoveryItemTypeSelection = (value: string | null | undefined): s
return normalized || 'all';
};
const normalizeProtectedStateFilter = (
value: string | null | undefined,
): ProtectedStateFilter => {
const normalizeProtectedStateFilter = (value: string | null | undefined): ProtectedStateFilter => {
const normalized = normalizeRecoveryRouteValue(value).toLowerCase();
if (normalized === 'success') return 'healthy';
if (
@@ -126,7 +124,7 @@ export function useRecoverySurfaceState() {
const storageRecoveryResources = useStorageRecoveryResources();
const [rollupId, setRollupId] = createSignal('');
const [workspaceView, setWorkspaceView] = createSignal<RecoveryWorkspaceView>('inventory');
const [workspaceView, setWorkspaceView] = createSignal<RecoveryWorkspaceView>('events');
const [queryFilter, setQueryFilter] = createSignal('');
const [platformFilter, setPlatformFilter] = createSignal('all');
const [itemTypeFilter, setItemTypeFilter] = createSignal('all');
@@ -139,8 +137,7 @@ export function useRecoverySurfaceState() {
const [scopeFilter, setScopeFilter] = createSignal<'all' | 'workload'>('all');
const [nodeFilter, setNodeFilter] = createSignal('all');
const [namespaceFilter, setNamespaceFilter] = createSignal('all');
const [protectedStateFilter, setProtectedStateFilter] =
createSignal<ProtectedStateFilter>('all');
const [protectedStateFilter, setProtectedStateFilter] = createSignal<ProtectedStateFilter>('all');
const [chartRangeDays, setChartRangeDays] = createSignal<7 | 30 | 90 | 365>(30);
const [selectedDateKey, setSelectedDateKey] = createSignal<string | null>(null);
const [currentPage, setCurrentPage] = createSignal(1);
@@ -332,12 +329,14 @@ export function useRecoverySurfaceState() {
const nextCluster = normalizeRecoveryRouteSelection(parsed.cluster || 'all') || 'all';
const normalizedDay = normalizeRecoveryRouteValue(parsed.day);
const nextDay = isRecoveryDateKey(normalizedDay) ? normalizedDay : '';
const derivedDefaultView: RecoveryWorkspaceView =
nextRollup || nextDay ? 'events' : 'inventory';
const legacyStaleState = normalizeRecoveryBooleanFlag(parsed.stale) ? 'stale' : '';
const hasInventoryState = Boolean(
normalizeRecoveryRouteValue(parsed.state) || legacyStaleState,
);
const derivedDefaultView: RecoveryWorkspaceView = hasInventoryState ? 'inventory' : 'events';
const resolvedView = (nextView || derivedDefaultView) as RecoveryWorkspaceView;
const visibleRollup = resolvedView === 'events' ? nextRollup : '';
const visibleDay = resolvedView === 'events' ? nextDay : '';
const legacyStaleState = normalizeRecoveryBooleanFlag(parsed.stale) ? 'stale' : '';
const nextMode = normalizeRecoveryModeQueryValue(parsed.mode);
const rawScope = normalizeRecoveryRouteValue(parsed.scope).toLowerCase();
const nextScope: 'all' | 'workload' = rawScope === 'workload' ? 'workload' : 'all';
@@ -416,7 +415,7 @@ export function useRecoverySurfaceState() {
createEffect(() => {
const rid = rollupId().trim();
const defaultView: RecoveryWorkspaceView = rid || selectedDateKey() ? 'events' : 'inventory';
const defaultView: RecoveryWorkspaceView = 'events';
const status = historyOutcomeFilter() !== 'all' ? historyOutcomeFilter() : null;
const verification = verificationFilter() !== 'all' ? verificationFilter() : null;
const currentPath = `${location.pathname}${location.search || ''}`;
@@ -43,7 +43,7 @@ describe('storage row presentation', () => {
});
it('keeps no-parity protection factual instead of rendering it as reduced-health state', () => {
const record = {
const record: StorageRecord = {
...baseRecord(),
health: 'healthy',
protectionLabel: 'No parity',
@@ -1120,7 +1120,8 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const requestVersion = scopeVersion;
const entryForRequest = cacheEntry;
const request = (async () => {
let request!: Promise<Resource[]>;
request = (async () => {
const isCurrentRequest = () =>
requestVersion === scopeVersion && entryForRequest === cacheEntry && enabled();
+1 -1
View File
@@ -52,7 +52,7 @@ export type ResourceType =
// Platform types - which system the resource comes from
export const PLATFORM_TYPES = GENERATED_PLATFORM_TYPE_KEYS;
export type PlatformType = GeneratedPlatformType | 'availability' | 'generic';
export type PlatformType = GeneratedPlatformType | 'availability' | 'generic' | 'unraid';
// Source types - how data is collected
export type SourceType =
@@ -2,20 +2,10 @@ import { describe, expect, it } from 'vitest';
import recoveryPointDetailsSource from '@/components/Recovery/RecoveryPointDetails.tsx?raw';
import recoverySurfaceStateSource from '@/features/recovery/useRecoverySurfaceState.ts?raw';
import recoverySummaryPresentationSource from '@/utils/recoverySummaryPresentation.ts?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
describe('recovery canonical vocabulary', () => {
it('keeps shared recovery summary helpers item-first', () => {
expect(recoverySummaryPresentationSource).toContain(
'const getRecoverySummaryItemTypePresentation = (',
);
expect(recoverySummaryPresentationSource).not.toContain(
'const getRecoverySummarySubjectTypePresentation = (',
);
});
it('keeps recovery platform filter iterators platform-first', () => {
// The intent of this guardrail is the canonical "platform" naming over
// legacy "provider", not a specific callback shape. Both files now use
@@ -29,9 +19,15 @@ describe('recovery canonical vocabulary', () => {
});
it('keeps recovery activity focus labels item-first', () => {
expect(recoverySurfaceStateSource).toContain('const selectedHistoryItemLabel = createMemo(() => {');
expect(recoverySurfaceStateSource).not.toContain('const selectedHistorySubjectLabel = createMemo(() => {');
expect(recoveryHistorySectionSource).toContain('selectedHistoryItemLabel: Accessor<string | null>;');
expect(recoverySurfaceStateSource).toContain(
'const selectedHistoryItemLabel = createMemo(() => {',
);
expect(recoverySurfaceStateSource).not.toContain(
'const selectedHistorySubjectLabel = createMemo(() => {',
);
expect(recoveryHistorySectionSource).toContain(
'selectedHistoryItemLabel: Accessor<string | null>;',
);
expect(recoveryHistorySectionSource).not.toContain(
'selectedHistorySubjectLabel: Accessor<string | null>;',
);
@@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest';
import {
formatRecoveryTimeOnly,
getRecoveryCompactAxisLabel,
getRecoveryFilterDateLabel,
getRecoveryFullDateLabel,
getRecoveryNiceAxisMax,
getRecoveryPrettyDateLabel,
normalizeRecoveryDateSearchText,
parseRecoveryDateKey,
recoveryDateKeyFromTimestamp,
resolveRecoveryDateSearchKey,
} from '@/utils/recoveryDatePresentation';
describe('recoveryDatePresentation', () => {
@@ -25,6 +28,7 @@ describe('recoveryDatePresentation', () => {
const key = '2026-03-09';
expect(getRecoveryPrettyDateLabel(key)).toContain('Mar');
expect(getRecoveryFullDateLabel(key)).toContain('2026');
expect(getRecoveryFilterDateLabel(key)).toBe('Mar 9, 2026');
expect(getRecoveryCompactAxisLabel(key, 30)).toBe('9');
expect(getRecoveryCompactAxisLabel('2026-04-01', 30)).toBe('4/1');
expect(getRecoveryCompactAxisLabel(key, 90)).toBe('3/9');
@@ -38,4 +42,16 @@ describe('recoveryDatePresentation', () => {
expect(getRecoveryNiceAxisMax(37)).toBe(50);
expect(getRecoveryNiceAxisMax(101)).toBe(200);
});
it('resolves clear date search text to recovery day keys', () => {
const candidateKeys = ['2026-02-13', '2026-02-14'];
expect(normalizeRecoveryDateSearchText('Friday, Feb 14th')).toBe('friday feb 14');
expect(resolveRecoveryDateSearchKey('2026-02-14', candidateKeys)).toBe('2026-02-14');
expect(resolveRecoveryDateSearchKey('Feb 14', candidateKeys)).toBe('2026-02-14');
expect(resolveRecoveryDateSearchKey('February 14, 2026', candidateKeys)).toBe('2026-02-14');
expect(resolveRecoveryDateSearchKey('Feb 14', [], new Date(2026, 4, 14))).toBe('2026-02-14');
expect(resolveRecoveryDateSearchKey('VM 123', candidateKeys)).toBeNull();
expect(resolveRecoveryDateSearchKey('Feb', candidateKeys)).toBeNull();
});
});
@@ -11,16 +11,16 @@ import {
} from '@/utils/recoveryEmptyStatePresentation';
describe('recoveryEmptyStatePresentation', () => {
it('returns the protected-items empty state copy', () => {
it('returns the protection coverage empty state copy', () => {
expect(getRecoveryProtectedItemsEmptyState()).toEqual({
title: 'No protected items yet',
description: 'Pulse hasn’t observed any protected items for this org yet.',
title: 'No protection coverage yet',
description: 'Pulse has not observed recovery coverage for this org yet.',
});
});
it('returns the recovery loading-state copy', () => {
expect(getRecoveryProtectedItemsLoadingState()).toEqual({
text: 'Loading protected items...',
text: 'Loading protection coverage...',
});
expect(getRecoveryActivityLoadingState()).toEqual({
text: 'Loading recovery activity...',
@@ -32,7 +32,7 @@ describe('recoveryEmptyStatePresentation', () => {
it('returns the recovery failure-state copy', () => {
expect(getRecoveryProtectedItemsFailureState()).toEqual({
title: 'Failed to load protected items',
title: 'Failed to load protection coverage',
});
expect(getRecoveryPointsFailureState()).toEqual({
title: 'Failed to load recovery points',
@@ -1,193 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
buildRecoveryActivitySummary,
buildRecoveryAttentionItems,
buildRecoveryFreshnessBuckets,
buildRecoveryItemCoverage,
buildRecoveryOutcomeSegments,
buildRecoveryPlatformCoverage,
buildRecoveryPostureSegments,
buildRecoveryPostureSummary,
getRecoveryAttentionChipClass,
getRecoveryAttentionDotClass,
RECOVERY_FRESHNESS_BUCKETS,
RECOVERY_SUMMARY_TIME_RANGES,
RECOVERY_SUMMARY_TIME_RANGE_LABELS,
} from '@/utils/recoverySummaryPresentation';
describe('recoverySummaryPresentation', () => {
it('exposes canonical recovery summary time ranges and labels', () => {
expect(RECOVERY_SUMMARY_TIME_RANGES).toEqual(['7d', '30d', '90d', '365d']);
expect(RECOVERY_SUMMARY_TIME_RANGE_LABELS['7d']).toBe('7d');
expect(RECOVERY_SUMMARY_TIME_RANGE_LABELS['30d']).toBe('30d');
expect(RECOVERY_SUMMARY_TIME_RANGE_LABELS['90d']).toBe('90d');
expect(RECOVERY_SUMMARY_TIME_RANGE_LABELS['365d']).toBe('365d');
});
it('exposes canonical freshness bucket presentation', () => {
expect(RECOVERY_FRESHNESS_BUCKETS).toEqual([
{ key: 'under1h', label: '<1h', color: 'bg-emerald-500' },
{ key: 'under24h', label: '<24h', color: 'bg-emerald-400' },
{ key: 'under7d', label: '<7d', color: 'bg-amber-400' },
{ key: 'over7d', label: '>7d', color: 'bg-red-500' },
]);
});
it('builds outcome segments with counts and percentages', () => {
expect(
buildRecoveryOutcomeSegments({
total: 10,
counts: {
success: 7,
warning: 2,
failed: 1,
running: 0,
unknown: 0,
},
}),
).toMatchObject([
{ outcome: 'success', count: 7, percent: 70 },
{ outcome: 'warning', count: 2, percent: 20 },
{ outcome: 'failed', count: 1, percent: 10 },
]);
});
it('builds freshness buckets from latest success timestamps', () => {
const now = Date.parse('2026-03-09T12:00:00Z');
const buckets = buildRecoveryFreshnessBuckets(
[
{ rollupId: 'a', lastOutcome: 'success', lastSuccessAt: '2026-03-09T11:30:00Z' },
{ rollupId: 'b', lastOutcome: 'success', lastSuccessAt: '2026-03-09T06:00:00Z' },
{ rollupId: 'c', lastOutcome: 'success', lastSuccessAt: '2026-03-05T12:00:00Z' },
{ rollupId: 'd', lastOutcome: 'failed', lastSuccessAt: null },
],
now,
);
expect(buckets.map((bucket) => bucket.count)).toEqual([1, 1, 1, 1]);
});
it('builds posture summary and disjoint posture segments for recovery readiness', () => {
const now = Date.parse('2026-03-09T12:00:00Z');
const rollups = [
{ rollupId: 'healthy', lastOutcome: 'success', lastSuccessAt: '2026-03-09T11:30:00Z' },
{ rollupId: 'stale', lastOutcome: 'success', lastSuccessAt: '2026-02-28T12:00:00Z' },
{ rollupId: 'failed', lastOutcome: 'failed', lastSuccessAt: '2026-03-08T12:00:00Z' },
{ rollupId: 'warning', lastOutcome: 'warning', lastSuccessAt: '2026-03-08T12:00:00Z' },
{ rollupId: 'running', lastOutcome: 'running', lastSuccessAt: '2026-03-09T10:30:00Z' },
{ rollupId: 'never', lastOutcome: 'failed', lastAttemptAt: '2026-03-09T10:00:00Z' },
];
expect(buildRecoveryPostureSummary(rollups, now)).toMatchObject({
healthy: 1,
stale: 1,
failed: 1,
warning: 1,
running: 1,
neverSucceeded: 1,
unknown: 0,
attention: 4,
});
expect(buildRecoveryPostureSegments(rollups, now)).toMatchObject([
{ key: 'healthy', count: 1, percent: 17 },
{ key: 'stale', count: 1, percent: 17 },
{ key: 'failed', count: 1, percent: 17 },
{ key: 'warning', count: 1, percent: 17 },
{ key: 'running', count: 1, percent: 17 },
{ key: 'never-succeeded', count: 1, percent: 17 },
]);
});
it('builds platform coverage from rollups and multi-platform protected items', () => {
const coverage = buildRecoveryPlatformCoverage([
{ rollupId: 'a', platforms: ['proxmox-pbs'] },
{ rollupId: 'b', platforms: ['proxmox-pve', 'kubernetes'] },
{ rollupId: 'c', platforms: [] },
]);
expect(coverage.platformCount).toBe(4);
expect(coverage.multiPlatformCount).toBe(1);
expect(coverage.primaryPlatformLabel).toBe('K8s');
expect(coverage.items).toMatchObject([
{ key: 'kubernetes', label: 'K8s', count: 1, percent: 33 },
{ key: 'proxmox-pbs', label: 'PBS', count: 1, percent: 33 },
{ key: 'proxmox-pve', label: 'PVE', count: 1, percent: 33 },
{ key: 'generic', label: 'Generic', count: 1, percent: 33 },
]);
});
it('builds protected item coverage from normalized recovery subject types', () => {
const coverage = buildRecoveryItemCoverage([
{ rollupId: 'a', display: { subjectType: 'proxmox-vm' } },
{ rollupId: 'b', display: { subjectType: 'proxmox-vm-backup' } },
{ rollupId: 'c', subjectRef: { type: 'truenas-dataset' } },
{ rollupId: 'd', subjectRef: { type: 'k8s-pvc' } },
]);
expect(coverage.itemTypeCount).toBe(3);
expect(coverage.primaryItemLabel).toBe('VM');
expect(coverage.items).toMatchObject([
{ key: 'vm', label: 'VM', count: 2, percent: 50 },
{ label: 'Dataset', count: 1, percent: 25 },
{ label: 'PVC', count: 1, percent: 25 },
]);
});
it('summarizes activity bars and headline metrics', () => {
const activity = buildRecoveryActivitySummary([
{ day: '2026-03-07', total: 0, snapshot: 0, local: 0, remote: 0 },
{ day: '2026-03-08', total: 4, snapshot: 1, local: 1, remote: 2 },
{ day: '2026-03-09', total: 2, snapshot: 1, local: 1, remote: 0 },
]);
expect(activity).toMatchObject({
hasData: true,
totalEvents: 6,
activeDays: 2,
latestCount: 2,
busiestCount: 4,
});
expect(activity.bars).toHaveLength(3);
expect(activity.bars[1]).toMatchObject({ isPeak: true });
expect(activity.bars[2]).toMatchObject({ isLatest: true });
});
it('builds attention items ordered by operational urgency', () => {
expect(
buildRecoveryAttentionItems({
counts: {
success: 5,
warning: 2,
failed: 1,
running: 1,
unknown: 0,
},
stale: 3,
neverSucceeded: 2,
}),
).toMatchObject([
{ key: 'failed', count: 1 },
{ key: 'never-succeeded', count: 2 },
{ key: 'warning', count: 2 },
{ key: 'stale', count: 3 },
{ key: 'running', count: 1 },
]);
});
it('exposes canonical recovery attention badge tones', () => {
expect(getRecoveryAttentionChipClass('rose')).toBe(
'border-rose-500/30 bg-rose-500/10 text-rose-200',
);
expect(getRecoveryAttentionChipClass('amber')).toBe(
'border-amber-500/30 bg-amber-500/10 text-amber-200',
);
expect(getRecoveryAttentionChipClass('blue')).toBe(
'border-blue-500/30 bg-blue-500/10 text-blue-200',
);
expect(getRecoveryAttentionDotClass('rose')).toBe('bg-rose-400');
expect(getRecoveryAttentionDotClass('amber')).toBe('bg-amber-400');
expect(getRecoveryAttentionDotClass('blue')).toBe('bg-blue-400');
});
});
@@ -42,16 +42,20 @@ describe('recoveryTablePresentation', () => {
expect(RECOVERY_ADVANCED_FILTER_LABEL_CLASS).toContain('text-muted');
expect(RECOVERY_ADVANCED_FILTER_FIELD_CLASS).toContain('focus:border-blue-500');
expect(RECOVERY_GROUP_NO_TIMESTAMP_LABEL).toBe('No Timestamp');
expect(RECOVERY_PROTECTED_SEARCH_PLACEHOLDER).toBe('Search protected items...');
expect(RECOVERY_HISTORY_SEARCH_PLACEHOLDER).toBe('Search recovery history...');
expect(RECOVERY_PROTECTED_SEARCH_PLACEHOLDER).toBe('Search protection coverage...');
expect(RECOVERY_HISTORY_SEARCH_PLACEHOLDER).toBe(
'Search recovery history or enter a date...',
);
expect(RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE).toBe('Recent searches appear here.');
expect(RECOVERY_ALL_HISTORY_LABEL).toBe('All history');
expect(RECOVERY_ALL_ITEM_TYPES_LABEL).toBe('All item types');
expect(RECOVERY_ALL_PLATFORMS_LABEL).toBe('All platforms');
expect(RECOVERY_ANY_ITEM_LABEL).toBe('Any item');
expect(getRecoveryGroupNoTimestampLabel()).toBe('No Timestamp');
expect(getRecoveryProtectedSearchPlaceholder()).toBe('Search protected items...');
expect(getRecoveryHistorySearchPlaceholder()).toBe('Search recovery history...');
expect(getRecoveryProtectedSearchPlaceholder()).toBe('Search protection coverage...');
expect(getRecoveryHistorySearchPlaceholder()).toBe(
'Search recovery history or enter a date...',
);
expect(getRecoverySearchHistoryEmptyMessage()).toBe('Recent searches appear here.');
expect(getRecoveryAllHistoryLabel()).toBe('All history');
expect(getRecoveryAllItemTypesLabel()).toBe('All item types');
@@ -1,18 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
getRecoveryTimelineBarMarkerClass,
getRecoveryTimelineColumnAriaLabel,
getRecoveryTimelineColumnButtonClass,
getRecoveryTimelineDayFilterLabel,
getRecoveryTimelineDayFilterStateLabel,
getRecoveryTimelineEmptyMarkerClass,
getRecoveryTimelinePointTotalLabel,
getRecoveryTimelineTooltipRows,
} from '@/utils/recoveryTimelinePresentation';
describe('getRecoveryTimelineColumnButtonClass', () => {
it('returns the canonical selected class', () => {
expect(getRecoveryTimelineColumnButtonClass(true)).toContain('bg-blue-100');
expect(getRecoveryTimelineColumnButtonClass(true)).toContain('ring-blue-500');
it('keeps the full-height column as an accessible hit target only', () => {
expect(getRecoveryTimelineColumnButtonClass(true)).toContain('group');
expect(getRecoveryTimelineColumnButtonClass(true)).toContain('focus-visible:outline');
expect(getRecoveryTimelineColumnButtonClass(true)).not.toContain('ring-blue-500');
expect(getRecoveryTimelineColumnButtonClass(true)).not.toContain('bg-blue-100');
});
it('returns the canonical hover class when unselected', () => {
expect(getRecoveryTimelineColumnButtonClass(false)).toContain('hover:bg-surface-hover');
it('does not dim the full-height click column when another day is focused', () => {
expect(getRecoveryTimelineColumnButtonClass(false)).toContain('focus-visible:outline');
expect(getRecoveryTimelineColumnButtonClass(false, true)).not.toContain('opacity-40');
});
it('applies selected and dimmed states to the actual bar marker', () => {
expect(getRecoveryTimelineBarMarkerClass(true, true)).toContain('ring-blue-500');
expect(getRecoveryTimelineBarMarkerClass(true, true)).toContain('ring-inset');
expect(getRecoveryTimelineBarMarkerClass(false, true)).toContain('opacity-40');
expect(getRecoveryTimelineBarMarkerClass(false, true)).toContain('group-hover:opacity-100');
expect(getRecoveryTimelineBarMarkerClass(false, false)).toContain('opacity-100');
});
it('shows a small selected baseline marker for empty focused days', () => {
expect(getRecoveryTimelineEmptyMarkerClass(true, true)).toContain('h-1');
expect(getRecoveryTimelineEmptyMarkerClass(true, true)).toContain('ring-blue-500');
expect(getRecoveryTimelineEmptyMarkerClass(false, true)).toContain('opacity-40');
});
it('builds accessible selected-day labels', () => {
@@ -23,4 +45,40 @@ describe('getRecoveryTimelineColumnButtonClass', () => {
'Feb 14, 2026: 2 recovery points, selected',
);
});
it('builds tooltip rows as a vertical mode breakdown', () => {
expect(
getRecoveryTimelineTooltipRows({ total: 10, snapshot: 2, local: 3, remote: 5 }).map((row) => [
row.label,
row.value,
row.muted,
]),
).toEqual([
['Snapshots', '2 (20%)', false],
['Local Copies', '3 (30%)', false],
['Remote Copies', '5 (50%)', false],
]);
expect(
getRecoveryTimelineTooltipRows({ total: 2, snapshot: 0, local: 2, remote: 0 }).map((row) => [
row.label,
row.value,
row.muted,
]),
).toEqual([
['Snapshots', '0', true],
['Local Copies', '2 (100%)', false],
['Remote Copies', '0', true],
]);
});
it('formats timeline tooltip and selected-day labels', () => {
expect(getRecoveryTimelinePointTotalLabel(1)).toBe('1 recovery point');
expect(getRecoveryTimelinePointTotalLabel(0)).toBe('0 recovery points');
expect(getRecoveryTimelineDayFilterStateLabel(true, true)).toBe('Day filter');
expect(getRecoveryTimelineDayFilterStateLabel(false, true)).toBe('Outside day filter');
expect(getRecoveryTimelineDayFilterLabel('Feb 14, 2026', 2)).toBe(
'Feb 14, 2026 - 2 recovery points',
);
});
});
@@ -6,6 +6,38 @@ export function recoveryDateKeyFromTimestamp(timestamp: number): string {
return `${year}-${month}-${day}`;
}
const RECOVERY_DATE_SEARCH_MONTHS = [
['january', 'jan'],
['february', 'feb'],
['march', 'mar'],
['april', 'apr'],
['may', 'may'],
['june', 'jun'],
['july', 'jul'],
['august', 'aug'],
['september', 'sep', 'sept'],
['october', 'oct'],
['november', 'nov'],
['december', 'dec'],
] as const;
const RECOVERY_DATE_SEARCH_WEEKDAYS = new Set([
'monday',
'mon',
'tuesday',
'tue',
'wednesday',
'wed',
'thursday',
'thu',
'friday',
'fri',
'saturday',
'sat',
'sunday',
'sun',
]);
export function parseRecoveryDateKey(key: string): Date {
const [year, month, day] = key.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return new Date(key);
@@ -29,6 +61,86 @@ export function getRecoveryFullDateLabel(key: string): string {
});
}
export function getRecoveryFilterDateLabel(key: string): string {
return parseRecoveryDateKey(key).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function getRecoveryDateSearchMonthIndex(value: string): number | null {
const normalized = value.toLowerCase();
const index = RECOVERY_DATE_SEARCH_MONTHS.findIndex((aliases) =>
(aliases as readonly string[]).includes(normalized),
);
return index >= 0 ? index : null;
}
function getRecoveryDateSearchKey(year: number, monthIndex: number, day: number): string | null {
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
return null;
}
const date = new Date(year, monthIndex, day);
if (date.getFullYear() !== year || date.getMonth() !== monthIndex || date.getDate() !== day) {
return null;
}
return recoveryDateKeyFromTimestamp(date.getTime());
}
export function normalizeRecoveryDateSearchText(value: string): string {
return String(value || '')
.toLowerCase()
.replace(/\b(\d{1,2})(st|nd|rd|th)\b/g, '$1')
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export function resolveRecoveryDateSearchKey(
value: string,
candidateKeys: readonly string[] = [],
now: Date = new Date(),
): string | null {
const normalized = normalizeRecoveryDateSearchText(value);
if (normalized.length < 5) return null;
const isoMatch = normalized.match(/^(\d{4}) (\d{1,2}) (\d{1,2})$/);
if (isoMatch) {
return getRecoveryDateSearchKey(
Number.parseInt(isoMatch[1], 10),
Number.parseInt(isoMatch[2], 10) - 1,
Number.parseInt(isoMatch[3], 10),
);
}
const tokens = normalized.split(' ').filter(Boolean);
if (RECOVERY_DATE_SEARCH_WEEKDAYS.has(tokens[0])) tokens.shift();
if (tokens.length < 2 || tokens.length > 3) return null;
const monthIndex = getRecoveryDateSearchMonthIndex(tokens[0]);
const day = Number.parseInt(tokens[1], 10);
if (monthIndex === null || !Number.isInteger(day)) return null;
if (tokens[2]) {
if (!/^\d{4}$/.test(tokens[2])) return null;
return getRecoveryDateSearchKey(Number.parseInt(tokens[2], 10), monthIndex, day);
}
const candidateMatches = candidateKeys
.map((key) => String(key || '').trim())
.filter(Boolean)
.filter((key) => {
const date = parseRecoveryDateKey(key);
return date.getMonth() === monthIndex && date.getDate() === day;
});
const uniqueCandidateMatches = [...new Set(candidateMatches)];
if (uniqueCandidateMatches.length === 1) return uniqueCandidateMatches[0];
if (uniqueCandidateMatches.length > 1) return null;
return getRecoveryDateSearchKey(now.getFullYear(), monthIndex, day);
}
export function getRecoveryCompactAxisLabel(key: string, days: 7 | 30 | 90 | 365): string {
const date = parseRecoveryDateKey(key);
if (days <= 30) {
@@ -1,19 +1,19 @@
export function getRecoveryProtectedItemsEmptyState() {
return {
title: 'No protected items yet',
description: 'Pulse hasn’t observed any protected items for this org yet.',
title: 'No protection coverage yet',
description: 'Pulse has not observed recovery coverage for this org yet.',
} as const;
}
export function getRecoveryProtectedItemsLoadingState() {
return {
text: 'Loading protected items...',
text: 'Loading protection coverage...',
} as const;
}
export function getRecoveryProtectedItemsFailureState() {
return {
title: 'Failed to load protected items',
title: 'Failed to load protection coverage',
} as const;
}
@@ -1,559 +0,0 @@
import type { ProtectionRollup, RecoveryOutcome, RecoveryPointsSeriesBucket } from '@/types/recovery';
import {
getRecoveryOutcomeBarClass,
getRecoveryOutcomeLabel,
getRecoveryOutcomeTextClass,
normalizeRecoveryOutcome,
} from '@/utils/recoveryOutcomePresentation';
import { isRecoveryRollupStale } from '@/utils/recoveryTablePresentation';
import {
getSourcePlatformLabel,
getSourcePlatformPresentation,
normalizeSourcePlatformQueryValue,
} from '@/utils/sourcePlatforms';
import {
getRecoveryItemTypePresentation,
getRecoveryRollupItemTypeKey,
} from '@/utils/recoveryItemTypePresentation';
import { getRecoveryRollupPlatforms } from '@/utils/recoveryPlatformModel';
export const RECOVERY_SUMMARY_TIME_RANGES = ['7d', '30d', '90d', '365d'] as const;
export type RecoverySummaryTimeRange = (typeof RECOVERY_SUMMARY_TIME_RANGES)[number];
export const RECOVERY_SUMMARY_TIME_RANGE_LABELS: Record<RecoverySummaryTimeRange, string> = {
'7d': '7d',
'30d': '30d',
'90d': '90d',
'365d': '365d',
};
export type RecoveryFreshnessBucketKey = 'under1h' | 'under24h' | 'under7d' | 'over7d';
export interface RecoveryFreshnessBucketPresentation {
key: RecoveryFreshnessBucketKey;
label: string;
color: string;
}
export interface RecoveryFreshnessBucketStat extends RecoveryFreshnessBucketPresentation {
count: number;
percent: number;
}
export interface RecoveryOutcomeSegment {
outcome: RecoveryOutcome;
label: string;
count: number;
percent: number;
color: string;
textColor: string;
}
export type RecoveryPostureKey =
| 'healthy'
| 'stale'
| 'failed'
| 'warning'
| 'running'
| 'never-succeeded'
| 'unknown';
export interface RecoveryPostureSegment {
key: RecoveryPostureKey;
label: string;
count: number;
percent: number;
color: string;
textColor: string;
}
export interface RecoveryPostureSummary {
healthy: number;
stale: number;
failed: number;
warning: number;
running: number;
neverSucceeded: number;
unknown: number;
attention: number;
}
export interface RecoveryActivityBar {
day: string;
total: number;
heightPct: number;
isLatest: boolean;
isPeak: boolean;
}
export interface RecoveryActivitySummary {
hasData: boolean;
totalEvents: number;
averagePerDay: number;
activeDays: number;
latestCount: number;
latestLabel: string | null;
busiestCount: number;
busiestLabel: string | null;
startLabel: string | null;
endLabel: string | null;
bars: RecoveryActivityBar[];
}
export interface RecoveryAttentionItem {
key: 'failed' | 'never-succeeded' | 'warning' | 'stale' | 'running';
label: string;
count: number;
tone: 'rose' | 'amber' | 'blue';
detail: string;
}
export interface RecoveryPlatformCoverageItem {
key: string;
label: string;
count: number;
percent: number;
toneClass: string;
}
export interface RecoveryPlatformCoverageSummary {
platformCount: number;
multiPlatformCount: number;
primaryPlatformLabel: string | null;
items: RecoveryPlatformCoverageItem[];
}
export interface RecoveryItemCoverageItem {
key: string;
label: string;
count: number;
percent: number;
toneClass: string;
}
export interface RecoveryItemCoverageSummary {
itemTypeCount: number;
primaryItemLabel: string | null;
items: RecoveryItemCoverageItem[];
}
export function getRecoveryAttentionChipClass(
tone: RecoveryAttentionItem['tone'],
): string {
switch (tone) {
case 'rose':
return 'border-rose-500/30 bg-rose-500/10 text-rose-200';
case 'blue':
return 'border-blue-500/30 bg-blue-500/10 text-blue-200';
default:
return 'border-amber-500/30 bg-amber-500/10 text-amber-200';
}
}
export function getRecoveryAttentionDotClass(
tone: RecoveryAttentionItem['tone'],
): string {
switch (tone) {
case 'rose':
return 'bg-rose-400';
case 'blue':
return 'bg-blue-400';
default:
return 'bg-amber-400';
}
}
export const RECOVERY_FRESHNESS_BUCKETS: RecoveryFreshnessBucketPresentation[] = [
{ key: 'under1h', label: '<1h', color: 'bg-emerald-500' },
{ key: 'under24h', label: '<24h', color: 'bg-emerald-400' },
{ key: 'under7d', label: '<7d', color: 'bg-amber-400' },
{ key: 'over7d', label: '>7d', color: 'bg-red-500' },
];
const formatShortDate = (value: string): string => {
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const getRecoverySummaryItemTypePresentation = (
value: string | null | undefined,
): { key: string; label: string; toneClass: string } | null => {
const presentation = getRecoveryItemTypePresentation(value);
if (!presentation) return null;
return {
key: presentation.key,
label: presentation.label,
toneClass: presentation.badgeClasses,
};
};
const toPercent = (count: number, total: number): number =>
total > 0 ? Math.round((count / total) * 100) : 0;
const RECOVERY_POSTURE_PRESENTATION: Record<
RecoveryPostureKey,
Pick<RecoveryPostureSegment, 'label' | 'color' | 'textColor'>
> = {
healthy: {
label: 'Healthy',
color: 'bg-emerald-500',
textColor: 'text-emerald-600 dark:text-emerald-400',
},
stale: {
label: 'Stale',
color: 'bg-amber-400',
textColor: 'text-amber-600 dark:text-amber-400',
},
failed: {
label: 'Failed',
color: 'bg-rose-500',
textColor: 'text-rose-600 dark:text-rose-400',
},
warning: {
label: 'Warning',
color: 'bg-amber-500',
textColor: 'text-amber-600 dark:text-amber-400',
},
running: {
label: 'Running',
color: 'bg-blue-500',
textColor: 'text-blue-600 dark:text-blue-400',
},
'never-succeeded': {
label: 'Never Succeeded',
color: 'bg-rose-400',
textColor: 'text-rose-600 dark:text-rose-400',
},
unknown: {
label: 'Unknown',
color: 'bg-slate-500',
textColor: 'text-muted',
},
};
export function buildRecoveryOutcomeSegments(summary: {
total: number;
counts: Record<RecoveryOutcome, number>;
}): RecoveryOutcomeSegment[] {
if (summary.total <= 0) return [];
return ['success', 'running', 'warning', 'failed', 'unknown']
.map((outcome) => ({
outcome: outcome as RecoveryOutcome,
label: getRecoveryOutcomeLabel(outcome),
count: summary.counts[outcome as RecoveryOutcome] || 0,
percent: toPercent(summary.counts[outcome as RecoveryOutcome] || 0, summary.total),
color: getRecoveryOutcomeBarClass(outcome),
textColor: getRecoveryOutcomeTextClass(outcome),
}))
.filter((segment) => segment.count > 0);
}
export function buildRecoveryPostureSummary(
rollups: ProtectionRollup[],
nowMs = Date.now(),
): RecoveryPostureSummary {
const counts: RecoveryPostureSummary = {
healthy: 0,
stale: 0,
failed: 0,
warning: 0,
running: 0,
neverSucceeded: 0,
unknown: 0,
attention: 0,
};
for (const rollup of rollups) {
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const neverSucceeded =
(!Number.isFinite(successMs) || successMs <= 0) &&
Number.isFinite(attemptMs) &&
attemptMs > 0;
const outcome = normalizeRecoveryOutcome(rollup.lastOutcome);
const stale = !neverSucceeded && isRecoveryRollupStale(rollup, nowMs);
if (neverSucceeded) {
counts.neverSucceeded += 1;
counts.attention += 1;
continue;
}
if (outcome === 'failed') {
counts.failed += 1;
counts.attention += 1;
continue;
}
if (outcome === 'warning') {
counts.warning += 1;
counts.attention += 1;
continue;
}
if (stale) {
counts.stale += 1;
counts.attention += 1;
continue;
}
if (outcome === 'running') {
counts.running += 1;
continue;
}
if (outcome === 'success') {
counts.healthy += 1;
continue;
}
counts.unknown += 1;
}
return counts;
}
export function buildRecoveryPostureSegments(
rollups: ProtectionRollup[],
nowMs = Date.now(),
): RecoveryPostureSegment[] {
const summary = buildRecoveryPostureSummary(rollups, nowMs);
const total = rollups.length;
const order: RecoveryPostureKey[] = [
'healthy',
'stale',
'failed',
'warning',
'running',
'never-succeeded',
'unknown',
];
return order
.map((key) => {
const count =
key === 'never-succeeded'
? summary.neverSucceeded
: key === 'unknown'
? summary.unknown
: summary[key];
const presentation = RECOVERY_POSTURE_PRESENTATION[key];
return {
key,
label: presentation.label,
count,
percent: toPercent(count, total),
color: presentation.color,
textColor: presentation.textColor,
};
})
.filter((segment) => segment.count > 0);
}
export function buildRecoveryFreshnessBuckets(
rollups: ProtectionRollup[],
nowMs = Date.now(),
): RecoveryFreshnessBucketStat[] {
const counts: Record<RecoveryFreshnessBucketKey, number> = {
under1h: 0,
under24h: 0,
under7d: 0,
over7d: 0,
};
for (const rollup of rollups) {
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
if (!successMs || !Number.isFinite(successMs)) {
counts.over7d += 1;
continue;
}
const ageMs = nowMs - successMs;
if (ageMs < 3_600_000) counts.under1h += 1;
else if (ageMs < 86_400_000) counts.under24h += 1;
else if (ageMs < 604_800_000) counts.under7d += 1;
else counts.over7d += 1;
}
return RECOVERY_FRESHNESS_BUCKETS.map((bucket) => ({
...bucket,
count: counts[bucket.key],
percent: toPercent(counts[bucket.key], rollups.length),
}));
}
export function buildRecoveryPlatformCoverage(
rollups: ProtectionRollup[],
): RecoveryPlatformCoverageSummary {
const counts = new Map<string, number>();
let multiPlatformCount = 0;
for (const rollup of rollups) {
const platforms = Array.from(
new Set(
getRecoveryRollupPlatforms(rollup)
.map((platform) => normalizeSourcePlatformQueryValue(String(platform || '').trim()))
.filter(Boolean),
),
);
const resolvedPlatforms = platforms.length > 0 ? platforms : ['generic'];
if (resolvedPlatforms.length > 1) multiPlatformCount += 1;
for (const platform of resolvedPlatforms) {
counts.set(platform, (counts.get(platform) || 0) + 1);
}
}
const items = Array.from(counts.entries())
.map(([key, count]) => ({
key,
label: getSourcePlatformLabel(key),
count,
percent: toPercent(count, rollups.length),
toneClass:
getSourcePlatformPresentation(key)?.tone ||
'bg-surface-alt text-base-content dark:bg-surface-alt dark:text-base-content',
}))
.sort((left, right) => {
if (left.count !== right.count) return right.count - left.count;
if (left.key === 'generic' && right.key !== 'generic') return 1;
if (right.key === 'generic' && left.key !== 'generic') return -1;
return left.label.localeCompare(right.label);
});
return {
platformCount: items.length,
multiPlatformCount,
primaryPlatformLabel: items[0]?.label || null,
items,
};
}
export function buildRecoveryItemCoverage(
rollups: ProtectionRollup[],
): RecoveryItemCoverageSummary {
const counts = new Map<string, RecoveryItemCoverageItem>();
for (const rollup of rollups) {
const presentation =
getRecoverySummaryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || {
key: 'unknown',
label: 'Unknown',
toneClass: 'bg-surface-alt text-base-content',
};
const existing = counts.get(presentation.key);
if (existing) {
existing.count += 1;
existing.percent = toPercent(existing.count, rollups.length);
continue;
}
counts.set(presentation.key, {
...presentation,
count: 1,
percent: toPercent(1, rollups.length),
});
}
const items = Array.from(counts.values()).sort((left, right) => {
if (left.count !== right.count) return right.count - left.count;
if (left.key === 'unknown' && right.key !== 'unknown') return 1;
if (right.key === 'unknown' && left.key !== 'unknown') return -1;
return left.label.localeCompare(right.label);
});
return {
itemTypeCount: items.length,
primaryItemLabel: items[0]?.label || null,
items,
};
}
export function buildRecoveryActivitySummary(
series: RecoveryPointsSeriesBucket[],
): RecoveryActivitySummary {
if (series.length === 0) {
return {
hasData: false,
totalEvents: 0,
averagePerDay: 0,
activeDays: 0,
latestCount: 0,
latestLabel: null,
busiestCount: 0,
busiestLabel: null,
startLabel: null,
endLabel: null,
bars: [],
};
}
const totalEvents = series.reduce((sum, bucket) => sum + bucket.total, 0);
const activeDays = series.filter((bucket) => bucket.total > 0).length;
const latest = series[series.length - 1];
const busiest = series.reduce((best, bucket) => (bucket.total > best.total ? bucket : best), series[0]);
const maxTotal = Math.max(...series.map((bucket) => bucket.total), 0);
return {
hasData: totalEvents > 0,
totalEvents,
averagePerDay: totalEvents / series.length,
activeDays,
latestCount: latest.total,
latestLabel: formatShortDate(latest.day),
busiestCount: busiest.total,
busiestLabel: formatShortDate(busiest.day),
startLabel: formatShortDate(series[0].day),
endLabel: formatShortDate(latest.day),
bars: series.map((bucket, index) => ({
day: bucket.day,
total: bucket.total,
heightPct:
maxTotal <= 0 || bucket.total <= 0 ? 0 : Math.max((bucket.total / maxTotal) * 100, 10),
isLatest: index === series.length - 1,
isPeak: bucket.day === busiest.day && bucket.total > 0,
})),
};
}
export function buildRecoveryAttentionItems(summary: {
counts: Record<RecoveryOutcome, number>;
stale: number;
neverSucceeded: number;
}): RecoveryAttentionItem[] {
const items: RecoveryAttentionItem[] = [
{
key: 'failed',
label: 'Failed',
count: summary.counts.failed || 0,
tone: 'rose',
detail: 'Latest protection run failed and needs investigation.',
},
{
key: 'never-succeeded',
label: 'Never succeeded',
count: summary.neverSucceeded || 0,
tone: 'rose',
detail: 'Protected items have not completed a successful run yet.',
},
{
key: 'warning',
label: 'Warnings',
count: summary.counts.warning || 0,
tone: 'amber',
detail: 'Latest protection run completed with warnings.',
},
{
key: 'stale',
label: 'Stale',
count: summary.stale || 0,
tone: 'amber',
detail: 'No successful protection has landed within the stale threshold.',
},
{
key: 'running',
label: 'Running now',
count: summary.counts.running || 0,
tone: 'blue',
detail: 'Protection jobs are currently in flight.',
},
];
return items.filter((item) => item.count > 0);
}
@@ -25,8 +25,8 @@ export const RECOVERY_ADVANCED_FILTER_LABEL_CLASS = 'text-[11px] font-medium tex
export const RECOVERY_ADVANCED_FILTER_FIELD_CLASS =
'min-h-[2.25rem] w-full rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content outline-none focus:border-blue-500';
export const RECOVERY_GROUP_NO_TIMESTAMP_LABEL = 'No Timestamp';
export const RECOVERY_PROTECTED_SEARCH_PLACEHOLDER = 'Search protected items...';
export const RECOVERY_HISTORY_SEARCH_PLACEHOLDER = 'Search recovery history...';
export const RECOVERY_PROTECTED_SEARCH_PLACEHOLDER = 'Search protection coverage...';
export const RECOVERY_HISTORY_SEARCH_PLACEHOLDER = 'Search recovery history or enter a date...';
export const RECOVERY_SEARCH_HISTORY_EMPTY_MESSAGE = 'Recent searches appear here.';
export const RECOVERY_ALL_HISTORY_LABEL = getAllFilterOptionLabel('history');
export const RECOVERY_ALL_ITEM_TYPES_LABEL = getAllFilterOptionLabel('item types');
@@ -1,9 +1,111 @@
export function getRecoveryTimelineColumnButtonClass(selected: boolean): string {
import {
getRecoveryArtifactModePresentation,
type RecoveryArtifactMode,
} from './recoveryArtifactModePresentation';
export interface RecoveryTimelineBreakdownPoint {
total: number;
snapshot: number;
local: number;
remote: number;
}
export interface RecoveryTimelineTooltipRow {
mode: RecoveryArtifactMode;
label: string;
count: number;
value: string;
segmentClassName: string;
muted: boolean;
}
const RECOVERY_TIMELINE_TOOLTIP_MODES: RecoveryArtifactMode[] = ['snapshot', 'local', 'remote'];
function getRecoveryTimelineModeValue(
point: RecoveryTimelineBreakdownPoint,
mode: RecoveryArtifactMode,
): number {
return Math.max(0, Number(point[mode] || 0));
}
export function getRecoveryTimelinePointTotalLabel(total: number): string {
const normalized = Math.max(0, Number(total || 0));
return `${normalized} recovery point${normalized === 1 ? '' : 's'}`;
}
export function getRecoveryTimelineTooltipRows(
point: RecoveryTimelineBreakdownPoint,
): RecoveryTimelineTooltipRow[] {
const total = Math.max(0, Number(point.total || 0));
return RECOVERY_TIMELINE_TOOLTIP_MODES.map((mode) => {
const presentation = getRecoveryArtifactModePresentation(mode);
const count = getRecoveryTimelineModeValue(point, mode);
const percentage = total > 0 && count > 0 ? Math.round((count / total) * 100) : 0;
return {
mode,
label: presentation.aggregateLabel,
count,
value: percentage > 0 ? `${count} (${percentage}%)` : String(count),
segmentClassName: presentation.segmentClassName,
muted: count === 0,
};
});
}
export function getRecoveryTimelineDayFilterStateLabel(
selected: boolean,
timelineHasDayFilter: boolean,
): string {
if (selected) return 'Day filter';
if (timelineHasDayFilter) return 'Outside day filter';
return 'Timeline day';
}
export function getRecoveryTimelineDayFilterLabel(dateLabel: string, total: number): string {
return `${dateLabel} - ${getRecoveryTimelinePointTotalLabel(total)}`;
}
export function getRecoveryTimelineColumnButtonClass(
_selected: boolean,
_timelineHasSelection = false,
): string {
const base =
'rounded-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500';
return selected
? `${base} bg-blue-100 ring-1 ring-blue-500/70 dark:bg-blue-900`
: `${base} hover:bg-surface-hover`;
'group rounded-sm transition-all duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500';
return base;
}
export function getRecoveryTimelineBarMarkerClass(
selected: boolean,
timelineHasSelection = false,
): string {
const base =
'absolute inset-x-0 bottom-0 overflow-hidden rounded-sm transition-all duration-150 group-hover:ring-1 group-hover:ring-inset group-hover:ring-border group-focus-visible:ring-1 group-focus-visible:ring-inset group-focus-visible:ring-blue-500/60';
if (selected) {
return `${base} opacity-100 ring-2 ring-inset ring-blue-500/80`;
}
const focusClass = timelineHasSelection
? 'opacity-40 group-hover:opacity-100 group-focus-visible:opacity-100'
: 'opacity-100';
return `${base} ${focusClass}`;
}
export function getRecoveryTimelineEmptyMarkerClass(
selected: boolean,
timelineHasSelection = false,
): string {
const base =
'absolute inset-x-0 bottom-0 rounded-sm transition-all duration-150 group-hover:h-1 group-hover:bg-border group-focus-visible:h-1 group-focus-visible:bg-blue-500/60';
if (selected) {
return `${base} h-1 bg-blue-500 ring-2 ring-inset ring-blue-500/80`;
}
const focusClass = timelineHasSelection
? 'opacity-40 group-hover:opacity-100 group-focus-visible:opacity-100'
: 'opacity-100';
return `${base} h-0.5 bg-transparent ${focusClass}`;
}
export function getRecoveryTimelineColumnAriaLabel(