From 9ee5871fbb42097e47c8c032360bffcf442c8faf Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 5 Nov 2025 18:30:10 +0100 Subject: [PATCH] feat: restore --- .../components/snapshot-file-browser.tsx | 51 +++++++++++++++++-- apps/server/src/utils/restic.ts | 18 +++---- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx index 527333a..7c60963 100644 --- a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx +++ b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx @@ -1,10 +1,12 @@ import { useCallback, useMemo, useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FileIcon } from "lucide-react"; -import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/api-client/@tanstack/react-query.gen"; import { FileTree, type FileEntry } from "~/components/file-tree"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; import type { Snapshot } from "~/lib/types"; +import { toast } from "sonner"; interface Props { snapshot: Snapshot; @@ -19,6 +21,7 @@ export const SnapshotFileBrowser = (props: Props) => { const [fetchedFolders, setFetchedFolders] = useState>(new Set()); const [loadingFolders, setLoadingFolders] = useState>(new Set()); const [allFiles, setAllFiles] = useState>(new Map()); + const [selectedPaths, setSelectedPaths] = useState>(new Set()); const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || ""; @@ -135,12 +138,49 @@ export const SnapshotFileBrowser = (props: Props) => { [repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], ); + 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.`, + }); + setSelectedPaths(new Set()); + }, + onError: (error) => { + toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" }); + }, + }); + + const handleRestoreClick = useCallback(() => { + const pathsArray = Array.from(selectedPaths); + const includePaths = pathsArray.map((path) => addBasePath(path)); + + restoreSnapshot({ + path: { name: repositoryName }, + body: { + snapshotId: snapshot.short_id, + include: includePaths, + }, + }); + }, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot]); + return (
- File Browser - {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} +
+
+ File Browser + {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} +
+ {selectedPaths.size > 0 && ( + + )} +
{filesLoading && fileArray.length === 0 && ( @@ -165,6 +205,9 @@ export const SnapshotFileBrowser = (props: Props) => { expandedFolders={expandedFolders} loadingFolders={loadingFolders} className="px-2 py-2" + withCheckboxes={true} + selectedPaths={selectedPaths} + onSelectionChange={setSelectedPaths} />
)} diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 9415434..3dafe13 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -181,6 +181,8 @@ const restoreOutputSchema = type({ total_files: "number", files_restored: "number", files_skipped: "number", + total_bytes: "number?", + bytes_restored: "number?", bytes_skipped: "number", }); @@ -203,12 +205,10 @@ const restore = async ( args[args.length - 4] = `${snapshotId}:${options.path}`; } - // Create a temporary file for include patterns if provided - let includeFile: string | null = null; - if (options?.include && options.include.length > 0) { - includeFile = `/tmp/restic-include-${crypto.randomBytes(8).toString("hex")}.txt`; - await fs.writeFile(includeFile, options.include.join("\n"), "utf-8"); - args.push("--include", includeFile); + if (options?.include && options.include.length === 0) { + for (const pattern of options.include) { + args.push("--include", pattern); + } } if (options?.exclude && options.exclude.length > 0) { @@ -221,11 +221,6 @@ const restore = async ( const res = await $`restic ${args}`.env(env).nothrow(); - // Clean up the temporary include file - if (includeFile) { - await fs.unlink(includeFile).catch(() => {}); - } - if (res.exitCode !== 0) { logger.error(`Restic restore failed: ${res.stderr}`); throw new Error(`Restic restore failed: ${res.stderr}`); @@ -247,7 +242,6 @@ const restore = async ( } const resSummary = JSON.parse(lastLine); - const result = restoreOutputSchema(resSummary); if (result instanceof type.errors) {