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*
|
.env*
|
||||||
|
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
|
mutagen.yml.lock
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type Options, getApiV1Volumes } from "../sdk.gen";
|
import { type Options, listVolumes, createVolume } from "../sdk.gen";
|
||||||
import { queryOptions } from "@tanstack/react-query";
|
import {
|
||||||
import type { GetApiV1VolumesData } from "../types.gen";
|
queryOptions,
|
||||||
|
type UseMutationOptions,
|
||||||
|
type DefaultError,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
ListVolumesData,
|
||||||
|
CreateVolumeData,
|
||||||
|
CreateVolumeResponse,
|
||||||
|
} from "../types.gen";
|
||||||
import { client as _heyApiClient } from "../client.gen";
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
|
|
||||||
export type QueryKey<TOptions extends Options> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
@@ -46,19 +54,16 @@ const createQueryKey = <TOptions extends Options>(
|
|||||||
return [params];
|
return [params];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getApiV1VolumesQueryKey = (
|
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) =>
|
||||||
options?: Options<GetApiV1VolumesData>,
|
createQueryKey("listVolumes", options);
|
||||||
) => createQueryKey("getApiV1Volumes", options);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
export const getApiV1VolumesOptions = (
|
export const listVolumesOptions = (options?: Options<ListVolumesData>) => {
|
||||||
options?: Options<GetApiV1VolumesData>,
|
|
||||||
) => {
|
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryFn: async ({ queryKey, signal }) => {
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
const { data } = await getApiV1Volumes({
|
const { data } = await listVolumes({
|
||||||
...options,
|
...options,
|
||||||
...queryKey[0],
|
...queryKey[0],
|
||||||
signal,
|
signal,
|
||||||
@@ -66,6 +71,54 @@ export const getApiV1VolumesOptions = (
|
|||||||
});
|
});
|
||||||
return data;
|
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 { Options as ClientOptions, TDataShape, Client } from "./client";
|
||||||
import type {
|
import type {
|
||||||
GetApiV1VolumesData,
|
ListVolumesData,
|
||||||
GetApiV1VolumesResponses,
|
ListVolumesResponses,
|
||||||
|
CreateVolumeData,
|
||||||
|
CreateVolumeResponses,
|
||||||
} from "./types.gen";
|
} from "./types.gen";
|
||||||
import { client as _heyApiClient } from "./client.gen";
|
import { client as _heyApiClient } from "./client.gen";
|
||||||
|
|
||||||
@@ -27,11 +29,11 @@ export type Options<
|
|||||||
/**
|
/**
|
||||||
* List all volumes
|
* List all volumes
|
||||||
*/
|
*/
|
||||||
export const getApiV1Volumes = <ThrowOnError extends boolean = false>(
|
export const listVolumes = <ThrowOnError extends boolean = false>(
|
||||||
options?: Options<GetApiV1VolumesData, ThrowOnError>,
|
options?: Options<ListVolumesData, ThrowOnError>,
|
||||||
) => {
|
) => {
|
||||||
return (options?.client ?? _heyApiClient).get<
|
return (options?.client ?? _heyApiClient).get<
|
||||||
GetApiV1VolumesResponses,
|
ListVolumesResponses,
|
||||||
unknown,
|
unknown,
|
||||||
ThrowOnError
|
ThrowOnError
|
||||||
>({
|
>({
|
||||||
@@ -39,3 +41,23 @@ export const getApiV1Volumes = <ThrowOnError extends boolean = false>(
|
|||||||
...options,
|
...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
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type GetApiV1VolumesData = {
|
export type ListVolumesData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
query?: never;
|
query?: never;
|
||||||
url: "/api/v1/volumes";
|
url: "/api/v1/volumes";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetApiV1VolumesResponses = {
|
export type ListVolumesResponses = {
|
||||||
/**
|
/**
|
||||||
* A list of volumes
|
* A list of volumes
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
volumes: Array<{
|
volumes: Array<{
|
||||||
createdAt: string;
|
createdAt: number;
|
||||||
mountpoint: string;
|
mountpoint: string;
|
||||||
name: string;
|
name: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetApiV1VolumesResponse =
|
export type ListVolumesResponse =
|
||||||
GetApiV1VolumesResponses[keyof GetApiV1VolumesResponses];
|
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 = {
|
export type ClientOptions = {
|
||||||
baseUrl: "http://localhost:3000" | (string & {});
|
baseUrl: "http://localhost:3000" | (string & {});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -14,22 +13,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
import {
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "./ui/form";
|
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.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>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -72,9 +53,7 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create volume</DialogTitle>
|
<DialogTitle>Create volume</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Enter a name for the new volume.</DialogDescription>
|
||||||
Enter a name for the new volume.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
@@ -92,25 +71,23 @@ export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
|||||||
min={1}
|
min={1}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>Unique identifier for the volume.</FormDescription>
|
||||||
Unique identifier for the volume.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{createVolume.error && (
|
{/* {createVolume.error && ( */}
|
||||||
<div className="text-red-500 text-sm">
|
{/* <div className="text-red-500 text-sm"> */}
|
||||||
{createVolume.error.message}
|
{/* {createVolume.error.message} */}
|
||||||
</div>
|
{/* </div> */}
|
||||||
)}
|
{/* )} */}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => setOpen(false)}>
|
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createVolume.status === "pending" || !nameValue}
|
// disabled={createVolume.status === "pending" || !nameValue}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { client } from "./api-client/client.gen";
|
||||||
|
|
||||||
|
client.setConfig({
|
||||||
|
baseUrl: "http://192.168.2.42:3000/",
|
||||||
|
});
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ 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 type { Route } from "./+types/home";
|
||||||
|
import { useFetcher } from "react-router";
|
||||||
|
|
||||||
export function meta(_: Route.MetaArgs) {
|
export function meta(_: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "Ironmount" },
|
{ 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() {
|
export async function clientAction({ request }: Route.ClientActionArgs) {
|
||||||
return <Welcome />;
|
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";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -42,3 +42,5 @@ export const volumesTable = sqliteTable("volumes_table", {
|
|||||||
.$type<typeof volumeConfigSchema.inferOut>()
|
.$type<typeof volumeConfigSchema.inferOut>()
|
||||||
.notNull(),
|
.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 { Hono } from "hono";
|
||||||
import { validator } from "hono-openapi/arktype";
|
import { validator } from "hono-openapi/arktype";
|
||||||
import { handleServiceError } from "../../utils/errors";
|
import { handleServiceError } from "../../utils/errors";
|
||||||
import {
|
import { createVolumeBody, createVolumeDto, type ListVolumesResponseDto, listVolumesDto } from "./volume.dto";
|
||||||
createVolumeBody,
|
|
||||||
createVolumeDto,
|
|
||||||
type ListVolumesResponseDto,
|
|
||||||
listVolumesDto,
|
|
||||||
} from "./volume.dto";
|
|
||||||
import { volumeService } from "./volume.service";
|
import { volumeService } from "./volume.service";
|
||||||
|
|
||||||
export const volumeController = new Hono()
|
export const volumeController = new Hono()
|
||||||
@@ -23,22 +18,17 @@ export const volumeController = new Hono()
|
|||||||
|
|
||||||
return c.json(response, 200);
|
return c.json(response, 200);
|
||||||
})
|
})
|
||||||
.post(
|
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
|
||||||
"/",
|
const body = c.req.valid("json");
|
||||||
createVolumeDto,
|
const res = await volumeService.createVolume(body.name, body.config);
|
||||||
validator("json", createVolumeBody),
|
|
||||||
async (c) => {
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
const res = await volumeService.createVolume(body.name, body.config);
|
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
const { message, status } = handleServiceError(res.error);
|
const { message, status } = handleServiceError(res.error);
|
||||||
return c.json(message, status);
|
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) => {
|
.get("/:name", (c) => {
|
||||||
return c.json({ message: `Details of volume ${c.req.param("name")}` });
|
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({
|
export const listVolumesDto = describeRoute({
|
||||||
description: "List all volumes",
|
description: "List all volumes",
|
||||||
tags: ["Volumes"],
|
tags: ["Volumes"],
|
||||||
|
operationId: "listVolumes",
|
||||||
|
validateResponse: true,
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "A list of volumes",
|
description: "A list of volumes",
|
||||||
@@ -46,6 +48,8 @@ export const createVolumeResponse = type({
|
|||||||
|
|
||||||
export const createVolumeDto = describeRoute({
|
export const createVolumeDto = describeRoute({
|
||||||
description: "Create a new volume",
|
description: "Create a new volume",
|
||||||
|
operationId: "createVolume",
|
||||||
|
validateResponse: true,
|
||||||
tags: ["Volumes"],
|
tags: ["Volumes"],
|
||||||
responses: {
|
responses: {
|
||||||
201: {
|
201: {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ConflictError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { config } from "../../core/config";
|
import { config } from "../../core/config";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { type BackendConfig, volumesTable } from "../../db/schema";
|
import { type BackendConfig, volumesTable } from "../../db/schema";
|
||||||
|
import { createVolumeBackend } from "../backends/backend";
|
||||||
|
|
||||||
const listVolumes = async () => {
|
const listVolumes = async () => {
|
||||||
const volumes = await db.query.volumesTable.findMany({});
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
@@ -38,7 +39,29 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
|
|||||||
return { volume: val[0], status: 201 };
|
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 = {
|
export const volumeService = {
|
||||||
listVolumes,
|
listVolumes,
|
||||||
createVolume,
|
createVolume,
|
||||||
|
mountVolume,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { ConflictError } from "http-errors-enhanced";
|
import { ConflictError, NotFoundError } from "http-errors-enhanced";
|
||||||
|
|
||||||
export const handleServiceError = (error: unknown) => {
|
export const handleServiceError = (error: unknown) => {
|
||||||
if (error instanceof ConflictError) {
|
if (error instanceof ConflictError) {
|
||||||
return { message: error.message, status: 409 as const };
|
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 };
|
return { message: "Internal Server Error", status: 500 as const };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab"
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 120
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
- SYS_ADMIN
|
- SYS_ADMIN
|
||||||
- SYS_MODULE
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
privileged: true
|
# privileged: true
|
||||||
env_file:
|
env_file:
|
||||||
- path: .env.local
|
- path: .env
|
||||||
required: false
|
required: false
|
||||||
# security_opt:
|
# security_opt:
|
||||||
# - apparmor:unconfined
|
# - apparmor:unconfined
|
||||||
@@ -25,4 +24,4 @@ services:
|
|||||||
|
|
||||||
- ./data:/data
|
- ./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";
|
import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: "http://localhost:3000/api/v1/openapi.json",
|
input: "http://192.168.2.42:3000/api/v1/openapi.json",
|
||||||
output: {
|
output: {
|
||||||
path: "./apps/client/app/api-client",
|
path: "./apps/client/app/api-client",
|
||||||
format: "biome",
|
format: "biome",
|
||||||
|
|||||||
Reference in New Issue
Block a user