mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Simplify recovery presentation and type contracts
This commit is contained in:
+20
-20
@@ -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) |
|
||||
|
||||
|
||||
@@ -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
+112
-16
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user