184 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|