From ee79fce2aa7d13a377c922c3c1e8aff4fbda2773 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 31 Oct 2025 21:52:54 +0100 Subject: [PATCH] feat(frontend): restore whole snapshot --- apps/client/app/api-client/types.gen.ts | 4 +- .../components/restore-snapshot-dialog.tsx | 95 +++++++++++++++++++ .../components/restore-snapshot-form.tsx | 89 +++++++++++++++++ .../repositories/routes/snapshot-details.tsx | 2 + .../repositories/repositories.controller.ts | 5 +- .../modules/repositories/repositories.dto.ts | 4 +- .../repositories/repositories.service.ts | 6 +- apps/server/src/utils/restic.ts | 22 ++--- 8 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 apps/client/app/modules/repositories/components/restore-snapshot-dialog.tsx create mode 100644 apps/client/app/modules/repositories/components/restore-snapshot-form.tsx diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index eae4d41..7702039 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -854,7 +854,6 @@ export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSna export type RestoreSnapshotData = { body?: { snapshotId: string; - targetPath: string; exclude?: Array; include?: Array; path?: string; @@ -872,10 +871,9 @@ export type RestoreSnapshotResponses = { */ 200: { filesRestored: number; - filesUpdated: number; + filesSkipped: number; message: string; success: boolean; - totalBytes: number; }; }; diff --git a/apps/client/app/modules/repositories/components/restore-snapshot-dialog.tsx b/apps/client/app/modules/repositories/components/restore-snapshot-dialog.tsx new file mode 100644 index 0000000..cad58e8 --- /dev/null +++ b/apps/client/app/modules/repositories/components/restore-snapshot-dialog.tsx @@ -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 ( + + + + + + + + Restore Snapshot + + Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path + + + + + + + + + + + ); +}; diff --git a/apps/client/app/modules/repositories/components/restore-snapshot-form.tsx b/apps/client/app/modules/repositories/components/restore-snapshot-form.tsx new file mode 100644 index 0000000..b9ab646 --- /dev/null +++ b/apps/client/app/modules/repositories/components/restore-snapshot-form.tsx @@ -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({ + resolver: arktypeResolver(restoreSnapshotFormSchema), + defaultValues: { + path: "", + include: "", + exclude: "", + }, + }); + + const handleSubmit = (values: RestoreSnapshotFormValues) => { + onSubmit(values); + }; + + return ( +
+ +
+ ( + + Path (Optional) + + + + + Restore only a specific path from the snapshot (leave empty to restore all) + + + + )} + /> + + ( + + Include Patterns (Optional) + + + + Include only files matching these patterns (comma-separated) + + + )} + /> + + ( + + Exclude Patterns (Optional) + + + + Exclude files matching these patterns (comma-separated) + + + )} + /> +
+
+ + ); +}; diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx index 44bffbd..f2acd6e 100644 --- a/apps/client/app/modules/repositories/routes/snapshot-details.tsx +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -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() {

{name}

Snapshot: {snapshotId}

+ diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index de572f2..5a805c9 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -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(result, 200); }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index a11972b..9098a49 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -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; diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index b729588..348f38a 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -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, }; }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index ca16693..0c39fa6 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -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;