feat: repositories frontend

This commit is contained in:
Nicolas Meienberger
2025-10-23 19:14:03 +02:00
parent 9d10e48da6
commit 8b1438ea62
8 changed files with 693 additions and 8 deletions

View File

@@ -33,13 +33,12 @@ export function AppSidebar() {
return (
<Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold">
<Mountain className="size-5 text-strong-accent ml-[6px]" />
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<Mountain className="size-5 text-strong-accent" />
<span
className={cn(
"text-base transition-opacity duration-200",
state === "collapsed" && "opacity-0 w-0 overflow-hidden",
)}
className={cn("text-base transition-all duration-200", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
})}
>
Ironmount
</span>

View File

@@ -0,0 +1,66 @@
import { useMutation } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { useId } from "react";
import { toast } from "sonner";
import { createRepositoryMutation } from "~/api-client/@tanstack/react-query.gen";
import { parseError } from "~/lib/errors";
import { CreateRepositoryForm } from "./create-repository-form";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { ScrollArea } from "./ui/scroll-area";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
};
export const CreateRepositoryDialog = ({ open, setOpen }: Props) => {
const formId = useId();
const create = useMutation({
...createRepositoryMutation(),
onSuccess: () => {
toast.success("Repository created successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to create repository", {
description: parseError(error)?.message,
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Database size={16} className="mr-2" />
Create Repository
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="h-[500px] p-4">
<DialogHeader>
<DialogTitle>Create repository</DialogTitle>
</DialogHeader>
<CreateRepositoryForm
className="mt-4"
mode="create"
formId={formId}
onSubmit={(values) => {
create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } });
}}
/>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={create.isPending}>
Create
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,223 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { COMPRESSION_MODES, repositoryConfigSchema } from "@ironmount/schemas/restic";
import { type } from "arktype";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: FormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<FormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch } = form;
const watchedBackend = watch("backend");
const watchedName = watch("name");
useEffect(() => {
if (watchedBackend && watchedBackend in defaultValuesForType) {
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}
}, [watchedBackend, watchedName, form]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedBackend === "local" && (
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/path/to/repository" {...field} />
</FormControl>
<FormDescription>Local filesystem path where the repository will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{watchedBackend === "s3" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes
</Button>
)}
</form>
</Form>
);
};

View File

@@ -0,0 +1,18 @@
import type { RepositoryBackend } from "@ironmount/schemas/restic";
import { Database, HardDrive, Cloud } from "lucide-react";
type Props = {
backend: RepositoryBackend;
className?: string;
};
export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
switch (backend) {
case "local":
return <HardDrive className={className} />;
case "s3":
return <Cloud className={className} />;
default:
return <Database className={className} />;
}
};

View File

@@ -15,14 +15,29 @@ export interface BreadcrumbItem {
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
const breadcrumbs: BreadcrumbItem[] = [];
// Always start with Home
if (pathname.startsWith("/repositories")) {
breadcrumbs.push({
label: "Repositories",
href: "/repositories",
isCurrentPage: pathname === "/repositories",
});
if (pathname.startsWith("/repositories/") && params.name) {
breadcrumbs.push({
label: params.name,
isCurrentPage: true,
});
}
return breadcrumbs;
}
breadcrumbs.push({
label: "Volumes",
href: "/volumes",
isCurrentPage: pathname === "/volumes",
});
// Handle volume details page
if (pathname.startsWith("/volumes/") && params.name) {
breadcrumbs.push({
label: params.name,

View File

@@ -7,5 +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"),
]),
] satisfies RouteConfig;

View File

@@ -0,0 +1,212 @@
import { useQuery } from "@tanstack/react-query";
import { Database, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { listRepositories } from "~/api-client/sdk.gen";
import { listRepositoriesOptions } from "~/api-client/@tanstack/react-query.gen";
import { CreateRepositoryDialog } from "~/components/create-repository-dialog";
import { RepositoryIcon } from "~/components/repository-icon";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
import type { Route } from "./+types/repositories";
import { cn } from "~/lib/utils";
export function meta(_: Route.MetaArgs) {
return [
{ title: "Ironmount - Repositories" },
{
name: "description",
content: "Manage your backup repositories",
},
];
}
export const clientLoader = async () => {
const repositories = await listRepositories();
if (repositories.data) return { repositories: repositories.data.repositories };
return { repositories: [] };
};
export default function Repositories({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [backendFilter, setBackendFilter] = useState("");
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
const clearFilters = () => {
setSearchQuery("");
setStatusFilter("");
setBackendFilter("");
};
const navigate = useNavigate();
const { data } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredRepositories =
data?.repositories.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend;
}) || [];
const hasNoRepositories = data?.repositories.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) {
return (
<Card className="p-0 gap-0">
<RepositoriesEmptyState />
</Card>
);
}
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]"
placeholder="Search repositories…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mr-[-1px] mt-[-1px]">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="healthy">Healthy</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
<Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] mt-[-1px]">
<SelectValue placeholder="All backends" />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
{(searchQuery || statusFilter || backendFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
</div>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase hidden sm:table-cell">Compression</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredRepositories ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No repositories match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredRepositories.map((repository) => (
<TableRow
key={repository.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/repositories/${repository.name}`)}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} />
{repository.type}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<span
className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{
"bg-green-500/10 text-green-500": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}
>
{repository.status || "unknown"}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
{hasNoFilteredRepositories ? (
"No repositories match filters."
) : (
<span>
<span className="text-strong-accent">{filteredRepositories.length}</span> repositor
{filteredRepositories.length === 1 ? "y" : "ies"}
</span>
)}
</div>
</Card>
);
}
function RepositoriesEmptyState() {
const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false);
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<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 mb-8">
<h3 className="text-2xl font-semibold text-foreground">No repositories yet</h3>
<p className="text-muted-foreground text-sm">
Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized
for storage efficiency.
</p>
</div>
<CreateRepositoryDialog open={createRepositoryOpen} setOpen={setCreateRepositoryOpen} />
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { deleteRepositoryMutation, getRepositoryOptions } 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";
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Ironmount - ${params.name}` },
{
name: "description",
content: "Manage your restic backup repositories with ease.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const repository = await getRepository({ path: { name: params.name ?? "" } });
if (repository.data) return repository.data;
};
export default function RepositoryDetailsPage({ loaderData }: Route.ComponentProps) {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const { data } = useQuery({
...getRepositoryOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const deleteRepo = useMutation({
...deleteRepositoryMutation(),
onSuccess: () => {
toast.success("Repository deleted successfully");
navigate("/repositories");
},
onError: (error) => {
toast.error("Failed to delete repository", {
description: parseError(error)?.message,
});
},
});
const handleDeleteConfirm = (name: string) => {
if (
confirm(
`Are you sure you want to delete the repository "${name}"? This action cannot be undone and will remove all backup data.`,
)
) {
deleteRepo.mutate({ path: { name } });
}
};
if (!name) {
return <div>Repository not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
const { repository } = data;
return (
<>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{
"bg-green-500/10 text-green-500": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}
>
{repository.status || "unknown"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{repository.type}</span>
</div>
</div>
<div className="flex gap-4">
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteRepo.isPending}>
Delete
</Button>
</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>
</>
);
}