feat: restore

This commit is contained in:
Nicolas Meienberger
2025-11-05 18:30:10 +01:00
parent 01c2a3669c
commit 9ee5871fbb
2 changed files with 53 additions and 16 deletions

View File

@@ -1,10 +1,12 @@
import { useCallback, useMemo, useState } from "react"; 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 { 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 { FileTree, type FileEntry } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import type { Snapshot } from "~/lib/types"; import type { Snapshot } from "~/lib/types";
import { toast } from "sonner";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
@@ -19,6 +21,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set()); const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set());
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set()); const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map()); const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || ""; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "";
@@ -135,12 +138,49 @@ export const SnapshotFileBrowser = (props: Props) => {
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath], [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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="h-[600px] flex flex-col"> <Card className="h-[600px] flex flex-col">
<CardHeader> <CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>File Browser</CardTitle> <CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription> <CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
{selectedPaths.size > 0 && (
<Button onClick={handleRestoreClick} variant="primary" size="sm" disabled={isRestoring}>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
)}
</div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0"> <CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && fileArray.length === 0 && ( {filesLoading && fileArray.length === 0 && (
@@ -165,6 +205,9 @@ export const SnapshotFileBrowser = (props: Props) => {
expandedFolders={expandedFolders} expandedFolders={expandedFolders}
loadingFolders={loadingFolders} loadingFolders={loadingFolders}
className="px-2 py-2" className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/> />
</div> </div>
)} )}

View File

@@ -181,6 +181,8 @@ const restoreOutputSchema = type({
total_files: "number", total_files: "number",
files_restored: "number", files_restored: "number",
files_skipped: "number", files_skipped: "number",
total_bytes: "number?",
bytes_restored: "number?",
bytes_skipped: "number", bytes_skipped: "number",
}); });
@@ -203,12 +205,10 @@ const restore = async (
args[args.length - 4] = `${snapshotId}:${options.path}`; args[args.length - 4] = `${snapshotId}:${options.path}`;
} }
// Create a temporary file for include patterns if provided if (options?.include && options.include.length === 0) {
let includeFile: string | null = null; for (const pattern of options.include) {
if (options?.include && options.include.length > 0) { args.push("--include", pattern);
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?.exclude && options.exclude.length > 0) { if (options?.exclude && options.exclude.length > 0) {
@@ -221,11 +221,6 @@ const restore = async (
const res = await $`restic ${args}`.env(env).nothrow(); 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) { if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`); logger.error(`Restic restore failed: ${res.stderr}`);
throw new 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 resSummary = JSON.parse(lastLine);
const result = restoreOutputSchema(resSummary); const result = restoreOutputSchema(resSummary);
if (result instanceof type.errors) { if (result instanceof type.errors) {