refactor: use rr actions/loader

refactor: use rr actions
This commit is contained in:
Nicolas Meienberger
2025-08-31 19:22:55 +02:00
parent 23f47bbb6b
commit 37effcb4e3
21 changed files with 454 additions and 219 deletions

2
.gitignore vendored
View File

@@ -39,3 +39,5 @@ node_modules/
.env*
.turbo
mutagen.yml.lock

View File

@@ -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;
};

View File

@@ -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,
},
});
};

View File

@@ -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 & {});

View File

@@ -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>

View File

@@ -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" },

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -42,3 +42,5 @@ export const volumesTable = sqliteTable("volumes_table", {
.$type<typeof volumeConfigSchema.inferOut>()
.notNull(),
});
export type Volume = typeof volumesTable.$inferSelect;

View 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`);
}
}
};

View File

@@ -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,
});

View 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,
});

View File

@@ -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,11 +18,7 @@ export const volumeController = new Hono()
return c.json(response, 200);
})
.post(
"/",
createVolumeDto,
validator("json", createVolumeBody),
async (c) => {
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config);
@@ -37,8 +28,7 @@ export const volumeController = new Hono()
}
return c.json({ message: "Volume created", volume: res.volume });
},
)
})
.get("/:name", (c) => {
return c.json({ message: `Details of volume ${c.req.param("name")}` });
})

View File

@@ -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: {

View File

@@ -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,
};

View File

@@ -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 };
};

View File

@@ -10,7 +10,8 @@
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
"indentStyle": "tab",
"lineWidth": 120
},
"linter": {
"enabled": true,

View File

@@ -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
View 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"

View File

@@ -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",