From 32660460944f09a787967ebcbd9dafa69eb0090e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 29 Nov 2025 17:27:58 +0100 Subject: [PATCH] refactor: unify restore snapshot dialogs --- .../components/restore-snapshot-dialog.tsx | 198 +++++++++++++++ .../components/snapshot-file-browser.tsx | 229 +++--------------- .../components/restore-snapshot-dialog.tsx | 101 -------- .../components/restore-snapshot-form.tsx | 158 ------------ .../repositories/routes/snapshot-details.tsx | 48 +++- 5 files changed, 273 insertions(+), 461 deletions(-) create mode 100644 app/client/components/restore-snapshot-dialog.tsx delete mode 100644 app/client/modules/repositories/components/restore-snapshot-dialog.tsx delete mode 100644 app/client/modules/repositories/components/restore-snapshot-form.tsx diff --git a/app/client/components/restore-snapshot-dialog.tsx b/app/client/components/restore-snapshot-dialog.tsx new file mode 100644 index 00000000..14ebb88d --- /dev/null +++ b/app/client/components/restore-snapshot-dialog.tsx @@ -0,0 +1,198 @@ +import { useCallback, useState } from "react"; +import { ChevronDown, FolderOpen, RotateCcw } from "lucide-react"; +import { Button } from "~/client/components/ui/button"; +import { Checkbox } from "~/client/components/ui/checkbox"; +import { Label } from "~/client/components/ui/label"; +import { Input } from "~/client/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/client/components/ui/alert-dialog"; +import { PathSelector } from "~/client/components/path-selector"; +import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; + +type RestoreLocation = "original" | "custom"; + +export interface RestoreSnapshotOptions { + include?: string[]; + delete?: boolean; + excludeXattr?: string[]; + targetPath?: string; + overwrite?: OverwriteMode; +} + +interface Props { + /** Number of selected items to restore (0 means restore everything) */ + selectedCount?: number; + /** Callback when restore is confirmed */ + onConfirm: (options: RestoreSnapshotOptions) => void; + /** Trigger element for the dialog */ + trigger: React.ReactNode; + /** Pre-selected paths to include in restore (already transformed) */ + includePaths?: string[]; +} + +export const RestoreSnapshotDialog = (props: Props) => { + const { selectedCount = 0, onConfirm, trigger, includePaths } = props; + + const [open, setOpen] = useState(false); + const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [excludeXattr, setExcludeXattr] = useState(""); + const [restoreLocation, setRestoreLocation] = useState("original"); + const [customTargetPath, setCustomTargetPath] = useState(""); + const [overwriteMode, setOverwriteMode] = useState("always"); + + const handleConfirmRestore = useCallback(() => { + const excludeXattrArray = excludeXattr + ?.split(",") + .map((s) => s.trim()) + .filter(Boolean); + + const isCustomLocation = restoreLocation === "custom"; + const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; + + onConfirm({ + include: includePaths && includePaths.length > 0 ? includePaths : undefined, + delete: deleteExtraFiles, + excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, + targetPath, + overwrite: overwriteMode, + }); + + setOpen(false); + }, [excludeXattr, restoreLocation, customTargetPath, includePaths, deleteExtraFiles, overwriteMode, onConfirm]); + + return ( + + {trigger} + + + Confirm Restore + + {selectedCount > 0 + ? `This will restore ${selectedCount} selected ${selectedCount === 1 ? "item" : "items"} from the snapshot.` + : "This will restore everything from the snapshot."} + + +
+
+ +
+ + +
+ {restoreLocation === "custom" && ( +
+ +

Files will be restored directly to this path

+
+ )} +
+ +
+ + +

+ {overwriteMode === OVERWRITE_MODES.always && + "Existing files will always be replaced with the snapshot version."} + {overwriteMode === OVERWRITE_MODES.ifChanged && + "Files are only replaced if their content differs from the snapshot."} + {overwriteMode === OVERWRITE_MODES.ifNewer && + "Files are only replaced if the snapshot version has a newer modification time."} + {overwriteMode === OVERWRITE_MODES.never && + "Existing files will never be replaced, only missing files are restored."} +

+
+ +
+ + + {showAdvanced && ( +
+
+ + setExcludeXattr(e.target.value)} + /> +

+ Exclude specific extended attributes during restore (comma-separated) +

+
+
+ setDeleteExtraFiles(checked === true)} + /> + +
+
+ )} +
+
+ + Cancel + + Confirm Restore + + +
+
+ ); +}; diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index bb08b982..6025a7b8 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -1,32 +1,15 @@ import { useCallback, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react"; +import { FileIcon } from "lucide-react"; import { FileTree } from "~/client/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Button } from "~/client/components/ui/button"; -import { Checkbox } from "~/client/components/ui/checkbox"; -import { Label } from "~/client/components/ui/label"; -import { Input } from "~/client/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "~/client/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; -import { PathSelector } from "~/client/components/path-selector"; +import { RestoreSnapshotDialog, type RestoreSnapshotOptions } from "~/client/components/restore-snapshot-dialog"; import type { Snapshot, Volume } from "~/client/lib/types"; import { toast } from "sonner"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { useFileBrowser } from "~/client/hooks/use-file-browser"; -import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic"; - -type RestoreLocation = "original" | "custom"; interface Props { snapshot: Snapshot; @@ -43,13 +26,6 @@ export const SnapshotFileBrowser = (props: Props) => { const queryClient = useQueryClient(); const [selectedPaths, setSelectedPaths] = useState>(new Set()); - const [showRestoreDialog, setShowRestoreDialog] = useState(false); - const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); - const [showAdvanced, setShowAdvanced] = useState(false); - const [excludeXattr, setExcludeXattr] = useState(""); - const [restoreLocation, setRestoreLocation] = useState("original"); - const [customTargetPath, setCustomTargetPath] = useState(""); - const [overwriteMode, setOverwriteMode] = useState("always"); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; @@ -122,47 +98,25 @@ export const SnapshotFileBrowser = (props: Props) => { }, }); - const handleRestoreClick = useCallback(() => { - setShowRestoreDialog(true); - }, []); + const handleConfirmRestore = useCallback( + (options: RestoreSnapshotOptions) => { + const pathsArray = Array.from(selectedPaths); + const includePaths = pathsArray.map((path) => addBasePath(path)); - const handleConfirmRestore = useCallback(() => { - const pathsArray = Array.from(selectedPaths); - const includePaths = pathsArray.map((path) => addBasePath(path)); - - const excludeXattrArray = excludeXattr - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean); - - const isCustomLocation = restoreLocation === "custom"; - const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; - - restoreSnapshot({ - path: { name: repositoryName }, - body: { - snapshotId: snapshot.short_id, - include: includePaths, - delete: deleteExtraFiles, - excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, - targetPath, - overwrite: overwriteMode, - }, - }); - - setShowRestoreDialog(false); - }, [ - selectedPaths, - addBasePath, - repositoryName, - snapshot.short_id, - restoreSnapshot, - deleteExtraFiles, - excludeXattr, - restoreLocation, - customTargetPath, - overwriteMode, - ]); + restoreSnapshot({ + path: { name: repositoryName }, + body: { + snapshotId: snapshot.short_id, + include: includePaths.length > 0 ? includePaths : undefined, + delete: options.delete, + excludeXattr: options.excludeXattr, + targetPath: options.targetPath, + overwrite: options.overwrite, + }, + }); + }, + [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot], + ); return (
@@ -178,16 +132,17 @@ export const SnapshotFileBrowser = (props: Props) => { - + + {isRestoring + ? "Restoring..." + : `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`} + + } + /> {isReadOnly && ( @@ -243,128 +198,6 @@ export const SnapshotFileBrowser = (props: Props) => { )} - - - - - Confirm Restore - - {selectedPaths.size > 0 - ? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.` - : "This will restore everything from the snapshot."} - - -
-
- -
- - -
- {restoreLocation === "custom" && ( -
- -

Files will be restored directly to this path

-
- )} -
- -
- - -

- {overwriteMode === OVERWRITE_MODES.always && - "Existing files will always be replaced with the snapshot version."} - {overwriteMode === OVERWRITE_MODES.ifChanged && - "Files are only replaced if their content differs from the snapshot."} - {overwriteMode === OVERWRITE_MODES.ifNewer && - "Files are only replaced if the snapshot version has a newer modification time."} - {overwriteMode === OVERWRITE_MODES.never && - "Existing files will never be replaced, only missing files are restored."} -

-
- -
- - - {showAdvanced && ( -
-
- - setExcludeXattr(e.target.value)} - /> -

- Exclude specific extended attributes during restore (comma-separated) -

-
-
- setDeleteExtraFiles(checked === true)} - /> - -
-
- )} -
-
- - Cancel - - Confirm Restore - - -
-
); }; diff --git a/app/client/modules/repositories/components/restore-snapshot-dialog.tsx b/app/client/modules/repositories/components/restore-snapshot-dialog.tsx deleted file mode 100644 index e799f4d9..00000000 --- a/app/client/modules/repositories/components/restore-snapshot-dialog.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { RotateCcw } from "lucide-react"; -import { useId, useState } from "react"; -import { toast } from "sonner"; -import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; -import { parseError } from "~/client/lib/errors"; -import { Button } from "~/client/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "~/client/components/ui/dialog"; -import { ScrollArea } from "~/client/components/ui/scroll-area"; -import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form"; - -type Props = { - name: string; - snapshotId: string; -}; - -export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => { - const [open, setOpen] = useState(false); - const formId = useId(); - - const restore = useMutation({ - ...restoreSnapshotMutation(), - onSuccess: (data) => { - toast.success("Snapshot restored successfully", { - description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`, - }); - setOpen(false); - }, - onError: (error) => { - toast.error("Failed to restore snapshot", { - description: parseError(error)?.message, - }); - }, - }); - - const handleSubmit = (values: RestoreSnapshotFormValues) => { - const include = values.include - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean); - - const exclude = values.exclude - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean); - - const excludeXattr = values.excludeXattr - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean); - - restore.mutate({ - path: { name }, - body: { - snapshotId, - targetPath: values.targetPath || undefined, - include: include && include.length > 0 ? include : undefined, - exclude: exclude && exclude.length > 0 ? exclude : undefined, - excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined, - }, - }); - }; - - return ( - - - - - - - - Restore Snapshot - - Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path - - - - - - - - - - - ); -}; diff --git a/app/client/modules/repositories/components/restore-snapshot-form.tsx b/app/client/modules/repositories/components/restore-snapshot-form.tsx deleted file mode 100644 index 12dffd2e..00000000 --- a/app/client/modules/repositories/components/restore-snapshot-form.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { arktypeResolver } from "@hookform/resolvers/arktype"; -import { type } from "arktype"; -import { ChevronDown } from "lucide-react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "~/client/components/ui/form"; -import { Input } from "~/client/components/ui/input"; -import { Button } from "~/client/components/ui/button"; -import { PathSelector } from "~/client/components/path-selector"; - -const restoreSnapshotFormSchema = type({ - path: "string?", - targetPath: "string?", - include: "string?", - exclude: "string?", - excludeXattr: "string?", -}); - -export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn; - -type Props = { - formId: string; - onSubmit: (values: RestoreSnapshotFormValues) => void; - className?: string; -}; - -export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => { - const [showAdvanced, setShowAdvanced] = useState(false); - - const form = useForm({ - resolver: arktypeResolver(restoreSnapshotFormSchema), - defaultValues: { - path: "", - targetPath: "", - include: "", - exclude: "", - excludeXattr: "", - }, - }); - - const handleSubmit = (values: RestoreSnapshotFormValues) => { - onSubmit(values); - }; - - return ( -
- -
- ( - - Path (Optional) - - - - - Restore only a specific path from the snapshot (leave empty to restore all) - - - - )} - /> - - ( - - Target Directory (Optional) - - - - - Restore to a custom location instead of the original path (defaults to /) - - - - )} - /> - - ( - - Include Patterns (Optional) - - - - Include only files matching these patterns (comma-separated) - - - )} - /> - - ( - - Exclude Patterns (Optional) - - - - Exclude files matching these patterns (comma-separated) - - - )} - /> - -
- - - {showAdvanced && ( -
- ( - - Exclude Extended Attributes (Optional) - - - - - Exclude specific extended attributes during restore (comma-separated) - - - - )} - /> -
- )} -
-
-
- - ); -}; diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 1697574b..8f2c9f7f 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -1,8 +1,13 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { redirect, useParams } from "react-router"; -import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { toast } from "sonner"; +import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; +import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; -import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog"; +import { + RestoreSnapshotDialog, + type RestoreSnapshotOptions, +} from "~/client/components/restore-snapshot-dialog"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { getSnapshotDetails } from "~/client/api-client"; import type { Route } from "./+types/snapshot-details"; @@ -48,6 +53,34 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps enabled: !!name && !!snapshotId, }); + const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ + ...restoreSnapshotMutation(), + onSuccess: (data) => { + toast.success("Restore completed", { + description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`, + }); + }, + onError: (error) => { + toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); + }, + }); + + const handleRestore = (options: RestoreSnapshotOptions) => { + if (!name || !snapshotId) return; + + restoreSnapshot({ + path: { name }, + body: { + snapshotId, + include: options.include, + delete: options.delete, + excludeXattr: options.excludeXattr, + targetPath: options.targetPath, + overwrite: options.overwrite, + }, + }); + }; + if (!name || !snapshotId) { return (
@@ -63,7 +96,14 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps

{name}

Snapshot: {snapshotId}

- + + {isRestoring ? "Restoring..." : "Restore Snapshot"} + + } + />