mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: restore
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user