From 91020e6f23ca6e881d2d24224eef39a0a6f00615 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 3 Sep 2025 21:42:18 +0200 Subject: [PATCH] feat: edit volume --- .../api-client/@tanstack/react-query.gen.ts | 52 +++- apps/client/app/api-client/sdk.gen.ts | 32 +++ apps/client/app/api-client/types.gen.ts | 112 +++++++- .../app/components/create-volume-dialog.tsx | 253 +++--------------- .../app/components/create-volume-form.tsx | 236 ++++++++++++++++ .../app/components/edit-volume-dialog.tsx | 59 ++++ apps/client/app/root.tsx | 12 +- apps/client/app/routes/home.tsx | 118 ++++---- apps/client/package.json | 1 + apps/server/src/modules/backends/backend.ts | 2 +- .../backends/directory/directory-backend.ts | 9 +- .../src/modules/backends/nfs/nfs-backend.ts | 28 +- .../src/modules/volumes/volume.controller.ts | 56 +++- apps/server/src/modules/volumes/volume.dto.ts | 77 +++++- .../src/modules/volumes/volume.service.ts | 51 ++++ bun.lock | 5 + openapi-ts.config.ts | 6 +- 17 files changed, 790 insertions(+), 319 deletions(-) create mode 100644 apps/client/app/components/create-volume-form.tsx create mode 100644 apps/client/app/components/edit-volume-dialog.tsx diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index a0e5d8b..7fc4838 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -1,6 +1,14 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type Options, listVolumes, createVolume, testConnection, deleteVolume } from "../sdk.gen"; +import { + type Options, + listVolumes, + createVolume, + testConnection, + deleteVolume, + getVolume, + updateVolume, +} from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { ListVolumesData, @@ -10,6 +18,9 @@ import type { TestConnectionResponse, DeleteVolumeData, DeleteVolumeResponse, + GetVolumeData, + UpdateVolumeData, + UpdateVolumeResponse, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -169,3 +180,42 @@ export const deleteVolumeMutation = ( }; return mutationOptions; }; + +export const getVolumeQueryKey = (options: Options) => createQueryKey("getVolume", options); + +/** + * Get a volume by name + */ +export const getVolumeOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getVolume({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getVolumeQueryKey(options), + }); +}; + +/** + * Update a volume's configuration + */ +export const updateVolumeMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await updateVolume({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 7016902..0fa88b6 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -10,6 +10,12 @@ import type { TestConnectionResponses, DeleteVolumeData, DeleteVolumeResponses, + GetVolumeData, + GetVolumeResponses, + GetVolumeErrors, + UpdateVolumeData, + UpdateVolumeResponses, + UpdateVolumeErrors, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -83,3 +89,29 @@ export const deleteVolume = ( ...options, }); }; + +/** + * Get a volume by name + */ +export const getVolume = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: "/api/v1/volumes/{name}", + ...options, + }); +}; + +/** + * Update a volume's configuration + */ +export const updateVolume = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).put({ + url: "/api/v1/volumes/{name}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index bd54ca3..6bfc979 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -31,9 +31,9 @@ export type CreateVolumeData = { | { backend: "nfs"; exportPath: string; - port: number; server: string; version: "3" | "4" | "4.1"; + port?: number | string; } | { backend: "smb"; @@ -70,9 +70,9 @@ export type TestConnectionData = { | { backend: "nfs"; exportPath: string; - port: number; server: string; version: "3" | "4" | "4.1"; + port?: number | string; } | { backend: "smb"; @@ -115,6 +115,114 @@ export type DeleteVolumeResponses = { export type DeleteVolumeResponse = DeleteVolumeResponses[keyof DeleteVolumeResponses]; +export type GetVolumeData = { + body?: never; + path: { + name: string; + }; + query?: never; + url: "/api/v1/volumes/{name}"; +}; + +export type GetVolumeErrors = { + /** + * Volume not found + */ + 404: unknown; +}; + +export type GetVolumeResponses = { + /** + * Volume details + */ + 200: { + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number | string; + } + | { + backend: "smb"; + }; + createdAt: number; + name: string; + path: string; + type: string; + updatedAt: number; + }; +}; + +export type GetVolumeResponse = GetVolumeResponses[keyof GetVolumeResponses]; + +export type UpdateVolumeData = { + body?: { + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number | string; + } + | { + backend: "smb"; + }; + }; + path: { + name: string; + }; + query?: never; + url: "/api/v1/volumes/{name}"; +}; + +export type UpdateVolumeErrors = { + /** + * Volume not found + */ + 404: unknown; +}; + +export type UpdateVolumeResponses = { + /** + * Volume updated successfully + */ + 200: { + message: string; + volume: { + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + server: string; + version: "3" | "4" | "4.1"; + port?: number | string; + } + | { + backend: "smb"; + }; + createdAt: number; + name: string; + path: string; + type: string; + updatedAt: number; + }; + }; +}; + +export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses]; + export type ClientOptions = { baseUrl: "http://localhost:3000" | (string & {}); }; diff --git a/apps/client/app/components/create-volume-dialog.tsx b/apps/client/app/components/create-volume-dialog.tsx index 5220011..2435b0f 100644 --- a/apps/client/app/components/create-volume-dialog.tsx +++ b/apps/client/app/components/create-volume-dialog.tsx @@ -1,11 +1,10 @@ -import { arktypeResolver } from "@hookform/resolvers/arktype"; -import { volumeConfigSchema } from "@ironmount/schemas"; -import { type } from "arktype"; -import { CheckCircle, Loader2, Plus, XCircle } from "lucide-react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { testConnection } from "~/api-client"; -import { slugify } from "~/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useId } from "react"; +import { toast } from "sonner"; +import { createVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/lib/errors"; +import { CreateVolumeForm } from "./create-volume-form"; import { Button } from "./ui/button"; import { Dialog, @@ -16,66 +15,28 @@ import { DialogTitle, DialogTrigger, } from "./ui/dialog"; -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"; -import { useMutation } from "@tanstack/react-query"; -import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; -import { toast } from "sonner"; -export const formSchema = type({ - name: "2<=string<=32", -}).and(volumeConfigSchema); - -type FormValues = typeof formSchema.infer; type Props = { open: boolean; setOpen: (open: boolean) => void; - onSubmit: (values: FormValues) => void; }; -export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => { - const [testStatus, setTestStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); - const [testMessage, setTestMessage] = useState(""); +export const CreateVolumeDialog = ({ open, setOpen }: Props) => { + const formId = useId(); - const form = useForm({ - resolver: arktypeResolver(formSchema), - defaultValues: { - name: "", - backend: "directory", + const create = useMutation({ + ...createVolumeMutation(), + onSuccess: () => { + toast.success("Volume created successfully"); + setOpen(false); + }, + onError: (error) => { + toast.error("Failed to create volume", { + description: parseError(error)?.message, + }); }, }); - const testBackendConnection = useMutation({ - ...testConnectionMutation(), - onMutate: () => { - setTestStatus("loading"); - }, - onError: () => { - setTestStatus("error"); - setTestMessage("Failed to test connection. Please try again."); - }, - onSuccess: (data) => { - if (data?.success) { - setTestStatus("success"); - setTestMessage(data.message); - } else { - setTestStatus("error"); - setTestMessage(data?.message || "Connection test failed"); - } - }, - }); - - const watchedBackend = form.watch("backend"); - - const handleTestConnection = async () => { - const formValues = form.getValues(); - - testBackendConnection.mutate({ - body: { config: formValues }, - }); - }; - return ( @@ -87,167 +48,23 @@ export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => { Create volume - Enter a name for the new volume. + Enter a name for the new volume -
- - ( - - Name - - field.onChange(slugify(e.target.value))} - max={32} - min={1} - /> - - Unique identifier for the volume. - - - )} - /> - ( - - Backend - - Choose the storage backend for this volume. - - - )} - /> - {watchedBackend === "nfs" && ( - <> - ( - - Server - - - - NFS server IP address or hostname. - - - )} - /> - ( - - Export Path - - - - Path to the NFS export on the server. - - - )} - /> - ( - - Port - - field.onChange(parseInt(e.target.value, 10) || undefined)} - /> - - NFS server port (default: 2049). - - - )} - /> - ( - - Version - - NFS protocol version to use. - - - )} - /> - - )} - {watchedBackend === "nfs" && ( -
-
- -
- {testMessage && ( -
- {testMessage} -
- )} -
- )} - - - - - - + { + create.mutate({ body: { config: values, name: values.name } }); + }} + /> + + + +
); diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx new file mode 100644 index 0000000..ae52fdf --- /dev/null +++ b/apps/client/app/components/create-volume-form.tsx @@ -0,0 +1,236 @@ +import { arktypeResolver } from "@hookform/resolvers/arktype"; +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 { useForm } from "react-hook-form"; +import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen"; +import { slugify } from "~/lib/utils"; +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", +}).and(volumeConfigSchema); + +type FormValues = typeof formSchema.inferIn; + +type Props = { + onSubmit: (values: FormValues) => void; + mode?: "create" | "update"; + initialValues?: Partial; + formId?: string; +}; + +export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId }: Props) => { + const form = useForm({ + resolver: arktypeResolver(formSchema), + defaultValues: { + name: "", + backend: "directory", + }, + }); + 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 watchedBackend = watch("backend"); + + const [testStatus, setTestStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [testMessage, setTestMessage] = useState(""); + + const testBackendConnection = useMutation({ + ...testConnectionMutation(), + onMutate: () => { + setTestStatus("loading"); + }, + onError: () => { + setTestStatus("error"); + setTestMessage("Failed to test connection. Please try again."); + }, + onSuccess: (data) => { + if (data?.success) { + setTestStatus("success"); + setTestMessage(data.message); + } else { + setTestStatus("error"); + setTestMessage(data?.message || "Connection test failed"); + } + }, + }); + + const handleTestConnection = async () => { + const formValues = getValues(); + + if (formValues.backend === "nfs") { + testBackendConnection.mutate({ + body: { config: formValues }, + }); + } + }; + + return ( +
+ + ( + + Name + + field.onChange(slugify(e.target.value))} + max={32} + min={1} + disabled={mode === "update"} + className={mode === "update" ? "bg-gray-50" : ""} + /> + + Unique identifier for the volume. + + + )} + /> + ( + + Backend + + Choose the storage backend for this volume. + + + )} + /> + + {watchedBackend === "nfs" && ( + <> + ( + + Server + + + + NFS server IP address or hostname. + + + )} + /> + ( + + Export Path + + + + Path to the NFS export on the server. + + + )} + /> + ( + + Port + + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> + + NFS server port (default: 2049). + + + )} + /> + ( + + Version + + NFS protocol version to use. + + + )} + /> + + )} + + {watchedBackend === "nfs" && ( +
+
+ +
+ {testMessage && ( +
+ {testMessage} +
+ )} +
+ )} + + + ); +}; diff --git a/apps/client/app/components/edit-volume-dialog.tsx b/apps/client/app/components/edit-volume-dialog.tsx new file mode 100644 index 0000000..ee64646 --- /dev/null +++ b/apps/client/app/components/edit-volume-dialog.tsx @@ -0,0 +1,59 @@ +import { useMutation } from "@tanstack/react-query"; +import { useId } from "react"; +import { toast } from "sonner"; +import type { GetVolumeResponse } from "~/api-client"; +import { updateVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/lib/errors"; +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/root.tsx b/apps/client/app/root.tsx index c275a27..99ddcb5 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -1,4 +1,5 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; import { Toaster } from "~/components/ui/sonner"; @@ -23,7 +24,13 @@ export const links: Route.LinksFunction = () => [ }, ]; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: () => { + queryClient.invalidateQueries(); + }, + }), +}); export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -41,6 +48,7 @@ export function Layout({ children }: { children: React.ReactNode }) { + ); diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index 518111c..47e3571 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -1,10 +1,11 @@ -import { type } from "arktype"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Copy, Folder } from "lucide-react"; import { useState } from "react"; -import { useFetcher } from "react-router"; import { toast } from "sonner"; -import { createVolume, deleteVolume, listVolumes } from "~/api-client"; -import { CreateVolumeDialog, formSchema } from "~/components/create-volume-dialog"; +import { type ListVolumesResponse, listVolumes } from "~/api-client"; +import { deleteVolumeMutation, 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"; @@ -23,71 +24,39 @@ export function meta(_: Route.MetaArgs) { ]; } -export async function clientAction({ request }: Route.ClientActionArgs) { - const formData = await request.formData(); - const { _action, ...rest } = Object.fromEntries(formData.entries()); +export const clientLoader = async () => { + const volumes = await listVolumes(); + if (volumes.data) return { volumes: volumes.data.volumes }; + return { volumes: [] }; +}; - if (_action === "delete") { - const name = rest.name as string; - const { error } = await deleteVolume({ path: { name: name } }); +export default function Home({ loaderData }: Route.ComponentProps) { + const [volumeToEdit, setVolumeToEdit] = useState(); + const [createVolumeOpen, setCreateVolumeOpen] = useState(false); - if (error) { + const deleteVol = useMutation({ + ...deleteVolumeMutation(), + onSuccess: () => { + toast.success("Volume deleted successfully"); + }, + onError: (error) => { toast.error("Failed to delete volume", { description: parseError(error)?.message, }); - } else { - toast.success("Volume deleted successfully"); - } - - return { error: parseError(error), _action: "delete" as const }; - } - - if (_action === "create") { - const validationResult = formSchema(rest); - - if (validationResult instanceof type.errors) { - toast.error("Invalid form data", { - description: "Please check your input and try again.", - }); - return { error: validationResult, _action: "create" as const }; - } - - const validatedData = validationResult as typeof formSchema.infer; - const { error } = await createVolume({ body: { name: validatedData.name, config: validatedData } }); - if (error) { - toast.error("Failed to create volume", { - description: parseError(error)?.message, - }); - } else { - toast.success("Volume created successfully"); - } - return { error: parseError(error), _action: "create" as const }; - } -} - -export async function clientLoader(_: Route.ClientLoaderArgs) { - const volumes = await listVolumes(); - return volumes.data; -} - -export default function Home({ loaderData, actionData }: Route.ComponentProps) { - const [open, setOpen] = useState(false); - - const createFetcher = useFetcher>(); - const deleteFetcher = useFetcher>(); - - createFetcher.data; - deleteFetcher.data; - - const isDeleting = deleteFetcher.state === "submitting"; - const isCreating = createFetcher.state === "submitting"; - - console.log(createFetcher); + }, + }); const handleDeleteConfirm = (name: string) => { - deleteFetcher.submit({ _action: "delete", name }, { method: "DELETE" }); + if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) { + deleteVol.mutate({ path: { name } }); + } }; + const { data } = useQuery({ + ...listVolumesOptions(), + initialData: loaderData, + }); + return (
- createFetcher.submit({ _action: "create", ...values }, { method: "POST" })} - /> +
A list of your managed volumes. @@ -145,7 +110,7 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) { - {loaderData?.volumes.map((volume) => ( + {data?.volumes.map((volume) => ( {volume.name} @@ -169,14 +134,29 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) { - +
+ + +
))}
+ setVolumeToEdit(undefined)} + initialValues={volumeToEdit} + /> ); diff --git a/apps/client/package.json b/apps/client/package.json index b647b21..7274a50 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -19,6 +19,7 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", + "@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.20", "class-variance-authority": "^0.7.1", diff --git a/apps/server/src/modules/backends/backend.ts b/apps/server/src/modules/backends/backend.ts index b6bb1d2..0302a8d 100644 --- a/apps/server/src/modules/backends/backend.ts +++ b/apps/server/src/modules/backends/backend.ts @@ -15,7 +15,7 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => { return makeNfsBackend(config, path); } case "directory": { - return makeDirectoryBackend(); + return makeDirectoryBackend(config, path); } default: { throw new Error(`Backend ${config.backend} not implemented`); diff --git a/apps/server/src/modules/backends/directory/directory-backend.ts b/apps/server/src/modules/backends/directory/directory-backend.ts index fcea499..110d8f4 100644 --- a/apps/server/src/modules/backends/directory/directory-backend.ts +++ b/apps/server/src/modules/backends/directory/directory-backend.ts @@ -1,14 +1,17 @@ +import * as fs from "node:fs/promises"; +import type { BackendConfig } from "@ironmount/schemas"; import type { VolumeBackend } from "../backend"; -const mount = async () => { +const mount = async (_config: BackendConfig, path: string) => { console.log("Mounting directory volume..."); + await fs.mkdir(path, { recursive: true }); }; const unmount = async () => { console.log("Cannot unmount directory volume."); }; -export const makeDirectoryBackend = (): VolumeBackend => ({ - mount, +export const makeDirectoryBackend = (config: BackendConfig, path: string): VolumeBackend => ({ + mount: () => mount(config, path), unmount, }); diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 8b0ec40..5763d49 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -1,4 +1,5 @@ import { exec } from "node:child_process"; +import * as fs from "node:fs/promises"; import * as os from "node:os"; import type { BackendConfig } from "@ironmount/schemas"; import type { VolumeBackend } from "../backend"; @@ -13,6 +14,8 @@ const mount = async (config: BackendConfig, path: string) => { return; } + await fs.mkdir(path, { recursive: true }); + const source = `${config.server}:${config.exportPath}`; const options = [`vers=${config.version}`, `port=${config.port}`]; const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`; @@ -21,7 +24,7 @@ const mount = async (config: BackendConfig, path: string) => { exec(cmd, (error, stdout, stderr) => { console.log("Mount command executed:", { cmd, error, stdout, stderr }); 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}`); @@ -30,11 +33,28 @@ const mount = async (config: BackendConfig, path: string) => { }); }; -const unmount = async () => { - console.log("Unmounting nfs volume..."); +const unmount = async (path: string) => { + if (os.platform() !== "linux") { + console.error("NFS unmounting is only supported on Linux hosts."); + return; + } + + const cmd = `umount -f ${path}`; + + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + console.log("Unmount command executed:", { cmd, error, stdout, stderr }); + if (error) { + console.error(`Error unmounting NFS volume: ${stderr}`); + return reject(new Error(`Failed to unmount NFS volume: ${stderr}`)); + } + console.log(`NFS volume unmounted successfully: ${stdout}`); + resolve(); + }); + }); }; export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({ mount: () => mount(config, path), - unmount, + unmount: () => unmount(path), }); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index 129402f..1950092 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -5,10 +5,13 @@ import { createVolumeBody, createVolumeDto, deleteVolumeDto, + getVolumeDto, type ListVolumesResponseDto, listVolumesDto, testConnectionBody, testConnectionDto, + updateVolumeBody, + updateVolumeDto, } from "./volume.dto"; import { volumeService } from "./volume.service"; @@ -18,8 +21,8 @@ export const volumeController = new Hono() const response = { volumes: volumes.map((volume) => ({ - name: volume.name, - path: volume.path, + ...volume, + updatedAt: volume.updatedAt.getTime(), createdAt: volume.createdAt.getTime(), })), } satisfies ListVolumesResponseDto; @@ -54,12 +57,47 @@ export const volumeController = new Hono() return c.json({ message: "Volume deleted" }); }) - .get("/:name", (c) => { - return c.json({ message: `Details of volume ${c.req.param("name")}` }); + .get("/:name", getVolumeDto, async (c) => { + const { name } = c.req.param(); + const res = await volumeService.getVolume(name); + + if (res.error) { + const { message, status } = handleServiceError(res.error); + return c.json(message, status); + } + + const response = { + name: res.volume.name, + path: res.volume.path, + type: res.volume.type, + createdAt: res.volume.createdAt.getTime(), + updatedAt: res.volume.updatedAt.getTime(), + config: res.volume.config, + }; + + return c.json(response, 200); }) - .put("/:name", (c) => { - return c.json({ message: `Update volume ${c.req.param("name")}` }); - }) - .delete("/:name", (c) => { - return c.json({ message: `Delete volume ${c.req.param("name")}` }); + .put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => { + const { name } = c.req.param(); + const body = c.req.valid("json"); + const res = await volumeService.updateVolume(name, body.config); + + if (res.error) { + const { message, status } = handleServiceError(res.error); + return c.json(message, status); + } + + const response = { + message: "Volume updated", + volume: { + name: res.volume.name, + path: res.volume.path, + type: res.volume.type, + createdAt: res.volume.createdAt.getTime(), + updatedAt: res.volume.updatedAt.getTime(), + config: res.volume.config, + }, + }; + + 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 93967bc..80cf10f 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -3,15 +3,20 @@ import { type } from "arktype"; import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/arktype"; +const volumeSchema = type({ + name: "string", + path: "string", + type: "string", + createdAt: "number", + updatedAt: "number", + config: volumeConfigSchema, +}); + /** * List all volumes */ export const listVolumesResponse = type({ - volumes: type({ - name: "string", - path: "string", - createdAt: "number", - }).array(), + volumes: volumeSchema.array(), }); export type ListVolumesResponseDto = typeof listVolumesResponse.infer; @@ -90,6 +95,68 @@ export const deleteVolumeDto = describeRoute({ }, }); +/** + * Get a volume + */ +export const getVolumeDto = describeRoute({ + description: "Get a volume by name", + operationId: "getVolume", + validateResponse: true, + tags: ["Volumes"], + responses: { + 200: { + description: "Volume details", + content: { + "application/json": { + schema: resolver(volumeSchema), + }, + }, + }, + 404: { + description: "Volume not found", + }, + }, +}); + +/** + * Update a volume + */ +export const updateVolumeBody = type({ + config: volumeConfigSchema, +}); + +export const updateVolumeResponse = type({ + message: "string", + volume: type({ + name: "string", + path: "string", + type: "string", + createdAt: "number", + updatedAt: "number", + config: volumeConfigSchema, + }), +}); + +export const updateVolumeDto = describeRoute({ + description: "Update a volume's configuration", + operationId: "updateVolume", + validateResponse: true, + tags: ["Volumes"], + responses: { + 200: { + description: "Volume updated successfully", + content: { + "application/json": { + schema: resolver(updateVolumeResponse), + }, + }, + }, + 404: { + description: "Volume not found", + }, + }, +}); + /** * Test connection */ diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index e704be8..16690ad 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -86,6 +86,55 @@ const mountVolume = async (name: string) => { } }; +const getVolume = async (name: string) => { + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); + + if (!volume) { + return { error: new NotFoundError("Volume not found") }; + } + + return { volume }; +}; + +const updateVolume = async (name: string, backendConfig: BackendConfig) => { + try { + const existing = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); + + if (!existing) { + return { error: new NotFoundError("Volume not found") }; + } + + const oldBackend = createVolumeBackend(existing); + await oldBackend.unmount(); + + const updated = await db + .update(volumesTable) + .set({ + config: backendConfig, + type: backendConfig.backend, + updatedAt: new Date(), + }) + .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 { + error: new InternalServerError("Failed to update volume", { + cause: error, + }), + }; + } +}; + const testConnection = async (backendConfig: BackendConfig) => { let tempDir: string | null = null; @@ -134,5 +183,7 @@ export const volumeService = { createVolume, mountVolume, deleteVolume, + getVolume, + updateVolume, testConnection, }; diff --git a/bun.lock b/bun.lock index 47614cd..177c574 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "@tanstack/react-query": "^5.84.2", + "@tanstack/react-query-devtools": "^5.85.9", "@tanstack/react-table": "^8.21.3", "arktype": "^2.1.20", "class-variance-authority": "^0.7.1", @@ -396,8 +397,12 @@ "@tanstack/query-core": ["@tanstack/query-core@5.85.5", "", {}, "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w=="], + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="], + "@tanstack/react-query": ["@tanstack/react-query@5.85.5", "", { "dependencies": { "@tanstack/query-core": "5.85.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A=="], + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.85.9", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.85.9", "react": "^18 || ^19" } }, "sha512-BAdhgwpzxkC1vdyCfiPbbC7FU/t/x6q2d9ZyhON/WykVUdznD69nlppuWpSIlIGipdRG7sF6tRZ6x3GtSq0EUQ=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index e716fef..4f95851 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -6,9 +6,5 @@ export default defineConfig({ path: "./apps/client/app/api-client", format: "biome", }, - plugins: [ - ...defaultPlugins, - "@tanstack/react-query", - "@hey-api/client-fetch", - ], + plugins: [...defaultPlugins, "@tanstack/react-query", "@hey-api/client-fetch"], });