zerobyte/app/client/modules/repositories/routes/snapshot-details.tsx

194 lines
6 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { redirect, useParams, Link, Await } from "react-router";
import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { formatDateTime } from "~/client/lib/datetime";
import { parseError } from "~/client/lib/errors";
import { getRepository, getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
import { Suspense } from "react";
import { Database } from "lucide-react";
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 },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Browse and 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 (!repository.data) return redirect("/repositories");
return {
snapshot: snapshot,
repository: repository.data,
snapshotError: parseError(snapshot.error)?.message ?? null,
};
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { id, snapshotId } = useParams<{
id: string;
snapshotId: string;
}>();
const { data } = useQuery({
...listSnapshotFilesOptions({
path: { id: id ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" },
}),
enabled: !!id && !!snapshotId && !loaderData.snapshotError,
});
const schedules = useQuery({
...listBackupSchedulesOptions(),
});
if (!id || !snapshotId) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p>
</div>
);
}
if (loaderData.snapshotError) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-12">
<Database className="mb-4 h-12 w-12 text-destructive" />
<p className="text-destructive font-semibold">Snapshot not found</p>
<p className="text-sm text-muted-foreground mt-2">
This snapshot does not exist in {loaderData.repository.name}.
</p>
<p className="text-sm text-muted-foreground mt-1">It may have been deleted manually outside of Zerobyte.</p>
<div className="mt-4">
<Link to={`/repositories/${id}?tab=snapshots`}>
<Button variant="outline">Back to repository</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{loaderData.repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
<Suspense
fallback={
<SnapshotFileBrowser
repositoryId={id}
snapshot={{ duration: 0, paths: [], short_id: "", size: 0, tags: [], time: 0 }}
/>
}
>
<Await resolve={loaderData.snapshot}>
{(value) => {
if (!value.data) return <div className="text-destructive">Snapshot data not found.</div>;
return <SnapshotFileBrowser repositoryId={id} snapshot={value.data} />;
}}
</Await>
</Suspense>
{data?.snapshot && (
<Card>
<CardHeader>
<CardTitle>Snapshot Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Snapshot ID:</span>
<p className="font-mono break-all">{data.snapshot.id}</p>
</div>
<div>
<span className="text-muted-foreground">Short ID:</span>
<p className="font-mono break-all">{data.snapshot.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Hostname:</span>
<p>{data.snapshot.hostname}</p>
</div>
<div>
<span className="text-muted-foreground">Time:</span>
<p>{formatDateTime(data.snapshot.time)}</p>
</div>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={loaderData.snapshot}>
{(value) => {
if (!value.data) return null;
const backupSchedule = schedules.data?.find((s) => value.data.tags.includes(s.shortId));
return (
<>
<div>
<span className="text-muted-foreground">Backup Schedule:</span>
<p>
<Link to={`/backups/${backupSchedule?.id}`} className="text-primary hover:underline">
{backupSchedule?.name}
</Link>
</p>
</div>
<div>
<span className="text-muted-foreground">Volume:</span>
<p>
<Link
to={`/volumes/${backupSchedule?.volume.shortId}`}
className="text-primary hover:underline"
>
{backupSchedule?.volume.name}
</Link>
</p>
</div>
</>
);
}}
</Await>
</Suspense>
<div className="col-span-2">
<span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1">
{data.snapshot.paths.map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
{path}
</p>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}