mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
restore as a page (#87)
* feat: add custom restore target directory Adds the ability to restore snapshots to a custom directory instead of only the original path. Closes #12. Changes: - Add target parameter to restore API endpoint - Add directory picker UI in file browser restore dialog - Add target input field in snapshot restore form - Create reusable PathSelector component Note: Run `bun run gen:api-client` after merging to regenerate types. * refactor: path selector design * refactor: unify restore snapshot dialogs * refactor: restore snapshot as a page * chore: fix liniting issues * chore(create-notification): remove un-used prop --------- Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
This commit is contained in:
@@ -1,100 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/client/components/ui/dialog";
|
||||
import { ScrollArea } from "~/client/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);
|
||||
|
||||
const excludeXattr = values.excludeXattr
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
restore.mutate({
|
||||
path: { name },
|
||||
body: {
|
||||
snapshotId,
|
||||
include: include && include.length > 0 ? include : undefined,
|
||||
exclude: exclude && exclude.length > 0 ? exclude : undefined,
|
||||
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : 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>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { type } from "arktype";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
||||
const restoreSnapshotFormSchema = type({
|
||||
path: "string?",
|
||||
include: "string?",
|
||||
exclude: "string?",
|
||||
excludeXattr: "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 [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const form = useForm<RestoreSnapshotFormValues>({
|
||||
resolver: arktypeResolver(restoreSnapshotFormSchema),
|
||||
defaultValues: {
|
||||
path: "",
|
||||
include: "",
|
||||
exclude: "",
|
||||
excludeXattr: "",
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="h-auto p-0 text-sm font-normal"
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="excludeXattr"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="com.apple.metadata,user.custom" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Exclude specific extended attributes during restore (comma-separated)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal file
45
app/client/modules/repositories/routes/restore-snapshot.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { redirect } from "react-router";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import { RestoreForm } from "~/client/components/restore-form";
|
||||
import type { Route } from "./+types/restore-snapshot";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.params.name, href: `/repositories/${match.params.name}` },
|
||||
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Restore files from a backup snapshot.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: params.name, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
|
||||
|
||||
return redirect("/repositories");
|
||||
};
|
||||
|
||||
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
|
||||
const { snapshot, name, snapshotId } = loaderData;
|
||||
|
||||
return (
|
||||
<RestoreForm
|
||||
snapshot={snapshot}
|
||||
repositoryName={name}
|
||||
snapshotId={snapshotId}
|
||||
returnPath={`/repositories/${name}/${snapshotId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useParams } from "react-router";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import type { Route } from "./+types/snapshot-details";
|
||||
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||
</div>
|
||||
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
|
||||
</div>
|
||||
|
||||
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
||||
|
||||
Reference in New Issue
Block a user