feat: snapshot files list frontend

This commit is contained in:
Nicolas Meienberger
2025-10-30 19:01:49 +01:00
parent b80a187108
commit 5846c1ff86
9 changed files with 272 additions and 13 deletions

View File

@@ -1,4 +1,5 @@
import { Calendar, Clock, Database, FolderTree, HardDrive } from "lucide-react";
import { useNavigate } from "react-router";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
@@ -9,9 +10,16 @@ type Snapshot = ListSnapshotsResponse["snapshots"][0];
type Props = {
snapshots: Snapshot[];
repositoryName: string;
};
export const SnapshotsTable = ({ snapshots }: Props) => {
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
const navigate = useNavigate();
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<div className="overflow-x-auto">
<Table className="border-t">
@@ -26,7 +34,11 @@ export const SnapshotsTable = ({ snapshots }: Props) => {
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow key={snapshot.short_id} className="hover:bg-accent/50">
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />

View File

@@ -23,10 +23,20 @@ export function generateBreadcrumbs(pathname: string, params: Record<string, str
});
if (pathname.startsWith("/repositories/") && params.name) {
const isSnapshotPage = !!params.snapshotId;
breadcrumbs.push({
label: params.name,
isCurrentPage: true,
href: isSnapshotPage ? `/repositories/${params.name}` : undefined,
isCurrentPage: !isSnapshotPage,
});
if (isSnapshotPage && params.snapshotId) {
breadcrumbs.push({
label: params.snapshotId,
isCurrentPage: true,
});
}
}
return breadcrumbs;

View File

@@ -135,7 +135,7 @@ export const ScheduleSummary = (props: Props) => {
</CardContent>
) : (
<>
<SnapshotsTable snapshots={snapshots.snapshots} />
<SnapshotsTable snapshots={snapshots.snapshots} repositoryName={repository.name} />
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>{`Showing ${snapshots.snapshots.length} of ${snapshots.snapshots.length}`}</span>
<span>

View File

@@ -26,7 +26,6 @@ export const FilesTabContent = ({ volume }: Props) => {
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
// Fetch root level files
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volume.name } }),
enabled: volume.status === "mounted",

View File

@@ -0,0 +1,148 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listSnapshotFiles } from "~/api-client";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree } from "~/components/file-tree";
interface FileEntry {
name: string;
path: string;
type: string;
size?: number;
mtime?: string;
}
type Props = {
name: string;
snapshotId: string;
};
export const SnapshotFilesList = ({ name, snapshotId }: Props) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set(["/"]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
...listSnapshotFilesOptions({
path: { name, snapshotId },
query: { path: "/" },
}),
});
useMemo(() => {
if (data?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of data.files) {
next.set(file.path, file);
}
return next;
});
}
}, [data]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await listSnapshotFiles({
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
query: { path: folderPath },
throwOnError: true,
});
if (result.data) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
next.set(file.path, file);
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add(folderPath));
}
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[name, snapshotId, fetchedFolders],
);
const handleFolderHover = useCallback(
async (folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name, snapshotId },
query: { path: folderPath },
}),
);
}
},
[name, snapshotId, fetchedFolders, loadingFolders, queryClient],
);
if (isLoading && fileArray.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading files...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
</div>
);
}
if (fileArray.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">This snapshot appears to be empty.</p>
</div>
);
}
return (
<div className="overflow-auto flex-1 border rounded-md bg-card">
<FileTree
files={fileArray.map((f) => ({
name: f.name,
path: f.path,
type: f.type === "dir" ? "directory" : "file",
size: f.size,
modifiedAt: f.mtime ? new Date(f.mtime).getTime() : undefined,
}))}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
/>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { SnapshotFilesList } from "../components/snapshot-files";
export default function SnapshotDetailsPage() {
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
const { data } = useQuery({
...listSnapshotFilesOptions({
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
query: { path: "/" },
}),
enabled: !!name && !!snapshotId,
});
if (!name || !snapshotId) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-destructive">Invalid snapshot reference</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
</div>
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Explorer</CardTitle>
<CardDescription>Browse the files and folders in this snapshot.</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col">
<SnapshotFilesList name={name} snapshotId={snapshotId} />
</CardContent>
</Card>
{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">{data.snapshot.id}</p>
</div>
<div>
<span className="text-muted-foreground">Short ID:</span>
<p className="font-mono">{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>{new Date(data.snapshot.time).toLocaleString()}</p>
</div>
<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">
{path}
</p>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -153,7 +153,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</TableBody>
</Table>
) : (
<SnapshotsTable snapshots={filteredSnapshots} />
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
)}
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>

View File

@@ -9,5 +9,6 @@ export default [
route("volumes/:name", "./routes/details.tsx"),
route("repositories", "./modules/repositories/routes/repositories.tsx"),
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -72,13 +72,18 @@ export const repositoriesController = new Hono()
return c.json<ListSnapshotsDto>(response, 200);
})
.get("/:name/snapshots/:snapshotId/files", listSnapshotFilesDto, validator("query", listSnapshotFilesQuery), async (c) => {
const { name, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
.get(
"/:name/snapshots/:snapshotId/files",
listSnapshotFilesDto,
validator("query", listSnapshotFilesQuery),
async (c) => {
const { name, snapshotId } = c.req.param();
const { path } = c.req.valid("query");
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, path);
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
return c.json<ListSnapshotFilesDto>(result, 200);
});
return c.json<ListSnapshotFilesDto>(result, 200);
},
);