mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: initial commit
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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";
|
||||
interface Props {
|
||||
serviceId: string;
|
||||
serviceType:
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "redis"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "mysql"
|
||||
| "mariadb";
|
||||
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: "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 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"
|
||||
>
|
||||
<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>
|
||||
<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="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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
mountId: string;
|
||||
refetch: () => void;
|
||||
}
|
||||
export const DeleteVolume = ({ mountId, refetch }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.mounts.remove.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the mount
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Mount deleted succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the mount");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { AlertTriangle, Package } from "lucide-react";
|
||||
import { AddVolumes } from "./add-volumes";
|
||||
import { DeleteVolume } from "./delete-volume";
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Volumes</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to persist data in this application use the following
|
||||
config to setup the volumes
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
<AddVolumes
|
||||
serviceId={applicationId}
|
||||
refetch={refetch}
|
||||
serviceType="application"
|
||||
>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.mounts.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<Package className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes
|
||||
serviceId={applicationId}
|
||||
refetch={refetch}
|
||||
serviceType="application"
|
||||
>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="text-yellow-600 size-5 sm:size-8 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
Please remember to click Redeploy after adding, editing, or
|
||||
deleting a mount to apply the changes.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 pt-6">
|
||||
{data?.mounts.map((mount) => (
|
||||
<div key={mount.mountId}>
|
||||
<div
|
||||
key={mount.mountId}
|
||||
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
|
||||
>
|
||||
{/* <Package className="size-8 self-center text-muted-foreground" /> */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Mount Type</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{mount.type === "volume" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Volume Name</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.volumeName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mount.type === "bind" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Host Path</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.hostPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Mount Path</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.mountPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user