diff --git a/.gitignore b/.gitignore index e2acc95..e4ff11c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ node_modules/ .env* .turbo + +mutagen.yml.lock 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 9471e97..07752c4 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -1,8 +1,16 @@ // This file is auto-generated by @hey-api/openapi-ts -import { type Options, getApiV1Volumes } from "../sdk.gen"; -import { queryOptions } from "@tanstack/react-query"; -import type { GetApiV1VolumesData } from "../types.gen"; +import { type Options, listVolumes, createVolume } from "../sdk.gen"; +import { + queryOptions, + type UseMutationOptions, + type DefaultError, +} from "@tanstack/react-query"; +import type { + ListVolumesData, + CreateVolumeData, + CreateVolumeResponse, +} from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; export type QueryKey = [ @@ -46,19 +54,16 @@ const createQueryKey = ( return [params]; }; -export const getApiV1VolumesQueryKey = ( - options?: Options, -) => createQueryKey("getApiV1Volumes", options); +export const listVolumesQueryKey = (options?: Options) => + createQueryKey("listVolumes", options); /** * List all volumes */ -export const getApiV1VolumesOptions = ( - options?: Options, -) => { +export const listVolumesOptions = (options?: Options) => { return queryOptions({ queryFn: async ({ queryKey, signal }) => { - const { data } = await getApiV1Volumes({ + const { data } = await listVolumes({ ...options, ...queryKey[0], signal, @@ -66,6 +71,54 @@ export const getApiV1VolumesOptions = ( }); return data; }, - queryKey: getApiV1VolumesQueryKey(options), + queryKey: listVolumesQueryKey(options), }); }; + +export const createVolumeQueryKey = (options?: Options) => + createQueryKey("createVolume", options); + +/** + * Create a new volume + */ +export const createVolumeOptions = (options?: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await createVolume({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: createVolumeQueryKey(options), + }); +}; + +/** + * Create a new volume + */ +export const createVolumeMutation = ( + options?: Partial>, +): UseMutationOptions< + CreateVolumeResponse, + DefaultError, + Options +> => { + const mutationOptions: UseMutationOptions< + CreateVolumeResponse, + DefaultError, + Options + > = { + mutationFn: async (localOptions) => { + const { data } = await createVolume({ + ...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 bd4c63f..fa8c244 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -2,8 +2,10 @@ import type { Options as ClientOptions, TDataShape, Client } from "./client"; import type { - GetApiV1VolumesData, - GetApiV1VolumesResponses, + ListVolumesData, + ListVolumesResponses, + CreateVolumeData, + CreateVolumeResponses, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -27,11 +29,11 @@ export type Options< /** * List all volumes */ -export const getApiV1Volumes = ( - options?: Options, +export const listVolumes = ( + options?: Options, ) => { return (options?.client ?? _heyApiClient).get< - GetApiV1VolumesResponses, + ListVolumesResponses, unknown, ThrowOnError >({ @@ -39,3 +41,23 @@ export const getApiV1Volumes = ( ...options, }); }; + +/** + * Create a new volume + */ +export const createVolume = ( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).post< + CreateVolumeResponses, + unknown, + ThrowOnError + >({ + url: "/api/v1/volumes", + ...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 50fd731..79876ba 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -1,27 +1,64 @@ // This file is auto-generated by @hey-api/openapi-ts -export type GetApiV1VolumesData = { +export type ListVolumesData = { body?: never; path?: never; query?: never; url: "/api/v1/volumes"; }; -export type GetApiV1VolumesResponses = { +export type ListVolumesResponses = { /** * A list of volumes */ 200: { volumes: Array<{ - createdAt: string; + createdAt: number; mountpoint: string; name: string; }>; }; }; -export type GetApiV1VolumesResponse = - GetApiV1VolumesResponses[keyof GetApiV1VolumesResponses]; +export type ListVolumesResponse = + ListVolumesResponses[keyof ListVolumesResponses]; + +export type CreateVolumeData = { + body?: { + config: + | { + backend: "directory"; + } + | { + backend: "nfs"; + exportPath: string; + port: number; + server: string; + version: string; + } + | { + backend: "smb"; + }; + name: string; + }; + path?: never; + query?: never; + url: "/api/v1/volumes"; +}; + +export type CreateVolumeResponses = { + /** + * Volume created successfully + */ + 201: { + createdAt: number; + mountpoint: string; + name: string; + }; +}; + +export type CreateVolumeResponse = + CreateVolumeResponses[keyof CreateVolumeResponses]; 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 fa69f8a..33f3343 100644 --- a/apps/client/app/components/create-volume-dialog.tsx +++ b/apps/client/app/components/create-volume-dialog.tsx @@ -1,5 +1,4 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -14,22 +13,9 @@ import { DialogTitle, DialogTrigger, } from "./ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "./ui/form"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Input } from "./ui/input"; -type Props = { - open: boolean; - setOpen: (open: boolean) => void; -}; - const formSchema = z.object({ name: z .string() @@ -41,7 +27,14 @@ const formSchema = z.object({ }), }); -export const CreateVolumeDialog = ({ open, setOpen }: Props) => { +type FormValues = z.infer; +type Props = { + open: boolean; + setOpen: (open: boolean) => void; + onSubmit: (values: FormValues) => void; +}; + +export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -49,18 +42,6 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => { }, }); - const nameValue = form.watch("name"); - const createVolume = useMutation({}); - - const onSubmit = (values: { name: string }) => { - createVolume.mutate(values, { - onSuccess: () => { - form.reset(); - setOpen(false); - }, - }); - }; - return ( @@ -72,9 +53,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => { Create volume - - Enter a name for the new volume. - + Enter a name for the new volume.
@@ -92,25 +71,23 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => { min={1} /> - - Unique identifier for the volume. - + Unique identifier for the volume. )} /> - {createVolume.error && ( -
- {createVolume.error.message} -
- )} + {/* {createVolume.error && ( */} + {/*
*/} + {/* {createVolume.error.message} */} + {/*
*/} + {/* )} */} diff --git a/apps/client/app/root.tsx b/apps/client/app/root.tsx index bf2e21f..882338e 100644 --- a/apps/client/app/root.tsx +++ b/apps/client/app/root.tsx @@ -10,6 +10,11 @@ import { import type { Route } from "./+types/root"; import "./app.css"; +import { client } from "./api-client/client.gen"; + +client.setConfig({ + baseUrl: "http://192.168.2.42:3000/", +}); export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, diff --git a/apps/client/app/routes/home.tsx b/apps/client/app/routes/home.tsx index 96828f8..7617658 100644 --- a/apps/client/app/routes/home.tsx +++ b/apps/client/app/routes/home.tsx @@ -1,13 +1,157 @@ -import { Welcome } from "../welcome/welcome"; +import { Copy, Folder } from "lucide-react"; +import { useState } from "react"; +import { listVolumes } from "~/api-client"; +import { CreateVolumeDialog } from "~/components/create-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 { cn } from "~/lib/utils"; import type { Route } from "./+types/home"; +import { useFetcher } from "react-router"; export function meta(_: Route.MetaArgs) { return [ { title: "Ironmount" }, - { name: "description", content: "Welcome to React Router!" }, + { + name: "description", + content: "Create, manage, monitor, and automate your Docker volumes with ease.", + }, ]; } -export default function Home() { - return ; +export async function clientAction({ request }: Route.ClientActionArgs) { + const formData = await request.formData(); + const { _action, ...rest } = Object.fromEntries(formData.entries()); + + if (_action === "delete") { + return { yolo: "swag", _action: "delete" as const }; + console.log("Delete action triggered", rest); + // Delete volume logic + } + + if (_action === "create") { + console.log("Create action triggered", rest); + return { + error: "Volume with this name already exists.", + _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" }); + }; + + return ( +
+
+
+

Ironmount

+

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

+
+ + + + + + createFetcher.submit({ _action: "create", ...values }, { method: "POST" })} + /> +
+ + A list of your managed Docker volumes. + + + Name + Backend + Mountpoint + Status + Actions + + + + {loaderData?.volumes.map((volume) => ( + + {volume.name} + + + + Dir + + + + + + {volume.mountpoint} + + + + + + + + + + + + + + + ))} + +
+
+
+ ); } diff --git a/apps/client/app/welcome/welcome.tsx b/apps/client/app/welcome/welcome.tsx deleted file mode 100644 index 4d00a48..0000000 --- a/apps/client/app/welcome/welcome.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { Copy, Folder } from "lucide-react"; -import { useState } from "react"; -import { getApiV1VolumesOptions } from "~/api-client/@tanstack/react-query.gen"; -import { CreateVolumeDialog } from "~/components/create-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 { cn } from "~/lib/utils"; - -export function Welcome() { - const { data } = useQuery({ - ...getApiV1VolumesOptions(), - }); - - const deleteVolume = useMutation({}); - const [open, setOpen] = useState(false); - - return ( -
-
-
-

Ironmount

-

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

-
- - - - - - -
- - A list of your managed Docker volumes. - - - Name - Backend - Mountpoint - Status - Actions - - - - {data?.volumes.map((volume) => ( - - {volume.name} - - - - Dir - - - - - - {volume.mountpoint} - - - - - - - - - - - - - - - ))} - -
-
-
- ); -} diff --git a/apps/client/react-router.config.ts b/apps/client/react-router.config.ts index 3afb75a..29a789d 100644 --- a/apps/client/react-router.config.ts +++ b/apps/client/react-router.config.ts @@ -1,5 +1,5 @@ import type { Config } from "@react-router/dev/config"; export default { - ssr: false, + ssr: false, } satisfies Config; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index abed4e5..eecf32c 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -42,3 +42,5 @@ export const volumesTable = sqliteTable("volumes_table", { .$type() .notNull(), }); + +export type Volume = typeof volumesTable.$inferSelect; diff --git a/apps/server/src/modules/backends/backend.ts b/apps/server/src/modules/backends/backend.ts new file mode 100644 index 0000000..b6bb1d2 --- /dev/null +++ b/apps/server/src/modules/backends/backend.ts @@ -0,0 +1,24 @@ +import type { Volume } from "../../db/schema"; +import { makeDirectoryBackend } from "./directory/directory-backend"; +import { makeNfsBackend } from "./nfs/nfs-backend"; + +export type VolumeBackend = { + mount: () => Promise; + unmount: () => Promise; +}; + +export const createVolumeBackend = (volume: Volume): VolumeBackend => { + const { config, path } = volume; + + switch (config.backend) { + case "nfs": { + return makeNfsBackend(config, path); + } + case "directory": { + return makeDirectoryBackend(); + } + 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 new file mode 100644 index 0000000..fcea499 --- /dev/null +++ b/apps/server/src/modules/backends/directory/directory-backend.ts @@ -0,0 +1,14 @@ +import type { VolumeBackend } from "../backend"; + +const mount = async () => { + console.log("Mounting directory volume..."); +}; + +const unmount = async () => { + console.log("Cannot unmount directory volume."); +}; + +export const makeDirectoryBackend = (): VolumeBackend => ({ + mount, + unmount, +}); diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts new file mode 100644 index 0000000..dd12aca --- /dev/null +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -0,0 +1,39 @@ +import { exec } from "node:child_process"; +import * as os from "node:os"; +import type { BackendConfig } from "../../../db/schema"; +import type { VolumeBackend } from "../backend"; + +const mount = async (config: BackendConfig, path: string) => { + if (config.backend !== "nfs") { + throw new Error("Invalid backend config for NFS"); + } + + if (os.platform() !== "linux") { + console.error("NFS mounting is only supported on Linux hosts."); + return; + } + + const source = `${config.server}:${config.exportPath}`; + const options = [`vers=${config.version}`, `port=${config.port}`]; + const cmd = `mount -t nfs -o ${options.join(",")} ${source} ${path}`; + + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) { + console.error(`Error mounting NFS volume: ${stderr}`); + return reject(new Error(`Failed to mount NFS volume: ${stderr}`)); + } + console.log(`NFS volume mounted successfully: ${stdout}`); + resolve(); + }); + }); +}; + +const unmount = async () => { + console.log("Unmounting nfs volume..."); +}; + +export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({ + mount: () => mount(config, path), + unmount, +}); diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index e3952c3..272f18f 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -1,12 +1,7 @@ import { Hono } from "hono"; import { validator } from "hono-openapi/arktype"; import { handleServiceError } from "../../utils/errors"; -import { - createVolumeBody, - createVolumeDto, - type ListVolumesResponseDto, - listVolumesDto, -} from "./volume.dto"; +import { createVolumeBody, createVolumeDto, type ListVolumesResponseDto, listVolumesDto } from "./volume.dto"; import { volumeService } from "./volume.service"; export const volumeController = new Hono() @@ -23,22 +18,17 @@ export const volumeController = new Hono() return c.json(response, 200); }) - .post( - "/", - createVolumeDto, - validator("json", createVolumeBody), - async (c) => { - const body = c.req.valid("json"); - const res = await volumeService.createVolume(body.name, body.config); + .post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => { + const body = c.req.valid("json"); + const res = await volumeService.createVolume(body.name, body.config); - if (res.error) { - const { message, status } = handleServiceError(res.error); - return c.json(message, status); - } + if (res.error) { + const { message, status } = handleServiceError(res.error); + return c.json(message, status); + } - return c.json({ message: "Volume created", volume: res.volume }); - }, - ) + return c.json({ message: "Volume created", volume: res.volume }); + }) .get("/:name", (c) => { return c.json({ message: `Details of volume ${c.req.param("name")}` }); }) diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 3935d8d..c3a0a74 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -18,6 +18,8 @@ export type ListVolumesResponseDto = typeof listVolumesResponse.infer; export const listVolumesDto = describeRoute({ description: "List all volumes", tags: ["Volumes"], + operationId: "listVolumes", + validateResponse: true, responses: { 200: { description: "A list of volumes", @@ -46,6 +48,8 @@ export const createVolumeResponse = type({ export const createVolumeDto = describeRoute({ description: "Create a new volume", + operationId: "createVolume", + validateResponse: true, tags: ["Volumes"], responses: { 201: { diff --git a/apps/server/src/modules/volumes/volume.service.ts b/apps/server/src/modules/volumes/volume.service.ts index ac7f9f1..0340899 100644 --- a/apps/server/src/modules/volumes/volume.service.ts +++ b/apps/server/src/modules/volumes/volume.service.ts @@ -1,10 +1,11 @@ import * as path from "node:path"; import { eq } from "drizzle-orm"; -import { ConflictError } from "http-errors-enhanced"; +import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { config } from "../../core/config"; import { db } from "../../db/db"; import { type BackendConfig, volumesTable } from "../../db/schema"; +import { createVolumeBackend } from "../backends/backend"; const listVolumes = async () => { const volumes = await db.query.volumesTable.findMany({}); @@ -38,7 +39,29 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { return { volume: val[0], status: 201 }; }; +const mountVolume = async (name: string) => { + try { + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.name, name), + }); + + if (!volume) { + return { error: new NotFoundError("Volume not found") }; + } + + const backend = createVolumeBackend(volume); + await backend.mount(); + } catch (error) { + return { + error: new InternalServerError("Failed to mount volume", { + cause: error, + }), + }; + } +}; + export const volumeService = { listVolumes, createVolume, + mountVolume, }; diff --git a/apps/server/src/utils/errors.ts b/apps/server/src/utils/errors.ts index 12143ba..8dccf2f 100644 --- a/apps/server/src/utils/errors.ts +++ b/apps/server/src/utils/errors.ts @@ -1,8 +1,14 @@ -import { ConflictError } from "http-errors-enhanced"; +import { ConflictError, NotFoundError } from "http-errors-enhanced"; export const handleServiceError = (error: unknown) => { if (error instanceof ConflictError) { return { message: error.message, status: 409 as const }; } + + if (error instanceof NotFoundError) { + return { message: error.message, status: 404 as const }; + } + + console.error("Unhandled service error:", error); return { message: "Internal Server Error", status: 500 as const }; }; diff --git a/biome.json b/biome.json index c875946..c7dc327 100644 --- a/biome.json +++ b/biome.json @@ -10,7 +10,8 @@ }, "formatter": { "enabled": true, - "indentStyle": "tab" + "indentStyle": "tab", + "lineWidth": 120 }, "linter": { "enabled": true, diff --git a/docker-compose.yml b/docker-compose.yml index e1278fd..04148e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,12 +7,11 @@ services: restart: unless-stopped cap_add: - SYS_ADMIN - - SYS_MODULE ports: - "3000:3000" - privileged: true + # privileged: true env_file: - - path: .env.local + - path: .env required: false # security_opt: # - apparmor:unconfined @@ -25,4 +24,4 @@ services: - ./data:/data - # - /home/nicolas/ironmount/tmp:/mounts #//:rshared + - /home/nicolas/ironmount/tmp:/mounts:rshared diff --git a/mutagen.yml b/mutagen.yml new file mode 100644 index 0000000..c506ed6 --- /dev/null +++ b/mutagen.yml @@ -0,0 +1,18 @@ +sync: + defaults: + ignore: + vcs: true + paths: + - "node_modules" + - ".git" + - ".DS_Store" + - "tmp" + - "logs" + ironmount: + alpha: "." + beta: "nicolas@192.168.2.42:/home/nicolas/ironmount" + mode: "one-way-safe" + flushOnCreate: true + ignore: + paths: + - "node_modules" diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index b7000fd..e716fef 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -1,7 +1,7 @@ import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; export default defineConfig({ - input: "http://localhost:3000/api/v1/openapi.json", + input: "http://192.168.2.42:3000/api/v1/openapi.json", output: { path: "./apps/client/app/api-client", format: "biome",