From f7718055ebde1e76099cd65dd6f6b4f84372936a Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 23 Oct 2025 20:55:44 +0200 Subject: [PATCH] feat: repositories snapshots frontend --- Dockerfile | 35 ++- apps/client/app/lib/types.ts | 4 +- .../repositories}/routes/repositories.tsx | 0 .../routes/repository-details.tsx | 88 +++--- .../app/modules/repositories/tabs/info.tsx | 62 +++++ .../modules/repositories/tabs/snapshots.tsx | 256 ++++++++++++++++++ apps/client/app/routes.ts | 4 +- .../repositories/repositories.controller.ts | 12 +- .../src/modules/volumes/volume.controller.ts | 2 +- apps/server/src/utils/restic.ts | 8 +- docker-compose.yml | 1 + package.json | 2 +- 12 files changed, 402 insertions(+), 72 deletions(-) rename apps/client/app/{ => modules/repositories}/routes/repositories.tsx (100%) rename apps/client/app/{ => modules/repositories}/routes/repository-details.tsx (53%) create mode 100644 apps/client/app/modules/repositories/tabs/info.tsx create mode 100644 apps/client/app/modules/repositories/tabs/snapshots.tsx diff --git a/Dockerfile b/Dockerfile index 2cbb392..f334073 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,42 @@ -ARG BUN_VERSION="1.3.0" +ARG BUN_VERSION="1.3.1" -FROM oven/bun:${BUN_VERSION}-alpine AS runner_base +FROM oven/bun:${BUN_VERSION}-alpine AS base -RUN apk add --no-cache davfs2 restic +RUN apk add --no-cache davfs2 + + +# ------------------------------ +# DEPENDENCIES +# ------------------------------ +FROM base AS deps + +WORKDIR /deps + +ARG TARGETARCH +ARG RESTIC_VERSION="0.18.1" +ENV TARGETARCH=${TARGETARCH} + +RUN apk add --no-cache curl bzip2 + +RUN echo "Building for ${TARGETARCH}" +RUN if [ "${TARGETARCH}" = "arm64" ]; then \ + curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \ + elif [ "${TARGETARCH}" = "amd64" ]; then \ + curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \ + fi + +RUN bzip2 -d restic.bz2 && chmod +x restic # ------------------------------ # DEVELOPMENT # ------------------------------ -FROM runner_base AS development +FROM base AS development ENV NODE_ENV="development" WORKDIR /app +COPY --from=deps /deps/restic /usr/local/bin/restic COPY ./package.json ./bun.lock ./ COPY ./packages/schemas/package.json ./packages/schemas/package.json COPY ./apps/client/package.json ./apps/client/package.json @@ -45,7 +69,7 @@ COPY . . RUN bun run build -FROM runner_base AS production +FROM base AS production ENV NODE_ENV="production" @@ -55,6 +79,7 @@ RUN apk add --no-cache davfs2=1.6.1-r2 WORKDIR /app +COPY --from=deps /deps/restic /usr/local/bin/restic COPY --from=builder /app/apps/server/dist ./ COPY --from=builder /app/apps/server/drizzle ./assets/migrations COPY --from=builder /app/apps/client/dist/client ./assets/frontend diff --git a/apps/client/app/lib/types.ts b/apps/client/app/lib/types.ts index 07f3024..1d3fd54 100644 --- a/apps/client/app/lib/types.ts +++ b/apps/client/app/lib/types.ts @@ -1,7 +1,9 @@ -import type { GetMeResponse, GetVolumeResponse } from "~/api-client"; +import type { GetMeResponse, GetRepositoryResponse, GetVolumeResponse } from "~/api-client"; export type Volume = GetVolumeResponse["volume"]; export type StatFs = GetVolumeResponse["statfs"]; export type VolumeStatus = Volume["status"]; export type User = GetMeResponse["user"]; + +export type Repository = GetRepositoryResponse["repository"]; diff --git a/apps/client/app/routes/repositories.tsx b/apps/client/app/modules/repositories/routes/repositories.tsx similarity index 100% rename from apps/client/app/routes/repositories.tsx rename to apps/client/app/modules/repositories/routes/repositories.tsx diff --git a/apps/client/app/routes/repository-details.tsx b/apps/client/app/modules/repositories/routes/repository-details.tsx similarity index 53% rename from apps/client/app/routes/repository-details.tsx rename to apps/client/app/modules/repositories/routes/repository-details.tsx index c434312..9ed6660 100644 --- a/apps/client/app/routes/repository-details.tsx +++ b/apps/client/app/modules/repositories/routes/repository-details.tsx @@ -1,13 +1,20 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams } from "react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; -import { deleteRepositoryMutation, getRepositoryOptions } from "~/api-client/@tanstack/react-query.gen"; +import { + deleteRepositoryMutation, + getRepositoryOptions, + listSnapshotsOptions, +} from "~/api-client/@tanstack/react-query.gen"; import { Button } from "~/components/ui/button"; -import { Card } from "~/components/ui/card"; import { parseError } from "~/lib/errors"; import { getRepository } from "~/api-client/sdk.gen"; import type { Route } from "./+types/repository-details"; import { cn } from "~/lib/utils"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { RepositoryInfoTabContent } from "../tabs/info"; +import { RepositorySnapshotsTabContent } from "../tabs/snapshots"; +import { useEffect } from "react"; export function meta({ params }: Route.MetaArgs) { return [ @@ -27,6 +34,10 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) { const { name } = useParams<{ name: string }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") || "info"; const { data } = useQuery({ ...getRepositoryOptions({ path: { name: name ?? "" } }), @@ -35,6 +46,12 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro refetchOnWindowFocus: true, }); + useEffect(() => { + if (name) { + queryClient.prefetchQuery(listSnapshotsOptions({ path: { name } })); + } + }, [name, queryClient]); + const deleteRepo = useMutation({ ...deleteRepositoryMutation(), onSuccess: () => { @@ -94,57 +111,18 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro - -
-
-

Repository Information

-
-
-
Name
-

{repository.name}

-
-
-
Backend
-

{repository.type}

-
-
-
Compression Mode
-

{repository.compressionMode || "off"}

-
-
-
Status
-

{repository.status || "unknown"}

-
-
-
Created At
-

{new Date(repository.createdAt).toLocaleString()}

-
-
-
Last Checked
-

- {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} -

-
-
-
- - {repository.lastError && ( -
-

Last Error

-
-

{repository.lastError}

-
-
- )} - -
-

Configuration

-
-
{JSON.stringify(repository.config, null, 2)}
-
-
-
-
+ setSearchParams({ tab: value })}> + + Configuration + Snapshots + + + + + + + + ); } diff --git a/apps/client/app/modules/repositories/tabs/info.tsx b/apps/client/app/modules/repositories/tabs/info.tsx new file mode 100644 index 0000000..efc32d3 --- /dev/null +++ b/apps/client/app/modules/repositories/tabs/info.tsx @@ -0,0 +1,62 @@ +import { Card } from "~/components/ui/card"; +import type { Repository } from "~/lib/types"; + +type Props = { + repository: Repository; +}; + +export const RepositoryInfoTabContent = ({ repository }: Props) => { + return ( + +
+
+

Repository Information

+
+
+
Name
+

{repository.name}

+
+
+
Backend
+

{repository.type}

+
+
+
Compression Mode
+

{repository.compressionMode || "off"}

+
+
+
Status
+

{repository.status || "unknown"}

+
+
+
Created At
+

{new Date(repository.createdAt).toLocaleString()}

+
+
+
Last Checked
+

+ {repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"} +

+
+
+
+ + {repository.lastError && ( +
+

Last Error

+
+

{repository.lastError}

+
+
+ )} + +
+

Configuration

+
+
{JSON.stringify(repository.config, null, 2)}
+
+
+
+
+ ); +}; diff --git a/apps/client/app/modules/repositories/tabs/snapshots.tsx b/apps/client/app/modules/repositories/tabs/snapshots.tsx new file mode 100644 index 0000000..5d6803f --- /dev/null +++ b/apps/client/app/modules/repositories/tabs/snapshots.tsx @@ -0,0 +1,256 @@ +import { useQuery } from "@tanstack/react-query"; +import { intervalToDuration } from "date-fns"; +import { Calendar, Clock, Database, FolderTree, HardDrive } 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 { 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, TableHead, TableHeader, TableRow } from "~/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; +import type { Repository } from "~/lib/types"; + +type Props = { + repository: Repository; +}; + +type Snapshot = ListSnapshotsResponse["snapshots"][0]; + +export const RepositorySnapshotsTabContent = ({ repository }: Props) => { + const [searchQuery, setSearchQuery] = useState(""); + + const { data, isLoading, error } = useQuery({ + ...listSnapshotsOptions({ path: { name: repository.name } }), + refetchInterval: 10000, + refetchOnWindowFocus: true, + }); + + const snapshots = data?.snapshots || []; + + const filteredSnapshots = snapshots.filter((snapshot: Snapshot) => { + if (!searchQuery) return true; + const searchLower = searchQuery.toLowerCase(); + return ( + snapshot.short_id.toLowerCase().includes(searchLower) || + snapshot.paths.some((path) => path.toLowerCase().includes(searchLower)) + ); + }); + + const hasNoSnapshots = snapshots.length === 0; + const hasNoFilteredSnapshots = filteredSnapshots.length === 0 && !hasNoSnapshots; + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleString(); + }; + + const formatSnapshotDuration = (seconds: number) => { + const duration = intervalToDuration({ start: 0, end: seconds * 1000 }); + const parts: string[] = []; + + if (duration.days) parts.push(`${duration.days}d`); + if (duration.hours) parts.push(`${duration.hours}h`); + if (duration.minutes) parts.push(`${duration.minutes}m`); + if (duration.seconds || parts.length === 0) parts.push(`${duration.seconds || 0}s`); + + return parts.join(" "); + }; + + if (repository.status === "error") { + return ( + + + +

Repository Error

+

+ This repository is in an error state and cannot be accessed. +

+ {repository.lastError && ( +
+

{repository.lastError}

+
+ )} +
+
+ ); + } + + if (isLoading && !data) { + return ( + + +

Loading snapshots...

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

Failed to Load Snapshots

+

{error.message}

+
+
+ ); + } + + if (hasNoSnapshots) { + return ( + + +
+
+
+
+
+ +
+
+
+

No snapshots yet

+

+ Snapshots are point-in-time backups of your data. Create your first backup to see it here. +

+
+ + + ); + } + + return ( + + +
+
+ Snapshots + + Backup snapshots stored in this repository. Total: {snapshots.length} + +
+
+ setSearchQuery(e.target.value)} + /> +
+
+
+
+ + + + Snapshot ID + Date & Time + Size + Duration + Paths + + + + {hasNoFilteredSnapshots ? ( + + +
+

No snapshots match your search.

+ +
+
+
+ ) : ( + filteredSnapshots.map((snapshot) => ( + + +
+ + {snapshot.short_id} +
+
+ +
+ + {formatDate(snapshot.time / 1000)} +
+
+ +
+ + + + +
+
+ +
+ + + {formatSnapshotDuration(snapshot.duration / 1000)} + +
+
+ +
+ +
+ + {snapshot.paths[0].split("/").filter(Boolean).pop() || "/"} + + + + + +{snapshot.paths.length - 1} + + + +
+ {snapshot.paths.slice(1).map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+
+
+ )) + )} +
+
+
+
+ + {hasNoFilteredSnapshots + ? "No snapshots match filters." + : `Showing ${filteredSnapshots.length} of ${snapshots.length}`} + + {!hasNoFilteredSnapshots && ( + + Total size:  + + sum + s.size, 0)} + base={1024} + maximumFractionDigits={1} + /> + + + )} +
+
+ ); +}; diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 749042d..144e30a 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -7,7 +7,7 @@ export default [ route("/", "./routes/root.tsx"), route("volumes", "./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx"), - route("repositories", "./routes/repositories.tsx"), - route("repositories/:name", "./routes/repository-details.tsx"), + route("repositories", "./modules/repositories/routes/repositories.tsx"), + route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"), ]), ] satisfies RouteConfig; diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index e9697ba..dfcf532 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -61,19 +61,25 @@ export const repositoriesController = new Hono() const snapshots = res.map((snapshot) => { const { summary } = snapshot; - const { backup_start, backup_end } = summary; - const duration = new Date(backup_end).getTime() - new Date(backup_start).getTime(); + + let duration = 0; + if (summary) { + const { backup_start, backup_end } = summary; + duration = new Date(backup_end).getTime() - new Date(backup_start).getTime(); + } return { short_id: snapshot.short_id, duration, paths: snapshot.paths, - size: summary.total_bytes_processed, + size: summary?.total_bytes_processed || 0, time: new Date(snapshot.time).getTime(), }; }); const response = { snapshots } satisfies ListSnapshotsResponseDto; + c.header("Cache-Control", "max-age=30, stale-while-revalidate=300"); + return c.json(response, 200); }); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index dacc391..5dc960d 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -1,6 +1,5 @@ import { Hono } from "hono"; import { validator } from "hono-openapi"; -import { getVolumePath } from "./helpers"; import { createVolumeBody, createVolumeDto, @@ -23,6 +22,7 @@ import { updateVolumeDto, } from "./volume.dto"; import { volumeService } from "./volume.service"; +import { getVolumePath } from "./helpers"; export const volumeController = new Hono() .get("/", listVolumesDto, async (c) => { diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 70e8628..9377a89 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -26,15 +26,15 @@ const backupOutputSchema = type({ }); const snapshotInfoSchema = type({ - gid: "number", + gid: "number?", hostname: "string", id: "string", parent: "string?", paths: "string[]", - program_version: "string", + program_version: "string?", short_id: "string", time: "string", - uid: "number", + uid: "number?", username: "string", summary: type({ backup_end: "string", @@ -51,7 +51,7 @@ const snapshotInfoSchema = type({ total_bytes_processed: "number", total_files_processed: "number", tree_blobs: "number", - }), + }).optional(), }); const ensurePassfile = async () => { diff --git a/docker-compose.yml b/docker-compose.yml index f050250..2edc103 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - /var/lib/ironmount/volumes/:/var/lib/ironmount/volumes:rshared + - /var/lib/repositories/:/var/lib/repositories - ironmount_data:/data - ./apps/client/app:/app/apps/client/app diff --git a/package.json b/package.json index ef6405d..1f0a407 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ironmount", "private": true, - "packageManager": "bun@1.3.0", + "packageManager": "bun@1.3.1", "scripts": { "dev": "turbo dev", "tsc": "turbo run tsc",