mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: volume details
This commit is contained in:
@@ -3,7 +3,7 @@ import { volumeConfigSchema } from "@ironmount/schemas";
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
import { CheckCircle, Loader2, XCircle } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
|
||||||
import { slugify } from "~/lib/utils";
|
import { slugify } from "~/lib/utils";
|
||||||
@@ -28,21 +28,9 @@ type Props = {
|
|||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => {
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: arktypeResolver(formSchema),
|
resolver: arktypeResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: initialValues,
|
||||||
name: "",
|
|
||||||
backend: "directory",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { setValue, formState, watch, getValues } = form;
|
const { 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 watchedBackend = watch("backend");
|
const watchedBackend = watch("backend");
|
||||||
|
|
||||||
@@ -108,7 +96,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Backend</FormLabel>
|
<FormLabel>Backend</FormLabel>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a backend" />
|
<SelectValue placeholder="Select a backend" />
|
||||||
|
|||||||
20
apps/client/app/components/layout.tsx
Normal file
20
apps/client/app/components/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/client/app/components/ui/switch.tsx
Normal file
29
apps/client/app/components/ui/switch.tsx
Normal 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 }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
89
apps/client/app/routes/details.tsx
Normal file
89
apps/client/app/routes/details.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Copy } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { useNavigate } from "react-router";
|
||||||
import { type ListVolumesResponse, listVolumes } from "~/api-client";
|
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 { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
||||||
import { EditVolumeDialog } from "~/components/edit-volume-dialog";
|
import { EditVolumeDialog } from "~/components/edit-volume-dialog";
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "~/components/ui/table";
|
||||||
import { VolumeIcon } from "~/components/volume-icon";
|
import { VolumeIcon } from "~/components/volume-icon";
|
||||||
import { parseError } from "~/lib/errors";
|
|
||||||
import { cn } from "~/lib/utils";
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
@@ -35,23 +32,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
const [volumeToEdit, setVolumeToEdit] = useState<ListVolumesResponse["volumes"][number]>();
|
const [volumeToEdit, setVolumeToEdit] = useState<ListVolumesResponse["volumes"][number]>();
|
||||||
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
|
||||||
|
|
||||||
const deleteVol = useMutation({
|
const navigate = useNavigate();
|
||||||
...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 { data } = useQuery({
|
const { data } = useQuery({
|
||||||
...listVolumesOptions(),
|
...listVolumesOptions(),
|
||||||
@@ -59,103 +40,81 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
||||||
"absolute inset-0",
|
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
|
||||||
"[background-size:40px_40px]",
|
Create, manage, monitor, and automate your volumes with ease.
|
||||||
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
</h2>
|
||||||
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
<div className="flex items-center gap-2 mt-4 justify-between">
|
||||||
)}
|
<span className="flex items-center gap-2">
|
||||||
>
|
<Input className="w-[180px]" placeholder="Search volumes…" />
|
||||||
<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>
|
<Select>
|
||||||
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
|
<SelectTrigger className="w-[180px]">
|
||||||
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
<SelectValue placeholder="All status" />
|
||||||
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
|
</SelectTrigger>
|
||||||
Create, manage, monitor, and automate your volumes with ease.
|
<SelectContent>
|
||||||
</h2>
|
<SelectItem value="mounted">Mounted</SelectItem>
|
||||||
<div className="flex items-center gap-2 mt-4 justify-between">
|
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||||
<span className="flex items-center gap-2">
|
<SelectItem value="error">Error</SelectItem>
|
||||||
<Input className="w-[180px]" placeholder="Search volumes…" />
|
</SelectContent>
|
||||||
<Select>
|
</Select>
|
||||||
<SelectTrigger className="w-[180px]">
|
<Select>
|
||||||
<SelectValue placeholder="All status" />
|
<SelectTrigger className="w-[180px]">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="All backends" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="mounted">Mounted</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
<SelectItem value="directory">Directory</SelectItem>
|
||||||
<SelectItem value="error">Error</SelectItem>
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="smb">SMB</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
<Select>
|
</Select>
|
||||||
<SelectTrigger className="w-[180px]">
|
</span>
|
||||||
<SelectValue placeholder="All backends" />
|
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
<Table className="mt-4 border bg-white dark:bg-secondary">
|
||||||
<SelectItem value="directory">Directory</SelectItem>
|
<TableCaption>A list of your managed volumes.</TableCaption>
|
||||||
<SelectItem value="nfs">NFS</SelectItem>
|
<TableHeader>
|
||||||
<SelectItem value="smb">SMB</SelectItem>
|
<TableRow>
|
||||||
</SelectContent>
|
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||||
</Select>
|
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||||
</span>
|
<TableHead className="uppercase">Mountpoint</TableHead>
|
||||||
<CreateVolumeDialog open={createVolumeOpen} setOpen={setCreateVolumeOpen} />
|
<TableHead className="uppercase text-center">Status</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
<Table className="mt-4 border bg-white dark:bg-secondary">
|
</TableHeader>
|
||||||
<TableCaption>A list of your managed volumes.</TableCaption>
|
<TableBody>
|
||||||
<TableHeader>
|
{data?.volumes.map((volume) => (
|
||||||
<TableRow>
|
<TableRow
|
||||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
key={volume.name}
|
||||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||||
<TableHead className="uppercase">Mountpoint</TableHead>
|
onClick={() => navigate(`/volumes/${volume.name}`)}
|
||||||
<TableHead className="uppercase text-center">Status</TableHead>
|
>
|
||||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
<TableCell className="font-medium">{volume.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<VolumeIcon backend={volume.type} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
|
||||||
|
{volume.path}
|
||||||
|
</span>
|
||||||
|
<Copy size={10} />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className="relative flex size-3 mx-auto">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{data?.volumes.map((volume) => (
|
</Table>
|
||||||
<TableRow key={volume.name}>
|
<EditVolumeDialog
|
||||||
<TableCell className="font-medium">{volume.name}</TableCell>
|
open={Boolean(volumeToEdit)}
|
||||||
<TableCell>
|
setOpen={() => setVolumeToEdit(undefined)}
|
||||||
<VolumeIcon backend={volume.type} />
|
initialValues={volumeToEdit}
|
||||||
</TableCell>
|
/>
|
||||||
<TableCell>
|
</>
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-xs truncate bg-primary/10 rounded-md px-2 py-1">
|
|
||||||
{volume.path}
|
|
||||||
</span>
|
|
||||||
<Copy size={10} />
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<span className="relative flex size-3 mx-auto">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
|
||||||
<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>
|
|
||||||
</Table>
|
|
||||||
<EditVolumeDialog
|
|
||||||
open={Boolean(volumeToEdit)}
|
|
||||||
setOpen={() => setVolumeToEdit(undefined)}
|
|
||||||
initialValues={volumeToEdit}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"@react-router/serve": "^7.7.1",
|
"@react-router/serve": "^7.7.1",
|
||||||
"@tanstack/react-query": "^5.84.2",
|
"@tanstack/react-query": "^5.84.2",
|
||||||
|
|||||||
@@ -22,10 +22,16 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||||||
const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`;
|
const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`;
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Mount command timed out"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
exec(cmd, (error, stdout, stderr) => {
|
exec(cmd, (error, stdout, stderr) => {
|
||||||
console.log("Mount command executed:", { cmd, error, stdout, stderr });
|
console.log("Mount command executed:", { cmd, error, stdout, stderr });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (error) {
|
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}`));
|
return reject(new Error(`Failed to mount NFS volume: ${stderr}`));
|
||||||
}
|
}
|
||||||
console.log(`NFS volume mounted successfully: ${stdout}`);
|
console.log(`NFS volume mounted successfully: ${stdout}`);
|
||||||
@@ -43,8 +49,14 @@ const unmount = async (path: string) => {
|
|||||||
const cmd = `umount -f ${path}`;
|
const cmd = `umount -f ${path}`;
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new Error("Mount command timed out"));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
exec(cmd, (error, stdout, stderr) => {
|
exec(cmd, (error, stdout, stderr) => {
|
||||||
console.log("Unmount command executed:", { cmd, error, stdout, stderr });
|
console.log("Unmount command executed:", { cmd, error, stdout, stderr });
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`Error unmounting NFS volume: ${stderr}`);
|
console.error(`Error unmounting NFS volume: ${stderr}`);
|
||||||
return reject(new Error(`Failed to unmount NFS volume: ${stderr}`));
|
return reject(new Error(`Failed to unmount NFS volume: ${stderr}`));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
testConnectionDto,
|
testConnectionDto,
|
||||||
updateVolumeBody,
|
updateVolumeBody,
|
||||||
updateVolumeDto,
|
updateVolumeDto,
|
||||||
|
type VolumeDto,
|
||||||
} from "./volume.dto";
|
} from "./volume.dto";
|
||||||
import { volumeService } from "./volume.service";
|
import { volumeService } from "./volume.service";
|
||||||
|
|
||||||
@@ -68,13 +69,11 @@ export const volumeController = new Hono()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
name: res.volume.name,
|
...res.volume,
|
||||||
path: res.volume.path,
|
|
||||||
type: res.volume.type,
|
|
||||||
createdAt: res.volume.createdAt.getTime(),
|
createdAt: res.volume.createdAt.getTime(),
|
||||||
updatedAt: res.volume.updatedAt.getTime(),
|
updatedAt: res.volume.updatedAt.getTime(),
|
||||||
config: res.volume.config,
|
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
|
||||||
};
|
} satisfies VolumeDto;
|
||||||
|
|
||||||
return c.json(response, 200);
|
return c.json(response, 200);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const volumeSchema = type({
|
|||||||
config: volumeConfigSchema,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type VolumeDto = typeof volumeSchema.infer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -108,25 +108,17 @@ const updateVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||||||
return { error: new NotFoundError("Volume not found") };
|
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
|
const updated = await db
|
||||||
.update(volumesTable)
|
.update(volumesTable)
|
||||||
.set({
|
.set({
|
||||||
config: backendConfig,
|
config: backendConfig,
|
||||||
type: backendConfig.backend,
|
type: backendConfig.backend,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
status: "unmounted",
|
||||||
})
|
})
|
||||||
.where(eq(volumesTable.name, name))
|
.where(eq(volumesTable.name, name))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Mount with new configuration
|
|
||||||
const newBackend = createVolumeBackend(updated[0]);
|
|
||||||
await newBackend.mount();
|
|
||||||
|
|
||||||
return { volume: updated[0] };
|
return { volume: updated[0] };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@@ -18,6 +18,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"@react-router/serve": "^7.7.1",
|
"@react-router/serve": "^7.7.1",
|
||||||
"@tanstack/react-query": "^5.84.2",
|
"@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-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-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=="],
|
"@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=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user