refactor(recovery): unify recovery workspace layout

This commit is contained in:
rcourtman
2026-03-26 00:16:14 +00:00
parent 69d516a7a4
commit 74a55ebcbe
6 changed files with 134 additions and 37 deletions
@@ -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
@@ -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<RecoveryWorkspaceView>('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 (
<div data-testid="recovery-page" class="flex flex-col gap-4">
<RecoveryProtectedInventorySection
filteredRollups={filteredRollups}
historyOutcomeFilter={historyOutcomeFilter}
isMobile={isMobile()}
kioskMode={kioskMode()}
loading={() => 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}
/>
<RecoveryActivitySection
activitySummary={activitySummary}
activeClusterLabel={activeClusterLabel}
@@ -496,13 +488,76 @@ const Recovery: Component = () => {
}}
timeline={timeline}
toggleSelectedDate={(key) => {
setWorkspaceView('events');
setSelectedDateKey((previous) => (previous === key ? null : key));
setCurrentPage(1);
}}
/>
<div ref={historySectionRef} class="order-1 flex flex-col gap-4">
<Show when={!recoveryPoints.response.loading && recoveryPoints.response.error}>
<Card padding="none" tone="card" class="overflow-hidden">
<div class="px-3 pt-1">
<Subtabs
value={workspaceView()}
onChange={(value) => setWorkspaceView(value as RecoveryWorkspaceView)}
ariaLabel="Recovery data view"
tabs={[
{
value: 'inventory',
label: (
<span class="inline-flex items-center gap-2">
<span>Protected items</span>
<span class="text-xs text-muted">{filteredRollups().length}</span>
</span>
),
},
{
value: 'events',
label: (
<span class="inline-flex items-center gap-2">
<span>Recovery events</span>
<span class="text-xs text-muted">{recoveryPoints.meta().total}</span>
</span>
),
},
]}
/>
</div>
<div class="border-b border-border px-3 pb-3 pt-2 text-xs text-muted">
<Show
when={workspaceView() === 'inventory'}
fallback="Cross-platform recovery artifacts grouped by day and filtered through one shared recovery model."
>
Platform-neutral protection rollups across every connected recovery provider in the selected window.
</Show>
</div>
</Card>
<Show when={workspaceView() === 'inventory'}>
<RecoveryProtectedInventorySection
filteredRollups={filteredRollups}
historyOutcomeFilter={historyOutcomeFilter}
isMobile={isMobile()}
kioskMode={kioskMode()}
loading={() => 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}
/>
</Show>
<Show when={workspaceView() === 'events' && !recoveryPoints.response.loading && recoveryPoints.response.error}>
<Card padding="sm">
<EmptyState
title={getRecoveryPointsFailureState().title}
@@ -513,7 +568,7 @@ const Recovery: Component = () => {
</Card>
</Show>
<Show when={!recoveryPoints.response.error}>
<Show when={workspaceView() === 'events' && !recoveryPoints.response.error}>
<RecoveryHistorySection
activeAdvancedFilterCount={activeAdvancedFilterCount}
artifactColumnVisibility={artifactColumnVisibility}
@@ -118,7 +118,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
return (
<Card padding="none" tone="card" class="mb-4 overflow-hidden">
<div class="border-b border-border bg-surface-hover px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted">
Backups By Date
Recovery Events
</div>
<Show when={!props.kioskMode}>
<div class="border-b border-border px-3 py-3">
@@ -146,8 +146,13 @@ export const RecoveryProtectedInventorySection: Component<
<Card padding="none" tone="card" class="order-3 overflow-hidden">
<div class="border-b border-border bg-surface-hover px-3 py-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Protected Inventory
<div class="flex flex-col gap-1">
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted">
Protected Items
</div>
<div class="text-xs text-muted">
Unified subject rollups across backups, snapshots, and replication providers.
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-muted">
<span>
@@ -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]) => (
@@ -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(() => <Recovery />);
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();
});
@@ -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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
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(() => <Recovery />);
await screen.findByText('Backups By Date');
await screen.findByText('Protected Items');
const end = new Date();
end.setHours(23, 59, 59, 999);