mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: repositories snapshots frontend
This commit is contained in:
35
Dockerfile
35
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
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
apps/client/app/modules/repositories/tabs/info.tsx
Normal file
62
apps/client/app/modules/repositories/tabs/info.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
256
apps/client/app/modules/repositories/tabs/snapshots.tsx
Normal file
256
apps/client/app/modules/repositories/tabs/snapshots.tsx
Normal 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:
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user