From a71735b64fc59aa27696dc15e34ff18eddfd1289 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 19:25:41 +0000 Subject: [PATCH] refactor(recovery): realign summary and badge primitives --- .../internal/subsystems/storage-recovery.md | 7 + .../src/components/Recovery/Recovery.tsx | 86 +-- .../RecoveryProtectedInventorySection.tsx | 51 +- .../components/Recovery/RecoverySummary.tsx | 602 +++++++----------- .../Recovery/__tests__/Recovery.test.tsx | 4 - .../recoveryItemTypePresentation.test.ts | 3 +- .../src/utils/recoveryItemTypePresentation.ts | 39 +- 7 files changed, 322 insertions(+), 470 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 84c1ed17f3..475b886011 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -636,6 +636,13 @@ surface before it drops into `RecoveryActivitySection`, so operators reach the main control surface ahead of trend telemetry. The activity timeline remains required even when point-history loading fails, but it is secondary to the workspace rather than a competing top-of-page peer. +That same recovery product surface must also stay stylistically aligned with +established Pulse monitoring surfaces once shared primitives already exist. +`frontend-modern/src/components/Recovery/RecoverySummary.tsx` should compose +the shared `SummaryPanel` and `SummaryMetricCard` primitives the way +infrastructure and workloads do, and recovery item-type labels should render +through canonical workload/resource badge classes instead of adding +recovery-only wrapper chrome around VM, container, or other resource badges. That shared unified-resource dependency now also includes policy-governed resource metadata on the frontend decode path: storage and recovery surfaces that route through `frontend-modern/src/hooks/useUnifiedResources.ts` must diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 85eb8e162c..2750684675 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -485,58 +485,40 @@ const Recovery: Component = () => { />
- -
-
-
-
- Recovery Workspace -
- - Inventory first, event drill-in second, with shared item-first filters. - -
- 0}> - - Focused drill-in - - -
-
-
- setWorkspaceView(value as RecoveryWorkspaceView)} - ariaLabel="Recovery data view" - listClass="flex flex-wrap items-center gap-3" - tabClass="min-h-9 px-2 py-2 text-sm" - tabs={[ - { - value: 'inventory', - label: ( - - Protected items - {filteredRollups().length} - - ), - }, - { - value: 'events', - label: ( - - Recovery events - {recoveryPoints.meta().total} - - ), - }, - ]} - /> -
-
+
+ setWorkspaceView(value as RecoveryWorkspaceView)} + ariaLabel="Recovery data view" + listClass="flex flex-wrap items-center gap-3" + tabClass="min-h-9 px-2 py-2 text-sm" + tabs={[ + { + value: 'inventory', + label: ( + + Protected items + {filteredRollups().length} + + ), + }, + { + value: 'events', + label: ( + + Recovery events + {recoveryPoints.meta().total} + + ), + }, + ]} + /> + 0}> + + Focused drill-in + + +
{ - switch (protectedSortCol()) { - case 'item': - return 'Item'; - case 'type': - return 'Item Type'; - case 'platform': - return 'Platform'; - case 'lastBackup': - return 'Latest Point'; - case 'outcome': - return 'Outcome'; - default: - return 'Latest Point'; - } - }); - createEffect(() => { props.queryFilter(); props.platformFilter(); @@ -220,12 +203,7 @@ export const RecoveryProtectedInventorySection: Component< >
-
-
Protected inventory
-
- Item class and latest-point posture stay visible in the primary grid. -
-
+
Protected inventory
{sortedRollups().length} of {props.rollups().length} items shown @@ -349,27 +327,6 @@ export const RecoveryProtectedInventorySection: Component<
- 0}> -
-
-
- - Showing {pageStart()} - {pageEnd()} - - - Page {protectedPage()} / {protectedTotalPages()} - - - Sorted by {protectedSortLabel()} - -
-
- Open any item to drill directly into its recovery events. -
-
-
-
-
{getRecoveryProtectedItemsLoadingState().text} @@ -514,11 +471,7 @@ export const RecoveryProtectedInventorySection: Component< when={itemTypePresentation} fallback={—} > - + {itemTypePresentation?.label} diff --git a/frontend-modern/src/components/Recovery/RecoverySummary.tsx b/frontend-modern/src/components/Recovery/RecoverySummary.tsx index ac8c33aa29..3a1b2acc22 100644 --- a/frontend-modern/src/components/Recovery/RecoverySummary.tsx +++ b/frontend-modern/src/components/Recovery/RecoverySummary.tsx @@ -1,21 +1,21 @@ import { Component, For, Show, createMemo } from 'solid-js'; -import { Card } from '@/components/shared/Card'; +import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges'; +import { SummaryPanel } from '@/components/shared/SummaryPanel'; +import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard'; import type { ProtectionRollup, RecoveryOutcome, RecoveryPointsSeriesBucket } from '@/types/recovery'; import { - buildRecoveryActivitySummary, buildRecoveryAttentionItems, + buildRecoveryActivitySummary, buildRecoveryFreshnessBuckets, buildRecoveryItemCoverage, buildRecoveryPlatformCoverage, buildRecoveryPostureSegments, buildRecoveryPostureSummary, - getRecoveryAttentionChipClass, getRecoveryAttentionDotClass, RECOVERY_SUMMARY_TIME_RANGES, RECOVERY_SUMMARY_TIME_RANGE_LABELS, type RecoverySummaryTimeRange, } from '@/utils/recoverySummaryPresentation'; -import { segmentedButtonClass } from '@/utils/segmentedButton'; export interface RecoverySummaryProps { rollups: () => ProtectionRollup[]; @@ -46,131 +46,65 @@ export const RecoverySummary: Component = (props) => { const healthyCount = createMemo(() => postureSummary().healthy); const attentionCount = createMemo(() => postureSummary().attention); - const attentionSummary = createMemo(() => { - const parts: string[] = []; - if (postureSummary().failed > 0) { - parts.push(`${postureSummary().failed} failed`); - } - if (postureSummary().neverSucceeded > 0) { - parts.push(`${postureSummary().neverSucceeded} never succeeded`); - } - if (postureSummary().stale > 0) { - parts.push(`${postureSummary().stale} stale`); - } - if (postureSummary().warning > 0) { - parts.push(`${postureSummary().warning} warning`); - } - if (parts.length === 0 && postureSummary().running > 0) { - parts.push(`${postureSummary().running} running`); - } - return parts.slice(0, 3).join(' • '); - }); const recentWindowLabel = createMemo(() => { const activitySummary = activity(); if (!activitySummary.startLabel || !activitySummary.endLabel) return null; return `${activitySummary.startLabel} to ${activitySummary.endLabel}`; }); + const handleTimeRangeChange = (range: string) => + props.onTimeRangeChange?.(range as RecoverySummaryTimeRange); return ( - -
-
-
- - {summary().total} protected + + + {summary().total} protected + + + {healthyCount()} healthy + + 0}> + + {attentionCount()} attention - - - {healthyCount()} healthy - - 0}> - - - {attentionCount()} attention - - - 0}> - - - {postureSummary().running} running - - -
- -
- - {(range) => ( - - )} - -
-
- -
-
-
-
-
- Recovery Posture -
-
- Recovery readiness across the current protected estate, with the attention - queue surfaced before timeline detail. -
-
- 0}> -
- Attention Queue - - {attentionCount()} - -
-
-
- -
-
-
Healthy
-
- {healthyCount()} -
-
Recent successful protection within the current window.
-
-
-
Attention
-
- {attentionCount()} -
-
- {attentionSummary() || 'No active recovery risks.'} -
-
-
-
Protected
-
- {summary().total} -
-
- {itemCoverage().itemTypeCount} item type - {itemCoverage().itemTypeCount === 1 ? '' : 's'} across {platformCoverage().platformCount}{' '} - platform{platformCoverage().platformCount === 1 ? '' : 's'}. -
+ 0}> + + {postureSummary().running} running + + + + } + timeRange={props.timeRange()} + onTimeRangeChange={props.onTimeRangeChange ? handleTimeRangeChange : undefined} + timeRanges={RECOVERY_SUMMARY_TIME_RANGES} + timeRangeLabels={RECOVERY_SUMMARY_TIME_RANGE_LABELS} + testId="recovery-summary" + > + +
+
+
+
Healthy
+
+ {healthyCount()}
- -
+
+
Attention
+
+ {attentionCount()} +
+
+
+
Protected
+
{summary().total}
+
+
+
+
{(segment) => ( @@ -183,258 +117,214 @@ export const RecoverySummary: Component = (props) => {
- -
+
{(segment) => ( -
-
+
+
- {segment.label} + {segment.label}
- {segment.count} + + {segment.count} +
)}
- -
-
-
- Freshness -
-
- - {(bucket) => ( -
- {bucket.label} -
-
0 ? 8 : 0)}%` }} - /> -
- {bucket.count} -
- )} - -
-
- -
-
- Attention Queue -
- 0} - fallback={
No active recovery risks.
} - > -
- - {(item) => ( -
-
-
- - - {item.label} - -
- {item.count} -
-
{item.detail}
-
- )} -
-
-
-
+
+
+
+ Freshness
-
- -
-
-
-
- Protected Footprint -
-
-
-
-
Item Types
-
- {itemCoverage().itemTypeCount} -
-
-
-
Primary Item
-
- {itemCoverage().primaryItemLabel ?? 'n/a'} -
-
-
-
Platforms
-
- {platformCoverage().platformCount} -
-
-
-
- Primary Platform -
-
- {platformCoverage().primaryPlatformLabel ?? 'n/a'} -
-
-
-
- -
- 0}> -
-
- Item Types -
-
- - {(item) => ( -
- - {item.label} - - {item.count} - {item.percent}% -
- )} -
-
-
-
-
-
- Platform Mix -
- 0}> -
- {platformCoverage().multiPlatformCount} protected item - {platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple - platforms. -
-
-
- - {(item) => ( -
- - {item.label} - - {item.count} - {item.percent}% -
- )} -
+
+ + {(bucket) => ( +
+ {bucket.label} +
+
0 ? 8 : 0)}%` }} + />
+ + {bucket.count} +
-
-
- -
-
- Recent History -
-
- - {recentWindowLabel()} - -
-
-
-
-
Recovery Points
-
- {activity().totalEvents} -
-
-
-
Avg / Day
-
- {activity().averagePerDay.toFixed(1)} -
-
-
-
Days Active
-
- {activity().activeDays} -
-
-
-
Peak Day
-
- {activity().busiestLabel ?? 'n/a'} -
-
-
-
- Latest Activity -
-
- {activity().latestLabel ?? 'n/a'} -
-
-
-
- -
- - {props.seriesFailed?.() ? 'Trend data unavailable' : 'No recovery activity yet'} -
- } - > -
-
-
-
- Peak Throughput -
-
- {activity().busiestCount} -
-
- {activity().busiestLabel ?? 'n/a'} -
-
-
-
- Latest Throughput -
-
- {activity().latestCount} -
-
- {activity().latestLabel ?? 'n/a'} -
-
-
-
- -
-
+ )} +
-
+
-
- + + + 0} + emptyMessage="No active recovery risks." + > +
+ + {(item) => ( +
+
+
+ + + {item.label} + +
+ + {item.count} + +
+
{item.detail}
+
+ )} +
+
+
+ + +
+
+
+
Item Types
+
{itemCoverage().itemTypeCount}
+
+
+
Primary Item
+
+ {itemCoverage().primaryItemLabel ?? 'n/a'} +
+
+
+
Platforms
+
{platformCoverage().platformCount}
+
+
+
Primary Platform
+
+ {platformCoverage().primaryPlatformLabel ?? 'n/a'} +
+
+
+ +
+ {itemCoverage().itemTypeCount} item type + {itemCoverage().itemTypeCount === 1 ? '' : 's'} across {platformCoverage().platformCount}{' '} + platform{platformCoverage().platformCount === 1 ? '' : 's'}. +
+ + 0}> +
+
+ Item Types +
+
+ + {(item) => ( +
+ {item.label} + {item.count} + {item.percent}% +
+ )} +
+
+
+
+ +
+
+ Platform Mix +
+ 0}> +
+ {platformCoverage().multiPlatformCount} protected item + {platformCoverage().multiPlatformCount === 1 ? '' : 's'} span multiple platforms. +
+
+
+ + {(item) => { + const badge = getSourcePlatformBadge(item.key); + return ( +
+ + {badge?.label || item.label} + + {item.count} + {item.percent}% +
+ ); + }} +
+
+
+
+
+ + +
+ +
{recentWindowLabel()}
+
+
+
+
Recovery Points
+
{activity().totalEvents}
+
+
+
Avg / Day
+
{activity().averagePerDay.toFixed(1)}
+
+
+
Days Active
+
{activity().activeDays}
+
+
+
Peak Day
+
{activity().busiestLabel ?? 'n/a'}
+
+
+
Latest Activity
+
{activity().latestLabel ?? 'n/a'}
+
+
+ +
+
+
Peak Throughput
+
{activity().busiestCount}
+
{activity().busiestLabel ?? 'n/a'}
+
+
+
Latest Throughput
+
{activity().latestCount}
+
{activity().latestLabel ?? 'n/a'}
+
+
+
+
+
); }; diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index 75129daf88..c031c1bdc4 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -746,10 +746,6 @@ describe('Recovery', () => { expect(await screen.findByText('Showing 1 - 24 of 26 protected items')).toBeInTheDocument(); expect(screen.getAllByText('Page 1 / 2').length).toBeGreaterThan(0); - expect(screen.getByText('Sorted by Latest Point')).toBeInTheDocument(); - expect( - screen.getByText('Open any item to drill directly into its recovery events.'), - ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Next' })); diff --git a/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts index 419217d5af..dc9298ab01 100644 --- a/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/recoveryItemTypePresentation.test.ts @@ -41,7 +41,8 @@ describe('recoveryItemTypePresentation', () => { it('falls back cleanly for unknown item types', () => { expect(getRecoveryItemTypeLabel('custom-thing')).toBe('Custom Thing'); - expect(getRecoveryItemTypeBadgeClass('custom-thing')).toBe('bg-surface-alt text-base-content'); + expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('bg-surface-alt text-base-content'); + expect(getRecoveryItemTypeBadgeClass('custom-thing')).toContain('inline-flex items-center'); }); it('derives canonical item type keys from recovery rollups and points', () => { diff --git a/frontend-modern/src/utils/recoveryItemTypePresentation.ts b/frontend-modern/src/utils/recoveryItemTypePresentation.ts index e45ca57fca..fe654acefe 100644 --- a/frontend-modern/src/utils/recoveryItemTypePresentation.ts +++ b/frontend-modern/src/utils/recoveryItemTypePresentation.ts @@ -9,7 +9,10 @@ export interface RecoveryItemTypePresentation { badgeClasses: string; } -const DEFAULT_BADGE_CLASSES = 'bg-surface-alt text-base-content'; +const BADGE_BASE_CLASSES = + 'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap'; +const DEFAULT_BADGE_TONE_CLASSES = 'bg-surface-alt text-base-content'; +const DEFAULT_BADGE_CLASSES = `${BADGE_BASE_CLASSES} ${DEFAULT_BADGE_TONE_CLASSES}`; interface RecoveryItemTypeLike { display?: { @@ -87,33 +90,53 @@ export const getRecoveryItemTypePresentation = ( switch (key) { case 'vm': { const presentation = getWorkloadTypePresentation('vm'); - return { key, label: presentation.label, badgeClasses: presentation.className }; + return { + key, + label: presentation.label, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`, + }; } case 'system-container': { const presentation = getWorkloadTypePresentation('system-container'); - return { key, label: presentation.label, badgeClasses: presentation.className }; + return { + key, + label: presentation.label, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`, + }; } case 'app-container': { const presentation = getWorkloadTypePresentation('app-container', { label: 'App Container', title: 'Application Container', }); - return { key, label: presentation.label, badgeClasses: presentation.className }; + return { + key, + label: presentation.label, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`, + }; } case 'oci-container': { const presentation = getWorkloadTypePresentation('oci-container'); - return { key, label: presentation.label, badgeClasses: presentation.className }; + return { + key, + label: presentation.label, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`, + }; } case 'pod': { const presentation = getWorkloadTypePresentation('pod'); - return { key, label: presentation.label, badgeClasses: presentation.className }; + return { + key, + label: presentation.label, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.className}`, + }; } case 'pvc': { const presentation = getResourceTypePresentation('k8s-pvc'); return { key, label: presentation?.label || 'PVC', - badgeClasses: presentation?.badgeClasses || DEFAULT_BADGE_CLASSES, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation?.badgeClasses || DEFAULT_BADGE_TONE_CLASSES}`, }; } default: { @@ -129,7 +152,7 @@ export const getRecoveryItemTypePresentation = ( return { key, label, - badgeClasses: presentation.badgeClasses, + badgeClasses: `${BADGE_BASE_CLASSES} ${presentation.badgeClasses}`, }; } return {