feat(frontend): restore whole snapshot

This commit is contained in:
Nicolas Meienberger
2025-10-31 21:52:54 +01:00
parent c7db88fb56
commit ee79fce2aa
8 changed files with 203 additions and 24 deletions

View File

@@ -854,7 +854,6 @@ export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSna
export type RestoreSnapshotData = {
body?: {
snapshotId: string;
targetPath: string;
exclude?: Array<string>;
include?: Array<string>;
path?: string;
@@ -872,10 +871,9 @@ export type RestoreSnapshotResponses = {
*/
200: {
filesRestored: number;
filesUpdated: number;
filesSkipped: number;
message: string;
success: boolean;
totalBytes: number;
};
};

View File

@@ -0,0 +1,95 @@
import { useMutation } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { ScrollArea } from "~/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);
restore.mutate({
path: { name },
body: {
snapshotId,
path: values.path || undefined,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : 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

@@ -0,0 +1,89 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
import { Input } from "~/components/ui/input";
const restoreSnapshotFormSchema = type({
path: "string?",
include: "string?",
exclude: "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 form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
include: "",
exclude: "",
},
});
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>
</form>
</Form>
);
};

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFilesList } from "../components/snapshot-files";
export default function SnapshotDetailsPage() {
@@ -30,6 +31,7 @@ export default function SnapshotDetailsPage() {
<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>
<Card className="h-[600px] flex flex-col">

View File

@@ -12,7 +12,6 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
type CreateRepositoryDto,
type DeleteRepositoryDto,
type GetRepositoryDto,
type ListRepositoriesDto,
@@ -93,9 +92,9 @@ export const repositoriesController = new Hono()
)
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const { name } = c.req.param();
const { snapshotId, targetPath, path, include, exclude } = c.req.valid("json");
const { snapshotId, path, include, exclude } = c.req.valid("json");
const result = await repositoriesService.restoreSnapshot(name, snapshotId, targetPath, { path, include, exclude });
const result = await repositoriesService.restoreSnapshot(name, snapshotId, { path, include, exclude });
return c.json<RestoreSnapshotDto>(result, 200);
});

View File

@@ -219,7 +219,6 @@ export const listSnapshotFilesDto = describeRoute({
*/
export const restoreSnapshotBody = type({
snapshotId: "string",
targetPath: "string",
path: "string?",
include: "string[]?",
exclude: "string[]?",
@@ -231,8 +230,7 @@ export const restoreSnapshotResponse = type({
success: "boolean",
message: "string",
filesRestored: "number",
filesUpdated: "number",
totalBytes: "number",
filesSkipped: "number",
});
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;

View File

@@ -164,7 +164,6 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
const restoreSnapshot = async (
name: string,
snapshotId: string,
targetPath: string,
options?: {
path?: string;
include?: string[];
@@ -179,14 +178,13 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found");
}
const result = await restic.restore(repository.config, snapshotId, targetPath, options);
const result = await restic.restore(repository.config, snapshotId, "/", options);
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesUpdated: result.files_updated,
totalBytes: result.total_bytes,
filesSkipped: result.files_skipped,
};
};

View File

@@ -161,11 +161,10 @@ const backup = async (
const restoreOutputSchema = type({
message_type: "'summary'",
total_files: "number",
files_restored: "number",
files_updated: "number",
files_unchanged: "number",
total_bytes: "number",
total_errors: "number?",
files_skipped: "number",
bytes_skipped: "number",
});
const restore = async (
@@ -216,14 +215,15 @@ const restore = async (
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
return {
message_type: "summary" as const,
total_files: 0,
files_restored: 0,
files_updated: 0,
files_unchanged: 0,
total_bytes: 0,
files_skipped: 0,
bytes_skipped: 0,
};
}
const resSummary = JSON.parse(lastLine);
const result = restoreOutputSchema(resSummary);
if (result instanceof type.errors) {
@@ -231,15 +231,15 @@ const restore = async (
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
return {
message_type: "summary" as const,
total_files: 0,
files_restored: 0,
files_updated: 0,
files_unchanged: 0,
total_bytes: 0,
files_skipped: 0,
bytes_skipped: 0,
};
}
logger.info(
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_updated} updated`,
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`,
);
return result;