Merge branch 'canary' into 57-dokploy-api-or-cli

This commit is contained in:
Mauricio Siu 2024-06-12 00:23:53 -06:00
commit ae4226531e
59 changed files with 763 additions and 448 deletions

1
.gitignore vendored
View File

@ -55,3 +55,4 @@ yarn-error.log*
*.lockb
*.rdb
.idea

View File

@ -55,5 +55,5 @@ Tested Systems:
## 📄 Documentation
For detailed documentation, visit [docs.dokploy.com/docs](https://docs.dokploy.com).
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).

View File

@ -183,8 +183,7 @@ export const AddPort = ({
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent defaultValue={"none"}>
<SelectItem value={"none"}>None</SelectItem>
<SelectContent>
<SelectItem value={"tcp"}>TCP</SelectItem>
<SelectItem value={"udp"}>UDP</SelectItem>
</SelectContent>

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
@ -19,8 +19,9 @@ import {
} from "@/components/ui/form";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { Textarea } from "@/components/ui/textarea";
import { Toggle } from "@/components/ui/toggle";
import { CodeEditor } from "@/components/shared/code-editor";
import { EyeIcon, EyeOffIcon } from "lucide-react";
const addEnvironmentSchema = z.object({
environment: z.string(),
@ -33,6 +34,7 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } =
api.application.saveEnvironment.useMutation();
@ -72,15 +74,50 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
toast.error("Error to add environment");
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
@ -97,6 +134,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<FormControl>
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
@ -111,7 +149,12 @@ PORT=3000
/>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>

View File

@ -11,6 +11,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { toast } from "sonner";
interface Props {
@ -51,18 +52,14 @@ export const DeployApplication = ({ applicationId }: Props) => {
applicationId,
})
.then(async () => {
toast.success("Application Deploying....");
toast.success("Deploying Application....");
await refetch();
await deploy({
applicationId,
})
.then(() => {
toast.success("Application Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Application");
});
}).catch(() => {
toast.error("Error to deploy Application");
});
await refetch();
})

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
@ -20,6 +20,8 @@ import {
import { api } from "@/utils/api";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Toggle } from "@/components/ui/toggle";
import { EyeIcon, EyeOffIcon } from "lucide-react";
const addEnvironmentSchema = z.object({
environment: z.string(),
@ -32,6 +34,7 @@ interface Props {
}
export const ShowEnvironmentCompose = ({ composeId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
@ -71,14 +74,50 @@ export const ShowEnvironmentCompose = ({ composeId }: Props) => {
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
@ -95,6 +134,7 @@ export const ShowEnvironmentCompose = ({ composeId }: Props) => {
<FormControl>
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
@ -109,7 +149,12 @@ PORT=3000
/>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>

View File

@ -49,18 +49,14 @@ export const DeployCompose = ({ composeId }: Props) => {
composeStatus: "running",
})
.then(async () => {
toast.success("Compose Deploying....");
toast.success("Deploying Compose....");
await refetch();
await deploy({
composeId,
})
.then(() => {
toast.success("Compose Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Compose");
});
}).catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
})

View File

@ -50,17 +50,13 @@ export const DeployMariadb = ({ mariadbId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mariadbId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@ -22,6 +22,7 @@ import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@ -136,7 +137,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-3">
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mariadbId: string;
@ -29,20 +30,18 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@ -58,7 +57,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mariadb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@ -50,17 +50,13 @@ export const DeployMongo = ({ mongoId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mongoId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@ -136,7 +137,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mongoId: string;
@ -26,10 +27,9 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
@ -46,7 +46,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
/>

View File

@ -50,17 +50,13 @@ export const DeployMysql = ({ mysqlId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
mysqlId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@ -136,7 +137,7 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput disabled value={connectionUrl} />
</div>
</div>
)}

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
mysqlId: string;
@ -29,20 +30,18 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
type="password"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<Input
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
type="password"
/>
</div>
</div>
@ -58,7 +57,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`mysql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:3306/${data?.databaseName}`}
/>

View File

@ -50,17 +50,13 @@ export const DeployPostgres = ({ postgresId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database....");
await refetch();
await deploy({
postgresId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@ -137,7 +138,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
postgresId: string;
@ -29,10 +30,9 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@ -48,7 +48,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
/>

View File

@ -22,16 +22,26 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Folder } from "lucide-react";
import { useEffect } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { slugify } from "@/lib/slug";
const AddTemplateSchema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
description: z.string().optional(),
});
@ -39,10 +49,13 @@ type AddTemplate = z.infer<typeof AddTemplateSchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddApplication = ({ projectId }: Props) => {
export const AddApplication = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation();
@ -50,34 +63,34 @@ export const AddApplication = ({ projectId }: Props) => {
const form = useForm<AddTemplate>({
defaultValues: {
name: "",
appName: `${slug}-`,
description: "",
},
resolver: zodResolver(AddTemplateSchema),
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
name: data.name,
appName: data.appName,
description: data.description,
projectId,
})
.then(async () => {
toast.success("Service Created");
form.reset();
setVisible(false);
await utils.project.one.invalidate({
projectId,
});
})
.catch(() => {
.catch((e) => {
toast.error("Error to create the service");
});
};
return (
<Dialog>
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@ -95,29 +108,46 @@ export const AddApplication = ({ projectId }: Props) => {
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Frontend" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
@ -136,47 +166,6 @@ export const AddApplication = ({ projectId }: Props) => {
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="buildType"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="dockerfile" />
</FormControl>
<FormLabel className="font-normal">
Dockerfile
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="nixpacks" />
</FormControl>
<FormLabel className="font-normal">Nixpacks</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
</form>
<DialogFooter>

View File

@ -34,12 +34,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { slugify } from "@/lib/slug";
const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(),
name: z.string().min(1, {
message: "Name is required",
}),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
description: z.string().optional(),
});
@ -47,11 +57,12 @@ type AddCompose = z.infer<typeof AddComposeSchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddCompose = ({ projectId }: Props) => {
export const AddCompose = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const slug = slugify(projectName);
const { mutateAsync, isLoading, error, isError } =
api.compose.create.useMutation();
@ -60,6 +71,7 @@ export const AddCompose = ({ projectId }: Props) => {
name: "",
description: "",
composeType: "docker-compose",
appName: `${slug}-`,
},
resolver: zodResolver(AddComposeSchema),
});
@ -74,6 +86,7 @@ export const AddCompose = ({ projectId }: Props) => {
description: data.description,
projectId,
composeType: data.composeType,
appName: data.appName,
})
.then(async () => {
toast.success("Compose Created");
@ -120,14 +133,34 @@ export const AddCompose = ({ projectId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Frontend" {...field} />
<Input
placeholder="Frontend"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="composeType"

View File

@ -27,10 +27,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Database } from "lucide-react";
import { useEffect } from "react";
import { Database, AlertTriangle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@ -58,6 +59,15 @@ const databasesUserDefaultPlaceholder: Record<
const baseDatabaseSchema = z.object({
name: z.string().min(1, "Name required"),
appName: z
.string()
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
}),
databasePassword: z.string(),
dockerImage: z.string(),
description: z.string().nullable(),
@ -101,30 +111,52 @@ const mySchema = z.discriminatedUnion("type", [
.merge(baseDatabaseSchema),
]);
const databasesMap = {
postgres: {
icon: <PostgresqlIcon />,
label: "PostgreSQL",
},
mongo: {
icon: <MongodbIcon />,
label: "MongoDB",
},
mariadb: {
icon: <MariadbIcon />,
label: "MariaDB",
},
mysql: {
icon: <MysqlIcon />,
label: "MySQL",
},
redis: {
icon: <RedisIcon />,
label: "Redis",
},
};
type AddDatabase = z.infer<typeof mySchema>;
interface Props {
projectId: string;
projectName?: string;
}
export const AddDatabase = ({ projectId }: Props) => {
export const AddDatabase = ({ projectId, projectName }: Props) => {
const utils = api.useUtils();
const { mutateAsync: createPostgresql } = api.postgres.create.useMutation();
const { mutateAsync: createMongo } = api.mongo.create.useMutation();
const { mutateAsync: createRedis } = api.redis.create.useMutation();
const { mutateAsync: createMariadb } = api.mariadb.create.useMutation();
const { mutateAsync: createMysql } = api.mysql.create.useMutation();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
const redisMutation = api.redis.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
const mysqlMutation = api.mysql.create.useMutation();
const form = useForm<AddDatabase>({
defaultValues: {
type: "postgres",
dockerImage: "",
name: "",
appName: `${slug}-`,
databasePassword: "",
description: "",
databaseName: "",
@ -133,76 +165,65 @@ export const AddDatabase = ({ projectId }: Props) => {
resolver: zodResolver(mySchema),
});
const type = form.watch("type");
useEffect(() => {
form.reset({
type: "postgres",
dockerImage: "",
name: "",
databasePassword: "",
description: "",
databaseName: "",
databaseUser: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const activeMutation = {
postgres: postgresMutation,
mongo: mongoMutation,
redis: redisMutation,
mariadb: mariadbMutation,
mysql: mysqlMutation,
};
const onSubmit = async (data: AddDatabase) => {
const defaultDockerImage =
data.dockerImage || dockerImageDefaultPlaceholder[data.type];
let promise: Promise<unknown> | null = null;
const commonParams = {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId,
description: data.description,
};
if (data.type === "postgres") {
promise = createPostgresql({
name: data.name,
dockerImage: defaultDockerImage,
promise = postgresMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
description: data.description,
});
} else if (data.type === "mongo") {
promise = createMongo({
name: data.name,
dockerImage: defaultDockerImage,
promise = mongoMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
description: data.description,
});
} else if (data.type === "redis") {
promise = createRedis({
name: data.name,
dockerImage: defaultDockerImage,
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
projectId,
description: data.description,
});
} else if (data.type === "mariadb") {
promise = createMariadb({
name: data.name,
dockerImage: defaultDockerImage,
promise = mariadbMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
projectId,
databaseRootPassword: data.databaseRootPassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
description: data.description,
});
} else if (data.type === "mysql") {
promise = createMysql({
name: data.name,
dockerImage: defaultDockerImage,
promise = mysqlMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
databaseName: data.databaseName,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
projectId,
databaseRootPassword: data.databaseRootPassword,
description: data.description,
});
}
@ -210,6 +231,17 @@ export const AddDatabase = ({ projectId }: Props) => {
await promise
.then(async () => {
toast.success("Database Created");
form.reset({
type: "postgres",
dockerImage: "",
name: "",
appName: `${projectName}-`,
databasePassword: "",
description: "",
databaseName: "",
databaseUser: "",
});
setVisible(false);
await utils.project.one.invalidate({
projectId,
});
@ -220,7 +252,7 @@ export const AddDatabase = ({ projectId }: Props) => {
}
};
return (
<Dialog>
<Dialog open={visible} onOpenChange={setVisible}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@ -230,18 +262,10 @@ export const AddDatabase = ({ projectId }: Props) => {
<span>Database</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-screen md:max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Databases</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
@ -264,99 +288,40 @@ export const AddDatabase = ({ projectId }: Props) => {
defaultValue={field.value}
className="grid w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
>
<FormItem className="flex w-full items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="postgres"
id="postgres"
className="peer sr-only"
/>
<Label
htmlFor="postgres"
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"
>
<PostgresqlIcon />
Postgresql
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mysql"
id="mysql"
className="peer sr-only"
/>
<Label
htmlFor="mysql"
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"
>
<MysqlIcon />
Mysql
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mariadb"
id="mariadb"
className="peer sr-only"
/>
<Label
htmlFor="mariadb"
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"
>
<MariadbIcon />
Mariadb
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="mongo"
id="mongo"
className="peer sr-only"
/>
<Label
htmlFor="mongo"
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"
>
<MongodbIcon />
Mongo
</Label>
</div>
</FormControl>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl className="w-full">
<div>
<RadioGroupItem
value="redis"
id="redis"
className="peer sr-only"
/>
<Label
htmlFor="redis"
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"
>
<RedisIcon />
Redis
</Label>
</div>
</FormControl>
</FormItem>
{Object.entries(databasesMap).map(([key, value]) => (
<FormItem
key={key}
className="flex w-full items-center space-x-3 space-y-0"
>
<FormControl className="w-full">
<div>
<RadioGroupItem
value={key}
id={key}
className="peer sr-only"
/>
<Label
htmlFor={key}
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 cursor-pointer"
>
{value.icon}
{value.label}
</Label>
</div>
</FormControl>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
{activeMutation[field.value].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">
{activeMutation[field.value].error?.message}
</span>
</div>
)}
</FormItem>
)}
/>
@ -372,13 +337,34 @@ export const AddDatabase = ({ projectId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name" {...field} />
<Input
placeholder="Name"
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
form.setValue("appName", `${slug}-${val}`);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>AppName</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}

View File

@ -50,17 +50,13 @@ export const DeployRedis = ({ redisId }: Props) => {
applicationStatus: "running",
})
.then(async () => {
toast.success("Database Deploying....");
toast.success("Deploying Database...");
await refetch();
await deploy({
redisId,
})
.then(() => {
toast.success("Database Deployed Succesfully");
})
.catch(() => {
toast.error("Error to deploy Database");
});
}).catch(() => {
toast.error("Error to deploy Database");
});
await refetch();
})
.catch((e) => {

View File

@ -1,3 +1,4 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
Card,
@ -129,7 +130,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full gap-8">
<div className="flex flex-col gap-3">
<Label>External Host</Label>
<Input disabled value={connectionUrl} />
<ToggleVisibilityInput value={connectionUrl} disabled />
</div>
</div>
)}

View File

@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
interface Props {
redisId: string;
@ -25,10 +26,9 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<Input
disabled
<ToggleVisibilityInput
value={data?.databasePassword}
type="password"
disabled
/>
</div>
</div>
@ -44,7 +44,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2 md:col-span-2">
<Label>Internal Connection URL </Label>
<Input
<ToggleVisibilityInput
disabled
value={`redis://default:${data?.databasePassword}@${data?.appName}:6379`}
/>

View File

@ -0,0 +1,26 @@
import { useState } from "react";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { Input, type InputProps } from "../ui/input";
import { Button } from "../ui/button";
export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const togglePasswordVisibility = () => {
setIsPasswordVisible((prevVisibility) => !prevVisibility);
};
const inputType = isPasswordVisible ? "text" : "password";
return (
<div className="flex w-full items-center space-x-2">
<Input type={inputType} {...props} />
<Button onClick={togglePasswordVisibility} variant={"secondary"}>
{inputType === "password" ? (
<EyeIcon className="size-4 text-muted-foreground" />
) : (
<EyeOffIcon className="size-4 text-muted-foreground" />
)}
</Button>
</div>
);
};

15
lib/slug.ts Normal file
View File

@ -0,0 +1,15 @@
import slug from "slugify";
export const slugify = (text: string | undefined) => {
if (!text) {
return "";
}
const cleanedText = text.trim().replace(/[^a-zA-Z0-9\s]/g, "");
return slug(cleanedText, {
lower: true,
trim: true,
strict: true,
});
};

View File

@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.2.0",
"version": "v0.2.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
@ -31,11 +31,11 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"@codemirror/language":"^6.10.1",
"@aws-sdk/client-s3": "3.515.0",
"@codemirror/legacy-modes":"6.4.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",
"@codemirror/legacy-modes": "6.4.0",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.3.4",
"@lucia-auth/adapter-drizzle": "1.0.7",
@ -76,6 +76,7 @@
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"copy-to-clipboard": "^3.3.3",
"copy-webpack-plugin": "^12.0.2",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dockerode-compose": "^1.4.0",
@ -105,6 +106,7 @@
"react-dom": "18.2.0",
"react-hook-form": "^7.49.3",
"recharts": "^2.12.3",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"superjson": "^2.2.1",
"tailwind-merge": "^2.2.0",
@ -113,9 +115,7 @@
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4",
"copy-webpack-plugin": "^12.0.2"
"zod": "^3.23.4"
},
"devDependencies": {
"@biomejs/biome": "1.7.1",

View File

@ -210,9 +210,12 @@ const Project = (
Actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
<AddApplication projectId={projectId} />
<AddDatabase projectId={projectId} />
<AddCompose projectId={projectId} />
<AddApplication
projectId={projectId}
projectName={data?.name}
/>
<AddDatabase projectId={projectId} projectName={data?.name} />
<AddCompose projectId={projectId} projectName={data?.name} />
<AddTemplate projectId={projectId} />
</DropdownMenuContent>
</DropdownMenu>

View File

@ -230,6 +230,9 @@ dependencies:
recharts:
specifier: ^2.12.3
version: 2.12.3(react-dom@18.2.0)(react@18.2.0)
slugify:
specifier: ^1.6.6
version: 1.6.6
sonner:
specifier: ^1.4.0
version: 1.4.3(react-dom@18.2.0)(react@18.2.0)
@ -8015,6 +8018,11 @@ packages:
engines: {node: '>=14.16'}
dev: false
/slugify@1.6.6:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
dev: false
/sonner@1.4.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==}
peerDependencies:

View File

@ -66,9 +66,10 @@ export const applicationRouter = createTRPCRouter({
if (ctx.user.rol === "user") {
await addNewService(ctx.user.authId, newApplication.applicationId);
}
return newApplication;
} catch (error) {
} catch (error: unknown) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the application",

View File

@ -34,13 +34,18 @@ import { nanoid } from "nanoid";
import { removeDeploymentsByComposeId } from "../services/deployment";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import { createCommand } from "@/server/utils/builders/compose";
import { loadTemplateModule, readComposeFile } from "@/templates/utils";
import {
generatePassword,
loadTemplateModule,
readComposeFile,
} from "@/templates/utils";
import { findAdmin } from "../services/admin";
import { TRPCError } from "@trpc/server";
import { findProjectById, slugifyProjectName } from "../services/project";
import { findProjectById } from "../services/project";
import { createMount } from "../services/mount";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import { templates } from "@/templates/templates";
import { slugify } from "@/lib/slug";
export const composeRouter = createTRPCRouter({
create: protectedProcedure
@ -229,7 +234,7 @@ export const composeRouter = createTRPCRouter({
const project = await findProjectById(input.projectId);
const projectName = slugifyProjectName(`${project.name}-${input.id}`);
const projectName = slugify(`${project.name} ${input.id}`);
const { envs, mounts } = generate({
serverIp: admin.serverIp,
projectName: projectName,
@ -241,6 +246,7 @@ export const composeRouter = createTRPCRouter({
env: envs.join("\n"),
name: input.id,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
});
if (ctx.user.rol === "user") {

View File

@ -49,6 +49,9 @@ export const mariadbRouter = createTRPCRouter({
return true;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting mariadb database",

View File

@ -49,6 +49,9 @@ export const mongoRouter = createTRPCRouter({
return true;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting mongo database",

View File

@ -50,6 +50,9 @@ export const mysqlRouter = createTRPCRouter({
return true;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting mysql database",

View File

@ -49,6 +49,9 @@ export const postgresRouter = createTRPCRouter({
return true;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting postgresql database",

View File

@ -17,7 +17,7 @@ import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
import { manageRegistry } from "@/server/utils/traefik/registry";
import { initializeRegistry } from "@/server/setup/registry-setup";
import { docker } from "@/server/constants";
import { execAsync } from "@/server/utils/process/execAsync";
export const registryRouter = createTRPCRouter({
create: adminProcedure
@ -57,15 +57,11 @@ export const registryRouter = createTRPCRouter({
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
const result = await docker.checkAuth({
username: input.username,
password: input.password,
serveraddress: input.registryUrl,
});
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
await execAsync(loginCommand);
return true;
} catch (error) {
console.log(error);
console.log("Error Registry:", error);
return false;
}
}),

View File

@ -15,11 +15,23 @@ import { findAdmin } from "./admin";
import { createTraefikConfig } from "@/server/utils/traefik/application";
import { docker } from "@/server/constants";
import { getAdvancedStats } from "@/server/monitoring/utilts";
import { validUniqueServerAppName } from "./project";
export type Application = typeof applications.$inferSelect;
export const createApplication = async (
input: typeof apiCreateApplication._type,
) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Application with this 'AppName' already exists",
});
}
}
return await db.transaction(async (tx) => {
const newApplication = await tx
.insert(applications)

View File

@ -13,10 +13,21 @@ import { join } from "node:path";
import { COMPOSE_PATH } from "@/server/constants";
import { cloneGithubRepository } from "@/server/utils/providers/github";
import { cloneGitRepository } from "@/server/utils/providers/git";
import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect;
export const createCompose = async (input: typeof apiCreateCompose._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newDestination = await db
.insert(compose)
.values({
@ -39,6 +50,16 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
export const createComposeByTemplate = async (
input: typeof compose.$inferInsert,
) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newDestination = await db
.insert(compose)
.values({

View File

@ -46,9 +46,7 @@ export const getContainers = async () => {
.filter((container) => !container.name.includes("dokploy"));
return containers;
} catch (error) {
console.error(`Execution error: ${error}`);
}
} catch (error) {}
};
export const getConfig = async (containerId: string) => {
@ -65,9 +63,7 @@ export const getConfig = async (containerId: string) => {
const config = JSON.parse(stdout);
return config;
} catch (error) {
console.error(`Execution error: ${error}`);
}
} catch (error) {}
};
export const getContainersByAppNameMatch = async (appName: string) => {
@ -103,9 +99,7 @@ export const getContainersByAppNameMatch = async (appName: string) => {
});
return containers || [];
} catch (error) {
console.error(`Execution error: ${error}`);
}
} catch (error) {}
return [];
};
@ -144,9 +138,7 @@ export const getContainersByAppLabel = async (appName: string) => {
});
return containers || [];
} catch (error) {
console.error(`Execution error: ${error}`);
}
} catch (error) {}
return [];
};

View File

@ -5,10 +5,22 @@ import { buildMariadb } from "@/server/utils/databases/mariadb";
import { pullImage } from "@/server/utils/docker/utils";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { validUniqueServerAppName } from "./project";
export type Mariadb = typeof mariadb.$inferSelect;
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newMariadb = await db
.insert(mariadb)
.values({

View File

@ -5,10 +5,22 @@ import { buildMongo } from "@/server/utils/databases/mongo";
import { pullImage } from "@/server/utils/docker/utils";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { validUniqueServerAppName } from "./project";
export type Mongo = typeof mongo.$inferSelect;
export const createMongo = async (input: typeof apiCreateMongo._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newMongo = await db
.insert(mongo)
.values({

View File

@ -5,11 +5,22 @@ import { buildMysql } from "@/server/utils/databases/mysql";
import { pullImage } from "@/server/utils/docker/utils";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { nanoid } from "nanoid";
import { validUniqueServerAppName } from "./project";
export type MySql = typeof mysql.$inferSelect;
export const createMysql = async (input: typeof apiCreateMySql._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newMysql = await db
.insert(mysql)
.values({

View File

@ -5,10 +5,22 @@ import { buildPostgres } from "@/server/utils/databases/postgres";
import { pullImage } from "@/server/utils/docker/utils";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { validUniqueServerAppName } from "./project";
export type Postgres = typeof postgres.$inferSelect;
export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newPostgres = await db
.insert(postgres)
.values({

View File

@ -1,5 +1,14 @@
import { db } from "@/server/db";
import { type apiCreateProject, projects } from "@/server/db/schema";
import {
type apiCreateProject,
applications,
mariadb,
mongo,
mysql,
postgres,
projects,
redis,
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { findAdmin } from "./admin";
@ -75,12 +84,40 @@ export const updateProjectById = async (
return result;
};
export const slugifyProjectName = (projectName: string): string => {
return projectName
.toLowerCase()
.replace(/[0-9]/g, "")
.replace(/[^a-z\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
export const validUniqueServerAppName = async (appName: string) => {
const query = await db.query.projects.findMany({
with: {
applications: {
where: eq(applications.appName, appName),
},
mariadb: {
where: eq(mariadb.appName, appName),
},
mongo: {
where: eq(mongo.appName, appName),
},
mysql: {
where: eq(mysql.appName, appName),
},
postgres: {
where: eq(postgres.appName, appName),
},
redis: {
where: eq(redis.appName, appName),
},
},
});
// Filter out items with non-empty fields
const nonEmptyProjects = query.filter(
(project) =>
project.applications.length > 0 ||
project.mariadb.length > 0 ||
project.mongo.length > 0 ||
project.mysql.length > 0 ||
project.postgres.length > 0 ||
project.redis.length > 0,
);
return nonEmptyProjects.length === 0;
};

View File

@ -5,11 +5,23 @@ import { buildRedis } from "@/server/utils/databases/redis";
import { pullImage } from "@/server/utils/docker/utils";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { validUniqueServerAppName } from "./project";
export type Redis = typeof redis.$inferSelect;
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const createRedis = async (input: typeof apiCreateRedis._type) => {
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
}
const newRedis = await db
.insert(redis)
.values({

View File

@ -9,28 +9,35 @@ import {
} from "@/server/utils/traefik/registry";
import { removeService } from "@/server/utils/docker/utils";
import { initializeRegistry } from "@/server/setup/registry-setup";
import { execAsync } from "@/server/utils/process/execAsync";
export type Registry = typeof registry.$inferSelect;
export const createRegistry = async (input: typeof apiCreateRegistry._type) => {
const admin = await findAdmin();
const newRegistry = await db
.insert(registry)
.values({
...input,
adminId: admin.adminId,
})
.returning()
.then((value) => value[0]);
return await db.transaction(async (tx) => {
const newRegistry = await tx
.insert(registry)
.values({
...input,
adminId: admin.adminId,
})
.returning()
.then((value) => value[0]);
if (!newRegistry) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting registry",
});
}
return newRegistry;
if (!newRegistry) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting registry",
});
}
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
await execAsync(loginCommand);
return newRegistry;
});
};
export const removeRegistry = async (registryId: string) => {
@ -53,6 +60,8 @@ export const removeRegistry = async (registryId: string) => {
await removeService("dokploy-registry");
}
await execAsync(`docker logout ${response.registryUrl}`);
return response;
} catch (error) {
throw new TRPCError({

View File

@ -20,6 +20,7 @@ import {
} from "drizzle-orm/pg-core";
import { generateAppName } from "./utils";
import { registry } from "./registry";
import { generatePassword } from "@/templates/utils";
export const sourceType = pgEnum("sourceType", ["docker", "git", "github"]);
@ -307,11 +308,17 @@ const createSchema = createInsertSchema(applications, {
networkSwarm: NetworkSwarmSchema.nullable(),
});
export const apiCreateApplication = createSchema.pick({
name: true,
description: true,
projectId: true,
});
export const apiCreateApplication = createSchema
.pick({
name: true,
appName: true,
description: true,
projectId: true,
})
.transform((data) => ({
...data,
appName: `${data.appName}-${generatePassword(6)}` || generateAppName("app"),
}));
export const apiFindOneApplication = createSchema
.pick({

View File

@ -8,6 +8,7 @@ import { deployments } from "./deployment";
import { generateAppName } from "./utils";
import { applicationStatus } from "./shared";
import { mounts } from "./mount";
import { generatePassword } from "@/templates/utils";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
@ -74,12 +75,19 @@ const createSchema = createInsertSchema(compose, {
composeType: z.enum(["docker-compose", "stack"]).optional(),
});
export const apiCreateCompose = createSchema.pick({
name: true,
description: true,
projectId: true,
composeType: true,
});
export const apiCreateCompose = createSchema
.pick({
name: true,
description: true,
projectId: true,
composeType: true,
appName: true,
})
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("compose"),
}));
export const apiCreateComposeByTemplate = createSchema
.pick({

View File

@ -8,6 +8,7 @@ import { projects } from "./project";
import { backups } from "./backups";
import { mounts } from "./mount";
import { generateAppName } from "./utils";
import { generatePassword } from "@/templates/utils";
export const mariadb = pgTable("mariadb", {
mariadbId: text("mariadbId")
@ -79,6 +80,7 @@ const createSchema = createInsertSchema(mariadb, {
export const apiCreateMariaDB = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
databaseRootPassword: true,
projectId: true,
@ -87,7 +89,12 @@ export const apiCreateMariaDB = createSchema
databaseUser: true,
databasePassword: true,
})
.required();
.required()
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("mariadb"),
}));
export const apiFindOneMariaDB = createSchema
.pick({

View File

@ -8,6 +8,7 @@ import { projects } from "./project";
import { backups } from "./backups";
import { mounts } from "./mount";
import { generateAppName } from "./utils";
import { generatePassword } from "@/templates/utils";
export const mongo = pgTable("mongo", {
mongoId: text("mongoId")
@ -73,13 +74,19 @@ const createSchema = createInsertSchema(mongo, {
export const apiCreateMongo = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
projectId: true,
description: true,
databaseUser: true,
databasePassword: true,
})
.required();
.required()
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("postgres"),
}));
export const apiFindOneMongo = createSchema
.pick({

View File

@ -8,6 +8,7 @@ import { projects } from "./project";
import { backups } from "./backups";
import { mounts } from "./mount";
import { generateAppName } from "./utils";
import { generatePassword } from "@/templates/utils";
export const mysql = pgTable("mysql", {
mysqlId: text("mysqlId")
@ -77,6 +78,7 @@ const createSchema = createInsertSchema(mysql, {
export const apiCreateMySql = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
projectId: true,
description: true,
@ -85,7 +87,12 @@ export const apiCreateMySql = createSchema
databasePassword: true,
databaseRootPassword: true,
})
.required();
.required()
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("mysql"),
}));
export const apiFindOneMySql = createSchema
.pick({

View File

@ -8,6 +8,7 @@ import { projects } from "./project";
import { backups } from "./backups";
import { mounts } from "./mount";
import { generateAppName } from "./utils";
import { generatePassword } from "@/templates/utils";
export const postgres = pgTable("postgres", {
postgresId: text("postgresId")
@ -74,6 +75,7 @@ const createSchema = createInsertSchema(postgres, {
export const apiCreatePostgres = createSchema
.pick({
name: true,
appName: true,
databaseName: true,
databaseUser: true,
databasePassword: true,
@ -81,7 +83,12 @@ export const apiCreatePostgres = createSchema
projectId: true,
description: true,
})
.required();
.required()
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("postgres"),
}));
export const apiFindOnePostgres = createSchema
.pick({

View File

@ -7,6 +7,7 @@ import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { projects } from "./project";
import { mounts } from "./mount";
import { generateAppName } from "./utils";
import { generatePassword } from "@/templates/utils";
export const redis = pgTable("redis", {
redisId: text("redisId")
@ -69,13 +70,18 @@ const createSchema = createInsertSchema(redis, {
export const apiCreateRedis = createSchema
.pick({
name: true,
appName: true,
databasePassword: true,
dockerImage: true,
projectId: true,
description: true,
})
.required();
.required()
.transform((data) => ({
...data,
appName:
`${data.appName}-${generatePassword(6)}` || generateAppName("redis"),
}));
export const apiFindOneRedis = createSchema
.pick({

View File

@ -7,6 +7,10 @@ import type { MainTraefikConfig } from "../utils/traefik/types";
import type { FileConfig } from "../utils/traefik/file-types";
import type { CreateServiceOptions } from "dockerode";
const TRAEFIK_SSL_PORT =
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT ?? "", 10) || 80;
export const initializeTraefik = async () => {
const imageName = "traefik:v2.5";
const containerName = "dokploy-traefik";
@ -47,12 +51,12 @@ export const initializeTraefik = async () => {
Ports: [
{
TargetPort: 443,
PublishedPort: 443,
PublishedPort: TRAEFIK_SSL_PORT,
PublishMode: "host",
},
{
TargetPort: 80,
PublishedPort: 80,
PublishedPort: TRAEFIK_PORT,
PublishMode: "host",
},
{
@ -146,10 +150,10 @@ export const createDefaultTraefikConfig = () => {
},
entryPoints: {
web: {
address: ":80",
address: `:${TRAEFIK_PORT}`,
},
websecure: {
address: ":443",
address: `:${TRAEFIK_SSL_PORT}`,
...(process.env.NODE_ENV === "production" && {
http: {
tls: {

View File

@ -108,7 +108,12 @@ const createEnvFile = (compose: ComposeNested) => {
join(COMPOSE_PATH, appName, "docker-compose.yml");
const envFilePath = join(dirname(composeFilePath), ".env");
const envFileContent = prepareEnvironmentVariables(env).join("\n");
let envContent = env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker/config.json";
}
const envFileContent = prepareEnvironmentVariables(envContent).join("\n");
if (!existsSync(dirname(envFilePath))) {
mkdirSync(dirname(envFilePath), { recursive: true });

View File

@ -148,7 +148,6 @@ export const mechanizeDockerContainer = async (
},
});
} catch (error) {
console.log(error);
await docker.createService(settings);
}
};

View File

@ -3,47 +3,47 @@ import { type ApplicationNested, mechanizeDockerContainer } from "../builders";
import { pullImage } from "../docker/utils";
interface RegistryAuth {
username: string;
password: string;
serveraddress: string;
username: string;
password: string;
serveraddress: string;
}
export const buildDocker = async (
application: ApplicationNested,
logPath: string,
application: ApplicationNested,
logPath: string,
): Promise<void> => {
const { buildType, dockerImage, username, password } = application;
const authConfig: Partial<RegistryAuth> = {
username: username || "",
password: password || "",
};
const { buildType, dockerImage, username, password } = application;
const authConfig: Partial<RegistryAuth> = {
username: username || "",
password: password || "",
};
const writeStream = createWriteStream(logPath, { flags: "a" });
const writeStream = createWriteStream(logPath, { flags: "a" });
writeStream.write(`\nBuild ${buildType}\n`);
writeStream.write(`\nBuild ${buildType}\n`);
writeStream.write(`Pulling ${dockerImage}: ✅\n`);
writeStream.write(`Pulling ${dockerImage}: ✅\n`);
try {
if (!dockerImage) {
throw new Error("Docker image not found");
}
try {
if (!dockerImage) {
throw new Error("Docker image not found");
}
await pullImage(
dockerImage,
(data) => {
if (writeStream.writable) {
writeStream.write(`${data.status}\n`);
}
},
authConfig,
);
await mechanizeDockerContainer(application);
writeStream.write("\nDocker Deployed: ✅\n");
} catch (error) {
writeStream.write(`ERROR: ${error}: ❌`);
throw error;
} finally {
writeStream.end();
}
await pullImage(
dockerImage,
(data) => {
if (writeStream.writable) {
writeStream.write(`${data.status}\n`);
}
},
authConfig,
);
await mechanizeDockerContainer(application);
writeStream.write("\nDocker Deployed: ✅\n");
} catch (error) {
writeStream.write(`ERROR: ${error}: ❌`);
throw error;
} finally {
writeStream.end();
}
};

View File

@ -35,17 +35,6 @@ export const api = createTRPCNext<AppRouter>({
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
// createWSClient({
// url: `ws://localhost:3000`,
// }),
// loggerLink({
// enabled: (opts) =>
// process.env.NODE_ENV === "development" ||
// (opts.direction === "down" && opts.result instanceof Error),
// }),
// httpBatchLink({
// url: `${getBaseUrl()}/api/trpc`,
// }),
],
};
},