dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx
Mauricio Siu 8f9d21c0f8
Docker compose support (#111)
* feat(WIP): compose implementation

* feat: add volumes, networks, services name hash generate

* feat: add compose config test unique

* feat: add tests for each unique config

* feat: implement lodash for docker compose parsing

* feat: add tests for generating compose file

* refactor: implement logs docker compose

* refactor: composeFile set not empty

* feat: implement providers for compose deployments

* feat: add Files volumes to compose

* feat: add stop compose button

* refactor: change strategie of building compose

* feat: create .env file in composepath

* refactor: simplify git and github function

* chore: update deps

* refactor: update migrations and add badge to recognize compose type

* chore: update lock yaml

* refactor: use code editor

* feat: add monitoring for app types

* refactor: reset stats on change appName

* refactor: add option to clean monitoring folder

* feat: show current command that will run

* feat: add prefix

* fix: add missing types

* refactor: add docker provider and expose by default as false

* refactor: customize error page

* refactor: unified deployments to be a single one

* feat: add vitest to ci/cd

* revert: back to initial version

* refactor: add maxconcurrency vitest

* refactor: add pool forks to vitest

* feat: add pocketbase template

* fix: update path resolution compose

* removed

* feat: add template pocketbase

* feat: add pocketbase template

* feat: add support button

* feat: add plausible template

* feat: add calcom template

* feat: add version to each template

* feat: add code editor to enviroment variables and swarm settings json

* refactor: add loader when download the image

* fix: use base64 to generate keys plausible

* feat: add recognized domain names by enviroment compose

* refactor: show alert to redeploy in each card advanced tab

* refactor: add validation to prevent create compose if not have permissions

* chore: add templates section to contributing

* chore: add example contributing
2024-06-02 15:26:28 -06:00

344 lines
9.0 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import type React from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface Props {
serviceId: string;
serviceType:
| "application"
| "postgres"
| "redis"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "compose";
refetch: () => void;
children?: React.ReactNode;
}
const mountSchema = z.object({
mountPath: z.string().min(1, "Mount path required"),
});
const mySchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("bind"),
hostPath: z.string().min(1, "Host path required"),
})
.merge(mountSchema),
z
.object({
type: z.literal("volume"),
volumeName: z.string().min(1, "Volume name required"),
})
.merge(mountSchema),
z
.object({
type: z.literal("file"),
content: z.string().optional(),
})
.merge(mountSchema),
]);
type AddMount = z.infer<typeof mySchema>;
export const AddVolumes = ({
serviceId,
serviceType,
refetch,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const { mutateAsync } = api.mounts.create.useMutation();
const form = useForm<AddMount>({
defaultValues: {
type: serviceType === "compose" ? "file" : "bind",
hostPath: "",
mountPath: "",
},
resolver: zodResolver(mySchema),
});
const type = form.watch("type");
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddMount) => {
if (data.type === "bind") {
await mutateAsync({
serviceId,
hostPath: data.hostPath,
mountPath: data.mountPath,
type: data.type,
serviceType,
})
.then(() => {
toast.success("Mount Created");
})
.catch(() => {
toast.error("Error to create the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
serviceId,
volumeName: data.volumeName,
mountPath: data.mountPath,
type: data.type,
serviceType,
})
.then(() => {
toast.success("Mount Created");
})
.catch(() => {
toast.error("Error to create the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
serviceId,
content: data.content,
mountPath: data.mountPath,
type: data.type,
serviceType,
})
.then(() => {
toast.success("Mount Created");
})
.catch(() => {
toast.error("Error to create the File mount");
});
}
refetch();
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Volumes / Mounts</DialogTitle>
</DialogHeader>
{/* {isError && (
<div className="flex items-center flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{error?.message}
</span>
</div>
)} */}
<Form {...form}>
<form
id="hook-form-volume"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<FormField
control={form.control}
defaultValue={form.control._defaultValues.type}
name="type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel className="text-muted-foreground">
Select the Mount Type
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
>
{serviceType !== "compose" && (
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="bind"
id="bind"
className="peer sr-only"
/>
<Label
htmlFor="bind"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Bind Mount
</Label>
</div>
</FormControl>
</FormItem>
)}
{serviceType !== "compose" && (
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="volume"
id="volume"
className="peer sr-only"
/>
<Label
htmlFor="volume"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
Volume Mount
</Label>
</div>
</FormControl>
</FormItem>
)}
<FormItem
className={cn(
serviceType === "compose" && "col-span-3",
"flex items-center space-x-3 space-y-0",
)}
>
<FormControl className="w-full">
<div>
<RadioGroupItem
value="file"
id="file"
className="peer sr-only"
/>
<Label
htmlFor="file"
className="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
>
File Mount
</Label>
</div>
</FormControl>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<FormLabel className="text-lg font-semibold leading-none tracking-tight">
Fill the next fields.
</FormLabel>
<div className="flex flex-col gap-2">
{type === "bind" && (
<FormField
control={form.control}
name="hostPath"
render={({ field }) => (
<FormItem>
<FormLabel>Host Path</FormLabel>
<FormControl>
<Input placeholder="Host Path" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "volume" && (
<FormField
control={form.control}
name="volumeName"
render={({ field }) => (
<FormItem>
<FormLabel>Volume Name</FormLabel>
<FormControl>
<Input
placeholder="Volume Name"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "file" && (
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="mountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path</FormLabel>
<FormControl>
<Input placeholder="Mount Path" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form-volume"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};