From d1e46918ec8493c474b2af6c2d19d9fab9b3f151 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 4 Nov 2025 14:57:22 +0100 Subject: [PATCH] refactor: simplify snapshot file explorer --- .../api-client/@tanstack/react-query.gen.ts | 23 +++ apps/client/app/api-client/sdk.gen.ts | 14 ++ apps/client/app/api-client/types.gen.ts | 25 +++ apps/client/app/components/file-tree.tsx | 2 +- apps/client/app/components/ui/button.tsx | 7 +- .../app/components/volume-file-browser.tsx | 18 +- .../components/snapshot-file-browser.tsx | 171 +++++++++++++----- .../backups/components/snapshot-timeline.tsx | 15 +- .../modules/backups/routes/backup-details.tsx | 24 +-- .../components/snapshot-files.tsx | 148 --------------- .../repositories/routes/snapshot-details.tsx | 27 +-- .../modules/repositories/tabs/snapshots.tsx | 24 +-- .../repositories/repositories.controller.ts | 25 ++- .../modules/repositories/repositories.dto.ts | 23 +++ .../repositories/repositories.service.ts | 20 ++ apps/server/src/utils/restic.ts | 1 - 16 files changed, 309 insertions(+), 258 deletions(-) delete mode 100644 apps/client/app/modules/repositories/components/snapshot-files.tsx diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index b17380d..0e60d3a 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -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) => { }); }; +export const getSnapshotDetailsQueryKey = (options: Options) => + createQueryKey("getSnapshotDetails", options); + +/** + * Get details of a specific snapshot + */ +export const getSnapshotDetailsOptions = (options: Options) => { + 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) => createQueryKey("listSnapshotFiles", options); diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index c83fb68..a6c739a 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -50,6 +50,8 @@ import type { GetRepositoryResponses, ListSnapshotsData, ListSnapshotsResponses, + GetSnapshotDetailsData, + GetSnapshotDetailsResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, RestoreSnapshotData, @@ -352,6 +354,18 @@ export const listSnapshots = ( }); }; +/** + * Get details of a specific snapshot + */ +export const getSnapshotDetails = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/repositories/{name}/snapshots/{snapshotId}", + ...options, + }); +}; + /** * List files and directories in a snapshot */ diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 0ee4cab..4b050c1 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -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; + short_id: string; + size: number; + time: number; + }; +}; + +export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSnapshotDetailsResponses]; + export type ListSnapshotFilesData = { body?: never; path: { diff --git a/apps/client/app/components/file-tree.tsx b/apps/client/app/components/file-tree.tsx index b09d876..8498b55 100644 --- a/apps/client/app/components/file-tree.tsx +++ b/apps/client/app/components/file-tree.tsx @@ -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; } diff --git a/apps/client/app/components/ui/button.tsx b/apps/client/app/components/ui/button.tsx index 9a101f6..3ea6777 100644 --- a/apps/client/app/components/ui/button.tsx +++ b/apps/client/app/components/ui/button.tsx @@ -38,6 +38,7 @@ function Button({ variant, size, asChild = false, + loading, ...props }: React.ComponentProps<"button"> & VariantProps & { @@ -47,13 +48,13 @@ function Button({ return ( - -
{props.children}
+ +
{props.children}
); } diff --git a/apps/client/app/components/volume-file-browser.tsx b/apps/client/app/components/volume-file-browser.tsx index 228aea3..0d144ed 100644 --- a/apps/client/app/components/volume-file-browser.tsx +++ b/apps/client/app/components/volume-file-browser.tsx @@ -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( diff --git a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx index cac9816..527333a 100644 --- a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx +++ b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx @@ -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>(new Set([""])); + const queryClient = useQueryClient(); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [fetchedFolders, setFetchedFolders] = useState>(new Set()); + const [loadingFolders, setLoadingFolders] = useState>(new Set()); + const [allFiles, setAllFiles] = useState>(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 ( - - -
-
-
-
-
- -
-
-
-

No snapshots

-

- Snapshots are point-in-time backups of your data. The first snapshot will appear here after the next - scheduled backup. -

-
- - - ); - } + 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 (
File Browser - {`Viewing snapshot from ${new Date(selectedSnapshot?.time ?? 0).toLocaleString()}`} + {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} - {filesLoading && ( + {filesLoading && fileArray.length === 0 && (

Loading files...

)} - {filesData?.files.length === 0 && ( + {fileArray.length === 0 && !filesLoading && (

No files in this snapshot

)} - {filesData?.files.length && ( + {fileArray.length > 0 && (
diff --git a/apps/client/app/modules/backups/components/snapshot-timeline.tsx b/apps/client/app/modules/backups/components/snapshot-timeline.tsx index 1b9104f..22d0bf3 100644 --- a/apps/client/app/modules/backups/components/snapshot-timeline.tsx +++ b/apps/client/app/modules/backups/components/snapshot-timeline.tsx @@ -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 (
@@ -33,10 +28,10 @@ export const SnapshotTimeline = (props: Props) => {
- {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 (
- {sortedSnapshots.length} snapshots + {snapshots.length} snapshots - {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()}
diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index 0ecb887..8c8216c 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -166,6 +166,8 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon ); } + const selectedSnapshot = snapshots.find((s) => s.short_id === selectedSnapshotId); + return (
- - - - + {selectedSnapshot && ( + <> + + + + )}
); } diff --git a/apps/client/app/modules/repositories/components/snapshot-files.tsx b/apps/client/app/modules/repositories/components/snapshot-files.tsx deleted file mode 100644 index e6e5b13..0000000 --- a/apps/client/app/modules/repositories/components/snapshot-files.tsx +++ /dev/null @@ -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>(new Set()); - const [fetchedFolders, setFetchedFolders] = useState>(new Set(["/"])); - const [loadingFolders, setLoadingFolders] = useState>(new Set()); - const [allFiles, setAllFiles] = useState>(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 ( -
-

Loading files...

-
- ); - } - - if (error) { - return ( -
-

Failed to load files: {(error as Error).message}

-
- ); - } - - if (fileArray.length === 0) { - return ( -
- -

This snapshot appears to be empty.

-
- ); - } - - return ( -
- ({ - 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} - /> -
- ); -}; diff --git a/apps/client/app/modules/repositories/routes/snapshot-details.tsx b/apps/client/app/modules/repositories/routes/snapshot-details.tsx index f2acd6e..a47ec4b 100644 --- a/apps/client/app/modules/repositories/routes/snapshot-details.tsx +++ b/apps/client/app/modules/repositories/routes/snapshot-details.tsx @@ -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() {
- - - File Explorer - Browse the files and folders in this snapshot. - - - - - + {data?.snapshot && ( diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx index 0f9e571..0114b97 100644 --- a/apps/client/app/modules/repositories/tabs/snapshots.tsx +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -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 ( -

Loading snapshots yo...

+

Loading snapshots

); @@ -94,7 +89,8 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { ); } - if (hasNoSnapshots) { + if (!data.length) { + console.log("No snapshots found for repository:", repository.name); return ( @@ -124,7 +120,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
Snapshots - Backup snapshots stored in this repository. Total: {snapshots.length} + Backup snapshots stored in this repository. Total: {data.length}
@@ -159,7 +155,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => { {hasNoFilteredSnapshots ? "No snapshots match filters." - : `Showing ${filteredSnapshots.length} of ${snapshots.length}`} + : `Showing ${filteredSnapshots.length} of ${data.length}`} {!hasNoFilteredSnapshots && ( diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index 7323c77..8c77ceb 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -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(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(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(result, 200); }, diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index fee5415..cd591e7 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -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 */ diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index 7d44b44..e459b62 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -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, }; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index a75329c..684260f 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -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'",