From 202759d9dec1331290dbd4ce13aba867aa0d248b Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 27 Sep 2025 15:50:34 +0200 Subject: [PATCH] feat: update volume --- .../app/components/create-volume-form.tsx | 10 +- .../app/components/edit-volume-dialog.tsx | 59 ----- .../client/app/components/ui/alert-dialog.tsx | 213 +++++++----------- .../details/components/healthchecks-card.tsx | 2 +- apps/client/app/modules/details/tabs/info.tsx | 92 +++++++- apps/client/app/root.tsx | 4 + apps/client/app/routes/home.tsx | 9 +- apps/server/src/index.ts | 8 +- .../src/modules/driver/driver.controller.ts | 2 +- .../src/modules/volumes/volume.service.ts | 34 ++- docker-compose.yml | 3 +- 11 files changed, 216 insertions(+), 220 deletions(-) delete mode 100644 apps/client/app/components/edit-volume-dialog.tsx diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index 9a523ec..815a1bc 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -16,16 +16,17 @@ export const formSchema = type({ name: "2<=string<=32", }).and(volumeConfigSchema); -type FormValues = typeof formSchema.inferIn; +export type FormValues = typeof formSchema.inferIn; type Props = { onSubmit: (values: FormValues) => void; mode?: "create" | "update"; initialValues?: Partial; formId?: string; + loading?: boolean; }; -export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => { +export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading }: Props) => { const form = useForm({ resolver: arktypeResolver(formSchema), defaultValues: initialValues, @@ -505,6 +506,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} )} + {mode === "update" && ( + + )} ); diff --git a/apps/client/app/components/edit-volume-dialog.tsx b/apps/client/app/components/edit-volume-dialog.tsx deleted file mode 100644 index 2ca4405..0000000 --- a/apps/client/app/components/edit-volume-dialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useId } from "react"; -import { toast } from "sonner"; -import { updateVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; -import { parseError } from "~/lib/errors"; -import type { Volume } from "../lib/types"; -import { CreateVolumeForm } from "./create-volume-form"; -import { Button } from "./ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; - -type Props = { - open: boolean; - setOpen: (open: boolean) => void; - initialValues?: Partial; -}; - -export const EditVolumeDialog = ({ open, setOpen, initialValues }: Props) => { - const formId = useId(); - - const update = useMutation({ - ...updateVolumeMutation(), - onSuccess: () => { - toast.success("Volume updated successfully"); - setOpen(false); - }, - onError: (error) => { - toast.error("Failed to update volume", { - description: parseError(error)?.message, - }); - }, - }); - - return ( - - - - Create volume - Enter a name for the new volume - - { - update.mutate({ body: { config: values }, path: { name: values.name } }); - }} - /> - - - - - - - ); -}; diff --git a/apps/client/app/components/ui/alert-dialog.tsx b/apps/client/app/components/ui/alert-dialog.tsx index 59eaa6c..6bbf718 100644 --- a/apps/client/app/components/ui/alert-dialog.tsx +++ b/apps/client/app/components/ui/alert-dialog.tsx @@ -1,155 +1,110 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; +import { buttonVariants } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; -import { cn } from "~/lib/utils" -import { buttonVariants } from "~/components/ui/button" - -function AlertDialog({ - ...props -}: React.ComponentProps) { - return +function AlertDialog({ ...props }: React.ComponentProps) { + return ; } -function AlertDialogTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; } -function AlertDialogPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; } -function AlertDialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); } -function AlertDialogContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - - ) +function AlertDialogContent({ className, ...props }: React.ComponentProps) { + return ( + + + + + ); } -function AlertDialogHeader({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); } -function AlertDialogFooter({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); } -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); } function AlertDialogDescription({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ); } -function AlertDialogAction({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogAction({ className, ...props }: React.ComponentProps) { + return ; } -function AlertDialogCancel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function AlertDialogCancel({ className, ...props }: React.ComponentProps) { + return ; } export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/details/components/healthchecks-card.tsx index 69bf357..8616f25 100644 --- a/apps/client/app/modules/details/components/healthchecks-card.tsx +++ b/apps/client/app/modules/details/components/healthchecks-card.tsx @@ -54,7 +54,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
- {volume.lastError && {volume.lastError}} + {volume.lastError && {volume.lastError}} {volume.status === "mounted" && Healthy} {volume.status !== "unmounted" && ( Checked {timeAgo || "never"} diff --git a/apps/client/app/modules/details/tabs/info.tsx b/apps/client/app/modules/details/tabs/info.tsx index a033e09..2b64056 100644 --- a/apps/client/app/modules/details/tabs/info.tsx +++ b/apps/client/app/modules/details/tabs/info.tsx @@ -1,4 +1,18 @@ -import { CreateVolumeForm } from "~/components/create-volume-form"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { updateVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; +import { CreateVolumeForm, type FormValues } from "~/components/create-volume-form"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog"; import { Card } from "~/components/ui/card"; import type { StatFs, Volume } from "~/lib/types"; import { HealthchecksCard } from "../components/healthchecks-card"; @@ -10,19 +24,73 @@ type Props = { }; export const VolumeInfoTabContent = ({ volume, statfs }: Props) => { + const updateMutation = useMutation({ + ...updateVolumeMutation(), + onSuccess: (_) => { + toast.success("Volume updated successfully"); + setOpen(false); + setPendingValues(null); + }, + onError: (error) => { + toast.error("Failed to update volume", { description: error.message }); + setOpen(false); + setPendingValues(null); + }, + }); + + const [open, setOpen] = useState(false); + const [pendingValues, setPendingValues] = useState(null); + + const handleSubmit = (values: FormValues) => { + console.log({ values }); + setPendingValues(values); + setOpen(true); + }; + + const confirmUpdate = () => { + if (pendingValues) { + updateMutation.mutate({ + path: { name: volume.name }, + body: { config: pendingValues }, + }); + } + }; + return ( -
- - - -
-
- -
-
- + <> +
+ + + +
+
+ +
+
+ +
-
+ + + + Update Volume Configuration + + Editing the volume will remount it with the new config immediately. This may temporarily disrupt access to + the volume. Continue? + + + + Cancel + Update + + + + ); }; diff --git a/apps/client/app/root.tsx b/apps/client/app/root.tsx index fa0491e..780ec81 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -29,6 +29,10 @@ const queryClient = new QueryClient({ onSuccess: () => { queryClient.invalidateQueries(); }, + onError: (error) => { + console.error("Mutation error:", error); + queryClient.invalidateQueries(); + }, }), }); diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index a90460d..d96ffcb 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -2,10 +2,9 @@ import { useQuery } from "@tanstack/react-query"; import { Copy, RotateCcw } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; -import { type ListVolumesResponse, listVolumes } from "~/api-client"; +import { listVolumes } from "~/api-client"; import { listVolumesOptions } from "~/api-client/@tanstack/react-query.gen"; import { CreateVolumeDialog } from "~/components/create-volume-dialog"; -import { EditVolumeDialog } from "~/components/edit-volume-dialog"; import { StatusDot } from "~/components/status-dot"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; @@ -31,7 +30,6 @@ export const clientLoader = async () => { }; export default function Home({ loaderData }: Route.ComponentProps) { - const [volumeToEdit, setVolumeToEdit] = useState(); const [createVolumeOpen, setCreateVolumeOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState(""); @@ -139,11 +137,6 @@ export default function Home({ loaderData }: Route.ComponentProps) { ))} - setVolumeToEdit(undefined)} - initialValues={volumeToEdit} - /> ); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9a57d0e..d13e05c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -32,10 +32,10 @@ export const scalarDescriptor = Scalar({ const driver = new Hono().use(honoLogger()).route("/", driverController); const app = new Hono() .use(honoLogger()) - .get("/healthcheck", (c) => c.json({ status: "ok" })) - .route("/api/v1/volumes", volumeController) - .get("/assets/*", serveStatic({ root: "./assets/frontend" })) - .get("*", serveStatic({ path: "./assets/frontend/index.html" })); + .get("*", serveStatic({ root: "./assets/frontend" })) + .get("healthcheck", (c) => c.json({ status: "ok" })) + .basePath("/api/v1") + .route("/volumes", volumeController); app.get("/openapi.json", generalDescriptor(app)); app.get("/docs", scalarDescriptor); diff --git a/apps/server/src/modules/driver/driver.controller.ts b/apps/server/src/modules/driver/driver.controller.ts index 09d3c44..5a52895 100644 --- a/apps/server/src/modules/driver/driver.controller.ts +++ b/apps/server/src/modules/driver/driver.controller.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { VOLUME_MOUNT_BASE } from "~/core/constants"; +import { VOLUME_MOUNT_BASE } from "../../core/constants"; import { volumeService } from "../volumes/volume.service"; export const driverController = new Hono() diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index be1712a..043243e 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -33,7 +33,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { const volumePathHost = path.join(VOLUME_MOUNT_BASE); - const val = await db + const [created] = await db .insert(volumesTable) .values({ name: slug, @@ -43,7 +43,19 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { }) .returning(); - return { volume: val[0], status: 201 }; + if (!created) { + throw new InternalServerError("Failed to create volume"); + } + + const backend = createVolumeBackend(created); + const { error, status } = await backend.mount(); + + await db + .update(volumesTable) + .set({ status, lastError: error ?? null, lastHealthCheck: new Date() }) + .where(eq(volumesTable.name, slug)); + + return { volume: created, status: 201 }; }; const deleteVolume = async (name: string) => { @@ -123,6 +135,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { throw new NotFoundError("Volume not found"); } + const configChanged = + JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined; + + if (configChanged) { + console.log("Unmounting existing volume before applying new config"); + const backend = createVolumeBackend(existing); + await backend.unmount(); + } + const [updated] = await db .update(volumesTable) .set({ @@ -138,6 +159,15 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { throw new InternalServerError("Failed to update volume"); } + if (configChanged) { + const backend = createVolumeBackend(updated); + const { error, status } = await backend.mount(); + await db + .update(volumesTable) + .set({ status, lastError: error ?? null, lastHealthCheck: new Date() }) + .where(eq(volumesTable.name, name)); + } + return { volume: updated }; }; diff --git a/docker-compose.yml b/docker-compose.yml index 7e1bca5..470e13b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,12 +18,11 @@ services: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared + - ironmount_data:/data - ./apps/client/app:/app/apps/client/app - ./apps/server/src:/app/apps/server/src - - ironmount_data:/data - ironmount-prod: build: context: .