zerobyte/app/client/modules/repositories/routes/snapshot-details.tsx
2026-04-22 21:14:37 +02:00

184 lines
5.9 KiB
TypeScript

import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import {
getRepositoryOptions,
getSnapshotDetailsOptions,
listBackupSchedulesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetSnapshotDetailsResponse } from "~/client/api-client/types.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 { useTimeFormat } from "~/client/lib/datetime";
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
import { useState } from "react";
import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router";
import { getVolumeMountPath } from "~/client/lib/volume-path";
const SnapshotError = () => {
const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" });
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 this repository</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/$repositoryId`} search={() => ({ tab: "snapshots" })} params={{ repositoryId }}>
<Button variant="outline">Back to repository</Button>
</Link>
</div>
</CardContent>
</Card>
);
};
const SnapshotFileBrowserSkeleton = () => (
<div className="space-y-4">
<Card className="h-150 flex flex-col">
<CardHeader>
<CardTitle>File Browser</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
<div className="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4 flex flex-col items-center justify-center p-6 text-center">
<p className="text-muted-foreground">Loading snapshot...</p>
</div>
</CardContent>
</Card>
</div>
);
type Props = {
repositoryId: string;
snapshotId: string;
initialSnapshot?: GetSnapshotDetailsResponse;
};
export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot }: Props) {
const [showAllPaths, setShowAllPaths] = useState(false);
const { formatDateTime } = useTimeFormat();
const { data: repository } = useSuspenseQuery({
...getRepositoryOptions({ path: { shortId: repositoryId } }),
});
const { data: schedules } = useSuspenseQuery({
...listBackupSchedulesOptions(),
});
const { data, error } = useQuery({
...getSnapshotDetailsOptions({ path: { shortId: repositoryId, snapshotId: snapshotId } }),
initialData: initialSnapshot,
});
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));
if (error) {
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold">{repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
<SnapshotError />
</>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{repository.name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
{data ? (
<SnapshotFileBrowser
repositoryId={repositoryId}
snapshot={data}
displayBasePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
/>
) : (
<SnapshotFileBrowserSkeleton />
)}
{data && (
<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.short_id}</p>
</div>
<div>
<span className="text-muted-foreground">Hostname:</span>
<p>{data.hostname}</p>
</div>
<div>
<span className="text-muted-foreground">Time:</span>
<p>{formatDateTime(data.time)}</p>
</div>
{backupSchedule && (
<>
<div>
<span className="text-muted-foreground">Backup Schedule:</span>
<p>
<Link
to="/backups/$backupId"
className="text-primary hover:underline"
params={{ backupId: backupSchedule.shortId }}
>
{backupSchedule?.name}
</Link>
</p>
</div>
<div>
<span className="text-muted-foreground">Volume:</span>
<p>
<Link
to={`/volumes/$volumeId`}
className="text-primary hover:underline"
params={{ volumeId: backupSchedule.volume.shortId }}
>
{backupSchedule?.volume.name}
</Link>
</p>
</div>
</>
)}
<div className="col-span-2">
<span className="text-muted-foreground">Paths:</span>
<div className="space-y-1 mt-1">
{data.paths.slice(0, showAllPaths ? undefined : 20).map((path) => (
<p key={path} className="font-mono text-xs bg-muted px-2 py-1 rounded break-all">
{path}
</p>
))}
{data.paths.length > 20 && (
<button
type="button"
onClick={() => setShowAllPaths(!showAllPaths)}
className="text-xs text-primary hover:underline mt-1"
>
{showAllPaths ? "Show less" : `+ ${data.paths.length - 20} more`}
</button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{data && <BackupSummaryCard summary={data.summary} />}
</div>
);
}