feat: volume details

This commit is contained in:
Nicolas Meienberger
2025-09-07 16:08:08 +02:00
parent aa82f95c56
commit 833bcb590f
13 changed files with 280 additions and 153 deletions

View File

@@ -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<FormValues>({
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 }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />

View File

@@ -0,0 +1,20 @@
import { Outlet } from "react-router";
import { cn } from "~/lib/utils";
export default function Layout() {
return (
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
<Outlet />
</main>
</div>
);
}

View File

@@ -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<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -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 (
<Card className="p-6 flex-1 flex flex-col">
<div className="flex flex-col flex-1 justify-start">
<span className="flex items-center gap-2 mb-4">
<ScanHeartIcon size={24} />
<h2 className="text-lg font-medium">Health Checks</h2>
</span>
<span className="">Status: {volume.status ?? "Unknown"}</span>
<span className="text-sm text-muted-foreground mb-4">
Last checked: {new Date(volume.lastHealthCheck).toLocaleString()}
</span>
<span className="flex items-center">
Enable auto remount
<Switch className="ml-auto cursor-pointer" checked={volume.autoRemount} />
</span>
</div>
<Button variant="outline">Run Health Check</Button>
</Card>
);
};

View File

@@ -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;

View File

@@ -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 <div>Volume not found</div>;
}
if (!data) {
return <div>Loading...</div>;
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-0 uppercase">Volume: {name}</h1>
<div className="text-sm font-semibold mb-2 text-muted-foreground flex items-center gap-2">
<span className="text-green-500 flex items-center gap-2">
<WifiIcon size={16} />
{data.status}
</span>
<VolumeIcon size={14} backend={data?.config.backend} />
</div>
</div>
<div className="flex gap-4">
<Button>Mount</Button>
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}>
Delete
</Button>
</div>
</div>
<div className="flex gap-4">
<Card className="my-4 p-6 flex-1">
<CreateVolumeForm initialValues={{ ...data, ...data?.config }} onSubmit={console.log} />
</Card>
<div className="flex flex-col my-4 gap-4">
<HealthchecksCard volume={data} />
<Card className="p-6 flex-1">
<h2 className="text-lg font-medium">Volume Information</h2>
</Card>
</div>
</div>
</>
);
}

View File

@@ -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<ListVolumesResponse["volumes"][number]>();
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,16 +40,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
});
return (
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
<>
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
Create, manage, monitor, and automate your volumes with ease.
@@ -107,12 +79,15 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<TableHead className="uppercase text-left">Backend</TableHead>
<TableHead className="uppercase">Mountpoint</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.volumes.map((volume) => (
<TableRow key={volume.name}>
<TableRow
key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/volumes/${volume.name}`)}
>
<TableCell className="font-medium">{volume.name}</TableCell>
<TableCell>
<VolumeIcon backend={volume.type} />
@@ -131,21 +106,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
type="button"
onClick={() => {
setVolumeToEdit(volume);
}}
>
Edit
</Button>
<Button type="button" variant="destructive" onClick={() => handleDeleteConfirm(volume.name)}>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -155,7 +115,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
setOpen={() => setVolumeToEdit(undefined)}
initialValues={volumeToEdit}
/>
</main>
</div>
</>
);
}

View File

@@ -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",

View File

@@ -22,10 +22,16 @@ const mount = async (config: BackendConfig, path: string) => {
const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`;
return new Promise<void>((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<void>((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}`));

View File

@@ -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);
})

View File

@@ -15,6 +15,8 @@ const volumeSchema = type({
config: volumeConfigSchema,
});
export type VolumeDto = typeof volumeSchema.infer;
/**
* List all volumes
*/

View File

@@ -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 {

View File

@@ -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=="],