From 833bcb590fe73252ff0334b427c51a7814ddd142 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 7 Sep 2025 16:08:08 +0200 Subject: [PATCH] feat: volume details --- .../app/components/create-volume-form.tsx | 20 +- apps/client/app/components/layout.tsx | 20 ++ apps/client/app/components/ui/switch.tsx | 29 +++ .../details/components/healthchecks-card.tsx | 31 +++ apps/client/app/routes.ts | 6 +- apps/client/app/routes/details.tsx | 89 ++++++++ apps/client/app/routes/home.tsx | 199 +++++++----------- apps/client/package.json | 1 + .../src/modules/backends/nfs/nfs-backend.ts | 14 +- .../src/modules/volumes/volume.controller.ts | 9 +- apps/server/src/modules/volumes/volume.dto.ts | 2 + .../src/modules/volumes/volume.service.ts | 10 +- bun.lock | 3 + 13 files changed, 280 insertions(+), 153 deletions(-) create mode 100644 apps/client/app/components/layout.tsx create mode 100644 apps/client/app/components/ui/switch.tsx create mode 100644 apps/client/app/modules/details/components/healthchecks-card.tsx create mode 100644 apps/client/app/routes/details.tsx diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index ae52fdf..a6d0861 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -3,7 +3,7 @@ import { volumeConfigSchema } from "@ironmount/schemas"; import { useMutation } from "@tanstack/react-query"; import { type } from "arktype"; import { CheckCircle, Loader2, XCircle } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; import { slugify } from "~/lib/utils"; @@ -28,21 +28,9 @@ type Props = { export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => { const form = useForm({ resolver: arktypeResolver(formSchema), - defaultValues: { - name: "", - backend: "directory", - }, + defaultValues: initialValues, }); - const { setValue, formState, watch, getValues } = form; - const { isDirty } = formState; - - useEffect(() => { - if (initialValues && !isDirty) { - for (const [key, value] of Object.entries(initialValues)) { - setValue(key as keyof FormValues, value as string); - } - } - }, [initialValues, isDirty, setValue]); + const { watch, getValues } = form; const watchedBackend = watch("backend"); @@ -108,7 +96,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for render={({ field }) => ( Backend - diff --git a/apps/client/app/components/layout.tsx b/apps/client/app/components/layout.tsx new file mode 100644 index 0000000..3084f35 --- /dev/null +++ b/apps/client/app/components/layout.tsx @@ -0,0 +1,20 @@ +import { Outlet } from "react-router"; +import { cn } from "~/lib/utils"; + +export default function Layout() { + return ( +
+
+
+ +
+
+ ); +} diff --git a/apps/client/app/components/ui/switch.tsx b/apps/client/app/components/ui/switch.tsx new file mode 100644 index 0000000..a9cecb6 --- /dev/null +++ b/apps/client/app/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "~/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/details/components/healthchecks-card.tsx new file mode 100644 index 0000000..6ff7f72 --- /dev/null +++ b/apps/client/app/modules/details/components/healthchecks-card.tsx @@ -0,0 +1,31 @@ +import { ScanHeartIcon } from "lucide-react"; +import type { GetVolumeResponse } from "~/api-client"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { Switch } from "~/components/ui/switch"; + +type Props = { + volume: GetVolumeResponse; +}; + +export const HealthchecksCard = ({ volume }: Props) => { + return ( + +
+ + +

Health Checks

+
+ Status: {volume.status ?? "Unknown"} + + Last checked: {new Date(volume.lastHealthCheck).toLocaleString()} + + + Enable auto remount + + +
+ +
+ ); +}; diff --git a/apps/client/app/routes.ts b/apps/client/app/routes.ts index 38f1a4d..b71113b 100644 --- a/apps/client/app/routes.ts +++ b/apps/client/app/routes.ts @@ -1,3 +1,5 @@ -import { index, type RouteConfig } from "@react-router/dev/routes"; +import { index, layout, type RouteConfig, route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [ + layout("./components/layout.tsx", [index("./routes/home.tsx"), route("volumes/:name", "./routes/details.tsx")]), +] satisfies RouteConfig; diff --git a/apps/client/app/routes/details.tsx b/apps/client/app/routes/details.tsx new file mode 100644 index 0000000..3ad6243 --- /dev/null +++ b/apps/client/app/routes/details.tsx @@ -0,0 +1,89 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { WifiIcon } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; +import { toast } from "sonner"; +import { getVolume } from "~/api-client"; +import { deleteVolumeMutation, getVolumeOptions } from "~/api-client/@tanstack/react-query.gen"; +import { CreateVolumeForm } from "~/components/create-volume-form"; +import { Button } from "~/components/ui/button"; +import { Card } from "~/components/ui/card"; +import { VolumeIcon } from "~/components/volume-icon"; +import { parseError } from "~/lib/errors"; +import { HealthchecksCard } from "~/modules/details/components/healthchecks-card"; +import type { Route } from "./+types/details"; + +export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => { + const volume = await getVolume({ path: { name: params.name ?? "" } }); + if (volume.data) return volume.data; +}; + +export default function DetailsPage({ loaderData }: Route.ComponentProps) { + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + + const { data } = useQuery({ + ...getVolumeOptions({ path: { name: name ?? "" } }), + initialData: loaderData, + }); + + const deleteVol = useMutation({ + ...deleteVolumeMutation(), + onSuccess: () => { + toast.success("Volume deleted successfully"); + navigate("/"); + }, + onError: (error) => { + toast.error("Failed to delete volume", { + description: parseError(error)?.message, + }); + }, + }); + + const handleDeleteConfirm = (name: string) => { + if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) { + deleteVol.mutate({ path: { name } }); + } + }; + + if (!name) { + return
Volume not found
; + } + + if (!data) { + return
Loading...
; + } + + return ( + <> +
+
+

Volume: {name}

+
+ + + {data.status} + + +
+
+
+ + +
+
+
+ + + +
+ + +

Volume Information

+
+
+
+ + ); +} diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index 6274c1e..b037558 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -1,18 +1,15 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Copy } from "lucide-react"; import { useState } from "react"; -import { toast } from "sonner"; +import { useNavigate } from "react-router"; import { type ListVolumesResponse, listVolumes } from "~/api-client"; -import { deleteVolumeMutation, listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; +import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { CreateVolumeDialog } from "~/components/create-volume-dialog"; import { EditVolumeDialog } from "~/components/edit-volume-dialog"; -import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table"; import { VolumeIcon } from "~/components/volume-icon"; -import { parseError } from "~/lib/errors"; -import { cn } from "~/lib/utils"; import type { Route } from "./+types/home"; export function meta(_: Route.MetaArgs) { @@ -35,23 +32,7 @@ export default function Home({ loaderData }: Route.ComponentProps) { const [volumeToEdit, setVolumeToEdit] = useState(); const [createVolumeOpen, setCreateVolumeOpen] = useState(false); - const deleteVol = useMutation({ - ...deleteVolumeMutation(), - onSuccess: () => { - toast.success("Volume deleted successfully"); - }, - onError: (error) => { - toast.error("Failed to delete volume", { - description: parseError(error)?.message, - }); - }, - }); - - const handleDeleteConfirm = (name: string) => { - if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) { - deleteVol.mutate({ path: { name } }); - } - }; + const navigate = useNavigate(); const { data } = useQuery({ ...listVolumesOptions(), @@ -59,103 +40,81 @@ export default function Home({ loaderData }: Route.ComponentProps) { }); return ( -
-
-
-

Ironmount

-

- Create, manage, monitor, and automate your volumes with ease. -

-
- - - - - - -
- - A list of your managed volumes. - - - Name - Backend - Mountpoint - Status - Actions + <> +

Ironmount

+

+ Create, manage, monitor, and automate your volumes with ease. +

+
+ + + + + + +
+
+ A list of your managed volumes. + + + Name + Backend + Mountpoint + Status + + + + {data?.volumes.map((volume) => ( + navigate(`/volumes/${volume.name}`)} + > + {volume.name} + + + + + + + {volume.path} + + + + + + + + + + - - - {data?.volumes.map((volume) => ( - - {volume.name} - - - - - - - {volume.path} - - - - - - - - - - - -
- - -
-
-
- ))} -
-
- setVolumeToEdit(undefined)} - initialValues={volumeToEdit} - /> -
-
+ ))} + + + setVolumeToEdit(undefined)} + initialValues={volumeToEdit} + /> + ); } diff --git a/apps/client/package.json b/apps/client/package.json index 7274a50..435433d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index a08cc0f..6064042 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -22,10 +22,16 @@ const mount = async (config: BackendConfig, path: string) => { const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`; return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Mount command timed out")); + }, 5000); + exec(cmd, (error, stdout, stderr) => { console.log("Mount command executed:", { cmd, error, stdout, stderr }); + clearTimeout(timeout); + if (error) { - // console.error(`Error mounting NFS volume: ${stderr}`); + console.error(`Error mounting NFS volume: ${stderr}`); return reject(new Error(`Failed to mount NFS volume: ${stderr}`)); } console.log(`NFS volume mounted successfully: ${stdout}`); @@ -43,8 +49,14 @@ const unmount = async (path: string) => { const cmd = `umount -f ${path}`; return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Mount command timed out")); + }, 5000); + exec(cmd, (error, stdout, stderr) => { console.log("Unmount command executed:", { cmd, error, stdout, stderr }); + clearTimeout(timeout); + if (error) { console.error(`Error unmounting NFS volume: ${stderr}`); return reject(new Error(`Failed to unmount NFS volume: ${stderr}`)); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index e8eba79..afc8c32 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -12,6 +12,7 @@ import { testConnectionDto, updateVolumeBody, updateVolumeDto, + type VolumeDto, } from "./volume.dto"; import { volumeService } from "./volume.service"; @@ -68,13 +69,11 @@ export const volumeController = new Hono() } const response = { - name: res.volume.name, - path: res.volume.path, - type: res.volume.type, + ...res.volume, createdAt: res.volume.createdAt.getTime(), updatedAt: res.volume.updatedAt.getTime(), - config: res.volume.config, - }; + lastHealthCheck: res.volume.lastHealthCheck.getTime(), + } satisfies VolumeDto; return c.json(response, 200); }) diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 5f0a65e..63e38ca 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -15,6 +15,8 @@ const volumeSchema = type({ config: volumeConfigSchema, }); +export type VolumeDto = typeof volumeSchema.infer; + /** * List all volumes */ diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index 4c6b618..ea32b0c 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -108,25 +108,17 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => { return { error: new NotFoundError("Volume not found") }; } - const oldBackend = createVolumeBackend(existing); - await oldBackend.unmount().catch((err) => { - console.warn("Failed to unmount backend:", err); - }); - const updated = await db .update(volumesTable) .set({ config: backendConfig, type: backendConfig.backend, updatedAt: new Date(), + status: "unmounted", }) .where(eq(volumesTable.name, name)) .returning(); - // Mount with new configuration - const newBackend = createVolumeBackend(updated[0]); - await newBackend.mount(); - return { volume: updated[0] }; } catch (error) { return { diff --git a/bun.lock b/bun.lock index 177c574..ca54246 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", @@ -287,6 +288,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],