restore as a page (#87)

* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
This commit is contained in:
Nico
2025-11-30 16:43:34 +01:00
committed by GitHub
parent 3bf3b22b96
commit 9a9991eb9b
18 changed files with 1933 additions and 2014 deletions

View File

@@ -1,100 +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,
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,138 +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";
const restoreSnapshotFormSchema = type({
path: "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: "",
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="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

@@ -0,0 +1,45 @@
import { redirect } from "react-router";
import { getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
});
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
return redirect("/repositories");
};
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
const { snapshot, name, snapshotId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={name}
snapshotId={snapshotId}
returnPath={`/repositories/${name}/${snapshotId}`}
/>
);
}

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { RestoreSnapshotDialog } from "../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";
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />