mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: repositories frontend
This commit is contained in:
@@ -33,13 +33,12 @@ export function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" collapsible="icon" className="p-0">
|
<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">
|
<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">
|
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
|
||||||
<Mountain className="size-5 text-strong-accent ml-[6px]" />
|
<Mountain className="size-5 text-strong-accent" />
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("text-base transition-all duration-200", {
|
||||||
"text-base transition-opacity duration-200",
|
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
|
||||||
state === "collapsed" && "opacity-0 w-0 overflow-hidden",
|
})}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Ironmount
|
Ironmount
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
66
apps/client/app/components/create-repository-dialog.tsx
Normal file
66
apps/client/app/components/create-repository-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
223
apps/client/app/components/create-repository-form.tsx
Normal file
223
apps/client/app/components/create-repository-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
18
apps/client/app/components/repository-icon.tsx
Normal file
18
apps/client/app/components/repository-icon.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,14 +15,29 @@ export interface BreadcrumbItem {
|
|||||||
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
|
export function generateBreadcrumbs(pathname: string, params: Record<string, string | undefined>): BreadcrumbItem[] {
|
||||||
const breadcrumbs: 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({
|
breadcrumbs.push({
|
||||||
label: "Volumes",
|
label: "Volumes",
|
||||||
href: "/volumes",
|
href: "/volumes",
|
||||||
isCurrentPage: pathname === "/volumes",
|
isCurrentPage: pathname === "/volumes",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle volume details page
|
|
||||||
if (pathname.startsWith("/volumes/") && params.name) {
|
if (pathname.startsWith("/volumes/") && params.name) {
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: params.name,
|
label: params.name,
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ export default [
|
|||||||
route("/", "./routes/root.tsx"),
|
route("/", "./routes/root.tsx"),
|
||||||
route("volumes", "./routes/home.tsx"),
|
route("volumes", "./routes/home.tsx"),
|
||||||
route("volumes/:name", "./routes/details.tsx"),
|
route("volumes/:name", "./routes/details.tsx"),
|
||||||
|
route("repositories", "./routes/repositories.tsx"),
|
||||||
|
route("repositories/:name", "./routes/repository-details.tsx"),
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
212
apps/client/app/routes/repositories.tsx
Normal file
212
apps/client/app/routes/repositories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/client/app/routes/repository-details.tsx
Normal file
150
apps/client/app/routes/repository-details.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user