refactor: simplify snapshot file explorer

This commit is contained in:
Nicolas Meienberger
2025-11-04 14:57:22 +01:00
parent 11ca80a929
commit d1e46918ec
16 changed files with 309 additions and 258 deletions

View File

@@ -23,6 +23,7 @@ import {
deleteRepository,
getRepository,
listSnapshots,
getSnapshotDetails,
listSnapshotFiles,
restoreSnapshot,
listBackupSchedules,
@@ -69,6 +70,7 @@ import type {
DeleteRepositoryResponse,
GetRepositoryData,
ListSnapshotsData,
GetSnapshotDetailsData,
ListSnapshotFilesData,
RestoreSnapshotData,
RestoreSnapshotResponse,
@@ -720,6 +722,27 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
});
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) =>
createQueryKey("getSnapshotDetails", options);
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSnapshotDetails({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getSnapshotDetailsQueryKey(options),
});
};
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) =>
createQueryKey("listSnapshotFiles", options);

View File

@@ -50,6 +50,8 @@ import type {
GetRepositoryResponses,
ListSnapshotsData,
ListSnapshotsResponses,
GetSnapshotDetailsData,
GetSnapshotDetailsResponses,
ListSnapshotFilesData,
ListSnapshotFilesResponses,
RestoreSnapshotData,
@@ -352,6 +354,18 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(
});
};
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(
options: Options<GetSnapshotDetailsData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}",
...options,
});
};
/**
* List files and directories in a snapshot
*/

View File

@@ -802,6 +802,31 @@ export type ListSnapshotsResponses = {
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
export type GetSnapshotDetailsData = {
body?: never;
path: {
name: string;
snapshotId: string;
};
query?: never;
url: "/api/v1/repositories/{name}/snapshots/{snapshotId}";
};
export type GetSnapshotDetailsResponses = {
/**
* Snapshot details
*/
200: {
duration: number;
paths: Array<string>;
short_id: string;
size: number;
time: number;
};
};
export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSnapshotDetailsResponses];
export type ListSnapshotFilesData = {
body?: never;
path: {

View File

@@ -18,7 +18,7 @@ const NODE_PADDING_LEFT = 12;
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
type: string;
size?: number;
modifiedAt?: number;
}

View File

@@ -38,6 +38,7 @@ function Button({
variant,
size,
asChild = false,
loading,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
@@ -47,13 +48,13 @@ function Button({
return (
<Comp
disabled={props.loading}
disabled={loading}
data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
{...props}
>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !props.loading })} />
<div className={cn("flex items-center justify-center", { invisible: props.loading })}>{props.children}</div>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
</Comp>
);
}

View File

@@ -1,7 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { listFiles } from "~/api-client";
import { listFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree } from "~/components/file-tree";
@@ -76,16 +75,17 @@ export const VolumeFileBrowser = ({
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const result = await listFiles({
path: { name: volumeName },
query: { path: folderPath },
throwOnError: true,
});
const result = await queryClient.fetchQuery(
listFilesOptions({
path: { name: volumeName },
query: { path: folderPath },
}),
);
if (result.data.files) {
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.data.files) {
for (const file of result.files) {
next.set(file.path, file);
}
return next;
@@ -104,7 +104,7 @@ export const VolumeFileBrowser = ({
}
}
},
[volumeName, fetchedFolders],
[volumeName, fetchedFolders, queryClient.fetchQuery],
);
const handleFolderHover = useCallback(

View File

@@ -1,90 +1,169 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileIcon, Folder } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { FileTree, type FileEntry } from "~/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import type { Snapshot } from "~/lib/types";
interface Props {
snapshots: ListSnapshotsResponse;
snapshot: Snapshot;
repositoryName: string;
snapshotId: string;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshots, repositoryName, snapshotId } = props;
const { snapshot, repositoryName } = props;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set([""]));
const queryClient = useQueryClient();
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 volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "";
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path: "/" },
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: volumeBasePath },
}),
});
const handleFolderExpand = (folderPath: string) => {
const newFolders = new Set(expandedFolders);
newFolders.add(folderPath);
setExpandedFolders(newFolders);
};
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const selectedSnapshot = useMemo(() => {
return snapshots.find((s) => s.short_id === snapshotId);
}, [snapshotId, snapshots]);
const addBasePath = useCallback(
(displayPath: string): string => {
if (!volumeBasePath) return displayPath;
if (displayPath === "/") return volumeBasePath;
return `${volumeBasePath}${displayPath}`;
},
[volumeBasePath],
);
if (snapshots.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Folder className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3">
<h3 className="text-2xl font-semibold text-foreground">No snapshots</h3>
<p className="text-muted-foreground text-sm">
Snapshots are point-in-time backups of your data. The first snapshot will appear here after the next
scheduled backup.
</p>
</div>
</CardContent>
</Card>
);
}
useMemo(() => {
if (filesData?.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of filesData.files) {
const strippedPath = stripBasePath(file.path);
if (strippedPath !== "/") {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
setFetchedFolders((prev) => new Set(prev).add("/"));
}
}, [filesData, stripBasePath]);
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 fullPath = addBasePath(folderPath);
const result = await queryClient.fetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
if (result.files) {
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of result.files) {
const strippedPath = stripBasePath(file.path);
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
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;
});
}
}
},
[repositoryName, snapshot, fetchedFolders, queryClient, stripBasePath, addBasePath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath)) {
const fullPath = addBasePath(folderPath);
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: fullPath },
}),
);
}
},
[repositoryName, snapshot, fetchedFolders, loadingFolders, queryClient, addBasePath],
);
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
<CardHeader>
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(selectedSnapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{filesLoading && (
{filesLoading && fileArray.length === 0 && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{filesData?.files.length === 0 && (
{fileArray.length === 0 && !filesLoading && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{filesData?.files.length && (
{fileArray.length > 0 && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={filesData?.files as FileEntry[]}
files={fileArray}
onFolderExpand={handleFolderExpand}
onFolderHover={handleFolderHover}
expandedFolders={expandedFolders}
loadingFolders={loadingFolders}
className="px-2 py-2"
/>
</div>

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { cn } from "~/lib/utils";
import { Card } from "~/components/ui/card";
@@ -13,10 +12,6 @@ interface Props {
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, onSnapshotSelect } = props;
const sortedSnapshots = useMemo(() => {
return [...snapshots].sort((a, b) => a.time - b.time);
}, [snapshots]);
if (snapshots.length === 0) {
return (
<div className="w-full bg-card border-t border-border py-4 px-4">
@@ -33,10 +28,10 @@ export const SnapshotTimeline = (props: Props) => {
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2">
{sortedSnapshots.map((snapshot, index) => {
{snapshots.map((snapshot, index) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
const isLatest = index === sortedSnapshots.length - 1;
const isLatest = index === snapshots.length - 1;
return (
<button
@@ -72,10 +67,10 @@ export const SnapshotTimeline = (props: Props) => {
</div>
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
<span>{sortedSnapshots.length} snapshots</span>
<span>{snapshots.length} snapshots</span>
<span>
{new Date(sortedSnapshots[0].time).toLocaleDateString()} -{" "}
{new Date(sortedSnapshots[sortedSnapshots.length - 1].time).toLocaleDateString()}
{new Date(snapshots[0].time).toLocaleDateString()} -{" "}
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
</span>
</div>
</div>

View File

@@ -166,6 +166,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
);
}
const selectedSnapshot = snapshots.find((s) => s.short_id === selectedSnapshotId);
return (
<div className="flex flex-col gap-6">
<ScheduleSummary
@@ -175,18 +177,16 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<SnapshotTimeline
snapshots={snapshots}
snapshotId={selectedSnapshotId}
onSnapshotSelect={setSelectedSnapshotId}
/>
<SnapshotFileBrowser
snapshots={snapshots}
repositoryName={schedule.repository.name}
snapshotId={selectedSnapshotId}
/>
{selectedSnapshot && (
<>
<SnapshotTimeline
snapshots={snapshots}
snapshotId={selectedSnapshot.short_id}
onSnapshotSelect={setSelectedSnapshotId}
/>
<SnapshotFileBrowser snapshot={selectedSnapshot} repositoryName={schedule.repository.name} />
</>
)}
</div>
);
}

View File

@@ -1,148 +0,0 @@
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

@@ -1,11 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router";
import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFilesList } from "../components/snapshot-files";
import { SnapshotFileBrowser } from "~/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/api-client";
import type { Route } from "./+types/snapshot-details";
export default function SnapshotDetailsPage() {
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({ path: { name: params.name, snapshotId: params.snapshotId } });
if (snapshot.data) return snapshot.data;
return redirect("/repositories");
};
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
const { name, snapshotId } = useParams<{ name: string; snapshotId: string }>();
const { data } = useQuery({
@@ -34,15 +43,7 @@ export default function SnapshotDetailsPage() {
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</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>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
{data?.snapshot && (
<Card>

View File

@@ -3,21 +3,18 @@ import { intervalToDuration } from "date-fns";
import { Database } from "lucide-react";
import { useState } from "react";
import { listSnapshotsOptions } from "~/api-client/@tanstack/react-query.gen";
import type { ListSnapshotsResponse } from "~/api-client/types.gen";
import { ByteSize } from "~/components/bytes-size";
import { SnapshotsTable } from "~/components/snapshots-table";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Table, TableBody, TableCell, TableRow } from "~/components/ui/table";
import type { Repository } from "~/lib/types";
import type { Repository, Snapshot } from "~/lib/types";
type Props = {
repository: Repository;
};
type Snapshot = ListSnapshotsResponse["snapshots"][0];
export const formatSnapshotDuration = (seconds: number) => {
const duration = intervalToDuration({ start: 0, end: seconds * 1000 });
const parts: string[] = [];
@@ -37,11 +34,10 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
...listSnapshotsOptions({ path: { name: repository.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
initialData: [],
});
const snapshots = data?.snapshots || [];
const filteredSnapshots = snapshots.filter((snapshot: Snapshot) => {
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
@@ -50,8 +46,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
);
});
const hasNoSnapshots = snapshots.length === 0;
const hasNoFilteredSnapshots = filteredSnapshots.length === 0 && !hasNoSnapshots;
const hasNoFilteredSnapshots = !filteredSnapshots?.length && !data.length;
if (repository.status === "error") {
return (
@@ -72,11 +67,11 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
);
}
if (isLoading && !data && !failureReason) {
if (isLoading && !data.length && !failureReason) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading snapshots yo...</p>
<p className="text-muted-foreground">Loading snapshots</p>
</CardContent>
</Card>
);
@@ -94,7 +89,8 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
);
}
if (hasNoSnapshots) {
if (!data.length) {
console.log("No snapshots found for repository:", repository.name);
return (
<Card>
<CardContent className="flex flex-col items-center justify-center text-center py-16 px-4">
@@ -124,7 +120,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<div className="flex-1">
<CardTitle>Snapshots</CardTitle>
<CardDescription className="mt-1">
Backup snapshots stored in this repository. Total: {snapshots.length}
Backup snapshots stored in this repository. Total: {data.length}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
@@ -159,7 +155,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
<span>
{hasNoFilteredSnapshots
? "No snapshots match filters."
: `Showing ${filteredSnapshots.length} of ${snapshots.length}`}
: `Showing ${filteredSnapshots.length} of ${data.length}`}
</span>
{!hasNoFilteredSnapshots && (
<span>

View File

@@ -5,6 +5,7 @@ import {
createRepositoryDto,
deleteRepositoryDto,
getRepositoryDto,
getSnapshotDetailsDto,
listRepositoriesDto,
listSnapshotFilesDto,
listSnapshotFilesQuery,
@@ -14,6 +15,7 @@ import {
restoreSnapshotDto,
type DeleteRepositoryDto,
type GetRepositoryDto,
type GetSnapshotDetailsDto,
type ListRepositoriesDto,
type ListSnapshotFilesDto,
type ListSnapshotsDto,
@@ -71,6 +73,27 @@ export const repositoriesController = new Hono()
return c.json<ListSnapshotsDto>(snapshots, 200);
})
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
const { name, snapshotId } = c.req.param();
const snapshot = await repositoriesService.getSnapshotDetails(name, snapshotId);
let duration = 0;
if (snapshot.summary) {
const { backup_start, backup_end } = snapshot.summary;
duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
}
const response = {
short_id: snapshot.short_id,
duration,
time: new Date(snapshot.time).getTime(),
paths: snapshot.paths,
size: snapshot.summary?.total_bytes_processed || 0,
summary: snapshot.summary,
};
return c.json<GetSnapshotDetailsDto>(response, 200);
})
.get(
"/:name/snapshots/:snapshotId/files",
listSnapshotFilesDto,
@@ -81,7 +104,7 @@ export const repositoriesController = new Hono()
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);
},

View File

@@ -163,6 +163,29 @@ export const listSnapshotsDto = describeRoute({
},
});
/**
* Get snapshot details
*/
export const getSnapshotDetailsResponse = snapshotSchema;
export type GetSnapshotDetailsDto = typeof getSnapshotDetailsResponse.infer;
export const getSnapshotDetailsDto = describeRoute({
description: "Get details of a specific snapshot",
tags: ["Repositories"],
operationId: "getSnapshotDetails",
responses: {
200: {
description: "Snapshot details",
content: {
"application/json": {
schema: resolver(getSnapshotDetailsResponse),
},
},
},
},
});
/**
* List files in a snapshot
*/

View File

@@ -184,6 +184,25 @@ const restoreSnapshot = async (
};
};
const getSnapshotDetails = async (name: string, snapshotId: string) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!repository) {
throw new NotFoundError("Repository not found");
}
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
}
return snapshot;
};
export const repositoriesService = {
listRepositories,
createRepository,
@@ -192,4 +211,5 @@ export const repositoriesService = {
listSnapshots,
listSnapshotFiles,
restoreSnapshot,
getSnapshotDetails,
};

View File

@@ -8,7 +8,6 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants";
import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { getVolumePath } from "../modules/volumes/helpers";
const backupOutputSchema = type({
message_type: "'summary'",