mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(frontend): restore whole snapshot
This commit is contained in:
@@ -854,7 +854,6 @@ export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSna
|
|||||||
export type RestoreSnapshotData = {
|
export type RestoreSnapshotData = {
|
||||||
body?: {
|
body?: {
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
targetPath: string;
|
|
||||||
exclude?: Array<string>;
|
exclude?: Array<string>;
|
||||||
include?: Array<string>;
|
include?: Array<string>;
|
||||||
path?: string;
|
path?: string;
|
||||||
@@ -872,10 +871,9 @@ export type RestoreSnapshotResponses = {
|
|||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
filesRestored: number;
|
filesRestored: number;
|
||||||
filesUpdated: number;
|
filesSkipped: number;
|
||||||
message: string;
|
message: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
totalBytes: number;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
|
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
||||||
import { SnapshotFilesList } from "../components/snapshot-files";
|
import { SnapshotFilesList } from "../components/snapshot-files";
|
||||||
|
|
||||||
export default function SnapshotDetailsPage() {
|
export default function SnapshotDetailsPage() {
|
||||||
@@ -30,6 +31,7 @@ export default function SnapshotDetailsPage() {
|
|||||||
<h1 className="text-2xl font-bold">{name}</h1>
|
<h1 className="text-2xl font-bold">{name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="h-[600px] flex flex-col">
|
<Card className="h-[600px] flex flex-col">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
listSnapshotsFilters,
|
listSnapshotsFilters,
|
||||||
restoreSnapshotBody,
|
restoreSnapshotBody,
|
||||||
restoreSnapshotDto,
|
restoreSnapshotDto,
|
||||||
type CreateRepositoryDto,
|
|
||||||
type DeleteRepositoryDto,
|
type DeleteRepositoryDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
@@ -93,9 +92,9 @@ export const repositoriesController = new Hono()
|
|||||||
)
|
)
|
||||||
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
||||||
const { name } = c.req.param();
|
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);
|
return c.json<RestoreSnapshotDto>(result, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ export const listSnapshotFilesDto = describeRoute({
|
|||||||
*/
|
*/
|
||||||
export const restoreSnapshotBody = type({
|
export const restoreSnapshotBody = type({
|
||||||
snapshotId: "string",
|
snapshotId: "string",
|
||||||
targetPath: "string",
|
|
||||||
path: "string?",
|
path: "string?",
|
||||||
include: "string[]?",
|
include: "string[]?",
|
||||||
exclude: "string[]?",
|
exclude: "string[]?",
|
||||||
@@ -231,8 +230,7 @@ export const restoreSnapshotResponse = type({
|
|||||||
success: "boolean",
|
success: "boolean",
|
||||||
message: "string",
|
message: "string",
|
||||||
filesRestored: "number",
|
filesRestored: "number",
|
||||||
filesUpdated: "number",
|
filesSkipped: "number",
|
||||||
totalBytes: "number",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;
|
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
|
|||||||
const restoreSnapshot = async (
|
const restoreSnapshot = async (
|
||||||
name: string,
|
name: string,
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
targetPath: string,
|
|
||||||
options?: {
|
options?: {
|
||||||
path?: string;
|
path?: string;
|
||||||
include?: string[];
|
include?: string[];
|
||||||
@@ -179,14 +178,13 @@ const restoreSnapshot = async (
|
|||||||
throw new NotFoundError("Repository not found");
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Snapshot restored successfully",
|
message: "Snapshot restored successfully",
|
||||||
filesRestored: result.files_restored,
|
filesRestored: result.files_restored,
|
||||||
filesUpdated: result.files_updated,
|
filesSkipped: result.files_skipped,
|
||||||
totalBytes: result.total_bytes,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -161,11 +161,10 @@ const backup = async (
|
|||||||
|
|
||||||
const restoreOutputSchema = type({
|
const restoreOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
|
total_files: "number",
|
||||||
files_restored: "number",
|
files_restored: "number",
|
||||||
files_updated: "number",
|
files_skipped: "number",
|
||||||
files_unchanged: "number",
|
bytes_skipped: "number",
|
||||||
total_bytes: "number",
|
|
||||||
total_errors: "number?",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const restore = async (
|
const restore = async (
|
||||||
@@ -216,14 +215,15 @@ const restore = async (
|
|||||||
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
||||||
return {
|
return {
|
||||||
message_type: "summary" as const,
|
message_type: "summary" as const,
|
||||||
|
total_files: 0,
|
||||||
files_restored: 0,
|
files_restored: 0,
|
||||||
files_updated: 0,
|
files_skipped: 0,
|
||||||
files_unchanged: 0,
|
bytes_skipped: 0,
|
||||||
total_bytes: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -231,15 +231,15 @@ const restore = async (
|
|||||||
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
||||||
return {
|
return {
|
||||||
message_type: "summary" as const,
|
message_type: "summary" as const,
|
||||||
|
total_files: 0,
|
||||||
files_restored: 0,
|
files_restored: 0,
|
||||||
files_updated: 0,
|
files_skipped: 0,
|
||||||
files_unchanged: 0,
|
bytes_skipped: 0,
|
||||||
total_bytes: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
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;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user