Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 63 additions & 25 deletions client/src/components/BackupWidget.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, memo, useCallback, useId } from 'react';
import { useState, memo, useCallback, useId, useRef } from 'react';
import { Link } from 'react-router';
import {HardDrive,
ChevronDown,
Expand Down Expand Up @@ -59,44 +59,73 @@ const HEALTH_STYLES = {
// RestorePanel
// ---------------------------------------------------------------------------

function RestorePanel({ snapshot, onClose }) {
function RestorePanel({ snapshot, onClose, restoring, onRestoreStateChange }) {
const filterId = useId();
const [filter, setFilter] = useState('');
const [preview, setPreview] = useState(null);
const [acceptedPreview, setAcceptedPreview] = useState(null);
const [previewing, setPreviewing] = useState(false);
const [restoring, setRestoring] = useState(false);
const previewGenerationRef = useRef(0);

const currentRequest = {
snapshotId: snapshot.id,
subdirFilter: filter.trim() || null,
};
const previewMatchesCurrentRequest = acceptedPreview
&& acceptedPreview.request.snapshotId === currentRequest.snapshotId
&& acceptedPreview.request.subdirFilter === currentRequest.subdirFilter;

const handleFilterChange = useCallback((event) => {
previewGenerationRef.current += 1;
setFilter(event.target.value);
setAcceptedPreview(null);
setPreviewing(false);
}, []);

const handlePreview = useCallback(async () => {
setPreviewing(true);
setPreview(null);
const result = await api.restoreBackup({
const generation = previewGenerationRef.current + 1;
previewGenerationRef.current = generation;
const request = {
snapshotId: snapshot.id,
subdirFilter: filter.trim() || null,
};
setPreviewing(true);
setAcceptedPreview(null);
const outcome = await api.restoreBackup({
...request,
dryRun: true,
subdirFilter: filter.trim() || null
}, { silent: true }).catch(err => {
toast.error(`Preview failed: ${err.message}`);
return null;
});
}, { silent: true }).then(
previewResult => ({ previewResult }),
previewError => ({ previewError }),
);

if (previewGenerationRef.current !== generation) return;
setPreviewing(false);
if (result) setPreview(result);
if (outcome.previewError) {
toast.error(`Preview failed: ${outcome.previewError.message}`);
return;
}
if (outcome.previewResult) setAcceptedPreview({ request, result: outcome.previewResult });
}, [snapshot.id, filter]);

const handleRestore = useCallback(async () => {
setRestoring(true);
if (!previewMatchesCurrentRequest || restoring) return;

onRestoreStateChange(snapshot.id);
const result = await api.restoreBackup({
snapshotId: snapshot.id,
...acceptedPreview.request,
dryRun: false,
subdirFilter: filter.trim() || null
}, { silent: true }).catch(err => {
toast.error(`Restore failed: ${err.message}`);
return null;
});
setRestoring(false);
onRestoreStateChange(null);
if (result) {
toast.success(`Restore complete — ${result.changedFiles?.length ?? 0} file(s) restored`);
onClose();
}
}, [snapshot.id, filter, onClose]);
}, [acceptedPreview, onClose, onRestoreStateChange, previewMatchesCurrentRequest, restoring, snapshot.id]);

const preview = previewMatchesCurrentRequest ? acceptedPreview.result : null;

return (
<div className="mt-3 p-3 bg-port-bg rounded-lg border border-port-border space-y-3">
Expand All @@ -106,7 +135,8 @@ function RestorePanel({ snapshot, onClose }) {
</span>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-300 transition-colors text-xs min-h-[32px] px-1"
disabled={restoring}
className="text-gray-500 hover:text-gray-300 transition-colors text-xs min-h-[32px] px-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
Expand All @@ -121,7 +151,8 @@ function RestorePanel({ snapshot, onClose }) {
id={filterId}
type="text"
value={filter}
onChange={e => setFilter(e.target.value)}
onChange={handleFilterChange}
disabled={restoring}
placeholder="e.g., brain"
className="w-full bg-port-card border border-port-border rounded px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent"
/>
Expand All @@ -133,7 +164,7 @@ function RestorePanel({ snapshot, onClose }) {
{/* Preview button */}
<button
onClick={handlePreview}
disabled={previewing}
disabled={previewing || restoring}
className="flex items-center gap-2 px-3 py-1.5 bg-port-border hover:bg-port-border/70 text-gray-300 rounded text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[36px]"
>
{previewing ? (
Expand Down Expand Up @@ -187,7 +218,7 @@ function RestorePanel({ snapshot, onClose }) {
// SnapshotList
// ---------------------------------------------------------------------------

function SnapshotList() {
function SnapshotList({ restoringSnapshotId, onRestoreStateChange }) {
// Let errors throw — `useAutoRefetch` preserves the last-good data on
// transient failures. A `.catch(() => null)` here would wipe the snapshot
// list on every blip per the hook's documented gotcha.
Expand Down Expand Up @@ -254,7 +285,7 @@ function SnapshotList() {
</button>
<button
onClick={() => setSelectedId(selectedId === snap.id ? null : snap.id)}
disabled={snap.incomplete}
disabled={snap.incomplete || restoringSnapshotId !== null}
className="flex items-center gap-1 px-2 py-1 text-xs text-port-accent hover:text-port-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[32px]"
>
<RotateCcw size={12} />
Expand All @@ -266,6 +297,8 @@ function SnapshotList() {
<RestorePanel
snapshot={snap}
onClose={() => setSelectedId(null)}
restoring={restoringSnapshotId === snap.id}
onRestoreStateChange={onRestoreStateChange}
/>
)}
</div>
Expand Down Expand Up @@ -296,6 +329,7 @@ const BackupWidget = memo(function BackupWidget() {
);
const [handleBackupNow, triggering] = useBackupRun();
const [snapshotsOpen, setSnapshotsOpen] = useState(false);
const [restoringSnapshotId, setRestoringSnapshotId] = useState(null);
// Tick every minute so the dedup-skipped widget still recomputes
// `relativeTime(lastRun/nextRun)` labels and the `computeHealth` 25h/49h
// thresholds when wall-clock time crosses a boundary even though the poll
Expand Down Expand Up @@ -407,7 +441,8 @@ const BackupWidget = memo(function BackupWidget() {
{/* Toggle snapshots */}
<button
onClick={() => setSnapshotsOpen(prev => !prev)}
className="flex items-center gap-1.5 px-3 py-2 bg-port-border/50 hover:bg-port-border text-gray-300 rounded-lg text-sm transition-colors min-h-[40px]"
disabled={restoringSnapshotId !== null}
className="flex items-center gap-1.5 px-3 py-2 bg-port-border/50 hover:bg-port-border text-gray-300 rounded-lg text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[40px]"
>
<ChevronDown
size={14}
Expand All @@ -420,7 +455,10 @@ const BackupWidget = memo(function BackupWidget() {
{/* Snapshots section */}
{snapshotsOpen && (
<div className="mt-4 pt-4 border-t border-port-border">
<SnapshotList />
<SnapshotList
restoringSnapshotId={restoringSnapshotId}
onRestoreStateChange={setRestoringSnapshotId}
/>
</div>
)}
</div>
Expand Down
128 changes: 128 additions & 0 deletions client/src/components/BackupWidget.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ const {
mockGetBackupStatus,
mockGetBackupSnapshots,
mockDownloadBackupSnapshot,
mockRestoreBackup,
mockTriggerBackup,
mockToast,
} = vi.hoisted(() => ({
mockGetBackupStatus: vi.fn(),
mockGetBackupSnapshots: vi.fn(),
mockDownloadBackupSnapshot: vi.fn(),
mockRestoreBackup: vi.fn(),
mockTriggerBackup: vi.fn(),
mockToast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
Expand All @@ -20,6 +22,7 @@ vi.mock('../services/api', () => ({
getBackupStatus: (...args) => mockGetBackupStatus(...args),
getBackupSnapshots: (...args) => mockGetBackupSnapshots(...args),
downloadBackupSnapshot: (...args) => mockDownloadBackupSnapshot(...args),
restoreBackup: (...args) => mockRestoreBackup(...args),
triggerBackup: (...args) => mockTriggerBackup(...args),
}));

Expand All @@ -33,6 +36,12 @@ const renderWidget = () => render(
</MemoryRouter>,
);

const openRestorePanel = async () => {
fireEvent.click(await screen.findByRole('button', { name: 'Snapshots' }));
fireEvent.click(await screen.findByRole('button', { name: 'Restore' }));
return screen.findByRole('textbox', { name: 'Selective restore (optional)' });
};

beforeEach(() => {
vi.clearAllMocks();
mockGetBackupStatus.mockResolvedValue({
Expand Down Expand Up @@ -94,6 +103,125 @@ describe('BackupWidget snapshots', () => {

await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('Download failed: Connection lost'));
});

it('restores exactly the selective scope accepted by the preview', async () => {
let finishRestore;
mockRestoreBackup
.mockResolvedValueOnce({
dryRun: true,
snapshotId: '2026-08-25T11-00-00',
subdirFilter: 'brain',
changedFiles: ['brain/example.json'],
})
.mockReturnValueOnce(new Promise(resolve => { finishRestore = resolve; }));
renderWidget();

const filter = await openRestorePanel();
fireEvent.change(filter, { target: { value: ' brain ' } });
fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));

expect(await screen.findByText('brain/example.json')).toBeInTheDocument();
expect(mockRestoreBackup).toHaveBeenNthCalledWith(1, {
snapshotId: '2026-08-25T11-00-00',
subdirFilter: 'brain',
dryRun: true,
}, { silent: true });

fireEvent.click(screen.getByRole('button', { name: 'Restore 1 file(s)' }));
expect(mockRestoreBackup).toHaveBeenNthCalledWith(2, {
snapshotId: '2026-08-25T11-00-00',
subdirFilter: 'brain',
dryRun: false,
}, { silent: true });
expect(filter).toBeDisabled();
expect(screen.getByRole('button', { name: 'Preview changes' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Restore' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Snapshots' })).toBeDisabled();

await act(async () => {
finishRestore({ changedFiles: ['brain/example.json'] });
});
expect(mockToast.success).toHaveBeenCalledWith('Restore complete — 1 file(s) restored');
});

it('invalidates a selective preview when the filter is cleared', async () => {
mockRestoreBackup
.mockResolvedValueOnce({ changedFiles: ['brain/example.json'] })
.mockResolvedValueOnce({ changedFiles: ['brain/example.json', 'media/example.json'] })
.mockResolvedValueOnce({ changedFiles: ['brain/example.json', 'media/example.json'] });
renderWidget();

const filter = await openRestorePanel();
fireEvent.change(filter, { target: { value: 'brain' } });
fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));
expect(await screen.findByRole('button', { name: 'Restore 1 file(s)' })).toBeEnabled();

fireEvent.change(filter, { target: { value: '' } });
expect(screen.queryByText('brain/example.json')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Restore \d+ file/ })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));
expect(await screen.findByRole('button', { name: 'Restore 2 file(s)' })).toBeEnabled();
expect(mockRestoreBackup).toHaveBeenNthCalledWith(2, {
snapshotId: '2026-08-25T11-00-00',
subdirFilter: null,
dryRun: true,
}, { silent: true });

fireEvent.click(screen.getByRole('button', { name: 'Restore 2 file(s)' }));
await waitFor(() => expect(mockRestoreBackup).toHaveBeenNthCalledWith(3, {
snapshotId: '2026-08-25T11-00-00',
subdirFilter: null,
dryRun: false,
}, { silent: true }));
});

it('ignores a late preview response after the filter changes', async () => {
let finishPreview;
mockRestoreBackup.mockReturnValue(new Promise(resolve => { finishPreview = resolve; }));
renderWidget();

const filter = await openRestorePanel();
fireEvent.change(filter, { target: { value: 'brain' } });
fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));
fireEvent.change(filter, { target: { value: 'media' } });

await act(async () => {
finishPreview({ changedFiles: ['brain/example.json'] });
});
expect(screen.queryByText('brain/example.json')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Restore \d+ file/ })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Preview changes' })).toBeEnabled();
});

it('reports a current preview failure and re-enables previewing', async () => {
mockRestoreBackup.mockRejectedValueOnce(new Error('disk offline'));
renderWidget();

await openRestorePanel();
fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));

await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('Preview failed: disk offline'));
expect(screen.getByRole('button', { name: 'Preview changes' })).toBeEnabled();
});

it('suppresses a late preview failure after the filter changes', async () => {
let rejectPreview;
mockRestoreBackup.mockReturnValue(new Promise((_, reject) => { rejectPreview = reject; }));
renderWidget();

const filter = await openRestorePanel();
fireEvent.change(filter, { target: { value: 'brain' } });
fireEvent.click(screen.getByRole('button', { name: 'Preview changes' }));
fireEvent.change(filter, { target: { value: 'media' } });

await act(async () => {
rejectPreview(new Error('disk offline'));
});
expect(mockToast.error).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Preview changes' })).toBeEnabled();
});
});

describe('BackupWidget manual backup', () => {
Expand Down