refactor: unify restore snapshot dialogs

This commit is contained in:
Nicolas Meienberger 2025-11-29 17:27:58 +01:00
parent a6a28f7352
commit 3266046094
5 changed files with 273 additions and 461 deletions

View file

@ -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<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription>
{selectedCount > 0
? `This will restore ${selectedCount} selected ${selectedCount === 1 ? "item" : "items"} from the snapshot.`
: "This will restore everything from the snapshot."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Restore Location</Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={restoreLocation === "original" ? "secondary" : "outline"}
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
>
<RotateCcw size={16} className="mr-1" />
Original location
</Button>
<Button
type="button"
variant={restoreLocation === "custom" ? "secondary" : "outline"}
size="sm"
className="justify-start gap-2"
onClick={() => setRestoreLocation("custom")}
>
<FolderOpen size={16} className="mr-1" />
Custom location
</Button>
</div>
{restoreLocation === "custom" && (
<div className="space-y-2">
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Overwrite Mode</Label>
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select overwrite behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{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."}
</p>
</div>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced Options
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-3">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmRestore}
disabled={restoreLocation === "custom" && !customTargetPath.trim()}
>
Confirm Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View file

@ -1,32 +1,15 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; 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 { 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 type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
type RestoreLocation = "original" | "custom";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@ -43,13 +26,6 @@ export const SnapshotFileBrowser = (props: Props) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set()); const [selectedPaths, setSelectedPaths] = useState<Set<string>>(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<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/"; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@ -122,47 +98,25 @@ export const SnapshotFileBrowser = (props: Props) => {
}, },
}); });
const handleRestoreClick = useCallback(() => { const handleConfirmRestore = useCallback(
setShowRestoreDialog(true); (options: RestoreSnapshotOptions) => {
}, []); const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
const handleConfirmRestore = useCallback(() => { restoreSnapshot({
const pathsArray = Array.from(selectedPaths); path: { name: repositoryName },
const includePaths = pathsArray.map((path) => addBasePath(path)); body: {
snapshotId: snapshot.short_id,
const excludeXattrArray = excludeXattr include: includePaths.length > 0 ? includePaths : undefined,
?.split(",") delete: options.delete,
.map((s) => s.trim()) excludeXattr: options.excludeXattr,
.filter(Boolean); targetPath: options.targetPath,
overwrite: options.overwrite,
const isCustomLocation = restoreLocation === "custom"; },
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; });
},
restoreSnapshot({ [selectedPaths, addBasePath, repositoryName, snapshot.short_id, 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,
]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -178,16 +132,17 @@ export const SnapshotFileBrowser = (props: Props) => {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}> <span tabIndex={isReadOnly ? 0 : undefined}>
<Button <RestoreSnapshotDialog
onClick={handleRestoreClick} selectedCount={selectedPaths.size}
variant="primary" onConfirm={handleConfirmRestore}
size="sm" trigger={
disabled={isRestoring || isReadOnly} <Button variant="primary" size="sm" disabled={isRestoring || isReadOnly}>
> {isRestoring
{isRestoring ? "Restoring..."
? "Restoring..." : `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`} </Button>
</Button> }
/>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
{isReadOnly && ( {isReadOnly && (
@ -243,128 +198,6 @@ export const SnapshotFileBrowser = (props: Props) => {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription>
{selectedPaths.size > 0
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
: "This will restore everything from the snapshot."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Restore Location</Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={restoreLocation === "original" ? "secondary" : "outline"}
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
>
<RotateCcw size={16} className="mr-1" />
Original location
</Button>
<Button
type="button"
variant={restoreLocation === "custom" ? "secondary" : "outline"}
size="sm"
className="justify-start gap-2"
onClick={() => setRestoreLocation("custom")}
>
<FolderOpen size={16} className="mr-1" />
Custom location
</Button>
</div>
{restoreLocation === "custom" && (
<div className="space-y-2">
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Overwrite Mode</Label>
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select overwrite behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{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."}
</p>
</div>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced Options
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-3">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmRestore}
disabled={restoreLocation === "custom" && !customTargetPath.trim()}
>
Confirm Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View file

@ -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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<RotateCcw size={16} className="mr-2" />
Restore
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="max-h-[600px] p-4">
<DialogHeader>
<DialogTitle>Restore Snapshot</DialogTitle>
<DialogDescription>
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
</DialogDescription>
</DialogHeader>
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={restore.isPending}>
{restore.isPending ? "Restoring..." : "Restore"}
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View file

@ -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<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
targetPath: "",
include: "",
exclude: "",
excludeXattr: "",
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
onSubmit(values);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
<div className="space-y-4">
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="/specific/path" {...field} />
</FormControl>
<FormDescription>
Restore only a specific path from the snapshot (leave empty to restore all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetPath"
render={({ field }) => (
<FormItem>
<FormLabel>Target Directory (Optional)</FormLabel>
<FormControl>
<PathSelector {...field} value={field.value ?? "/"} />
</FormControl>
<FormDescription>
Restore to a custom location instead of the original path (defaults to /)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include"
render={({ field }) => (
<FormItem>
<FormLabel>Include Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.txt,/documents/**" {...field} />
</FormControl>
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.log,/temp/**" {...field} />
</FormControl>
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4">
<FormField
control={form.control}
name="excludeXattr"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
<FormControl>
<Input placeholder="com.apple.metadata,user.custom" {...field} />
</FormControl>
<FormDescription>
Exclude specific extended attributes during restore (comma-separated)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
</form>
</Form>
);
};

View file

@ -1,8 +1,13 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router"; 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 { 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 { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/client/api-client"; import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; import type { Route } from "./+types/snapshot-details";
@ -48,6 +53,34 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
enabled: !!name && !!snapshotId, 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) { if (!name || !snapshotId) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@ -63,7 +96,14 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<h1 className="text-2xl font-bold">{name}</h1> <h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p> <p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div> </div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} /> <RestoreSnapshotDialog
onConfirm={handleRestore}
trigger={
<Button variant="primary" disabled={isRestoring}>
{isRestoring ? "Restoring..." : "Restore Snapshot"}
</Button>
}
/>
</div> </div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} /> <SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />