diff --git a/apps/client/app/components/app-sidebar.tsx b/apps/client/app/components/app-sidebar.tsx index 989a72f..d0fbd5b 100644 --- a/apps/client/app/components/app-sidebar.tsx +++ b/apps/client/app/components/app-sidebar.tsx @@ -33,13 +33,12 @@ export function AppSidebar() { return ( - - + + Ironmount diff --git a/apps/client/app/components/create-repository-dialog.tsx b/apps/client/app/components/create-repository-dialog.tsx new file mode 100644 index 0000000..5ee614c --- /dev/null +++ b/apps/client/app/components/create-repository-dialog.tsx @@ -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 ( + + + + + + + + Create repository + + { + create.mutate({ body: { config: values, name: values.name, compressionMode: values.compressionMode } }); + }} + /> + + + + + + + + ); +}; diff --git a/apps/client/app/components/create-repository-form.tsx b/apps/client/app/components/create-repository-form.tsx new file mode 100644 index 0000000..f6373a7 --- /dev/null +++ b/apps/client/app/components/create-repository-form.tsx @@ -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; + 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({ + 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 ( +
+ + ( + + Name + + field.onChange(slugify(e.target.value))} + max={32} + min={2} + disabled={mode === "update"} + className={mode === "update" ? "bg-gray-50" : ""} + /> + + Unique identifier for the repository. + + + )} + /> + ( + + Backend + + Choose the storage backend for this repository. + + + )} + /> + + ( + + Compression Mode + + Compression mode for backups stored in this repository. + + + )} + /> + + {watchedBackend === "local" && ( + ( + + Path + + + + Local filesystem path where the repository will be stored. + + + )} + /> + )} + + {watchedBackend === "s3" && ( + <> + ( + + Endpoint + + + + S3-compatible endpoint URL. + + + )} + /> + ( + + Bucket + + + + S3 bucket name for storing backups. + + + )} + /> + ( + + Access Key ID + + + + S3 access key ID for authentication. + + + )} + /> + ( + + Secret Access Key + + + + S3 secret access key for authentication. + + + )} + /> + + )} + + {mode === "update" && ( + + )} + + + ); +}; diff --git a/apps/client/app/components/repository-icon.tsx b/apps/client/app/components/repository-icon.tsx new file mode 100644 index 0000000..a706448 --- /dev/null +++ b/apps/client/app/components/repository-icon.tsx @@ -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 ; + case "s3": + return ; + default: + return ; + } +}; diff --git a/apps/client/app/lib/breadcrumbs.ts b/apps/client/app/lib/breadcrumbs.ts index 6aa7162..699f91e 100644 --- a/apps/client/app/lib/breadcrumbs.ts +++ b/apps/client/app/lib/breadcrumbs.ts @@ -15,14 +15,29 @@ export interface BreadcrumbItem { export function generateBreadcrumbs(pathname: string, params: Record): 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, diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index fb586e3..749042d 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -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; diff --git a/apps/client/app/routes/repositories.tsx b/apps/client/app/routes/repositories.tsx new file mode 100644 index 0000000..1f22e3f --- /dev/null +++ b/apps/client/app/routes/repositories.tsx @@ -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 ( + + + + ); + } + + return ( + +
+ + setSearchQuery(e.target.value)} + /> + + + {(searchQuery || statusFilter || backendFilter) && ( + + )} + + +
+
+ + + + Name + Backend + Compression + Status + + + + {hasNoFilteredRepositories ? ( + + +
+

No repositories match your filters.

+ +
+
+
+ ) : ( + filteredRepositories.map((repository) => ( + navigate(`/repositories/${repository.name}`)} + > + {repository.name} + + + + {repository.type} + + + + + {repository.compressionMode || "off"} + + + + + {repository.status || "unknown"} + + + + )) + )} +
+
+
+
+ {hasNoFilteredRepositories ? ( + "No repositories match filters." + ) : ( + + {filteredRepositories.length} repositor + {filteredRepositories.length === 1 ? "y" : "ies"} + + )} +
+
+ ); +} + +function RepositoriesEmptyState() { + const [createRepositoryOpen, setCreateRepositoryOpen] = useState(false); + + return ( +
+
+
+
+
+ +
+ +
+
+
+

No repositories yet

+

+ Repositories are remote storage locations where you can backup your volumes securely. Encrypted and optimized + for storage efficiency. +

+
+ + +
+ ); +} diff --git a/apps/client/app/routes/repository-details.tsx b/apps/client/app/routes/repository-details.tsx new file mode 100644 index 0000000..c434312 --- /dev/null +++ b/apps/client/app/routes/repository-details.tsx @@ -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
Repository not found
; + } + + if (!data) { + return
Loading...
; + } + + const { repository } = data; + + return ( + <> +
+
+
+ + {repository.status || "unknown"} + + {repository.type} +
+
+
+ +
+
+ + +
+
+

Repository Information

+
+
+
Name
+

{repository.name}

+
+
+
Backend
+

{repository.type}

+
+
+
Compression Mode
+

{repository.compressionMode || "off"}

+
+
+
Status
+

{repository.status || "unknown"}

+
+
+
Created At
+

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

+
+
+
Last Checked
+

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

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

Last Error

+
+

{repository.lastError}

+
+
+ )} + +
+

Configuration

+
+
{JSON.stringify(repository.config, null, 2)}
+
+
+
+
+ + ); +}