refactor: create volume & restore snapshot

This commit is contained in:
Nicolas Meienberger 2026-02-08 15:53:58 +01:00
parent 8a25b79377
commit e424009dd3
11 changed files with 204 additions and 126 deletions

View file

@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
@ -16,6 +15,7 @@ import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Repository, Snapshot } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
type RestoreLocation = "original" | "custom";
@ -101,7 +101,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
...restoreSnapshotMutation(),
onSuccess: () => {
toast.success("Restore completed");
void navigate(returnPath);
void navigate({ to: returnPath });
},
onError: (error) => {
handleRepositoryError("Restore failed", error, repository.id);
@ -156,7 +156,7 @@ export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: Re
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(returnPath)}>
<Button variant="outline" onClick={() => navigate({ to: returnPath })}>
Cancel
</Button>
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>

View file

@ -1,62 +0,0 @@
import { redirect } from "react-router";
import { getBackupSchedule, getRepository, 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: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
{ label: 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 schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!schedule.data) return redirect("/backups");
const [snapshot, repository] = await Promise.all([
getSnapshotDetails({
path: {
id: schedule.data.repositoryId,
snapshotId: params.snapshotId,
},
}),
getRepository({ path: { id: schedule.data.repositoryId } }),
]);
if (!snapshot.data) return redirect(`/backups/${params.id}`);
if (!repository.data) return redirect(`/backups/${params.id}`);
return {
snapshot: snapshot.data,
repository: repository.data,
snapshotId: params.snapshotId,
backupId: params.id,
};
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repository, snapshotId, backupId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repository={repository}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
);
}

View file

@ -1,48 +1,24 @@
import { redirect } from "react-router";
import { getRepository, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
import type { Repository, Snapshot } from "~/client/lib/types";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
{ label: "Restore" },
],
// export const handle = {
// breadcrumb: (match: Route.MetaArgs) => [
// { label: "Repositories", href: "/repositories" },
// { label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
// { label: match.params.snapshotId, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
// { label: "Restore" },
// ],
// };
type Props = {
snapshot: Snapshot;
repository: Repository;
snapshotId: string;
returnPath: string;
};
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, repository] = await Promise.all([
getSnapshotDetails({ path: { id: params.id, snapshotId: params.snapshotId } }),
getRepository({ path: { id: params.id } }),
]);
if (!snapshot.data) return redirect("/repositories");
if (!repository.data) return redirect(`/repositories`);
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
};
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
const { snapshot, id, snapshotId, repository } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repository={repository}
snapshotId={snapshotId}
returnPath={`/repositories/${id}/${snapshotId}`}
/>
);
export function RestoreSnapshotPage(props: Props) {
const { snapshot, returnPath, snapshotId, repository } = props;
return <RestoreForm snapshot={snapshot} repository={repository} snapshotId={snapshotId} returnPath={returnPath} />;
}

View file

@ -1,31 +1,20 @@
import { useMutation } from "@tanstack/react-query";
import { HardDrive, Plus } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-volume";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { useNavigate } from "@tanstack/react-router";
export const handle = {
breadcrumb: () => [{ label: "Volumes", href: "/volumes" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Create Volume" },
{
name: "description",
content: "Create a new storage volume with automatic mounting and health checks.",
},
];
}
export default function CreateVolume() {
export function CreateVolumePage() {
const navigate = useNavigate();
const formId = useId();
@ -33,7 +22,7 @@ export default function CreateVolume() {
...createVolumeMutation(),
onSuccess: (data) => {
toast.success("Volume created successfully");
void navigate(`/volumes/${data.shortId}`);
void navigate({ to: `/volumes/${data.shortId}` });
},
});
@ -69,7 +58,7 @@ export default function CreateVolume() {
)}
<CreateVolumeForm mode="create" formId={formId} onSubmit={handleSubmit} loading={createVolume.isPending} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/volumes")}>
<Button type="button" variant="secondary" onClick={() => navigate({ to: "/volumes" })}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createVolume.isPending}>

View file

@ -17,12 +17,15 @@ import { Route as authDownloadRecoveryKeyRouteImport } from './routes/(auth)/dow
import { Route as dashboardVolumesIndexRouteImport } from './routes/(dashboard)/volumes/index'
import { Route as dashboardRepositoriesIndexRouteImport } from './routes/(dashboard)/repositories/index'
import { Route as dashboardBackupsIndexRouteImport } from './routes/(dashboard)/backups/index'
import { Route as dashboardVolumesCreateRouteImport } from './routes/(dashboard)/volumes/create'
import { Route as dashboardVolumesVolumeIdRouteImport } from './routes/(dashboard)/volumes/$volumeId'
import { Route as dashboardRepositoriesCreateRouteImport } from './routes/(dashboard)/repositories/create'
import { Route as dashboardRepositoriesRepositoryIdRouteImport } from './routes/(dashboard)/repositories/$repositoryId'
import { Route as dashboardBackupsCreateRouteImport } from './routes/(dashboard)/backups/create'
import { Route as dashboardBackupsScheduleIdRouteImport } from './routes/(dashboard)/backups/$scheduleId'
import { Route as dashboardRepositoriesRepoIdSnapshotIdRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapshotId'
import { Route as dashboardRepositoriesRepoIdSnapIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repoId.$snapId.restore'
import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId.$snapshotId.restore'
const dashboardRouteRoute = dashboardRouteRouteImport.update({
id: '/(dashboard)',
@ -64,6 +67,11 @@ const dashboardBackupsIndexRoute = dashboardBackupsIndexRouteImport.update({
path: '/backups/',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardVolumesCreateRoute = dashboardVolumesCreateRouteImport.update({
id: '/volumes/create',
path: '/volumes/create',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardVolumesVolumeIdRoute =
dashboardVolumesVolumeIdRouteImport.update({
id: '/volumes/$volumeId',
@ -99,6 +107,18 @@ const dashboardRepositoriesRepoIdSnapshotIdRoute =
path: '/repositories/$repoId/$snapshotId',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepoIdSnapIdRestoreRoute =
dashboardRepositoriesRepoIdSnapIdRestoreRouteImport.update({
id: '/repositories/$repoId/$snapId/restore',
path: '/repositories/$repoId/$snapId/restore',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardBackupsBackupIdSnapshotIdRestoreRoute =
dashboardBackupsBackupIdSnapshotIdRestoreRouteImport.update({
id: '/backups/$backupId/$snapshotId/restore',
path: '/backups/$backupId/$snapshotId/restore',
getParentRoute: () => dashboardRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -110,10 +130,13 @@ export interface FileRoutesByFullPath {
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
'/backups/': typeof dashboardBackupsIndexRoute
'/repositories/': typeof dashboardRepositoriesIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -125,10 +148,13 @@ export interface FileRoutesByTo {
'/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/repositories/create': typeof dashboardRepositoriesCreateRoute
'/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/volumes/create': typeof dashboardVolumesCreateRoute
'/backups': typeof dashboardBackupsIndexRoute
'/repositories': typeof dashboardRepositoriesIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -142,10 +168,13 @@ export interface FileRoutesById {
'/(dashboard)/repositories/$repositoryId': typeof dashboardRepositoriesRepositoryIdRoute
'/(dashboard)/repositories/create': typeof dashboardRepositoriesCreateRoute
'/(dashboard)/volumes/$volumeId': typeof dashboardVolumesVolumeIdRoute
'/(dashboard)/volumes/create': typeof dashboardVolumesCreateRoute
'/(dashboard)/backups/': typeof dashboardBackupsIndexRoute
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/repositories/$repoId/$snapshotId': typeof dashboardRepositoriesRepoIdSnapshotIdRoute
'/(dashboard)/backups/$backupId/$snapshotId/restore': typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
'/(dashboard)/repositories/$repoId/$snapId/restore': typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -159,10 +188,13 @@ export interface FileRouteTypes {
| '/repositories/$repositoryId'
| '/repositories/create'
| '/volumes/$volumeId'
| '/volumes/create'
| '/backups/'
| '/repositories/'
| '/volumes/'
| '/repositories/$repoId/$snapshotId'
| '/backups/$backupId/$snapshotId/restore'
| '/repositories/$repoId/$snapId/restore'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -174,10 +206,13 @@ export interface FileRouteTypes {
| '/repositories/$repositoryId'
| '/repositories/create'
| '/volumes/$volumeId'
| '/volumes/create'
| '/backups'
| '/repositories'
| '/volumes'
| '/repositories/$repoId/$snapshotId'
| '/backups/$backupId/$snapshotId/restore'
| '/repositories/$repoId/$snapId/restore'
id:
| '__root__'
| '/'
@ -190,10 +225,13 @@ export interface FileRouteTypes {
| '/(dashboard)/repositories/$repositoryId'
| '/(dashboard)/repositories/create'
| '/(dashboard)/volumes/$volumeId'
| '/(dashboard)/volumes/create'
| '/(dashboard)/backups/'
| '/(dashboard)/repositories/'
| '/(dashboard)/volumes/'
| '/(dashboard)/repositories/$repoId/$snapshotId'
| '/(dashboard)/backups/$backupId/$snapshotId/restore'
| '/(dashboard)/repositories/$repoId/$snapId/restore'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -262,6 +300,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardBackupsIndexRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/volumes/create': {
id: '/(dashboard)/volumes/create'
path: '/volumes/create'
fullPath: '/volumes/create'
preLoaderRoute: typeof dashboardVolumesCreateRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/volumes/$volumeId': {
id: '/(dashboard)/volumes/$volumeId'
path: '/volumes/$volumeId'
@ -304,6 +349,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repoId/$snapId/restore': {
id: '/(dashboard)/repositories/$repoId/$snapId/restore'
path: '/repositories/$repoId/$snapId/restore'
fullPath: '/repositories/$repoId/$snapId/restore'
preLoaderRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/backups/$backupId/$snapshotId/restore': {
id: '/(dashboard)/backups/$backupId/$snapshotId/restore'
path: '/backups/$backupId/$snapshotId/restore'
fullPath: '/backups/$backupId/$snapshotId/restore'
preLoaderRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRouteImport
parentRoute: typeof dashboardRouteRoute
}
}
}
@ -313,10 +372,13 @@ interface dashboardRouteRouteChildren {
dashboardRepositoriesRepositoryIdRoute: typeof dashboardRepositoriesRepositoryIdRoute
dashboardRepositoriesCreateRoute: typeof dashboardRepositoriesCreateRoute
dashboardVolumesVolumeIdRoute: typeof dashboardVolumesVolumeIdRoute
dashboardVolumesCreateRoute: typeof dashboardVolumesCreateRoute
dashboardBackupsIndexRoute: typeof dashboardBackupsIndexRoute
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardRepositoriesRepoIdSnapshotIdRoute: typeof dashboardRepositoriesRepoIdSnapshotIdRoute
dashboardBackupsBackupIdSnapshotIdRestoreRoute: typeof dashboardBackupsBackupIdSnapshotIdRestoreRoute
dashboardRepositoriesRepoIdSnapIdRestoreRoute: typeof dashboardRepositoriesRepoIdSnapIdRestoreRoute
}
const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
@ -326,11 +388,16 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardRepositoriesRepositoryIdRoute,
dashboardRepositoriesCreateRoute: dashboardRepositoriesCreateRoute,
dashboardVolumesVolumeIdRoute: dashboardVolumesVolumeIdRoute,
dashboardVolumesCreateRoute: dashboardVolumesCreateRoute,
dashboardBackupsIndexRoute: dashboardBackupsIndexRoute,
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardRepositoriesRepoIdSnapshotIdRoute:
dashboardRepositoriesRepoIdSnapshotIdRoute,
dashboardBackupsBackupIdSnapshotIdRestoreRoute:
dashboardBackupsBackupIdSnapshotIdRestoreRoute,
dashboardRepositoriesRepoIdSnapIdRestoreRoute:
dashboardRepositoriesRepoIdSnapIdRestoreRoute,
}
const dashboardRouteRouteWithChildren = dashboardRouteRoute._addFileChildren(

View file

@ -0,0 +1,38 @@
import { createFileRoute } from "@tanstack/react-router";
import { getBackupSchedule } from "~/client/api-client";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
component: RouteComponent,
loader: async ({ params, context }) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.backupId } });
if (!schedule.data) {
throw new Response("Not Found", { status: 404 });
}
const [snapshot, repository] = await Promise.all([
context.queryClient.ensureQueryData({
...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }),
}),
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
]);
return { snapshot, repository };
},
});
function RouteComponent() {
const { backupId, snapshotId } = Route.useParams();
const { snapshot, repository } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/backups/${backupId}`}
snapshotId={snapshotId}
snapshot={snapshot}
repository={repository}
/>
);
}

View file

@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router";
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
export const Route = createFileRoute("/(dashboard)/repositories/$repoId/$snapId/restore")({
component: RouteComponent,
errorComponent: (e) => <div>{e.error.message}</div>,
loader: async ({ params, context }) => {
const [snapshot, repository] = await Promise.all([
context.queryClient.ensureQueryData({
...getSnapshotDetailsOptions({ path: { id: params.repoId, snapshotId: params.snapId } }),
}),
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repoId } }) }),
]);
return { snapshot, repository };
},
head: ({ params }) => ({
meta: [
{ title: `Zerobyte - Restore Snapshot ${params.snapId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
],
}),
});
function RouteComponent() {
const { repoId, snapId } = Route.useParams();
const { snapshot, repository } = Route.useLoaderData();
return (
<RestoreSnapshotPage
returnPath={`/repositories/${repoId}/${snapId}`}
snapshot={snapshot}
repository={repository}
snapshotId={snapId}
/>
);
}

View file

@ -0,0 +1,19 @@
import { createFileRoute } from "@tanstack/react-router";
import { CreateVolumePage } from "~/client/modules/volumes/routes/create-volume";
export const Route = createFileRoute("/(dashboard)/volumes/create")({
component: RouteComponent,
head: () => ({
meta: [
{ title: "Zerobyte - Create Volume" },
{
name: "description",
content: "Create a new storage volume with automatic mounting and health checks.",
},
],
}),
});
function RouteComponent() {
return <CreateVolumePage />;
}

View file

@ -5,6 +5,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "~/client/components/ui/sonner";
import { useServerEvents } from "~/client/hooks/use-server-events";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: {
@ -38,6 +39,8 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
});
function RootLayout() {
useServerEvents();
return (
<html lang="en">
<head>

View file

@ -1,7 +1,16 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
head: () => ({
meta: [
{ title: "Zerobyte" },
{
name: "description",
content: "Zerobyte - Manage your backups and storage volumes with ease.",
},
],
}),
beforeLoad: () => {
redirect({ to: "/volumes" });
throw redirect({ to: "/volumes" });
},
});

View file

@ -315,8 +315,6 @@ const getSnapshotDetails = async (id: string, snapshotId: string) => {
const organizationId = getOrganizationId();
const repository = await findRepository(id);
console.log("Getting snapshot details for:", id, snapshotId);
if (!repository) {
throw new NotFoundError("Repository not found");
}