Merge remote-tracking branch 'origin/canary' into canary

This commit is contained in:
songtianlun
2024-08-22 13:23:38 +08:00
116 changed files with 8090 additions and 935 deletions

View File

@@ -0,0 +1,89 @@
import type { Domain } from "@/server/api/services/domain";
import { createDomainLabels } from "@/server/utils/docker/domain";
import { describe, expect, it } from "vitest";
describe("createDomainLabels", () => {
const appName = "test-app";
const baseDomain: Domain = {
host: "example.com",
port: 8080,
https: false,
uniqueConfigKey: 1,
certificateType: "none",
applicationId: "",
composeId: "",
domainType: "compose",
serviceName: "test-app",
domainId: "",
path: "/",
createdAt: "",
};
it("should create basic labels for web entrypoint", async () => {
const labels = await createDomainLabels(appName, baseDomain, "web");
expect(labels).toEqual([
"traefik.http.routers.test-app-1-web.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-web.entrypoints=web",
"traefik.http.services.test-app-1-web.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-web.service=test-app-1-web",
]);
});
it("should create labels for websecure entrypoint", async () => {
const labels = await createDomainLabels(appName, baseDomain, "websecure");
expect(labels).toEqual([
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-websecure.entrypoints=websecure",
"traefik.http.services.test-app-1-websecure.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-websecure.service=test-app-1-websecure",
]);
});
it("should add redirect middleware for https on web entrypoint", async () => {
const httpsBaseDomain = { ...baseDomain, https: true };
const labels = await createDomainLabels(appName, httpsBaseDomain, "web");
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
});
it("should add Let's Encrypt configuration for websecure with letsencrypt certificate", async () => {
const letsencryptDomain = {
...baseDomain,
https: true,
certificateType: "letsencrypt" as const,
};
const labels = await createDomainLabels(
appName,
letsencryptDomain,
"websecure",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should not add Let's Encrypt configuration for non-letsencrypt certificate", async () => {
const nonLetsencryptDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(
appName,
nonLetsencryptDomain,
"websecure",
);
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should handle different ports correctly", async () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");
expect(labels).toContain(
"traefik.http.services.test-app-1-web.loadbalancer.server.port=3000",
);
});
});

View File

@@ -0,0 +1,29 @@
import { addDokployNetworkToRoot } from "@/server/utils/docker/domain";
import { describe, expect, it } from "vitest";
describe("addDokployNetworkToRoot", () => {
it("should create network object if networks is undefined", () => {
const result = addDokployNetworkToRoot(undefined);
expect(result).toEqual({ "dokploy-network": { external: true } });
});
it("should add network to an empty object", () => {
const result = addDokployNetworkToRoot({});
expect(result).toEqual({ "dokploy-network": { external: true } });
});
it("should not modify existing network configuration", () => {
const existing = { "dokploy-network": { external: false } };
const result = addDokployNetworkToRoot(existing);
expect(result).toEqual({ "dokploy-network": { external: true } });
});
it("should add network alongside existing networks", () => {
const existing = { "other-network": { external: true } };
const result = addDokployNetworkToRoot(existing);
expect(result).toEqual({
"other-network": { external: true },
"dokploy-network": { external: true },
});
});
});

View File

@@ -0,0 +1,24 @@
import { addDokployNetworkToService } from "@/server/utils/docker/domain";
import { describe, expect, it } from "vitest";
describe("addDokployNetworkToService", () => {
it("should add network to an empty array", () => {
const result = addDokployNetworkToService([]);
expect(result).toEqual(["dokploy-network"]);
});
it("should not add duplicate network to an array", () => {
const result = addDokployNetworkToService(["dokploy-network"]);
expect(result).toEqual(["dokploy-network"]);
});
it("should add network to an existing array with other networks", () => {
const result = addDokployNetworkToService(["other-network"]);
expect(result).toEqual(["other-network", "dokploy-network"]);
});
it("should add network to an object if networks is an object", () => {
const result = addDokployNetworkToService({ "other-network": {} });
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
});
});

View File

@@ -67,6 +67,9 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
serviceName: "",
composeId: "",
domainType: "application",
uniqueConfigKey: 1,
};

View File

@@ -28,7 +28,7 @@ import {
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -52,6 +52,7 @@ export const AddPort = ({
applicationId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
@@ -82,6 +83,7 @@ export const AddPort = ({
await utils.application.one.invalidate({
applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the port");
@@ -89,7 +91,7 @@ export const AddPort = ({
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{children}</Button>
</DialogTrigger>

View File

@@ -28,7 +28,7 @@ import {
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -49,6 +49,7 @@ interface Props {
}
export const UpdatePort = ({ portId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.port.one.useQuery(
{
@@ -89,6 +90,7 @@ export const UpdatePort = ({ portId }: Props) => {
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the port");
@@ -96,7 +98,7 @@ export const UpdatePort = ({ portId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -23,7 +23,7 @@ import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -45,6 +45,7 @@ export const AddRedirect = ({
applicationId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
@@ -80,6 +81,7 @@ export const AddRedirect = ({
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the redirect");
@@ -87,7 +89,7 @@ export const AddRedirect = ({
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{children}</Button>
</DialogTrigger>

View File

@@ -23,7 +23,7 @@ import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
export const UpdateRedirect = ({ redirectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.redirects.one.useQuery(
{
redirectId,
@@ -84,6 +85,7 @@ export const UpdateRedirect = ({ redirectId }: Props) => {
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the redirect");
@@ -91,7 +93,7 @@ export const UpdateRedirect = ({ redirectId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -43,7 +43,7 @@ export const AddSecurity = ({
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading, error, isError } =
api.security.create.useMutation();
@@ -72,6 +72,7 @@ export const AddSecurity = ({
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the security");
@@ -79,7 +80,7 @@ export const AddSecurity = ({
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{children}</Button>
</DialogTrigger>

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -38,6 +38,7 @@ interface Props {
}
export const UpdateSecurity = ({ securityId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.security.one.useQuery(
{
@@ -79,6 +80,7 @@ export const UpdateSecurity = ({ securityId }: Props) => {
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the security");
@@ -86,7 +88,7 @@ export const UpdateSecurity = ({ securityId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -24,7 +24,7 @@ import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import type React from "react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -77,6 +77,7 @@ export const AddVolumes = ({
refetch,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync } = api.mounts.create.useMutation();
const form = useForm<AddMount>({
defaultValues: {
@@ -103,6 +104,7 @@ export const AddVolumes = ({
})
.then(() => {
toast.success("Mount Created");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the Bind mount");
@@ -117,6 +119,7 @@ export const AddVolumes = ({
})
.then(() => {
toast.success("Mount Created");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the Volume mount");
@@ -132,6 +135,7 @@ export const AddVolumes = ({
})
.then(() => {
toast.success("Mount Created");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the File mount");
@@ -142,7 +146,7 @@ export const AddVolumes = ({
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button>{children}</Button>
</DialogTrigger>

View File

@@ -2,6 +2,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -22,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -76,6 +77,7 @@ export const UpdateVolume = ({
refetch,
serviceType,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.mounts.one.useQuery(
{
@@ -135,6 +137,7 @@ export const UpdateVolume = ({
})
.then(() => {
toast.success("Mount Update");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Bind mount");
@@ -148,6 +151,7 @@ export const UpdateVolume = ({
})
.then(() => {
toast.success("Mount Update");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Volume mount");
@@ -162,6 +166,7 @@ export const UpdateVolume = ({
})
.then(() => {
toast.success("Mount Update");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the File mount");
@@ -171,7 +176,7 @@ export const UpdateVolume = ({
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
@@ -291,13 +296,15 @@ export const UpdateVolume = ({
)}
</div>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-volume"
type="submit"
>
Update
</Button>
<DialogClose>
<Button
isLoading={isLoading}
form="hook-form-update-volume"
type="submit"
>
Update
</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>

View File

@@ -27,13 +27,20 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { domain } from "@/server/db/validations";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import type z from "zod";
type Domain = z.infer<typeof domain>;
@@ -60,10 +67,22 @@ export const AddDomain = ({
},
);
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const form = useForm<Domain>({
resolver: zodResolver(domain),
});
@@ -142,9 +161,42 @@ export const AddDomain = ({
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<div className="flex max-lg:flex-wrap sm:flex-row gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: application?.appName || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>

View File

@@ -44,12 +44,19 @@ export const DeleteDomain = ({ domainId }: Props) => {
domainId,
})
.then((data) => {
utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
if (data?.applicationId) {
utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
} else if (data?.composeId) {
utils.domain.byComposeId.invalidate({
composeId: data?.composeId,
});
}
toast.success("Domain delete succesfully");
})
.catch(() => {

View File

@@ -1,79 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import Link from "next/link";
import { GenerateTraefikMe } from "./generate-traefikme";
import { GenerateWildCard } from "./generate-wildcard";
interface Props {
applicationId: string;
}
export const GenerateDomain = ({ applicationId }: Props) => {
return (
<Dialog>
<DialogTrigger className="" asChild>
<Button variant="secondary">
Generate Domain
<RefreshCcw className="size-4 text-muted-foreground " />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Generate Domain</DialogTitle>
<DialogDescription>
Generate Domains for your applications
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 w-full">
<ul className="flex flex-col gap-4">
<li className="flex flex-row items-center gap-4">
<div className="flex flex-col gap-2">
<div className="text-base font-bold">
1. Generate TraefikMe Domain
</div>
<div className="text-sm text-muted-foreground">
This option generates a free domain provided by{" "}
<Link
href="https://traefik.me"
className="text-primary"
target="_blank"
>
TraefikMe
</Link>
. We recommend using this for quick domain testing or if you
don't have a domain yet.
</div>
</div>
</li>
{/* <li className="flex flex-row items-center gap-4">
<div className="flex flex-col gap-2">
<div className="text-base font-bold">
2. Use Wildcard Domain
</div>
<div className="text-sm text-muted-foreground">
To use this option, you need to set up an 'A' record in your
domain provider. For example, create a record for
*.yourdomain.com.
</div>
</div>
</li> */}
</ul>
<div className="flex flex-row gap-4 w-full">
<GenerateTraefikMe applicationId={applicationId} />
{/* <GenerateWildCard applicationId={applicationId} /> */}
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,69 +0,0 @@
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 { RefreshCcw } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const GenerateTraefikMe = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.domain.generateDomain.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Generate Domain
<RefreshCcw className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to generate a new domain?
</AlertDialogTitle>
<AlertDialogDescription>
This will generate a new domain and will be used to access to the
application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
})
.then((data) => {
utils.domain.byApplicationId.invalidate({
applicationId: applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: applicationId,
});
toast.success("Generated Domain succesfully");
})
.catch(() => {
toast.error("Error to generate Domain");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,69 +0,0 @@
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 { SquareAsterisk } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const GenerateWildCard = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.domain.generateWildcard.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Generate Wildcard Domain
<SquareAsterisk className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to generate a new wildcard domain?
</AlertDialogTitle>
<AlertDialogDescription>
This will generate a new domain and will be used to access to the
application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
})
.then((data) => {
utils.domain.byApplicationId.invalidate({
applicationId: applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: applicationId,
});
toast.success("Generated Domain succesfully");
})
.catch((e) => {
toast.error(`Error to generate Domain: ${e.message}`);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -12,7 +12,6 @@ import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { AddDomain } from "./add-domain";
import { DeleteDomain } from "./delete-domain";
import { GenerateDomain } from "./generate-domain";
interface Props {
applicationId: string;
@@ -46,9 +45,6 @@ export const ShowDomains = ({ applicationId }: Props) => {
</Button>
</AddDomain>
)}
{data && data?.length > 0 && (
<GenerateDomain applicationId={applicationId} />
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
@@ -65,8 +61,6 @@ export const ShowDomains = ({ applicationId }: Props) => {
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
<GenerateDomain applicationId={applicationId} />
</div>
</div>
) : (

View File

@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.application.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
utils.application.one.invalidate({
applicationId: applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the application");
@@ -87,7 +89,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />

View File

@@ -0,0 +1,441 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import type z from "zod";
type Domain = z.infer<typeof domainCompose>;
export type CacheType = "fetch" | "cache";
interface Props {
composeId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddDomainCompose = ({
composeId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
},
);
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
});
}
if (!domainId) {
form.reset({});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
composeId,
domainType: "compose",
...data,
})
.then(async () => {
await utils.domain.byComposeId.invalidate({
composeId,
});
toast.success(dictionary.success);
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error(dictionary.error);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<div className="flex flex-row gap-4 w-full items-end">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex max-lg:flex-wrap sm:flex-row gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<div className="flex max-lg:flex-wrap sm:flex-row gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
appName: compose?.appName || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormControl>
<Input
placeholder={"3000"}
{...field}
onChange={(e) => {
field.onChange(Number.parseInt(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,111 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import Link from "next/link";
import { DeleteDomain } from "../../application/domains/delete-domain";
import { AddDomainCompose } from "./add-domain";
interface Props {
composeId: string;
}
export const ShowDomainsCompose = ({ composeId }: Props) => {
const { data } = api.domain.byComposeId.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Domains</CardTitle>
<CardDescription>
Domains are used to access to the application
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access to the application is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
{data?.map((item) => {
return (
<div
key={item.domainId}
className="flex w-full items-center gap-4 max-sm:flex-wrap border p-4 rounded-lg"
>
<Link target="_blank" href={`http://${item.host}`}>
<ExternalLink className="size-5" />
</Link>
<Button variant="outline" disabled>
{item.serviceName}
</Button>
<Input disabled value={item.host} />
<Button variant="outline" disabled>
{item.path}
</Button>
<Button variant="outline" disabled>
{item.port}
</Button>
<Button variant="outline" disabled>
{item.https ? "HTTPS" : "HTTP"}
</Button>
<div className="flex flex-row gap-1">
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</AddDomainCompose>
<DeleteDomain domainId={item.domainId} />
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -72,7 +72,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
.then(async () => {
toast.success("Compose config Updated");
refetch();
await utils.compose.allServices.invalidate({
await utils.compose.getConvertedCompose.invalidate({
composeId,
});
})

View File

@@ -5,6 +5,7 @@ import { GitBranch, LockIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ComposeFileEditor } from "../compose-file-editor";
import { ShowConvertedCompose } from "../show-converted-compose";
import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
@@ -29,7 +30,8 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<div className="hidden space-y-1 text-sm font-normal md:flex flex-row items-center gap-2">
<ShowConvertedCompose composeId={composeId} />
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>

View File

@@ -0,0 +1,87 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Puzzle, RefreshCw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const ShowConvertedCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const {
data: compose,
error,
isError,
refetch,
} = api.compose.getConvertedCompose.useQuery(
{ composeId },
{
retry: false,
},
);
const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation();
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="max-lg:w-full" variant="outline">
<Puzzle className="h-4 w-4" />
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[50rem] overflow-y-auto">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isLoading}
onClick={() => {
mutateAsync({ composeId })
.then(() => {
refetch();
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error to fetch source type", {
description: err.message,
});
});
}}
>
Refresh <RefreshCw className="ml-2 h-4 w-4" />
</Button>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</DialogContent>
</Dialog>
);
};

View File

@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.compose.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
utils.compose.one.invalidate({
composeId: composeId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Compose");
@@ -87,7 +89,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />

View File

@@ -36,7 +36,7 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, PenBoxIcon, Pencil } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -57,6 +57,7 @@ interface Props {
}
export const UpdateBackup = ({ backupId, refetch }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
@@ -105,6 +106,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
.then(async () => {
toast.success("Backup Updated");
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the backup");
@@ -112,7 +114,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateMongo = ({ mongoId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.mongo.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
utils.mongo.one.invalidate({
mongoId: mongoId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update mongo database");
@@ -87,7 +89,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />

View File

@@ -22,7 +22,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdatePostgres = ({ postgresId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.postgres.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
utils.postgres.one.invalidate({
postgresId: postgresId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the postgres");
@@ -87,7 +89,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />

View File

@@ -23,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -42,6 +42,7 @@ interface Props {
}
export const UpdateProject = ({ projectId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError } = api.project.update.useMutation();
const { data } = api.project.one.useQuery(
@@ -77,6 +78,7 @@ export const UpdateProject = ({ projectId }: Props) => {
.then(() => {
toast.success("Project updated succesfully");
utils.project.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the project");
@@ -85,7 +87,7 @@ export const UpdateProject = ({ projectId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"

View File

@@ -23,12 +23,14 @@ export const AddManager = () => {
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version 24.0
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy("curl https://get.docker.com | sh -s -- --version 24.0");
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
@@ -43,12 +45,12 @@ export const AddManager = () => {
cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data}
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data || "");
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>

View File

@@ -22,12 +22,14 @@ export const AddWorker = () => {
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version 24.0
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy("curl https://get.docker.com | sh -s -- --version 24.0");
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
@@ -42,12 +44,12 @@ export const AddWorker = () => {
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data}
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data || "");
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -43,6 +43,7 @@ interface Props {
export const UpdateDestination = ({ destinationId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.destination.one.useQuery(
{
destinationId,
@@ -93,13 +94,14 @@ export const UpdateDestination = ({ destinationId }: Props) => {
toast.success("Destination Updated");
await refetch();
await utils.destination.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Destination");
});
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -27,7 +27,7 @@ import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@@ -41,6 +41,7 @@ interface Props {
export const UpdateNotification = ({ notificationId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.notification.one.useQuery(
{
notificationId,
@@ -207,6 +208,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
toast.success("Notification Updated");
await utils.notification.all.invalidate();
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update a notification");
@@ -214,7 +216,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
}
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />

View File

@@ -0,0 +1,15 @@
DO $$ BEGIN
CREATE TYPE "public"."domainType" AS ENUM('compose', 'application');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "domain" ALTER COLUMN "applicationId" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "domainType" "domainType" DEFAULT 'application';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "composeId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "domain" ADD CONSTRAINT "domain_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "domain" ALTER COLUMN "port" SET DEFAULT 3000;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -218,6 +218,20 @@
"when": 1723608499147,
"tag": "0030_little_kabuki",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1723701656243,
"tag": "0031_steep_vulture",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1723705257806,
"tag": "0032_flashy_shadow_king",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.6.3",
"version": "v0.7.0",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -2,6 +2,7 @@ import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-c
import { ShowVolumesCompose } from "@/components/dashboard/compose/advanced/show-volumes";
import { DeleteCompose } from "@/components/dashboard/compose/delete-compose";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
@@ -34,6 +35,7 @@ type TabState =
| "settings"
| "advanced"
| "deployments"
| "domains"
| "monitoring";
const Service = (
@@ -117,12 +119,13 @@ const Service = (
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-y-scroll justify-start">
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-y-scroll justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<div className="flex flex-row gap-2">
@@ -168,6 +171,12 @@ const Service = (
<ShowDeploymentsCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomainsCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -35,14 +35,23 @@ export const clusterRouter = createTRPCRouter({
}),
addWorker: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Worker
} ${await getPublicIpWithFallback()}:2377`;
const docker_version = await docker.version();
return {
command: `docker swarm join --token ${
result.JoinTokens.Worker
} ${await getPublicIpWithFallback()}:2377`,
version: docker_version.Version,
};
}),
addManager: protectedProcedure.query(async ({ input }) => {
const result = await docker.swarmInspect();
return `docker swarm join --token ${
result.JoinTokens.Manager
} ${await getPublicIpWithFallback()}:2377`;
const docker_version = await docker.version();
return {
command: `docker swarm join --token ${
result.JoinTokens.Manager
} ${await getPublicIpWithFallback()}:2377`,
version: docker_version.Version,
};
}),
});

View File

@@ -3,6 +3,7 @@ import { db } from "@/server/db";
import {
apiCreateCompose,
apiCreateComposeByTemplate,
apiFetchServices,
apiFindCompose,
apiRandomizeCompose,
apiUpdateCompose,
@@ -15,16 +16,18 @@ import {
import { myQueue } from "@/server/queues/queueSetup";
import { createCommand } from "@/server/utils/builders/compose";
import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { addDomainToCompose, cloneCompose } from "@/server/utils/docker/domain";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import { templates } from "@/templates/templates";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import {
generatePassword,
loadTemplateModule,
readComposeFile,
readTemplateComposeFile,
} from "@/templates/utils";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { dump } from "js-yaml";
import _ from "lodash";
import { nanoid } from "nanoid";
import { findAdmin } from "../services/admin";
@@ -38,6 +41,7 @@ import {
updateCompose,
} from "../services/compose";
import { removeDeploymentsByComposeId } from "../services/deployment";
import { createDomain, findDomainsByComposeId } from "../services/domain";
import { createMount } from "../services/mount";
import { findProjectById } from "../services/project";
import { addNewService, checkServiceAccess } from "../services/user";
@@ -113,10 +117,25 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
}),
allServices: protectedProcedure
.input(apiFindCompose)
loadServices: protectedProcedure
.input(apiFetchServices)
.query(async ({ input }) => {
return await loadServices(input.composeId);
return await loadServices(input.composeId, input.type);
}),
fetchSourceType: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
try {
const compose = await findComposeById(input.composeId);
await cloneCompose(compose);
return compose.sourceType;
} catch (err) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to fetch source type",
cause: err,
});
}
}),
randomizeCompose: protectedProcedure
@@ -124,6 +143,17 @@ export const composeRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return await randomizeComposeFile(input.composeId, input.prefix);
}),
getConvertedCompose: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
const compose = await findComposeById(input.composeId);
const domains = await findDomainsByComposeId(input.composeId);
const composeFile = await addDomainToCompose(compose, domains);
return dump(composeFile, {
lineWidth: 1000,
});
}),
deploy: protectedProcedure
.input(apiFindCompose)
@@ -189,7 +219,7 @@ export const composeRouter = createTRPCRouter({
if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.projectId, "create");
}
const composeFile = await readComposeFile(input.id);
const composeFile = await readTemplateComposeFile(input.id);
const generate = await loadTemplateModule(input.id as TemplatesKeys);
@@ -206,7 +236,7 @@ export const composeRouter = createTRPCRouter({
const project = await findProjectById(input.projectId);
const projectName = slugify(`${project.name} ${input.id}`);
const { envs, mounts } = generate({
const { envs, mounts, domains } = generate({
serverIp: admin.serverIp,
projectName: projectName,
});
@@ -214,7 +244,7 @@ export const composeRouter = createTRPCRouter({
const compose = await createComposeByTemplate({
...input,
composeFile: composeFile,
env: envs.join("\n"),
env: envs?.join("\n"),
name: input.id,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
@@ -237,6 +267,17 @@ export const composeRouter = createTRPCRouter({
}
}
if (domains && domains?.length > 0) {
for (const domain of domains) {
await createDomain({
...domain,
domainType: "compose",
certificateType: "none",
composeId: compose.composeId,
});
}
}
return null;
}),

View File

@@ -1,8 +1,12 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
apiCreateDomain,
apiCreateTraefikMeDomain,
apiFindCompose,
apiFindDomain,
apiFindDomainByApplication,
apiFindDomainByCompose,
apiFindOneApplication,
apiUpdateDomain,
} from "@/server/db/schema";
import { manageDomain, removeDomain } from "@/server/utils/traefik/domain";
@@ -12,8 +16,8 @@ import {
createDomain,
findDomainById,
findDomainsByApplicationId,
generateDomain,
generateWildcard,
findDomainsByComposeId,
generateTraefikMeDomain,
removeDomainById,
updateDomainById,
} from "../services/domain";
@@ -33,27 +37,30 @@ export const domainRouter = createTRPCRouter({
}
}),
byApplicationId: protectedProcedure
.input(apiFindDomainByApplication)
.input(apiFindOneApplication)
.query(async ({ input }) => {
return await findDomainsByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindCompose)
.query(async ({ input }) => {
return await findDomainsByComposeId(input.composeId);
}),
generateDomain: protectedProcedure
.input(apiFindDomainByApplication)
.input(apiCreateTraefikMeDomain)
.mutation(async ({ input }) => {
return generateDomain(input);
}),
generateWildcard: protectedProcedure
.input(apiFindDomainByApplication)
.mutation(async ({ input }) => {
return generateWildcard(input);
return generateTraefikMeDomain(input.appName);
}),
update: protectedProcedure
.input(apiUpdateDomain)
.mutation(async ({ input }) => {
const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId);
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
}
return result;
}),
one: protectedProcedure.input(apiFindDomain).query(async ({ input }) => {
@@ -64,7 +71,9 @@ export const domainRouter = createTRPCRouter({
.mutation(async ({ input }) => {
const domain = await findDomainById(input.domainId);
const result = await removeDomainById(input.domainId);
await removeDomain(domain.application.appName, domain.uniqueConfigKey);
if (domain.application) {
await removeDomain(domain.application.appName, domain.uniqueConfigKey);
}
return result;
}),

View File

@@ -4,6 +4,7 @@ import { db } from "@/server/db";
import { type apiCreateCompose, compose } from "@/server/db/schema";
import { generateAppName } from "@/server/db/schema/utils";
import { buildCompose } from "@/server/utils/builders/compose";
import { cloneCompose, loadDockerCompose } from "@/server/utils/docker/domain";
import type { ComposeSpecification } from "@/server/utils/docker/types";
import { sendBuildErrorNotifications } from "@/server/utils/notifications/build-error";
import { sendBuildSuccessNotifications } from "@/server/utils/notifications/build-success";
@@ -14,7 +15,6 @@ import { createComposeFile } from "@/server/utils/providers/raw";
import { generatePassword } from "@/templates/utils";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { load } from "js-yaml";
import { findAdmin, getDokployUrl } from "./admin";
import { createDeploymentCompose, updateDeploymentStatus } from "./deployment";
import { validUniqueServerAppName } from "./project";
@@ -91,6 +91,7 @@ export const findComposeById = async (composeId: string) => {
project: true,
deployments: true,
mounts: true,
domains: true,
},
});
if (!result) {
@@ -102,20 +103,27 @@ export const findComposeById = async (composeId: string) => {
return result;
};
export const loadServices = async (composeId: string) => {
export const loadServices = async (
composeId: string,
type: "fetch" | "cache" = "fetch",
) => {
const compose = await findComposeById(composeId);
// use js-yaml to parse the docker compose file and then extact the services
const composeFile = compose.composeFile;
const composeData = load(composeFile) as ComposeSpecification;
if (type === "fetch") {
await cloneCompose(compose);
}
const composeData = await loadDockerCompose(compose);
if (!composeData?.services) {
return ["All Services"];
throw new TRPCError({
code: "NOT_FOUND",
message: "Services not found",
});
}
const services = Object.keys(composeData.services);
return [...services, "All Services"];
return [...services];
};
export const updateCompose = async (

View File

@@ -15,8 +15,6 @@ export type Domain = typeof domains.$inferSelect;
export const createDomain = async (input: typeof apiCreateDomain._type) => {
await db.transaction(async (tx) => {
const application = await findApplicationById(input.applicationId);
const domain = await tx
.insert(domains)
.values({
@@ -32,52 +30,19 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
});
}
await manageDomain(application, domain);
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
}
});
};
export const generateDomain = async (
input: typeof apiFindDomainByApplication._type,
) => {
const application = await findApplicationById(input.applicationId);
export const generateTraefikMeDomain = async (appName: string) => {
const admin = await findAdmin();
const domain = await createDomain({
applicationId: application.applicationId,
host: generateRandomDomain({
serverIp: admin.serverIp || "",
projectName: application.appName,
}),
port: 3000,
certificateType: "none",
https: false,
path: "/",
return generateRandomDomain({
serverIp: admin.serverIp || "",
projectName: appName,
});
return domain;
};
export const generateWildcard = async (
input: typeof apiFindDomainByApplication._type,
) => {
const application = await findApplicationById(input.applicationId);
const admin = await findAdmin();
if (!admin.host) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "We need a host to generate a wildcard domain",
});
}
const domain = await createDomain({
applicationId: application.applicationId,
host: generateWildcardDomain(application.appName, admin.host || ""),
port: 3000,
certificateType: "none",
https: false,
path: "/",
});
return domain;
};
export const generateWildcardDomain = (
@@ -114,6 +79,17 @@ export const findDomainsByApplicationId = async (applicationId: string) => {
return domainsArray;
};
export const findDomainsByComposeId = async (composeId: string) => {
const domainsArray = await db.query.domains.findMany({
where: eq(domains.composeId, composeId),
with: {
compose: true,
},
});
return domainsArray;
};
export const updateDomainById = async (
domainId: string,
domainData: Partial<Domain>,

View File

@@ -1,11 +1,11 @@
import { sshKeys } from "@/server/db/schema/ssh-key";
import { generatePassword } from "@/templates/utils";
import { relations } from "drizzle-orm";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { deployments } from "./deployment";
import { domains } from "./domain";
import { mounts } from "./mount";
import { projects } from "./project";
import { applicationStatus } from "./shared";
@@ -72,6 +72,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
fields: [compose.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
domains: many(domains),
}));
const createSchema = createInsertSchema(compose, {
@@ -106,6 +107,11 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1),
});
export const apiFetchServices = z.object({
composeId: z.string().min(1),
type: z.enum(["fetch", "cache"]).optional().default("cache"),
});
export const apiUpdateCompose = createSchema.partial().extend({
composeId: z.string(),
composeFile: z.string().optional(),

View File

@@ -1,11 +1,22 @@
import { domain } from "@/server/db/validations";
import { domain } from "@/server/db/validations/domain";
import { relations } from "drizzle-orm";
import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
pgEnum,
pgTable,
serial,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { certificateType } from "./shared";
export const domainType = pgEnum("domainType", ["compose", "application"]);
export const domains = pgTable("domain", {
domainId: text("domainId")
.notNull()
@@ -13,15 +24,21 @@ export const domains = pgTable("domain", {
.$defaultFn(() => nanoid()),
host: text("host").notNull(),
https: boolean("https").notNull().default(false),
port: integer("port").default(80),
port: integer("port").default(3000),
path: text("path").default("/"),
serviceName: text("serviceName"),
domainType: domainType("domainType").default("application"),
uniqueConfigKey: serial("uniqueConfigKey"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
applicationId: text("applicationId")
.notNull()
.references(() => applications.applicationId, { onDelete: "cascade" }),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
applicationId: text("applicationId").references(
() => applications.applicationId,
{ onDelete: "cascade" },
),
certificateType: certificateType("certificateType").notNull().default("none"),
});
@@ -30,6 +47,10 @@ export const domainsRelations = relations(domains, ({ one }) => ({
fields: [domains.applicationId],
references: [applications.applicationId],
}),
compose: one(compose, {
fields: [domains.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
@@ -41,6 +62,9 @@ export const apiCreateDomain = createSchema.pick({
https: true,
applicationId: true,
certificateType: true,
composeId: true,
serviceName: true,
domainType: true,
});
export const apiFindDomain = createSchema
@@ -53,6 +77,14 @@ export const apiFindDomainByApplication = createSchema.pick({
applicationId: true,
});
export const apiCreateTraefikMeDomain = createSchema.pick({}).extend({
appName: z.string().min(1),
});
export const apiFindDomainByCompose = createSchema.pick({
composeId: true,
});
export const apiUpdateDomain = createSchema
.pick({
host: true,
@@ -60,5 +92,7 @@ export const apiUpdateDomain = createSchema
port: true,
https: true,
certificateType: true,
serviceName: true,
domainType: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
});
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(),
serviceName: z.string().min(1, { message: "Service name is required" }),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
});

View File

@@ -35,25 +35,3 @@ export const sshKeyUpdate = sshKeyCreate.pick({
export const sshKeyType = z.object({
type: z.enum(["rsa", "ed25519"]).optional(),
});
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
});

View File

@@ -8,20 +8,28 @@ import { dirname, join } from "node:path";
import { COMPOSE_PATH } from "@/server/constants";
import type { InferResultType } from "@/server/types/with";
import boxen from "boxen";
import { writeDomainsToCompose } from "../docker/domain";
import { prepareEnvironmentVariables } from "../docker/utils";
import { spawnAsync } from "../process/spawnAsync";
export type ComposeNested = InferResultType<
"compose",
{ project: true; mounts: true }
{ project: true; mounts: true; domains: true }
>;
export const buildCompose = async (compose: ComposeNested, logPath: string) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const { sourceType, appName, mounts, composeType, env, composePath } =
compose;
const {
sourceType,
appName,
mounts,
composeType,
env,
composePath,
domains,
} = compose;
try {
const command = createCommand(compose);
await writeDomainsToCompose(compose, domains);
createEnvFile(compose);
const logContent = `

View File

@@ -0,0 +1,220 @@
import fs, { existsSync, readFileSync, writeSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Compose } from "@/server/api/services/compose";
import type { Domain } from "@/server/api/services/domain";
import { COMPOSE_PATH } from "@/server/constants";
import { dump, load } from "js-yaml";
import { cloneGitRawRepository } from "../providers/git";
import { cloneRawGithubRepository } from "../providers/github";
import { createComposeFileRaw } from "../providers/raw";
import type {
ComposeSpecification,
DefinitionsService,
PropertiesNetworks,
} from "./types";
export const cloneCompose = async (compose: Compose) => {
if (compose.sourceType === "github") {
await cloneRawGithubRepository(compose);
} else if (compose.sourceType === "git") {
await cloneGitRawRepository(compose);
} else if (compose.sourceType === "raw") {
await createComposeFileRaw(compose);
}
};
export const getComposePath = (compose: Compose) => {
const { appName, sourceType, composePath } = compose;
let path = "";
if (sourceType === "raw") {
path = "docker-compose.yml";
} else {
path = composePath;
}
return join(COMPOSE_PATH, appName, "code", path);
};
export const loadDockerCompose = async (
compose: Compose,
): Promise<ComposeSpecification | null> => {
const path = getComposePath(compose);
if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8");
const parsedConfig = load(yamlStr) as ComposeSpecification;
return parsedConfig;
}
return null;
};
export const readComposeFile = async (compose: Compose) => {
const path = getComposePath(compose);
if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8");
return yamlStr;
}
return null;
};
export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
if (!domains.length) {
return;
}
const composeConverted = await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
const composeString = dump(composeConverted, { lineWidth: 1000 });
try {
await writeFile(path, composeString, "utf8");
} catch (error) {
throw error;
}
};
export const addDomainToCompose = async (
compose: Compose,
domains: Domain[],
) => {
const { appName } = compose;
const result = await loadDockerCompose(compose);
if (!result || domains.length === 0) {
return null;
}
for (const domain of domains) {
const { serviceName, https } = domain;
if (!serviceName) {
throw new Error("Service name not found");
}
if (!result?.services?.[serviceName]) {
throw new Error(`The service ${serviceName} not found in the compose`);
}
if (!result.services[serviceName].labels) {
result.services[serviceName].labels = [];
}
const httpLabels = await createDomainLabels(appName, domain, "web");
if (https) {
const httpsLabels = await createDomainLabels(
appName,
domain,
"websecure",
);
httpLabels.push(...httpsLabels);
}
const labels = result.services[serviceName].labels;
if (Array.isArray(labels)) {
if (!labels.includes("traefik.enable=true")) {
labels.push("traefik.enable=true");
}
labels.push(...httpLabels);
}
// Add the dokploy-network to the service
result.services[serviceName].networks = addDokployNetworkToService(
result.services[serviceName].networks,
);
}
// Add dokploy-network to the root of the compose file
result.networks = addDokployNetworkToRoot(result.networks);
return result;
};
export const writeComposeFile = async (
compose: Compose,
composeSpec: ComposeSpecification,
) => {
const path = getComposePath(compose);
try {
const composeFile = dump(composeSpec, {
lineWidth: 1000,
});
fs.writeFileSync(path, composeFile, "utf8");
} catch (e) {
console.error("Error saving the YAML config file:", e);
}
};
export const createDomainLabels = async (
appName: string,
domain: Domain,
entrypoint: "web" | "websecure",
) => {
const { host, port, https, uniqueConfigKey, certificateType } = domain;
const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`;
const labels = [
`traefik.http.routers.${routerName}.rule=Host(\`${host}\`)`,
`traefik.http.routers.${routerName}.entrypoints=${entrypoint}`,
`traefik.http.services.${routerName}.loadbalancer.server.port=${port}`,
`traefik.http.routers.${routerName}.service=${routerName}`,
];
if (entrypoint === "web" && https) {
labels.push(
`traefik.http.routers.${routerName}.middlewares=redirect-to-https@file`,
);
}
if (entrypoint === "websecure") {
if (certificateType === "letsencrypt") {
labels.push(
`traefik.http.routers.${routerName}.tls.certresolver=letsencrypt`,
);
}
}
return labels;
};
export const addDokployNetworkToService = (
networkService: DefinitionsService["networks"],
) => {
let networks = networkService;
const network = "dokploy-network";
if (!networks) {
networks = [];
}
if (Array.isArray(networks)) {
if (!networks.includes(network)) {
networks.push(network);
}
} else if (networks && typeof networks === "object") {
if (!(network in networks)) {
networks[network] = {};
}
}
return networks;
};
export const addDokployNetworkToRoot = (
networkRoot: PropertiesNetworks | undefined,
) => {
let networks = networkRoot;
const network = "dokploy-network";
if (!networks) {
networks = {};
}
if (networks[network] || !networks[network]) {
networks[network] = {
external: true,
};
}
return networks;
};

View File

@@ -139,3 +139,61 @@ const sanitizeRepoPathSSH = (input: string) => {
},
};
};
export const cloneGitRawRepository = async (entity: {
appName: string;
customGitUrl?: string | null;
customGitBranch?: string | null;
customGitSSHKeyId?: string | null;
}) => {
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
if (!customGitUrl || !customGitBranch) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error: Repository not found",
});
}
const keyPath = path.join(SSH_PATH, `${customGitSSHKeyId}_rsa`);
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
try {
await addHostToKnownHosts(customGitUrl);
await recreateDirectory(outputPath);
if (customGitSSHKeyId) {
await updateSSHKeyById({
sshKeyId: customGitSSHKeyId,
lastUsedAt: new Date().toISOString(),
});
}
await spawnAsync(
"git",
[
"clone",
"--branch",
customGitBranch,
"--depth",
"1",
customGitUrl,
outputPath,
"--progress",
],
(data) => {},
{
env: {
...process.env,
...(customGitSSHKeyId && {
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
},
);
} catch (error) {
throw error;
}
};

View File

@@ -1,6 +1,6 @@
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import type { Admin } from "@/server/api/services/admin";
import { type Admin, findAdmin } from "@/server/api/services/admin";
import { APPLICATIONS_PATH, COMPOSE_PATH } from "@/server/constants";
import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
@@ -128,3 +128,34 @@ export const cloneGithubRepository = async (
writeStream.end();
}
};
export const cloneRawGithubRepository = async (entity: {
appName: string;
repository?: string | null;
owner?: string | null;
branch?: string | null;
}) => {
const { appName, repository, owner, branch } = entity;
const admin = await findAdmin();
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
const octokit = authGithub(admin);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;
await recreateDirectory(outputPath);
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
try {
await spawnAsync("git", [
"clone",
"--branch",
branch!,
"--depth",
"1",
cloneUrl,
outputPath,
"--progress",
]);
} catch (error) {
throw error;
}
};

View File

@@ -26,3 +26,15 @@ export const createComposeFile = async (compose: Compose, logPath: string) => {
writeStream.end();
}
};
export const createComposeFileRaw = async (compose: Compose) => {
const { appName, composeFile } = compose;
const outputPath = join(COMPOSE_PATH, appName, "code");
const filePath = join(outputPath, "docker-compose.yml");
try {
await recreateDirectory(outputPath);
await writeFile(filePath, composeFile);
} catch (error) {
throw error;
}
};

View File

@@ -2,17 +2,5 @@ version: "3.8"
services:
appsmith:
image: index.docker.io/appsmith/appsmith-ee:v1.29
networks:
- dokploy-network
ports:
- ${APP_SMITH_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${APP_SMITH_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${APP_SMITH_PORT}"
volumes:
- ../files/stacks:/appsmith-stacks
networks:
dokploy-network:
external: true

View File

@@ -1,4 +1,5 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
@@ -7,14 +8,16 @@ import {
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`APP_SMITH_HOST=${randomDomain}`,
"APP_SMITH_PORT=80",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 80,
serviceName: "appsmith",
},
];
return {
envs,
domains,
};
}

View File

@@ -2,21 +2,9 @@ version: "3.8"
services:
baserow:
image: baserow/baserow:1.25.2
networks:
- dokploy-network
environment:
BASEROW_PUBLIC_URL: "http://${BASEROW_HOST}"
ports:
- ${BASEROW_PORT}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${BASEROW_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${BASEROW_PORT}
volumes:
- baserow_data:/baserow/data
volumes:
baserow_data:
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,24 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`BASEROW_HOST=${randomDomain}`,
"BASEROW_PORT=80",
`HASH=${mainServiceHash}`,
const mainHost = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainHost,
port: 80,
serviceName: "baserow",
},
];
const envs = [`BASEROW_HOST=${mainHost}`];
return {
envs,
domains,
};
}

View File

@@ -21,16 +21,6 @@ services:
- DATABASE_URL=postgres://postgres:password@postgres:5432/db
- NEXT_PUBLIC_WEBAPP_URL=http://${CALCOM_HOST}
- NEXTAUTH_URL=http://${CALCOM_HOST}/api/auth
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${CALCOM_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${CALCOM_PORT}"
networks:
dokploy-network:
external: true
volumes:
calcom-data:

View File

@@ -1,27 +1,32 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
// https://cal.com/
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const calcomEncryptionKey = generateBase64(32);
const nextAuthSecret = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 3000,
serviceName: "calcom",
},
];
const envs = [
`CALCOM_HOST=${randomDomain}`,
"CALCOM_PORT=3000",
`HASH=${mainServiceHash}`,
`CALCOM_HOST=${mainDomain}`,
`NEXTAUTH_SECRET=${nextAuthSecret}`,
`CALENDSO_ENCRYPTION_KEY=${calcomEncryptionKey}`,
];
return {
envs,
domains,
};
}

View File

@@ -18,8 +18,6 @@ services:
directus:
image: directus/directus:10.12.1
networks:
- dokploy-network
ports:
- 8055
volumes:
@@ -28,10 +26,6 @@ services:
depends_on:
- cache
- database
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${DIRECTUS_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${DIRECTUS_PORT}
environment:
SECRET: "replace-with-secure-random-value"
@@ -49,8 +43,5 @@ services:
ADMIN_EMAIL: "admin@example.com"
ADMIN_PASSWORD: "d1r3ctu5"
networks:
dokploy-network:
external: true
volumes:
directus:

View File

@@ -1,20 +1,20 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`DIRECTUS_HOST=${randomDomain}`,
"DIRECTUS_PORT=8055",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 8055,
serviceName: "directus",
},
];
return {
envs,
domains,
};
}

View File

@@ -19,8 +19,6 @@ services:
documenso:
image: documenso/documenso:1.5.6-rc.2
networks:
- dokploy-network
depends_on:
postgres:
condition: service_healthy
@@ -38,16 +36,8 @@ services:
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=/opt/documenso/cert.p12
ports:
- ${DOCUMENSO_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${DOCUMENSO_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${DOCUMENSO_PORT}"
volumes:
- /opt/documenso/cert.p12:/opt/documenso/cert.p12
networks:
dokploy-network:
external: true
volumes:
documenso-data:

View File

@@ -1,24 +1,29 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const nextAuthSecret = generateBase64(32);
const documensoEncryptionKey = generatePassword(32);
const documensoSecondaryEncryptionKey = generatePassword(64);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 3000,
serviceName: "documenso",
},
];
const envs = [
`DOCUMENSO_HOST=${randomDomain}`,
`DOCUMENSO_HOST=${mainDomain}`,
"DOCUMENSO_PORT=3000",
`HASH=${mainServiceHash}`,
`NEXTAUTH_SECRET=${nextAuthSecret}`,
`NEXT_PRIVATE_ENCRYPTION_KEY=${documensoEncryptionKey}`,
`NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=${documensoSecondaryEncryptionKey}`,
@@ -26,5 +31,6 @@ export function generate(schema: Schema): Template {
return {
envs,
domains,
};
}

View File

@@ -2,10 +2,6 @@ services:
doublezero:
restart: always
image: liltechnomancer/double-zero:0.2.1
ports:
- ${DOUBLEZERO_PORT}
networks:
- dokploy-network
volumes:
- db-data:/var/lib/doublezero/data
environment:
@@ -17,15 +13,7 @@ services:
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
PHX_HOST: ${DOUBLEZERO_HOST}
DATABASE_PATH: ./00.db
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${DOUBLEZERO_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${DOUBLEZERO_PORT}"
volumes:
db-data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,26 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const secretKeyBase = generateBase64(64);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 4000,
serviceName: "doublezero",
},
];
const envs = [
`DOUBLEZERO_HOST=${randomDomain}`,
`DOUBLEZERO_HOST=${mainDomain}`,
"DOUBLEZERO_PORT=4000",
`HASH=${mainServiceHash}`,
`SECRET_KEY_BASE=${secretKeyBase}`,
"AWS_ACCESS_KEY_ID=your-aws-access-key",
"AWS_SECRET_ACCESS_KEY=your-aws-secret-key",
@@ -25,5 +31,6 @@ export function generate(schema: Schema): Template {
return {
envs,
domains,
};
}

View File

@@ -1,17 +1,7 @@
version: '3.8'
version: "3.8"
services:
excalidraw:
networks:
- dokploy-network
image: excalidraw/excalidraw:latest
ports:
- ${EXCALIDRAW_PORT}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${EXCALIDRAW_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${EXCALIDRAW_PORT}
networks:
dokploy-network:
external: true

View File

@@ -1,4 +1,5 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
@@ -6,15 +7,17 @@ import {
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`EXCALIDRAW_HOST=${randomDomain}`,
"EXCALIDRAW_PORT=80",
`HASH=${mainServiceHash}`,
const mainDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 80,
serviceName: "excalidraw",
},
];
return {
envs,
domains,
};
}

View File

@@ -1,13 +1,8 @@
version: "3.8"
services:
ghost:
image: ghost:5-alpine
restart: always
networks:
- dokploy-network
ports:
- ${GHOST_PORT}
environment:
database__client: mysql
database__connection__host: db
@@ -15,10 +10,7 @@ services:
database__connection__password: example
database__connection__database: ghost
url: http://${GHOST_HOST}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${GHOST_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${GHOST_PORT}
volumes:
- ghost:/var/lib/ghost/content
@@ -35,7 +27,3 @@ services:
volumes:
ghost:
db:
networks:
dokploy-network:
external: true

View File

@@ -1,4 +1,5 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
@@ -6,15 +7,19 @@ import {
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`GHOST_HOST=${randomDomain}`,
"GHOST_PORT=2368",
`HASH=${mainServiceHash}`,
const mainDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 2368,
serviceName: "ghost",
},
];
const envs = [`GHOST_HOST=${mainDomain}`];
return {
envs,
domains,
};
}

View File

@@ -1,16 +1,14 @@
x-environment:
&default-environment
x-environment: &default-environment
DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
SECRET_KEY: ${SECRET_KEY}
PORT: ${GLITCHTIP_PORT}
EMAIL_URL: consolemail://
GLITCHTIP_DOMAIN: http://${GLITCHTIP_HOST}
GLITCHTIP_DOMAIN: http://${GLITCHTIP_HOST}
DEFAULT_FROM_EMAIL: email@glitchtip.com
CELERY_WORKER_AUTOSCALE: "1,3"
CELERY_WORKER_AUTOSCALE: "1,3"
CELERY_WORKER_MAX_TASKS_PER_CHILD: "10000"
x-depends_on:
&default-depends_on
x-depends_on: &default-depends_on
- postgres
- redis
@@ -18,7 +16,7 @@ services:
postgres:
image: postgres:16
environment:
POSTGRES_HOST_AUTH_METHOD: "trust"
POSTGRES_HOST_AUTH_METHOD: "trust"
restart: unless-stopped
volumes:
- pg-data:/var/lib/postgresql/data
@@ -36,21 +34,15 @@ services:
- ${GLITCHTIP_PORT}
environment: *default-environment
restart: unless-stopped
volumes:
volumes:
- uploads:/code/uploads
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${GLITCHTIP_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${GLITCHTIP_PORT}
worker:
image: glitchtip/glitchtip:v4.0
command: ./bin/run-celery-with-beat.sh
depends_on: *default-depends_on
environment: *default-environment
restart: unless-stopped
volumes:
volumes:
- uploads:/code/uploads
networks:
- dokploy-network
@@ -65,7 +57,3 @@ services:
volumes:
pg-data:
uploads:
networks:
dokploy-network:
external: true

View File

@@ -1,23 +1,30 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const secretKey = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 8000,
serviceName: "web",
},
];
const envs = [
`GLITCHTIP_HOST=${randomDomain}`,
`GLITCHTIP_HOST=${mainDomain}`,
"GLITCHTIP_PORT=8000",
`SECRET_KEY=${secretKey}`,
`HASH=${mainServiceHash}`,
];
return {
envs,
domains,
};
}

View File

@@ -1,20 +1,9 @@
version: "3.8"
services:
grafana:
networks:
- dokploy-network
image: grafana/grafana-enterprise:9.5.20
restart: unless-stopped
ports:
- ${GRAFANA_PORT}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${GRAFANA_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${GRAFANA_PORT}
volumes:
- grafana-storage:/var/lib/grafana
networks:
dokploy-network:
external: true
volumes:
grafana-storage: {}
grafana-storage: {}

View File

@@ -1,20 +1,19 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`GRAFANA_HOST=${randomDomain}`,
"GRAFANA_PORT=3000",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 3000,
serviceName: "grafana",
},
];
return {
envs,
domains,
};
}

View File

@@ -1,30 +1,19 @@
version: '3.8'
version: "3.8"
services:
jellyfin:
image: jellyfin/jellyfin:10
networks:
- dokploy-network
ports:
- ${JELLYFIN_PORT}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${JELLYFIN_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${JELLYFIN_PORT}"
volumes:
- config:/config
- cache:/cache
- media:/media
restart: 'unless-stopped'
restart: "unless-stopped"
# Optional - alternative address used for autodiscovery
environment:
- JELLYFIN_PublishedServerUrl=http://${JELLYFIN_HOST}
# Optional - may be necessary for docker healthcheck to pass if running in host network mode
extra_hosts:
- 'host.docker.internal:host-gateway'
- "host.docker.internal:host-gateway"
volumes:
config:
cache:
media:
networks:
dokploy-network:
external: true

View File

@@ -1,22 +1,25 @@
// EXAMPLE
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const port = 8096;
const envs = [
`JELLYFIN_HOST=${randomDomain}`,
`HASH=${mainServiceHash}`,
`JELLYFIN_PORT=${port}`,
const domain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: domain,
port: 8096,
serviceName: "jellyfin",
},
];
const envs = [`JELLYFIN_HOST=${domain}`];
return {
envs,
domains,
};
}

View File

@@ -36,10 +36,6 @@ services:
app:
restart: unless-stopped
image: listmonk/listmonk:v3.0.0
ports:
- "${LISTMONK_PORT}"
networks:
- dokploy-network
environment:
- TZ=Etc/UTC
depends_on:
@@ -47,15 +43,7 @@ services:
- setup
volumes:
- ../files/config.toml:/listmonk/config.toml
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${LISTMONK_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${LISTMONK_PORT}"
volumes:
listmonk-data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,24 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const adminPassword = generatePassword(32);
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 9000,
serviceName: "app",
},
];
const envs = [
`LISTMONK_HOST=${randomDomain}`,
"LISTMONK_PORT=9000",
`HASH=${mainServiceHash}`,
`# login with admin:${adminPassword}`,
"# check config.toml in Advanced / Volumes for more options",
];
@@ -48,5 +52,6 @@ params = ""
return {
envs,
mounts,
domains,
};
}

View File

@@ -1,25 +1,14 @@
version: '3.8'
version: "3.8"
services:
meilisearch:
networks:
- dokploy-network
image: getmeili/meilisearch:v1.8.3
ports:
- ${MEILISEARCH_PORT}
volumes:
- meili_data:/meili_data
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}
MEILI_ENV: ${MEILI_ENV}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${MEILISEARCH_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${MEILISEARCH_PORT}
volumes:
meili_data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,24 +1,26 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const masterKey = generateBase64(32);
const envs = [
`MEILISEARCH_HOST=${randomDomain}`,
"MEILISEARCH_PORT=7700",
"MEILI_ENV=development",
`MEILI_MASTER_KEY=${masterKey}`,
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 7700,
serviceName: "meilisearch",
},
];
const envs = ["MEILI_ENV=development", `MEILI_MASTER_KEY=${masterKey}`];
return {
envs,
domains,
};
}

View File

@@ -4,8 +4,6 @@ services:
image: metabase/metabase:v0.50.8
volumes:
- /dev/urandom:/dev/random:ro
ports:
- ${METABASE_PORT}
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: metabaseappdb
@@ -13,17 +11,11 @@ services:
MB_DB_USER: metabase
MB_DB_PASS: mysecretpassword
MB_DB_HOST: postgres
networks:
- dokploy-network
healthcheck:
test: curl --fail -I http://localhost:3000/api/health || exit 1
interval: 15s
timeout: 5s
retries: 5
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${METABASE_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${METABASE_PORT}
postgres:
image: postgres:14
environment:
@@ -32,7 +24,3 @@ services:
POSTGRES_PASSWORD: mysecretpassword
networks:
- dokploy-network
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,22 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`METABASE_HOST=${randomDomain}`,
"METABASE_PORT=3000",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 3000,
serviceName: "metabase",
},
];
return {
envs,
domains,
};
}

View File

@@ -1,31 +1,13 @@
version: '3.8'
version: "3.8"
services:
minio:
image: minio/minio
ports:
- ${MINIO_API_PORT}
- ${MINIO_DASHBOARD_PORT}
volumes:
- minio-data:/data
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin123
command: server /data --console-address ":9001"
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.service=${HASH}
- traefik.http.routers.${HASH}.rule=Host(`${MINIO_DASHBOARD_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${MINIO_DASHBOARD_PORT}
# API router and service
- traefik.http.routers.${HASH}-api.service=${HASH}-api
- traefik.http.routers.${HASH}-api.rule=Host(`${MINIO_API_HOST}`)
- traefik.http.services.${HASH}-api.loadbalancer.server.port=${MINIO_API_PORT}
volumes:
minio-data:
networks:
dokploy-network:
external: true

View File

@@ -1,23 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const apiDomain = generateRandomDomain(schema);
const envs = [
`MINIO_DASHBOARD_HOST=${randomDomain}`,
"MINIO_DASHBOARD_PORT=9001",
`MINIO_API_HOST=${apiDomain}`,
"MINIO_API_PORT=9000",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 9001,
serviceName: "minio",
},
{
host: apiDomain,
port: 9000,
serviceName: "minio",
},
];
return {
envs,
domains,
};
}

View File

@@ -3,17 +3,9 @@ services:
n8n:
image: docker.n8n.io/n8nio/n8n:1.48.1
restart: always
networks:
- dokploy-network
ports:
- ${N8N_PORT}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${N8N_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${N8N_PORT}
environment:
- N8N_HOST=${N8N_HOST}
- N8N_PORT=5678
- N8N_PORT=${N8N_PORT}
- N8N_PROTOCOL=http
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST}/
@@ -23,7 +15,4 @@ services:
- n8n_data:/home/node/.n8n
volumes:
n8n_data:
networks:
dokploy-network:
external: true
n8n_data:

View File

@@ -1,21 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 5678,
serviceName: "n8n",
},
];
const envs = [
`N8N_HOST=${randomDomain}`,
`N8N_HOST=${mainDomain}`,
"N8N_PORT=5678",
`HASH=${mainServiceHash}`,
"GENERIC_TIMEZONE=Europe/Berlin",
];
return {
envs,
domains,
};
}

View File

@@ -3,18 +3,10 @@ services:
nocodb:
image: nocodb/nocodb:0.251.1
restart: always
networks:
- dokploy-network
ports:
- ${NOCODB_PORT}
environment:
NC_DB : "pg://root_db?u=postgres&p=password&d=root_db"
PORT : ${NOCODB_PORT}
NC_DB: "pg://root_db?u=postgres&p=password&d=root_db"
PORT: ${NOCODB_PORT}
NC_REDIS_URL: ${NC_REDIS_URL}
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${NOCODB_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${NOCODB_PORT}
volumes:
- nc_data:/usr/app/data
@@ -30,15 +22,11 @@ services:
healthcheck:
interval: 10s
retries: 10
test: "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""
test: 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"'
timeout: 2s
volumes:
- "db_data:/var/lib/postgresql/data"
networks:
dokploy-network:
external: true
volumes:
db_data: {}
nc_data: {}
nc_data: {}

View File

@@ -1,26 +1,28 @@
// EXAMPLE
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const envs = [
`NOCODB_HOST=${randomDomain}`,
"NOCODB_PORT=8000",
`NC_AUTH_JWT_SECRET=${secretBase}`,
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 8000,
serviceName: "nocodb",
},
];
const envs = ["NOCODB_PORT=8000", `NC_AUTH_JWT_SECRET=${secretBase}`];
return {
envs,
domains,
};
}

View File

@@ -2,20 +2,12 @@ version: "3.8"
services:
web:
image: odoo:16.0
networks:
- dokploy-network
depends_on:
- db
ports:
- ${ODOO_PORT}
environment:
- HOST=db
- USER=odoo
- PASSWORD=odoo
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${ODOO_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${ODOO_PORT}"
volumes:
- odoo-web-data:/var/lib/odoo
- ../files/config:/etc/odoo
@@ -35,7 +27,3 @@ services:
volumes:
odoo-web-data:
odoo-db-data:
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,22 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`ODOO_HOST=${randomDomain}`,
"ODOO_PORT=8069",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 8069,
serviceName: "web",
},
];
return {
envs,
domains,
};
}

View File

@@ -1,6 +1,5 @@
version: '3.8'
version: "3.8"
services:
ollama:
volumes:
- ollama:/root/.ollama
@@ -13,24 +12,14 @@ services:
open-webui:
image: ghcr.io/open-webui/open-webui:${WEBUI_DOCKER_TAG-main}
networks:
- dokploy-network
volumes:
- open-webui:/app/backend/data
depends_on:
- ollama
environment:
- 'OLLAMA_BASE_URL=http://ollama:11434'
- 'WEBUI_SECRET_KEY='
- "OLLAMA_BASE_URL=http://ollama:11434"
- "WEBUI_SECRET_KEY="
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${OPEN_WEBUI_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${OPEN_WEBUI_PORT}"
networks:
dokploy-network:
external: true
volumes:
ollama: {}

View File

@@ -1,22 +1,24 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const envs = [
`OPEN_WEBUI_HOST=${randomDomain}`,
"OPEN_WEBUI_PORT=8080",
`HASH=${mainServiceHash}`,
"OLLAMA_DOCKER_TAG=0.1.47",
"WEBUI_DOCKER_TAG=0.3.7",
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 8080,
serviceName: "open-webui",
},
];
const envs = ["OLLAMA_DOCKER_TAG=0.1.47", "WEBUI_DOCKER_TAG=0.3.7"];
return {
envs,
domains,
};
}

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
db:
@@ -20,21 +20,9 @@ services:
PMA_USER: ${MYSQL_USER}
PMA_PASSWORD: ${MYSQL_PASSWORD}
PMA_ARBITRARY: 1
ports:
- ${PHPMYADMIN_PORT}
depends_on:
- db
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${PHPMYADMIN_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${PHPMYADMIN_PORT}
networks:
- dokploy-network
volumes:
db_data:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,20 +1,24 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const rootPassword = generatePassword(32);
const password = generatePassword(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 80,
serviceName: "phpmyadmin",
},
];
const envs = [
`PHPMYADMIN_HOST=${randomDomain}`,
"PHPMYADMIN_PORT=80",
`HASH=${mainServiceHash}`,
`MYSQL_ROOT_PASSWORD=${rootPassword}`,
"MYSQL_DATABASE=mysql",
"MYSQL_USER=phpmyadmin",
@@ -23,5 +27,6 @@ export function generate(schema: Schema): Template {
return {
envs,
domains,
};
}

View File

@@ -32,17 +32,9 @@ services:
depends_on:
- plausible_db
- plausible_events_db
ports:
- ${PLAUSIBLE_PORT}
networks:
- dokploy-network
env_file:
- .env
labels:
- "traefik.enable=true"
- "traefik.http.routers.${HASH}.rule=Host(`${PLAUSIBLE_HOST}`)"
- "traefik.http.services.${HASH}.loadbalancer.server.port=${PLAUSIBLE_PORT}"
volumes:
db-data:
driver: local
@@ -50,7 +42,3 @@ volumes:
driver: local
event-logs:
driver: local
networks:
dokploy-network:
external: true

View File

@@ -1,24 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 8000,
serviceName: "plausible",
},
];
const envs = [
`PLAUSIBLE_HOST=${randomDomain}`,
"PLAUSIBLE_PORT=8000",
`BASE_URL=http://${randomDomain}`,
`BASE_URL=http://${mainDomain}`,
`SECRET_KEY_BASE=${secretBase}`,
`TOTP_VAULT_KEY=${toptKeyBase}`,
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
@@ -62,5 +66,6 @@ export function generate(schema: Schema): Template {
return {
envs,
mounts,
domains,
};
}

View File

@@ -3,19 +3,7 @@ services:
pocketbase:
image: spectado/pocketbase:0.22.12
restart: unless-stopped
ports:
- ${POCKETBASE_PORT}
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${POCKETBASE_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${POCKETBASE_PORT}
volumes:
- /etc/dokploy/templates/${HASH}/data:/pb_data
- /etc/dokploy/templates/${HASH}/public:/pb_public
- /etc/dokploy/templates/${HASH}/migrations:/pb_migrations
networks:
dokploy-network:
external: true

View File

@@ -1,21 +1,22 @@
import {
type DomainSchema,
type Schema,
type Template,
generateHash,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const envs = [
`POCKETBASE_HOST=${randomDomain}`,
"POCKETBASE_PORT=80",
`HASH=${mainServiceHash}`,
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 80,
serviceName: "pocketbase",
},
];
return {
envs,
domains,
};
}

View File

@@ -9,18 +9,10 @@ services:
ROOT_URL: ${ROOT_URL:-http://${ROCKETCHAT_HOST}:${ROCKETCHAT_PORT}}
PORT: ${ROCKETCHAT_PORT}
DEPLOY_METHOD: docker
DEPLOY_PLATFORM:
REG_TOKEN:
DEPLOY_PLATFORM:
REG_TOKEN:
depends_on:
- mongodb
ports:
- ${ROCKETCHAT_PORT}
networks:
- dokploy-network
labels:
- traefik.enable=true
- traefik.http.routers.${HASH}.rule=Host(`${ROCKETCHAT_HOST}`)
- traefik.http.services.${HASH}.loadbalancer.server.port=${ROCKETCHAT_PORT}
mongodb:
image: docker.io/bitnami/mongodb:5.0
@@ -41,8 +33,3 @@ services:
volumes:
mongodb_data: { driver: local }
networks:
dokploy-network:
external: true

Some files were not shown because too many files have changed in this diff Show More