mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
refactor(recovery): unify recovery workspace layout
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user