feat: repositories snapshots frontend

This commit is contained in:
Nicolas Meienberger
2025-10-23 20:55:44 +02:00
parent cae8538b2e
commit f7718055eb
12 changed files with 402 additions and 72 deletions

View File

@@ -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

View File

@@ -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"];

View File

@@ -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
</div>
</div>
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div>
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
</div>
</Card>
<Tabs value={activeTab} onValueChange={(value) => setSearchParams({ tab: value })}>
<TabsList className="mb-2">
<TabsTrigger value="info">Configuration</TabsTrigger>
<TabsTrigger value="snapshots">Snapshots</TabsTrigger>
</TabsList>
<TabsContent value="info">
<RepositoryInfoTabContent repository={data.repository} />
</TabsContent>
<TabsContent value="snapshots">
<RepositorySnapshotsTabContent repository={data.repository} />
</TabsContent>
</Tabs>
</>
);
}

View File

@@ -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 (
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div>
<h3 className="text-lg font-semibold mb-4 text-red-500">Last Error</h3>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
</div>
</Card>
);
};

View File

@@ -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 (
<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">Repository Error</p>
<p className="text-sm text-muted-foreground mt-2">
This repository is in an error state and cannot be accessed.
</p>
{repository.lastError && (
<div className="mt-4 max-w-md bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{repository.lastError}</p>
</div>
)}
</CardContent>
</Card>
);
}
if (isLoading && !data) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Loading snapshots...</p>
</CardContent>
</Card>
);
}
if (error) {
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">Failed to Load Snapshots</p>
<p className="text-sm text-muted-foreground mt-2">{error.message}</p>
</CardContent>
</Card>
);
}
if (hasNoSnapshots) {
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">
<Database 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 yet</h3>
<p className="text-muted-foreground text-sm">
Snapshots are point-in-time backups of your data. Create your first backup to see it here.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="p-0 gap-0">
<CardHeader className="p-4 bg-card-header">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-4 justify-between">
<div className="flex-1">
<CardTitle>Snapshots</CardTitle>
<CardDescription className="mt-1">
Backup snapshots stored in this repository. Total: {snapshots.length}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<Input
className="w-full lg:w-[240px]"
placeholder="Search snapshots..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
</CardHeader>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredSnapshots ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No snapshots match your search.</p>
<Button onClick={() => setSearchQuery("")} variant="outline" size="sm">
Clear search
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredSnapshots.map((snapshot) => (
<TableRow key={snapshot.short_id} className="hover:bg-accent/50">
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{formatDate(snapshot.time / 1000)}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{formatSnapshotDuration(snapshot.duration / 1000)}
</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-wrap gap-1">
<span
className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1"
title={snapshot.paths[0]}
>
{snapshot.paths[0].split("/").filter(Boolean).pop() || "/"}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`text-xs bg-muted text-muted-foreground rounded-md px-2 py-1 cursor-help ${snapshot.paths.length <= 1 ? "hidden" : ""}`}
>
+{snapshot.paths.length - 1}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.slice(1).map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>
{hasNoFilteredSnapshots
? "No snapshots match filters."
: `Showing ${filteredSnapshots.length} of ${snapshots.length}`}
</span>
{!hasNoFilteredSnapshots && (
<span>
Total size:&nbsp;
<span className="text-strong-accent font-medium">
<ByteSize
bytes={filteredSnapshots.reduce((sum, s) => sum + s.size, 0)}
base={1024}
maximumFractionDigits={1}
/>
</span>
</span>
)}
</div>
</Card>
);
};

View File

@@ -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;

View File

@@ -61,19 +61,25 @@ export const repositoriesController = new Hono()
const snapshots = res.map((snapshot) => {
const { summary } = snapshot;
let duration = 0;
if (summary) {
const { backup_start, backup_end } = summary;
const duration = new Date(backup_end).getTime() - new Date(backup_start).getTime();
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);
});

View File

@@ -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) => {

View File

@@ -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 () => {

View File

@@ -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

View File

@@ -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",