diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b27d5c83f..2e89137e8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -89,6 +89,7 @@ querying, and the operator-facing storage health presentation layer. 11. Letting non-canonical recovery provider values survive in route or transport state; shared recovery URLs must collapse unsupported or fake `provider` values back to the canonical unset state, and only owned source-platform provider options or canonical aliases may reach rollups, points, series, and facets transport filters 12. Letting protected-item recovery outcome filtering fork from the canonical history status filter; the protected inventory status control must drive the same route-backed `status` field and the same rollups, points, series, and facets transport filters as the history surface instead of keeping a protected-only local outcome branch 13. Letting visible protected-item filters fall out of shared recovery links; the protected `Stale only` toggle must restore from the canonical recovery URL and rewrite to one owned `stale=1` route form instead of disappearing on refresh or copy/paste +14. Reintroducing stacked full-width recovery tables as the primary desktop layout; the governed recovery surface must expose one primary data region at a time with explicit protected-items versus recovery-events view switching so Pulse stays inventory-first for Proxmox operators without collapsing the page back into a single-platform backup screen ## Completion Obligations @@ -131,6 +132,18 @@ protected inventory, activity, and history presentation layers. The history surface is further split so `RecoveryHistorySection.tsx` owns the toolbar and controller boundary, `useRecoveryHistorySectionState.ts` owns local section UI state, and `RecoveryHistoryTable.tsx` owns the row/detail renderer. +That composition root now also owns one primary recovery workspace rather than +stacking protected-inventory and event-history tables on the same desktop page. +The governed default remains inventory-first so Proxmox-oriented operators land +on the familiar protected-items view, but drill-in actions such as selecting a +subject or a timeline day must switch the primary workspace to recovery events +instead of leaving two competing table surfaces visible at once. +That same workspace contract also keeps Pulse's provider-neutral recovery model +explicit in the page language: recovery sections should talk about protected +items, recovery events, and latest points so PBS backups, TrueNAS snapshots, +Kubernetes artifacts, and future providers all fit the same first-class UI +frame without removing the source badges and row-level cues that make Proxmox +operators productive. That history table layout now also derives its minimum width from the same canonical column-width spec that owns the header sizing in `frontend-modern/src/utils/recoveryTablePresentation.ts`, so longer governed diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 1684ef0d8..448125bae 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -1,4 +1,4 @@ -import { Show, createEffect, createMemo } from 'solid-js'; +import { Show, createEffect, createMemo, createSignal } from 'solid-js'; import type { Component } from 'solid-js'; import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivitySection'; @@ -6,6 +6,7 @@ import { RecoveryHistorySection } from '@/components/Recovery/RecoveryHistorySec import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; +import { Subtabs } from '@/components/shared/Subtabs'; import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { useColumnVisibility } from '@/hooks/useColumnVisibility'; @@ -35,11 +36,14 @@ import { getRecoveryTimelineLabelEvery } from '@/utils/recoveryTimelineChartPres import { createHiddenCanonicalTypeColumn } from '@/utils/typeColumnDefinition'; const MOBILE_RECOVERY_COLUMNS = new Set(['time', 'subject', 'outcome']); +type RecoveryWorkspaceView = 'inventory' | 'events'; const Recovery: Component = () => { const kioskMode = useKioskMode(); const { isMobile } = useBreakpoint(); let historySectionRef: HTMLDivElement | undefined; + const [workspaceView, setWorkspaceView] = createSignal('inventory'); + const [workspaceViewInitialized, setWorkspaceViewInitialized] = createSignal(false); const { chartRangeDays, @@ -331,6 +335,15 @@ const Recovery: Component = () => { if (currentPage() > totalPages()) setCurrentPage(totalPages()); }); + createEffect(() => { + const defaultView: RecoveryWorkspaceView = + rollupId().trim().length > 0 || selectedDateKey() ? 'events' : 'inventory'; + if (!workspaceViewInitialized()) { + setWorkspaceView(defaultView); + setWorkspaceViewInitialized(true); + } + }); + const timeline = createMemo(() => { const points = (recoverySeries.series() || []).map((bucket) => ({ key: String(bucket.day || '').trim(), @@ -427,6 +440,7 @@ const Recovery: Component = () => { }; const handleSelectRollup = (nextRollupId: string) => { + setWorkspaceView('events'); setRollupId(nextRollupId); requestAnimationFrame(() => historySectionRef && typeof historySectionRef.scrollIntoView === 'function' @@ -437,28 +451,6 @@ const Recovery: Component = () => { return (
- recoveryRollups.rollups.loading} - error={() => recoveryRollups.rollups.error} - onSelectRollup={handleSelectRollup} - protectedStaleOnly={protectedStaleOnly} - providerFilter={providerFilter} - providerOptions={providerOptions} - queryFilter={queryFilter} - resourcesById={resourcesById} - rollups={rollups} - rollupsSummary={rollupsSummary} - setHistoryOutcomeFilter={setHistoryOutcomeFilter} - setProtectedStaleOnly={setProtectedStaleOnly} - setProviderFilter={setProviderFilter} - setQueryFilter={setQueryFilter} - setVerificationFilter={setVerificationFilter} - /> - { }} timeline={timeline} toggleSelectedDate={(key) => { + setWorkspaceView('events'); setSelectedDateKey((previous) => (previous === key ? null : key)); setCurrentPage(1); }} />
- + +
+ setWorkspaceView(value as RecoveryWorkspaceView)} + ariaLabel="Recovery data view" + tabs={[ + { + value: 'inventory', + label: ( + + Protected items + {filteredRollups().length} + + ), + }, + { + value: 'events', + label: ( + + Recovery events + {recoveryPoints.meta().total} + + ), + }, + ]} + /> +
+
+ + Platform-neutral protection rollups across every connected recovery provider in the selected window. + +
+
+ + + recoveryRollups.rollups.loading} + error={() => recoveryRollups.rollups.error} + onSelectRollup={handleSelectRollup} + protectedStaleOnly={protectedStaleOnly} + providerFilter={providerFilter} + providerOptions={providerOptions} + queryFilter={queryFilter} + resourcesById={resourcesById} + rollups={rollups} + rollupsSummary={rollupsSummary} + setHistoryOutcomeFilter={setHistoryOutcomeFilter} + setProtectedStaleOnly={setProtectedStaleOnly} + setProviderFilter={setProviderFilter} + setQueryFilter={setQueryFilter} + setVerificationFilter={setVerificationFilter} + /> + + + { - + = (p return (
- Backups By Date + Recovery Events
diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx index a6e23bfdf..11960fbc2 100644 --- a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx @@ -146,8 +146,13 @@ export const RecoveryProtectedInventorySection: Component<
-
- Protected Inventory +
+
+ Protected Items +
+
+ Unified subject rollups across backups, snapshots, and replication providers. +
@@ -280,7 +285,7 @@ export const RecoveryProtectedInventorySection: Component< [ ['subject', 'Subject'], ['source', 'Source'], - ['lastBackup', 'Last Backup'], + ['lastBackup', 'Latest Point'], ['outcome', 'Outcome'], ] as const ).map(([column, label]) => ( diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx index a4e76990c..cde9c32b1 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.layout-guards.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@solidjs/testing-library'; +import { fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; let mockRecoverySurfaceState: any; @@ -129,11 +129,13 @@ describe('Recovery layout guards', () => { vi.clearAllMocks(); }); - it('keeps the activity chart mounted when history points fail', () => { + it('keeps the activity chart mounted when recovery events fail', () => { render(() => ); expect(screen.getByTestId('protected-inventory')).toBeInTheDocument(); expect(screen.getByTestId('activity-chart')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('tab', { name: /recovery events/i })); + expect(screen.queryByTestId('protected-inventory')).not.toBeInTheDocument(); expect(screen.queryByTestId('history-section')).not.toBeInTheDocument(); expect(screen.getByText('Failed to load recovery points')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx index c96fd5fe9..b13544b94 100644 --- a/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx +++ b/frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx @@ -144,6 +144,26 @@ describe('Recovery', () => { expect(screen.getByText('tank/apps')).toBeInTheDocument(); }); + it('shows one primary recovery table at a time with unified recovery labels', async () => { + render(() => ); + + expect(await screen.findByText('Protected Items')).toBeInTheDocument(); + await screen.findByText('VM 123'); + expect(screen.queryByText('Recovery Events')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getAllByRole('table')).toHaveLength(1); + }); + expect(screen.queryByText('Backups By Date')).not.toBeInTheDocument(); + + fireEvent.click(await screen.findByText('VM 123')); + + expect(await screen.findByText('Recovery Events')).toBeInTheDocument(); + expect(screen.queryByText('Protected Items')).not.toBeInTheDocument(); + await waitFor(() => { + expect(screen.getAllByRole('table')).toHaveLength(1); + }); + }); + it('renders canonical rollup and history subject labels when linked resources are unavailable', async () => { rollupsPayload.push({ rollupId: 'res:vm-404', @@ -372,6 +392,7 @@ describe('Recovery', () => { render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: /recovery events/i })); fireEvent.click(await screen.findByRole('button', { name: /^filter$/i })); const clusterSelect = await screen.findByLabelText('Cluster'); @@ -450,6 +471,7 @@ describe('Recovery', () => { render(() => ); + fireEvent.click(await screen.findByRole('tab', { name: /recovery events/i })); fireEvent.click(await screen.findByRole('button', { name: /^filter$/i })); fireEvent.change(await screen.findByLabelText('Node or agent'), { @@ -559,7 +581,7 @@ describe('Recovery', () => { ).toBe(true); }); - expect(screen.getByText('Backups By Date')).toBeInTheDocument(); + expect(screen.getByText('Recovery Events')).toBeInTheDocument(); expect(screen.getByText(/Showing 1 - 1 of 1 recovery points/i)).toBeInTheDocument(); if (!delayedPointsReady) { @@ -578,7 +600,6 @@ describe('Recovery', () => { it('narrows recovery point history to the selected timeline day', async () => { render(() => ); - await screen.findByText('Backups By Date'); await waitFor(() => { const pointUrls = apiFetchMock.mock.calls .map((call) => String(call[0] || '')) @@ -593,6 +614,7 @@ describe('Recovery', () => { const timelineButtons = await screen.findAllByRole('button', { name: /recovery points/i }); fireEvent.click(timelineButtons[0]); + await screen.findByText('Recovery Events'); const selectedDay = '2026-02-13'; const selectedStart = parseRecoveryDateKey(selectedDay); @@ -632,7 +654,7 @@ describe('Recovery', () => { render(() => ); - await screen.findByText('Backups By Date'); + await screen.findByText('Recovery Events'); const selectedStart = parseRecoveryDateKey('2026-02-13'); selectedStart.setHours(0, 0, 0, 0); @@ -654,7 +676,7 @@ describe('Recovery', () => { render(() => ); - await screen.findByText('Backups By Date'); + await screen.findByText('Recovery Events'); const selectedStart = parseRecoveryDateKey('2026-02-13'); selectedStart.setHours(0, 0, 0, 0); @@ -689,7 +711,7 @@ describe('Recovery', () => { it('persists the selected timeline range in the recovery URL', async () => { render(() => ); - await screen.findByText('Backups By Date'); + await screen.findByText('Protected Items'); fireEvent.click(await screen.findByRole('button', { name: '7d' })); @@ -703,7 +725,7 @@ describe('Recovery', () => { render(() => ); - await screen.findByText('Backups By Date'); + await screen.findByText('Protected Items'); const end = new Date(); end.setHours(23, 59, 59, 999);