mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: use rr actions/loader
refactor: use rr actions
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ node_modules/
|
||||
.env*
|
||||
|
||||
.turbo
|
||||
|
||||
mutagen.yml.lock
|
||||
|
||||
@@ -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<TOptions extends Options> = [
|
||||
@@ -46,19 +54,16 @@ const createQueryKey = <TOptions extends Options>(
|
||||
return [params];
|
||||
};
|
||||
|
||||
export const getApiV1VolumesQueryKey = (
|
||||
options?: Options<GetApiV1VolumesData>,
|
||||
) => createQueryKey("getApiV1Volumes", options);
|
||||
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) =>
|
||||
createQueryKey("listVolumes", options);
|
||||
|
||||
/**
|
||||
* List all volumes
|
||||
*/
|
||||
export const getApiV1VolumesOptions = (
|
||||
options?: Options<GetApiV1VolumesData>,
|
||||
) => {
|
||||
export const listVolumesOptions = (options?: Options<ListVolumesData>) => {
|
||||
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<CreateVolumeData>) =>
|
||||
createQueryKey("createVolume", options);
|
||||
|
||||
/**
|
||||
* Create a new volume
|
||||
*/
|
||||
export const createVolumeOptions = (options?: Options<CreateVolumeData>) => {
|
||||
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<Options<CreateVolumeData>>,
|
||||
): UseMutationOptions<
|
||||
CreateVolumeResponse,
|
||||
DefaultError,
|
||||
Options<CreateVolumeData>
|
||||
> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
CreateVolumeResponse,
|
||||
DefaultError,
|
||||
Options<CreateVolumeData>
|
||||
> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await createVolume({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
@@ -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 = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<GetApiV1VolumesData, ThrowOnError>,
|
||||
export const listVolumes = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListVolumesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).get<
|
||||
GetApiV1VolumesResponses,
|
||||
ListVolumesResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
@@ -39,3 +41,23 @@ export const getApiV1Volumes = <ThrowOnError extends boolean = false>(
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new volume
|
||||
*/
|
||||
export const createVolume = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateVolumeData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<
|
||||
CreateVolumeResponses,
|
||||
unknown,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/v1/volumes",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 & {});
|
||||
|
||||
@@ -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<typeof formSchema>;
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSubmit: (values: FormValues) => void;
|
||||
};
|
||||
|
||||
export const CreateVolumeDialog = ({ open, setOpen, onSubmit }: Props) => {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -72,9 +53,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create volume</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for the new volume.
|
||||
</DialogDescription>
|
||||
<DialogDescription>Enter a name for the new volume.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
@@ -92,25 +71,23 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
||||
min={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier for the volume.
|
||||
</FormDescription>
|
||||
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{createVolume.error && (
|
||||
<div className="text-red-500 text-sm">
|
||||
{createVolume.error.message}
|
||||
</div>
|
||||
)}
|
||||
{/* {createVolume.error && ( */}
|
||||
{/* <div className="text-red-500 text-sm"> */}
|
||||
{/* {createVolume.error.message} */}
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createVolume.status === "pending" || !nameValue}
|
||||
// disabled={createVolume.status === "pending" || !nameValue}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 <Welcome />;
|
||||
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<Extract<typeof actionData, { _action: "create" }>>();
|
||||
const deleteFetcher = useFetcher<Extract<typeof actionData, { _action: "delete" }>>();
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
"[background-size:40px_40px]",
|
||||
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
|
||||
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
||||
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
|
||||
Create, manage, monitor, and automate your Docker volumes with ease.
|
||||
</h2>
|
||||
<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…" />
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mounted">Mounted</SelectItem>
|
||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
<CreateVolumeDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onSubmit={(values) => createFetcher.submit({ _action: "create", ...values }, { method: "POST" })}
|
||||
/>
|
||||
</div>
|
||||
<Table className="mt-4 border bg-white dark:bg-secondary">
|
||||
<TableCaption>A list of your managed Docker volumes.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase">Mountpoint</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loaderData?.volumes.map((volume) => (
|
||||
<TableRow key={volume.name}>
|
||||
<TableCell className="font-medium">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1">
|
||||
<Folder size={10} />
|
||||
Dir
|
||||
</span>
|
||||
</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.mountpoint}
|
||||
</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">
|
||||
<Button variant="destructive" onClick={() => handleDeleteConfirm(volume.name)}>
|
||||
Delete
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
"[background-size:40px_40px]",
|
||||
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-black"></div>
|
||||
<main className="relative flex flex-col pt-16 p-4 container mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-0 uppercase">Ironmount</h1>
|
||||
<h2 className="text-sm font-semibold mb-2 text-muted-foreground">
|
||||
Create, manage, monitor, and automate your Docker volumes with ease.
|
||||
</h2>
|
||||
<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…" />
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mounted">Mounted</SelectItem>
|
||||
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All backends" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</span>
|
||||
<CreateVolumeDialog open={open} setOpen={setOpen} />
|
||||
</div>
|
||||
<Table className="mt-4 border bg-white dark:bg-secondary">
|
||||
<TableCaption>A list of your managed Docker volumes.</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] uppercase">Name</TableHead>
|
||||
<TableHead className="uppercase text-left">Backend</TableHead>
|
||||
<TableHead className="uppercase">Mountpoint</TableHead>
|
||||
<TableHead className="uppercase text-center">Status</TableHead>
|
||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.volumes.map((volume) => (
|
||||
<TableRow key={volume.name}>
|
||||
<TableCell className="font-medium">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1">
|
||||
<Folder size={10} />
|
||||
Dir
|
||||
</span>
|
||||
</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.mountpoint}
|
||||
</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">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteVolume.mutate({ name: volume.name })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
ssr: false,
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -42,3 +42,5 @@ export const volumesTable = sqliteTable("volumes_table", {
|
||||
.$type<typeof volumeConfigSchema.inferOut>()
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export type Volume = typeof volumesTable.$inferSelect;
|
||||
|
||||
24
apps/server/src/modules/backends/backend.ts
Normal file
24
apps/server/src/modules/backends/backend.ts
Normal file
@@ -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<void>;
|
||||
unmount: () => Promise<void>;
|
||||
};
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
39
apps/server/src/modules/backends/nfs/nfs-backend.ts
Normal file
39
apps/server/src/modules/backends/nfs/nfs-backend.ts
Normal file
@@ -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<void>((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,
|
||||
});
|
||||
@@ -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")}` });
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
18
mutagen.yml
Normal file
18
mutagen.yml
Normal file
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user