Merge pull request #845 from Dokploy/canary

v0.14.0
This commit is contained in:
Mauricio Siu
2024-12-08 20:59:38 -06:00
committed by GitHub
107 changed files with 16688 additions and 350 deletions

View File

@@ -99,14 +99,14 @@ workflows:
only:
- main
- canary
- fix/build-i18n
- 379-preview-deployment
- build-arm64:
filters:
branches:
only:
- main
- canary
- fix/build-i18n
- 379-preview-deployment
- combine-manifests:
requires:
- build-amd64
@@ -116,4 +116,4 @@ workflows:
only:
- main
- canary
- fix/build-i18n
- 379-preview-deployment

View File

@@ -241,7 +241,7 @@ export function generate(schema: Schema): Template {
- Use the same name of the folder as the id of the template.
- The logo should be in the public folder.
- If you want to show a domain in the UI, please add the prefix \_HOST at the end of the variable name.
- If you want to show a domain in the UI, please add the `_HOST` suffix at the end of the variable name.
- Test first on a vps or a server to make sure the template works.
## Docs & Website

View File

@@ -35,7 +35,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
COPY --from=build /prod/dokploy/.next ./.next
COPY --from=build /prod/dokploy/dist ./dist
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
COPY --from=build /prod/dokploy/public ./public
COPY --from=build /prod/dokploy/package.json ./package.json
COPY --from=build /prod/dokploy/drizzle ./drizzle

View File

@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
COPY --from=build /prod/dokploy/.next ./.next
COPY --from=build /prod/dokploy/dist ./dist
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
COPY --from=build /prod/dokploy/public ./public
COPY --from=build /prod/dokploy/package.json ./package.json
COPY --from=build /prod/dokploy/drizzle ./drizzle

View File

@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
applicationType: z.literal("compose"),
serverId: z.string().min(1),
}),
z.object({
applicationId: z.string(),
previewDeploymentId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),
]);
export type DeployJob = z.infer<typeof deployJobSchema>;

View File

@@ -1,10 +1,12 @@
import {
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema";
@@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => {
});
}
}
} else if (job.applicationType === "application-preview") {
await updatePreviewDeployment(job.previewDeploymentId, {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
previewDeploymentId: job.previewDeploymentId,
});
}
}
}
} catch (error) {
if (job.applicationType === "application") {
@@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => {
await updateCompose(job.composeId, {
composeStatus: "error",
});
} else if (job.applicationType === "application-preview") {
await updatePreviewDeployment(job.previewDeploymentId, {
previewStatus: "error",
});
}
}

View File

@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -17,6 +17,7 @@ describe("createDomainLabels", () => {
domainId: "",
path: "/",
createdAt: "",
previewDeploymentId: "",
};
it("should create basic labels for web entrypoint", async () => {

View File

@@ -26,6 +26,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -33,6 +34,15 @@ const baseApp: ApplicationNested = {
registryUrl: "",
branch: null,
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewWildcard: "",
project: {
env: "",
adminId: "",

View File

@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -14,6 +15,15 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "",
registryUrl: "",
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewWildcard: "",
project: {
env: "",
adminId: "",
@@ -95,6 +105,7 @@ const baseDomain: Domain = {
composeId: "",
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",
};
const baseRedirect: Redirect = {

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,7 +19,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -150,7 +150,7 @@ export const AddVolumes = ({
<DialogTrigger className="" asChild>
<Button>{children}</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Volumes / Mounts</DialogTitle>
</DialogHeader>
@@ -303,9 +303,12 @@ export const AddVolumes = ({
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -1,8 +1,8 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -19,7 +19,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
@@ -119,7 +118,7 @@ export const UpdateVolume = ({
} else if (typeForm === "file") {
form.reset({
content: data.content || "",
mountPath: "/",
mountPath: serviceType === "compose" ? "/" : data.mountPath,
filePath: data.filePath || "",
type: "file",
});
@@ -182,7 +181,7 @@ export const UpdateVolume = ({
<Pencil className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the mount</DialogDescription>
@@ -247,9 +246,12 @@ export const UpdateVolume = ({
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
<Textarea
placeholder="Any content"
className="h-64"
<CodeEditor
language="properties"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -41,6 +41,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal("heroku_buildpacks"),
herokuVersion: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal("paketo_buildpacks"),
@@ -90,6 +91,13 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
dockerBuildStage: data.dockerBuildStage || "",
}),
});
} else if (data.buildType === "heroku_buildpacks") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
herokuVersion: data.herokuVersion || "",
}),
});
} else {
form.reset({
buildType: data.buildType,
@@ -110,6 +118,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === "dockerfile" ? data.dockerContextPath : null,
dockerBuildStage:
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
herokuVersion:
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
})
.then(async () => {
toast.success("Build type saved");
@@ -200,6 +210,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
);
}}
/>
{buildType === "heroku_buildpacks" && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
<FormControl>
<Input
placeholder={"Heroku Version (Default: 24)"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{buildType === "dockerfile" && (
<>
<FormField

View File

@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);

View File

@@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-5 "
>
<Card className="bg-background">
<Card className="bg-background p-6">
<Secrets
name="env"
title="Environment Settings"

View File

@@ -2,9 +2,9 @@ import { ShowBuildChooseForm } from "@/components/dashboard/application/build/sh
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Toggle } from "@/components/ui/toggle";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { CheckCircle2, Terminal } from "lucide-react";
import { Terminal } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -39,27 +39,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
appName={data?.appName || ""}
/>
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
>
Autodeploy
{data?.autoDeploy && <CheckCircle2 className="size-4" />}
</Toggle>
<RedbuildApplication applicationId={applicationId} />
{data?.applicationStatus === "idle" ? (
<StartApplication applicationId={applicationId} />
@@ -75,6 +54,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
/>
</div>
</CardContent>
</Card>
<ShowProviderForm applicationId={applicationId} />

View File

@@ -0,0 +1,304 @@
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, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
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/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import type z from "zod";
type Domain = z.infer<typeof domain>;
interface Props {
previewDeploymentId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddPreviewDomain = ({
previewDeploymentId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: previewDeployment } = api.previewDeployment.one.useQuery(
{
previewDeploymentId,
},
{
enabled: !!previewDeploymentId,
},
);
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),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || 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,
previewDeploymentId,
...data,
})
.then(async () => {
toast.success(dictionary.success);
await utils.previewDeployment.all.invalidate({
applicationId: previewDeployment?.applicationId,
});
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">
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<div className="flex 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: previewDeployment?.appName || "",
serverId:
previewDeployment?.application
?.serverId || "",
})
.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>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg 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>
)}
/>
{form.getValues().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>
)}
/>
)}
</div>
</div>
</form>
<DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit">
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,87 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
interface Props {
deployments: RouterOutputs["deployment"]["all"];
serverId?: string;
}
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Builds</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Preview Builds</DialogTitle>
<DialogDescription>
See all the preview builds for this application on this Pull Request
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</Dialog>
);
};

View File

@@ -0,0 +1,212 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds";
interface Props {
applicationId: string;
}
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
// const [url, setUrl] = React.useState("");
// useEffect(() => {
// setUrl(document.location.origin);
// }, []);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Preview Deployments</CardTitle>
<CardDescription>See all the preview deployments</CardDescription>
</div>
{data?.isPreviewDeploymentsActive && (
<ShowPreviewSettings applicationId={applicationId} />
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.isPreviewDeploymentsActive ? (
<>
<div className="flex flex-col gap-2 text-sm">
<span>
Preview deployments are a way to test your application before it
is deployed to production. It will create a new deployment for
each pull request you create.
</span>
</div>
{data?.previewDeployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => {
const { deployments, domain } = previewDeployment;
return (
<div
key={previewDeployment?.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
>
<div className="flex justify-between gap-2 max-sm:flex-wrap">
<div className="flex flex-col gap-2">
{deployments?.length === 0 ? (
<div>
<span className="text-sm text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
{previewDeployment?.pullRequestURL && (
<div className="flex items-center gap-2">
<GithubIcon />
<Link
target="_blank"
href={previewDeployment?.pullRequestURL}
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
Pull Request URL
</Link>
</div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div>
</div>
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
{previewDeployment?.createdAt && (
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip
date={previewDeployment?.createdAt}
/>
</div>
)}
<ShowPreviewBuilds
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<ShowModalLogs
appName={previewDeployment.appName}
serverId={data?.serverId || ""}
>
<Button variant="outline">View Logs</Button>
</ShowModalLogs>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() => {
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
</Button>
</DialogAction>
</div>
</div>
</div>
);
})}
</div>
)}
</>
) : (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
Preview deployments are disabled for this application, please
enable it
</span>
<ShowPreviewSettings applicationId={applicationId} />
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,351 @@
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
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, NumberInput } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Secrets } from "@/components/ui/secrets";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const schema = z.object({
env: z.string(),
buildArgs: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
previewHttps: z.boolean(),
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none"]),
});
type Schema = z.infer<typeof schema>;
interface Props {
applicationId: string;
}
export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
},
resolver: zodResolver(schema),
});
const previewHttps = form.watch("previewHttps");
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
}, [data?.isPreviewDeploymentsActive]);
useEffect(() => {
if (data) {
form.reset({
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLimit: data.previewLimit || 3,
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
});
}
}, [data]);
const onSubmit = async (formData: Schema) => {
updateApplication({
previewEnv: formData.env,
previewBuildArgs: formData.buildArgs,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
applicationId,
previewLimit: formData.previewLimit,
previewHttps: formData.previewHttps,
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
})
.then(() => {
toast.success("Preview Deployments settings updated");
})
.catch((error) => {
toast.error(error.message);
});
};
return (
<div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Settings</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
<DialogHeader>
<DialogTitle>Preview Deployment Settings</DialogTitle>
<DialogDescription>
Adjust the settings for preview deployments of this application,
including environment variables, build options, and deployment
rules.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<div className="grid gap-4 lg:grid-cols-2">
<FormField
control={form.control}
name="wildcardDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.traefik.me" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewPath"
render={({ field }) => (
<FormItem>
<FormLabel>Preview Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<NumberInput placeholder="3000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewLimit"
render={({ field }) => (
<FormItem>
<FormLabel>Preview Limit</FormLabel>
{/* <FormDescription>
Set the limit of preview deployments that can be
created for this app.
</FormDescription> */}
<FormControl>
<NumberInput placeholder="3000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="previewHttps"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg 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>
)}
/>
{previewHttps && (
<FormField
control={form.control}
name="previewCertificateType"
render={({ field }) => (
<FormItem>
<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>
)}
/>
)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable preview deployments
</FormLabel>
<FormDescription>
Enable or disable preview deployments for this
application.
</FormDescription>
</div>
<Switch
checked={isEnabled}
onCheckedChange={(checked) => {
updateApplication({
isPreviewDeploymentsActive: checked,
applicationId,
})
.then(() => {
refetch();
toast.success("Preview deployments enabled");
})
.catch((error) => {
toast.error(error.message);
});
}}
/>
</div>
</div>
<FormField
control={form.control}
name="env"
render={({ field }) => (
<FormItem>
<FormControl>
<Secrets
name="env"
title="Environment Settings"
description="You can add environment variables to your resource."
placeholder={[
"NODE_ENV=production",
"PORT=3000",
].join("\n")}
/>
{/* <CodeEditor
lineWrapping
language="properties"
wrapperClassName="h-[25rem] font-mono"
placeholder={`NODE_ENV=production
PORT=3000
`}
{...field}
/> */}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
description={
<span>
Available only at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/guide/build-args/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* */}
</div>
);
};

View File

@@ -8,12 +8,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Toggle } from "@/components/ui/toggle";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import { StartCompose } from "../start-compose";
import { DeployCompose } from "./deploy-compose";
import { RedbuildCompose } from "./rebuild-compose";
import { StopCompose } from "./stop-compose";
@@ -50,28 +51,11 @@ export const ComposeActions = ({ composeId }: Props) => {
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<Toggle
aria-label="Toggle italic"
pressed={data?.autoDeploy || false}
onPressedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
>
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
</Toggle>
<RedbuildCompose composeId={composeId} />
{data?.composeType === "docker-compose" && (
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<StartCompose composeId={composeId} />
) : (
<StopCompose composeId={composeId} />
)}
@@ -84,6 +68,27 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
/>
</div>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -0,0 +1,65 @@
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 { CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StartCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.start.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Start
<CheckCircle2 className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to start the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will start the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose started succesfully");
})
.catch(() => {
toast.error("Error to start the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,65 @@
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 { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure to stop the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose stopped succesfully");
})
.catch(() => {
toast.error("Error to stop the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,13 +1,16 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import dynamic from "next/dynamic";
import { useState } from "react";
const Terminal = dynamic(
() => import("./docker-terminal").then((e) => e.DockerTerminal),
@@ -27,8 +30,27 @@ export const DockerTerminalModal = ({
containerId,
serverId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open) {
setConfirmDialogOpen(true);
} else {
setMainDialogOpen(true);
}
};
const handleConfirm = () => {
setConfirmDialogOpen(false);
setMainDialogOpen(false);
};
const handleCancel = () => {
setConfirmDialogOpen(false);
};
return (
<Dialog>
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
@@ -50,6 +72,24 @@ export const DockerTerminalModal = ({
containerId={containerId}
serverId={serverId || ""}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Are you sure you want to close the terminal?
</DialogTitle>
<DialogDescription>
By clicking the confirm button, the terminal will be closed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);

View File

@@ -49,8 +49,11 @@ export const columns: ColumnDef<LogEntry>[] = [
const log = row.original;
return (
<div className=" flex flex-col gap-2">
<div className="flex flex-row gap-3 ">
<div className="flex items-center flex-row gap-3 ">
{log.RequestMethod}{" "}
<div className="inline-flex items-center gap-2 bg-muted p-1 rounded">
<span>{log.RequestAddr}</span>
</div>
{log.RequestPath.length > 100
? `${log.RequestPath.slice(0, 82)}...`
: log.RequestPath}

View File

@@ -27,6 +27,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Languages } from "@/lib/languages";
import useLocale from "@/utils/hooks/use-locale";
import { useTranslation } from "next-i18next";
import { useTheme } from "next-themes";
@@ -37,12 +38,9 @@ const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark", "system"], {
required_error: "Please select a theme.",
}),
language: z.enum(
["en", "pl", "ru", "fr", "de", "tr", "zh-Hant", "zh-Hans", "fa"],
{
required_error: "Please select a language.",
},
),
language: z.nativeEnum(Languages, {
required_error: "Please select a language.",
}),
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
@@ -50,7 +48,7 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "system",
language: "en",
language: Languages.English,
};
export function AppearanceForm() {
@@ -175,24 +173,15 @@ export function AppearanceForm() {
<SelectValue placeholder="No preset selected" />
</SelectTrigger>
<SelectContent>
{[
{ label: "English", value: "en" },
{ label: "Polski", value: "pl" },
{ label: "Русский", value: "ru" },
{ label: "Français", value: "fr" },
{ label: "Deutsch", value: "de" },
{ label: "繁體中文", value: "zh-Hant" },
{ label: "简体中文", value: "zh-Hans" },
{ label: "Türkçe", value: "tr" },
{
label: "Persian",
value: "fa",
},
].map((preset) => (
<SelectItem key={preset.label} value={preset.value}>
{preset.label}
</SelectItem>
))}
{Object.keys(Languages).map((preset) => {
const value =
Languages[preset as keyof typeof Languages];
return (
<SelectItem key={value} value={value}>
{preset}
</SelectItem>
);
})}
</SelectContent>
</Select>
</FormItem>

View File

@@ -667,7 +667,7 @@ export const AddNotification = () => {
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when a dokploy is restarted.
Trigger the action when dokploy is restarted.
</FormDescription>
</div>
<FormControl>

View File

@@ -33,6 +33,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { GPUSupport } from "./gpu-support";
import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
@@ -90,9 +91,10 @@ export const SetupServer = ({ serverId }: Props) => {
) : (
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<Tabs defaultValue="ssh-keys">
<TabsList className="grid grid-cols-3 w-[400px]">
<TabsList className="grid grid-cols-4 w-[600px]">
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
@@ -203,7 +205,7 @@ export const SetupServer = ({ serverId }: Props) => {
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">
Deployments
@@ -293,6 +295,14 @@ export const SetupServer = ({ serverId }: Props) => {
</div>
</CardContent>
</TabsContent>
<TabsContent
value="validate"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"

View File

@@ -0,0 +1,136 @@
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { StatusRow } from "./gpu-support";
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface Props {
serverId: string;
}
export const ValidateServer = ({ serverId }: Props) => {
const [isRefreshing, setIsRefreshing] = useState(false);
const { data, refetch, error, isLoading, isError } =
api.server.validate.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const utils = api.useUtils();
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<PcCase className="size-5" />
<CardTitle className="text-xl">Setup Validation</CardTitle>
</div>
<CardDescription>
Check if your server is ready for deployment
</CardDescription>
</div>
<Button
isLoading={isRefreshing}
onClick={async () => {
setIsRefreshing(true);
await refetch();
setIsRefreshing(false);
}}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</div>
<div className="flex items-center gap-2 w-full">
{isError && (
<AlertBlock type="error" className="w-full">
{error.message}
</AlertBlock>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{isLoading ? (
<div className="flex items-center justify-center text-muted-foreground py-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Checking Server Configuration</span>
</div>
) : (
<div className="grid w-full gap-4">
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
</p>
<div className="grid gap-2.5">
<StatusRow
label="Docker Installed"
isEnabled={data?.docker?.enabled}
description={
data?.docker?.enabled
? `Installed: ${data?.docker?.version}`
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
description={
data?.nixpacks?.enabled
? `Installed: ${data?.nixpacks?.version}`
: undefined
}
/>
<StatusRow
label="Buildpacks Installed"
isEnabled={data?.buildpacks?.enabled}
description={
data?.buildpacks?.enabled
? `Installed: ${data?.buildpacks?.version}`
: undefined
}
/>
<StatusRow
label="Dokploy Network Installed"
isEnabled={data?.isDokployNetworkInstalled}
/>
<StatusRow
label="Swarm Installed"
isEnabled={data?.isSwarmInstalled}
/>
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
/>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</CardContent>
);
};

View File

@@ -1,7 +1,9 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
@@ -49,14 +51,34 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open) {
setConfirmDialogOpen(true);
} else {
setMainDialogOpen(true);
}
};
const handleConfirm = () => {
setConfirmDialogOpen(false);
setMainDialogOpen(false);
};
const handleCancel = () => {
setConfirmDialogOpen(false);
};
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
<Dialog>
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
<DialogHeader>
@@ -96,6 +118,24 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
id="terminal"
containerId={containerId || "select-a-container"}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Are you sure you want to close the terminal?
</DialogTitle>
<DialogDescription>
By clicking the confirm button, the terminal will be closed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);

View File

@@ -29,7 +29,7 @@ export const Secrets = (props: Props) => {
return (
<>
<CardHeader className="flex flex-row w-full items-center justify-between">
<CardHeader className="flex flex-row w-full items-center justify-between px-0">
<div>
<CardTitle className="text-xl">{props.title}</CardTitle>
<CardDescription>{props.description}</CardDescription>
@@ -47,7 +47,7 @@ export const Secrets = (props: Props) => {
)}
</Toggle>
</CardHeader>
<CardContent className="w-full space-y-4">
<CardContent className="w-full space-y-4 p-0">
<FormField
control={form.control}
name={props.name}

View File

@@ -1,3 +1,5 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
@@ -9,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
@@ -17,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "herokuVersion" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ALTER COLUMN "herokuVersion" SET DEFAULT '24';

View File

@@ -0,0 +1,53 @@
ALTER TYPE "domainType" ADD VALUE 'preview';--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "preview_deployments" (
"previewDeploymentId" text PRIMARY KEY NOT NULL,
"branch" text NOT NULL,
"pullRequestId" text NOT NULL,
"pullRequestNumber" text NOT NULL,
"pullRequestURL" text NOT NULL,
"pullRequestTitle" text NOT NULL,
"pullRequestCommentId" text NOT NULL,
"previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
"appName" text NOT NULL,
"applicationId" text NOT NULL,
"domainId" text,
"createdAt" text NOT NULL,
"expiresAt" text,
CONSTRAINT "preview_deployments_appName_unique" UNIQUE("appName")
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewEnv" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewBuildArgs" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewWildcard" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewPort" integer DEFAULT 3000;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewHttps" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "certificateType" "certificateType" DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "previewLimit" integer DEFAULT 3;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "isPreviewDeploymentsActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "isPreviewDeployment" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_domainId_domain_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domain"("domainId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "domain" ADD CONSTRAINT "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -330,6 +330,27 @@
"when": 1732851191048,
"tag": "0046_purple_sleeper",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1733599090582,
"tag": "0047_tidy_revanche",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1733599163710,
"tag": "0048_flat_expediter",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1733628762978,
"tag": "0049_dark_leopardon",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,16 @@
export enum Languages {
English = "en",
Polish = "pl",
Russian = "ru",
French = "fr",
German = "de",
ChineseTraditional = "zh-Hant",
ChineseSimplified = "zh-Hans",
Turkish = "tr",
Kazakh = "kz",
Persian = "fa",
Korean = "ko",
Portuguese = "pt-br",
}
export type Language = keyof typeof Languages;

View File

@@ -1,10 +1,23 @@
/** @type {import('next-i18next').UserConfig} */
module.exports = {
i18n: {
defaultLocale: "en",
locales: ["en", "pl", "ru", "fr", "de", "tr", "zh-Hant", "zh-Hans", "fa"],
localeDetection: false,
},
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: [
"en",
"pl",
"ru",
"fr",
"de",
"tr",
"kz",
"zh-Hant",
"zh-Hans",
"fa",
"ko",
"pt-br",
],
localeDetection: false,
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.13.1",
"version": "v0.14.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -11,7 +11,8 @@
"build-next": "next build",
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"dev": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",

View File

@@ -1,6 +1,7 @@
import "@/styles/globals.css";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
import type { NextPage } from "next";
import { appWithTranslation } from "next-i18next";
@@ -71,17 +72,7 @@ export default api.withTRPC(
{
i18n: {
defaultLocale: "en",
locales: [
"en",
"pl",
"ru",
"fr",
"de",
"tr",
"zh-Hant",
"zh-Hans",
"fa",
],
locales: Object.values(Languages),
localeDetection: false,
},
fallbackLng: "en",

View File

@@ -3,145 +3,259 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { IS_CLOUD } from "@dokploy/server";
import {
createPreviewDeployment,
type Domain,
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { extractCommitMessage, extractHash } from "./[refreshToken]";
import { generateRandomDomain } from "@/templates/utils";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
req: NextApiRequest,
res: NextApiResponse,
) {
const signature = req.headers["x-hub-signature-256"];
const githubBody = req.body;
const signature = req.headers["x-hub-signature-256"];
const githubBody = req.body;
if (!githubBody?.installation?.id) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubBody?.installation?.id) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
const githubResult = await db.query.github.findFirst({
where: eq(github.githubInstallationId, githubBody.installation.id),
});
const githubResult = await db.query.github.findFirst({
where: eq(github.githubInstallationId, githubBody.installation.id),
});
if (!githubResult) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubResult) {
res.status(400).json({ message: "Github Installation not found" });
return;
}
if (!githubResult.githubWebhookSecret) {
res.status(400).json({ message: "Github Webhook Secret not set" });
return;
}
const webhooks = new Webhooks({
secret: githubResult.githubWebhookSecret,
});
if (!githubResult.githubWebhookSecret) {
res.status(400).json({ message: "Github Webhook Secret not set" });
return;
}
const webhooks = new Webhooks({
secret: githubResult.githubWebhookSecret,
});
const verified = await webhooks.verify(
JSON.stringify(githubBody),
signature as string
);
const verified = await webhooks.verify(
JSON.stringify(githubBody),
signature as string,
);
if (!verified) {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (!verified) {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
}
if (req.headers["x-github-event"] === "ping") {
res.status(200).json({ message: "Ping received, webhook is active" });
return;
}
if (req.headers["x-github-event"] !== "push") {
res.status(400).json({ message: "We only accept push events" });
return;
}
if (
req.headers["x-github-event"] !== "push" &&
req.headers["x-github-event"] !== "pull_request"
) {
res
.status(400)
.json({ message: "We only accept push events or pull_request events" });
return;
}
try {
const branchName = githubBody?.ref?.replace("refs/heads/", "");
const repository = githubBody?.repository?.name;
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
if (req.headers["x-github-event"] === "push") {
try {
const branchName = githubBody?.ref?.replace("refs/heads/", "");
const repository = githubBody?.repository?.name;
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = githubBody?.repository?.owner?.name;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.branch, branchName),
eq(applications.repository, repository)
),
});
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.branch, branchName),
eq(applications.repository, repository),
eq(applications.owner, owner),
),
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
}
);
}
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.branch, branchName),
eq(compose.repository, repository)
),
});
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.branch, branchName),
eq(compose.repository, repository),
eq(compose.owner, owner),
),
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
return true;
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
}
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
const emptyApps = totalApps === 0;
const totalApps = apps.length + composeApps.length;
const emptyApps = totalApps === 0;
if (emptyApps) {
res.status(200).json({ message: "No apps to deploy" });
return;
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
res.status(400).json({ message: "Error To Deploy Application", error });
}
if (emptyApps) {
res.status(200).json({ message: "No apps to deploy" });
return;
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
res.status(400).json({ message: "Error To Deploy Application", error });
}
} else if (req.headers["x-github-event"] === "pull_request") {
const prId = githubBody?.pull_request?.id;
if (githubBody?.action === "closed") {
const previewDeploymentResult =
await findPreviewDeploymentsByPullRequestId(prId);
if (previewDeploymentResult.length > 0) {
for (const previewDeployment of previewDeploymentResult) {
try {
await removePreviewDeployment(
previewDeployment.previewDeploymentId,
);
} catch (error) {
console.log(error);
}
}
}
res.status(200).json({ message: "Preview Deployment Closed" });
return;
}
// opened or synchronize or reopened
const repository = githubBody?.repository?.name;
const deploymentHash = githubBody?.pull_request?.head?.sha;
const branch = githubBody?.pull_request?.base?.ref;
const owner = githubBody?.repository?.owner?.login;
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.repository, repository),
eq(applications.branch, branch),
eq(applications.isPreviewDeploymentsActive, true),
eq(applications.owner, owner),
),
with: {
previewDeployments: true,
},
});
const prBranch = githubBody?.pull_request?.head?.ref;
const prNumber = githubBody?.pull_request?.number;
const prTitle = githubBody?.pull_request?.title;
const prURL = githubBody?.pull_request?.html_url;
for (const app of apps) {
const previewLimit = app?.previewLimit || 0;
if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult) {
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
previewDeploymentId = previewDeployment.previewDeploymentId;
}
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
return res.status(200).json({ message: "Apps Deployed" });
}
return res.status(400).json({ message: "No Actions matched" });
}

View File

@@ -12,6 +12,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { DockerMonitoring } from "@/components/dashboard/monitoring/docker/show";
import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -51,7 +52,8 @@ type TabState =
| "advanced"
| "deployments"
| "domains"
| "monitoring";
| "monitoring"
| "preview-deployments";
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
@@ -191,8 +193,8 @@ const Service = (
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
data?.serverId ? "md:grid-cols-6" : "md:grid-cols-7",
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
data?.serverId ? "md:grid-cols-7" : "md:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
@@ -202,6 +204,9 @@ const Service = (
)}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
@@ -244,6 +249,11 @@ const Service = (
<ShowDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="preview-deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPreviewDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains applicationId={applicationId} />

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,44 @@
{
"settings.common.save": "저장",
"settings.server.domain.title": "서버 도메인",
"settings.server.domain.description": "서버 애플리케이션에 도메인을 추가합니다.",
"settings.server.domain.form.domain": "도메인",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 이메일",
"settings.server.domain.form.certificate.label": "인증서",
"settings.server.domain.form.certificate.placeholder": "인증서 선택",
"settings.server.domain.form.certificateOptions.none": "없음",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (기본)",
"settings.server.webServer.title": "웹 서버",
"settings.server.webServer.description": "웹 서버를 재시작하거나 정리합니다.",
"settings.server.webServer.actions": "작업",
"settings.server.webServer.reload": "재시작",
"settings.server.webServer.watchLogs": "로그 보기",
"settings.server.webServer.updateServerIp": "서버 IP 갱신",
"settings.server.webServer.server.label": "서버",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "환경 변수 수정",
"settings.server.webServer.storage.label": "저장 공간",
"settings.server.webServer.storage.cleanUnusedImages": "사용하지 않는 이미지 정리",
"settings.server.webServer.storage.cleanUnusedVolumes": "사용하지 않는 볼륨 정리",
"settings.server.webServer.storage.cleanStoppedContainers": "정지된 컨테이너 정리",
"settings.server.webServer.storage.cleanDockerBuilder": "도커 빌더 & 시스템 정리",
"settings.server.webServer.storage.cleanMonitoring": "모니터링 데이터 정리",
"settings.server.webServer.storage.cleanAll": "전체 정리",
"settings.profile.title": "계정",
"settings.profile.description": "여기에서 프로필 세부 정보를 변경하세요.",
"settings.profile.email": "이메일",
"settings.profile.password": "비밀번호",
"settings.profile.avatar": "아바타",
"settings.appearance.title": "외관",
"settings.appearance.description": "대시보드의 테마를 사용자 설정합니다.",
"settings.appearance.theme": "테마",
"settings.appearance.themeDescription": "대시보드 테마 선택",
"settings.appearance.themes.light": "라이트",
"settings.appearance.themes.dark": "다크",
"settings.appearance.themes.system": "시스템",
"settings.appearance.language": "언어",
"settings.appearance.languageDescription": "대시보드에서 사용할 언어 선택"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,41 @@
{
"settings.common.save": "Сақтау",
"settings.server.domain.title": "Сервер домені",
"settings.server.domain.description": "Dokploy сервер қолданбасына домен енгізіңіз.",
"settings.server.domain.form.domain": "Домен",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Эл. поштасы",
"settings.server.domain.form.certificate.label": "Сертификат",
"settings.server.domain.form.certificate.placeholder": "Сертификатты таңдаңыз",
"settings.server.domain.form.certificateOptions.none": "Жоқ",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Стандартты)",
"settings.server.webServer.title": "Веб-Сервер",
"settings.server.webServer.description": "Веб-серверді қайта жүктеу немесе тазалау.",
"settings.server.webServer.actions": "Әрекеттер",
"settings.server.webServer.reload": "Қайта жүктеу",
"settings.server.webServer.watchLogs": "Журналдарды қарау",
"settings.server.webServer.updateServerIp": "Сервердің IP жаңарту",
"settings.server.webServer.server.label": "Сервер",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Env Өзгерту",
"settings.server.webServer.storage.label": "Диск кеңістігі",
"settings.server.webServer.storage.cleanUnusedImages": "Пайдаланылмаған образды тазалау",
"settings.server.webServer.storage.cleanUnusedVolumes": "Пайдаланылмаған томды тазалау",
"settings.server.webServer.storage.cleanStoppedContainers": "Тоқтатылған контейнерлерді тазалау",
"settings.server.webServer.storage.cleanDockerBuilder": "Docker Builder & Системаны тазалау",
"settings.server.webServer.storage.cleanMonitoring": "Мониторингті тазалау",
"settings.server.webServer.storage.cleanAll": "Барлығын тазалау",
"settings.profile.title": "Аккаунт",
"settings.profile.description": "Профиль мәліметтерін осы жерден өзгертіңіз.",
"settings.profile.email": "Эл. пошта",
"settings.profile.password": "Құпия сөз",
"settings.profile.avatar": "Аватар",
"settings.appearance.title": "Сыртқы түрі",
"settings.appearance.description": "Dokploy сыртқы келбетін өзгерту.",
"settings.appearance.theme": "Келбеті",
"settings.appearance.themeDescription": "Жүйе тақтасының келбетің таңдаңыз",
"settings.appearance.themes.light": "Жарық",
"settings.appearance.themes.dark": "Қараңғы",
"settings.appearance.themes.system": "Жүйелік",
"settings.appearance.language": "Тіл",
"settings.appearance.languageDescription": "Жүйе тақтасының тілің таңдаңыз"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,44 @@
{
"settings.common.save": "Salvar",
"settings.server.domain.title": "Domínio do Servidor",
"settings.server.domain.description": "Configure o domínio do servidor",
"settings.server.domain.form.domain": "Domínio",
"settings.server.domain.form.letsEncryptEmail": "Email do Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificado",
"settings.server.domain.form.certificate.placeholder": "Selecione um Certificado",
"settings.server.domain.form.certificateOptions.none": "Nenhum",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Padrão)",
"settings.server.webServer.title": "Servidor web",
"settings.server.webServer.description": "Limpar e recarregar servidor web.",
"settings.server.webServer.actions": "Ações",
"settings.server.webServer.reload": "Recarregar",
"settings.server.webServer.watchLogs": "Ver logs",
"settings.server.webServer.updateServerIp": "Atualizar IP do Servidor",
"settings.server.webServer.server.label": "Servidor",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Alterar Env",
"settings.server.webServer.storage.label": "Armazenamento",
"settings.server.webServer.storage.cleanUnusedImages": "Limpar imagens não utilizadas",
"settings.server.webServer.storage.cleanUnusedVolumes": "Limpar volumes não utilizados",
"settings.server.webServer.storage.cleanStoppedContainers": "Limpar containers parados",
"settings.server.webServer.storage.cleanDockerBuilder": "Limpar Docker Builder & System",
"settings.server.webServer.storage.cleanMonitoring": "Limpar Monitoramento",
"settings.server.webServer.storage.cleanAll": "Limpar Tudo",
"settings.profile.title": "Conta",
"settings.profile.description": "Altere os detalhes do seu perfil aqui.",
"settings.profile.email": "Email",
"settings.profile.password": "Senha",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aparencia",
"settings.appearance.description": "Personalize o tema do seu dashboard.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Selecione um tema para o dashboard",
"settings.appearance.themes.light": "Claro",
"settings.appearance.themes.dark": "Escuro",
"settings.appearance.themes.system": "Automático",
"settings.appearance.language": "Linguagem",
"settings.appearance.languageDescription": "Selecione o idioma do dashboard"
}

View File

@@ -0,0 +1,13 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<title>favicon</title>
<defs>
<linearGradient id="g1" x1="220.3" y1="854.7" x2="760.4" y2="517.2" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ff3c95"/>
<stop offset="1" stop-color="#ffc550"/>
</linearGradient>
</defs>
<style>
.s0 { fill: url(#g1) }
</style>
<path id="Path 0" class="s0" d="m243.9 0c0.3 0.1 26.4 15 115.1 66.5v411.8c0 391.6 0.1 411.7 1.8 411.2 0.9-0.3 69.7-34 304.1-149l0.1-61.8c0-48.9-0.3-61.6-1.3-61.3-0.6 0.2-43.6 20-95.5 44-51.8 24-94.4 43.5-94.7 43.3-0.2-0.1-0.3-128.3 0-569.6l115.5 68 1.1 256.4 191.4 111.4 0.5 243.6-213.1 104.5c-117.2 57.5-213.9 104.6-214.8 104.8-0.9 0.2-26.5-16.3-112.2-73.3l0.1-296.5c0-163.1 0.3-376.9 0.7-475.2 0.3-98.4 0.9-178.8 1.2-178.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 836 B

View File

@@ -0,0 +1,13 @@
<svg width="265" height="265" viewBox="0 0 265 265" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_1799)">
<path d="M158.2 8.6V116.6C158.2 121.3 162 125.2 166.8 125.2H213.8C218 125.2 222 123.2 224.6 119.8L262.9 68.9C265.7 65.2 265.7 60.1 262.9 56.4L224.6 5.4C222 2 218 0 213.8 0H166.8C162 0 158.2 3.8 158.2 8.6Z" fill="#FF4E4E"/>
<path d="M158.2 148.4V256.4C158.2 261.1 162 265 166.8 265H213.8C218 265 222 263 224.6 259.6L262.9 208.7C265.7 205 265.7 199.9 262.9 196.2L224.6 145.3C222.1 141.9 218.1 139.9 213.8 139.9H166.8C162 139.8 158.2 143.7 158.2 148.4Z" fill="#6E56FF"/>
<path d="M0 8.6V116.6C0 121.3 3.8 125.2 8.6 125.2H109.6C113.8 125.2 117.8 123.2 120.4 119.8L155.9 72.5C160.3 66.6 160.3 58.5 155.9 52.6L120.3 5.4C117.8 2 113.8 0 109.5 0H8.6C3.8 0 0 3.8 0 8.6Z" fill="#F97777"/>
<path d="M0 148.4V256.4C0 261.1 3.8 265 8.6 265H109.6C113.8 265 117.8 263 120.4 259.6L155.9 212.3C160.3 206.4 160.3 198.3 155.9 192.4L120.4 145.1C117.9 141.7 113.9 139.7 109.6 139.7H8.6C3.8 139.8 0 143.7 0 148.4Z" fill="#9F8FFF"/>
</g>
<defs>
<clipPath id="clip0_1_1799">
<rect width="265" height="265" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 161.6 161.6" style="enable-background:new 0 0 161.6 161.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#F08705;}
.st1{fill:#DF6C0C;}
.st2{fill:#FFFFFF;}
.st3{fill:#333333;}
</style>
<g>
<path class="st0" d="M161.6,154.7c0,3.9-3.2,6.9-6.9,6.9H6.9c-3.9,0-6.9-3.2-6.9-6.9V6.9C0,3,3.2,0,6.9,0h147.8 c3.9,0,6.9,3.2,6.9,6.9L161.6,154.7L161.6,154.7z"/>
<g>
<path class="st1" d="M161.6,154.7c0,3.9-3.2,6.9-6.9,6.9H55.3l-32.2-32.7l20-32.7l59.4-73.8l58.9,60.7L161.6,154.7z"/>
</g>
<path class="st2" d="M132.7,90.3h-17l-18-30.6c4-0.8,7-4.4,7-8.6V28c0-4.9-3.9-8.8-8.8-8.8h-30c-4.9,0-8.8,3.9-8.8,8.8v23.1 c0,4.3,3,7.8,6.9,8.6L46,90.4H29c-4.9,0-8.8,3.9-8.8,8.8v23.1c0,4.9,3.9,8.8,8.8,8.8h30c4.9,0,8.8-3.9,8.8-8.8V99.2 c0-4.9-3.9-8.8-8.8-8.8h-2.9L73.9,60h13.9l17.9,30.4h-3c-4.9,0-8.8,3.9-8.8,8.8v23.1c0,4.9,3.9,8.8,8.8,8.8h30 c4.9,0,8.8-3.9,8.8-8.8V99.2C141.5,94.3,137.6,90.3,132.7,90.3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1 @@
<svg cursor="default" enable-background="new" height="512" viewBox="0 0 440 440" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><radialGradient id="a" cx="611" cy="41.266644" gradientTransform="matrix(1.1673343 0 0 1.4196623 -102.24121 -19.722475)" gradientUnits="userSpaceOnUse" r="160.5"><stop offset="0" stop-color="#01fd00"/><stop offset="1" stop-color="#009d39"/></radialGradient><radialGradient id="b" cx="873.78265" cy="-16.37981" gradientTransform="matrix(1.9295131 -1.1140049 1.1550268 2.0005649 -1087.6858 1104.8947)" gradientUnits="userSpaceOnUse" r="55.747124"><stop offset="0" stop-color="#f9f9f9"/><stop offset="1" stop-color="#fff"/></radialGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="611.14288" x2="610.57141" y1="234.57143" y2="-110.91601"><stop offset="0" stop-color="#f2f2f2"/><stop offset="1" stop-color="#f2f2f2"/></linearGradient><g transform="translate(-390.56693 181.56689)"><g><path d="m391.020508-182.838852h439.95895v439.95895h-439.95895z" fill="#fff" stroke="#58dd58" stroke-linejoin="round" stroke-width=".040996" visibility="hidden"/><circle cx="611" cy="38" fill="url(#c)" r="187.776825"/><circle cx="611" cy="38" fill="url(#a)" r="166.285645" stroke="#fff" stroke-linejoin="round" stroke-width="3"/><path d="m571.156433 108.308197 110-63.999998-110-64z" fill="url(#b)" fill-rule="evenodd"/></g><g fill="#c7efc4" fill-rule="evenodd"><rect height="32" opacity=".5" ry="7" transform="matrix(-.83616052 -.5484848 .5484848 -.83616052 0 0)" width="14" x="-542.46875" y="419.410309"/><rect height="32" opacity=".5" ry="7" transform="matrix(-.45720822 -.88935968 .88935968 -.45720822 0 0)" width="14" x="-320.158203" y="642.978027"/><rect height="41" opacity=".5" ry="9" transform="rotate(-90)" width="18" x="-48.536366" y="718.754395"/><rect height="32" opacity=".5" ry="7" transform="matrix(.47159375 -.88181593 .88181593 .47159375 0 0)" width="14" x="244.405457" y="673.798523"/><rect height="32" opacity=".5" ry="7" transform="matrix(-.8503618 .52619845 -.52619845 -.8503618 0 0)" width="14" x="-505.884949" y="-503.867859"/><rect height="41" opacity=".5" ry="9" transform="scale(-1)" width="18" x="-620.297424" y="-187.942719"/><rect height="32" opacity=".5" ry="7" transform="matrix(-.84033379 -.54206929 .54206929 -.84033379 0 0)" width="14" x="-539.16571" y="148.581909"/><rect height="32" opacity=".5" ry="7" transform="matrix(-.45720822 -.88935968 .88935968 -.45720822 0 0)" width="14" x="-319.728088" y="374.804901"/><rect height="41" opacity=".5" ry="9" transform="rotate(-90)" width="18" x="-48.536366" y="460.964569"/><rect height="32" opacity=".5" ry="7" transform="matrix(.47159375 -.88181593 .88181593 .47159375 0 0)" width="14" x="250.841736" y="406.195648"/><rect height="32" opacity=".5" ry="7" transform="matrix(-.849305 .52790248 -.52790248 -.849305 0 0)" width="14" x="-505.957611" y="-236.959518"/><g transform="scale(-1)"><rect height="41" opacity=".5" ry="9" width="18" x="-633.02533" y="69.120789"/><rect height="41" opacity=".5" ry="9" width="18" x="-606.761353" y="69.120789"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -31,6 +31,7 @@ import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { userRouter } from "./routers/user";
import { previewDeploymentRouter } from "./routers/preview-deployment";
/**
* This is the primary router for your server.
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
destination: destinationRouter,
backup: backupRouter,
deployment: deploymentRouter,
previewDeployment: previewDeploymentRouter,
mounts: mountRouter,
certificates: certificateRouter,
settings: settingsRouter,

View File

@@ -296,6 +296,7 @@ export const applicationRouter = createTRPCRouter({
publishDirectory: input.publishDirectory,
dockerContextPath: input.dockerContextPath,
dockerBuildStage: input.dockerBuildStage,
herokuVersion: input.herokuVersion,
});
return true;

View File

@@ -48,6 +48,7 @@ import {
removeCompose,
removeComposeDirectory,
removeDeploymentsByComposeId,
startCompose,
stopCompose,
updateCompose,
} from "@dokploy/server";
@@ -309,6 +310,20 @@ export const composeRouter = createTRPCRouter({
}
await stopCompose(input.composeId);
return true;
}),
start: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this compose",
});
}
await startCompose(input.composeId);
return true;
}),
getDefaultCommand: protectedProcedure

View File

@@ -13,6 +13,7 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findPreviewDeploymentById,
generateTraefikMeDomain,
manageDomain,
removeDomain,
@@ -108,12 +109,33 @@ export const domainRouter = createTRPCRouter({
message: "You are not authorized to access this compose",
});
}
} else if (currentDomain.previewDeploymentId) {
const newPreviewDeployment = await findPreviewDeploymentById(
currentDomain.previewDeploymentId,
);
if (
newPreviewDeployment.application.project.adminId !== ctx.user.adminId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
}
const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId);
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
} else if (domain.previewDeploymentId) {
const previewDeployment = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
const application = await findApplicationById(
previewDeployment.applicationId,
);
application.appName = previewDeployment.appName;
await manageDomain(application, domain);
}
return result;
}),

View File

@@ -188,8 +188,9 @@ export const notificationRouter = createTRPCRouter({
.mutation(async ({ input }) => {
try {
await sendDiscordNotification(input, {
title: "Test Notification",
description: "Hi, From Dokploy 👋",
title: "> `🤚` - Test Notification",
description: "> Hi, From Dokploy 👋",
color: 0xf3f7f4,
});
return true;
} catch (error) {

View File

@@ -0,0 +1,54 @@
import { apiFindAllByApplication } from "@/server/db/schema";
import {
findApplicationById,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure
.input(apiFindAllByApplication)
.query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (application.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findPreviewDeploymentsByApplicationId(input.applicationId);
}),
delete: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (previewDeployment.application.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this preview deployment",
});
}
await removePreviewDeployment(input.previewDeploymentId);
return true;
}),
one: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.query(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (previewDeployment.application.project.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
return previewDeployment;
}),
});

View File

@@ -26,6 +26,7 @@ import {
haveActiveServices,
removeDeploymentsByServerId,
serverSetup,
serverValidate,
updateServerById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -118,6 +119,47 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
validate: protectedProcedure
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
try {
const server = await findServerById(input.serverId);
if (server.adminId !== ctx.user.adminId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to validate this server",
});
}
const response = await serverValidate(input.serverId);
return response as unknown as {
docker: {
enabled: boolean;
version: string;
};
rclone: {
enabled: boolean;
version: string;
};
nixpacks: {
enabled: boolean;
version: string;
};
buildpacks: {
enabled: boolean;
version: string;
};
isDokployNetworkInstalled: boolean;
isSwarmInstalled: boolean;
isMainDirectoryInstalled: boolean;
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error?.message : `Error: ${error}`,
cause: error as Error,
});
}
}),
remove: protectedProcedure
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,14 +1,17 @@
import {
deployApplication,
deployCompose,
deployPreviewApplication,
deployRemoteApplication,
deployRemoteCompose,
deployRemotePreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildRemoteApplication,
rebuildRemoteCompose,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import { type Job, Worker } from "bullmq";
import type { DeploymentJob } from "./queue-types";
@@ -20,6 +23,7 @@ export const deploymentWorker = new Worker(
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.server) {
if (job.data.type === "redeploy") {
await rebuildRemoteApplication({
@@ -83,6 +87,29 @@ export const deploymentWorker = new Worker(
});
}
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (job.data.server) {
if (job.data.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
} else {
if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
}
} catch (error) {
console.log("Error", error);

View File

@@ -16,6 +16,16 @@ type DeployJob =
type: "deploy" | "redeploy";
applicationType: "compose";
serverId?: string;
}
| {
applicationId: string;
titleLog: string;
descriptionLog: string;
server?: boolean;
type: "deploy";
applicationType: "application-preview";
previewDeploymentId: string;
serverId?: string;
};
export type DeploymentJob = DeployJob;

View File

@@ -24,7 +24,7 @@ import { setupTerminalWebSocketServer } from "./wss/terminal";
config({ path: ".env" });
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev, turbopack: dev });
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
const handle = app.getRequestHandler();
void app.prepare().then(async () => {
try {

View File

@@ -0,0 +1,16 @@
services:
browserless:
image: ghcr.io/browserless/chromium:v2.23.0
environment:
TOKEN: ${BROWSERLESS_TOKEN}
expose:
- 3000
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://127.0.0.1:3000/docs'
interval: 2s
timeout: 10s
retries: 15

View File

@@ -0,0 +1,28 @@
import {
type DomainSchema,
type Schema,
type Template,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainHost = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: mainHost,
port: 3000,
serviceName: "browserless",
},
];
const envs = [
`BROWERLESS_HOST=${mainHost}`,
`BROWSERLESS_TOKEN=${generatePassword(16)}`,
];
return {
envs,
domains,
};
}

View File

@@ -0,0 +1,199 @@
services:
apps:
image: budibase.docker.scarf.sh/budibase/apps:3.2.25
restart: unless-stopped
networks:
- dokploy-network
environment:
SELF_HOSTED: 1
LOG_LEVEL: info
PORT: 4002
INTERNAL_API_KEY: ${BB_INTERNAL_API_KEY}
API_ENCRYPTION_KEY: ${BB_API_ENCRYPTION_KEY}
JWT_SECRET: ${BB_JWT_SECRET}
MINIO_ACCESS_KEY: ${BB_MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${BB_MINIO_SECRET_KEY}
MINIO_URL: http://minio:9000
REDIS_URL: redis:6379
REDIS_PASSWORD: ${BB_REDIS_PASSWORD}
WORKER_URL: http://worker:4003
COUCH_DB_USERNAME: budibase
COUCH_DB_PASSWORD: ${BB_COUCHDB_PASSWORD}
COUCH_DB_URL: http://budibase:${BB_COUCHDB_PASSWORD}@couchdb:5984
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT:-PRODUCTION}
ENABLE_ANALYTICS: ${ENABLE_ANALYTICS:-true}
BB_ADMIN_USER_EMAIL: ''
BB_ADMIN_USER_PASSWORD: ''
depends_on:
worker:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test:
- CMD
- wget
- '--spider'
- '-qO-'
- 'http://localhost:4002/health'
interval: 15s
timeout: 15s
retries: 5
start_period: 10s
worker:
image: budibase.docker.scarf.sh/budibase/worker:3.2.25
restart: unless-stopped
networks:
- dokploy-network
environment:
SELF_HOSTED: 1
LOG_LEVEL: info
PORT: 4003
CLUSTER_PORT: 10000
INTERNAL_API_KEY: ${BB_INTERNAL_API_KEY}
API_ENCRYPTION_KEY: ${BB_API_ENCRYPTION_KEY}
JWT_SECRET: ${BB_JWT_SECRET}
MINIO_ACCESS_KEY: ${BB_MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${BB_MINIO_SECRET_KEY}
APPS_URL: http://apps:4002
MINIO_URL: http://minio:9000
REDIS_URL: redis:6379
REDIS_PASSWORD: ${BB_REDIS_PASSWORD}
COUCH_DB_USERNAME: budibase
COUCH_DB_PASSWORD: ${BB_COUCHDB_PASSWORD}
COUCH_DB_URL: http://budibase:${BB_COUCHDB_PASSWORD}@couchdb:5984
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT:-PRODUCTION}
ENABLE_ANALYTICS: ${ENABLE_ANALYTICS:-true}
depends_on:
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test:
- CMD
- wget
- '--spider'
- '-qO-'
- 'http://localhost:4003/health'
interval: 15s
timeout: 15s
retries: 5
start_period: 10s
minio:
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
restart: unless-stopped
networks:
- dokploy-network
volumes:
- 'minio_data:/data'
environment:
MINIO_ROOT_USER: ${BB_MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${BB_MINIO_SECRET_KEY}
MINIO_BROWSER: off
command: 'server /data --console-address ":9001"'
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:9000/minio/health/live'
interval: 30s
timeout: 20s
retries: 3
proxy:
image: budibase/proxy:3.2.25
restart: unless-stopped
networks:
- dokploy-network
environment:
PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND: 10
PROXY_RATE_LIMIT_API_PER_SECOND: 20
APPS_UPSTREAM_URL: http://apps:4002
WORKER_UPSTREAM_URL: http://worker:4003
MINIO_UPSTREAM_URL: http://minio:9000
COUCHDB_UPSTREAM_URL: http://couchdb:5984
WATCHTOWER_UPSTREAM_URL: http://watchtower:8080
RESOLVER: 127.0.0.11
depends_on:
minio:
condition: service_healthy
worker:
condition: service_healthy
apps:
condition: service_healthy
couchdb:
condition: service_healthy
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:10000/'
interval: 15s
timeout: 15s
retries: 5
start_period: 10s
couchdb:
image: budibase/couchdb:v3.3.3
restart: unless-stopped
networks:
- dokploy-network
environment:
COUCHDB_USER: budibase
COUCHDB_PASSWORD: ${BB_COUCHDB_PASSWORD}
TARGETBUILD: docker-compose
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://localhost:5984/'
interval: 15s
timeout: 15s
retries: 5
start_period: 10s
volumes:
- 'couchdb3_data:/opt/couchdb/data'
redis:
image: redis:7.2-alpine
networks:
- dokploy-network
restart: unless-stopped
command: 'redis-server --requirepass "${BB_REDIS_PASSWORD}"'
volumes:
- 'redis_data:/data'
healthcheck:
test:
- CMD
- redis-cli
- '-a'
- ${BB_REDIS_PASSWORD}
- ping
interval: 15s
timeout: 15s
retries: 5
start_period: 10s
watchtower:
restart: unless-stopped
networks:
- dokploy-network
image: containrrr/watchtower:1.7.1
volumes:
- '/var/run/docker.sock:/var/run/docker.sock'
command: '--debug --http-api-update bbapps bbworker bbproxy'
environment:
WATCHTOWER_HTTP_API: true
WATCHTOWER_HTTP_API_TOKEN: ${BB_WATCHTOWER_PASSWORD}
WATCHTOWER_CLEANUP: true
labels:
- com.centurylinklabs.watchtower.enable=false
networks:
dokploy-network:
external: true
volumes:
minio_data:
couchdb3_data:
redis_data:

View File

@@ -0,0 +1,45 @@
import {
type DomainSchema,
type Schema,
type Template,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const apiKey = generatePassword(32);
const encryptionKey = generatePassword(32);
const jwtSecret = generatePassword(32);
const couchDbPassword = generatePassword(32);
const redisPassword = generatePassword(32);
const minioAccessKey = generatePassword(32);
const minioSecretKey = generatePassword(32);
const watchtowerPassword = generatePassword(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 10000,
serviceName: "proxy",
},
];
const envs = [
`BB_HOST=${mainDomain}`,
`BB_INTERNAL_API_KEY=${apiKey}`,
`BB_API_ENCRYPTION_KEY=${encryptionKey}`,
`BB_JWT_SECRET=${jwtSecret}`,
`BB_COUCHDB_PASSWORD=${couchDbPassword}`,
`BB_REDIS_PASSWORD=${redisPassword}`,
`BB_WATCHTOWER_PASSWORD=${watchtowerPassword}`,
`BB_MINIO_ACCESS_KEY=${minioAccessKey}`,
`BB_MINIO_SECRET_KEY=${minioSecretKey}`,
];
return {
domains,
envs,
};
}

View File

@@ -0,0 +1,62 @@
version: '3'
services:
plantuml-server:
image: plantuml/plantuml-server
ports:
- "8080"
networks:
- dokploy-network
volumes:
- fonts_volume:/usr/share/fonts/drawio
image-export:
image: jgraph/export-server
ports:
- "8000"
networks:
- dokploy-network
volumes:
- fonts_volume:/usr/share/fonts/drawio
environment:
- DRAWIO_BASE_URL=${DRAWIO_BASE_URL}
drawio:
image: jgraph/drawio:24.7.17
ports:
- "8080"
links:
- plantuml-server:plantuml-server
- image-export:image-export
depends_on:
- plantuml-server
- image-export
networks:
- dokploy-network
environment:
RAWIO_SELF_CONTAINED: 1
DRAWIO_USE_HTTP: 1
PLANTUML_URL: http://plantuml-server:8080/
EXPORT_URL: http://image-export:8000/
DRAWIO_BASE_URL: ${DRAWIO_BASE_URL}
DRAWIO_SERVER_URL: ${DRAWIO_SERVER_URL}
DRAWIO_CSP_HEADER: ${DRAWIO_CSP_HEADER}
DRAWIO_VIEWER_URL: ${DRAWIO_VIEWER_URL}
DRAWIO_LIGHTBOX_URL: ${DRAWIO_LIGHTBOX_URL}
DRAWIO_CONFIG: ${DRAWIO_CONFIG}
DRAWIO_GOOGLE_CLIENT_ID: ${DRAWIO_GOOGLE_CLIENT_ID}
DRAWIO_GOOGLE_APP_ID: ${DRAWIO_GOOGLE_APP_ID}
DRAWIO_GOOGLE_CLIENT_SECRET: ${DRAWIO_GOOGLE_CLIENT_SECRET}
DRAWIO_GOOGLE_VIEWER_CLIENT_ID: ${DRAWIO_GOOGLE_VIEWER_CLIENT_ID}
DRAWIO_GOOGLE_VIEWER_APP_ID: ${DRAWIO_GOOGLE_VIEWER_APP_ID}
DRAWIO_GOOGLE_VIEWER_CLIENT_SECRET: ${DRAWIO_GOOGLE_VIEWER_CLIENT_SECRET}
DRAWIO_MSGRAPH_CLIENT_ID: ${DRAWIO_MSGRAPH_CLIENT_ID}
DRAWIO_MSGRAPH_CLIENT_SECRET: ${DRAWIO_MSGRAPH_CLIENT_SECRET}
DRAWIO_MSGRAPH_TENANT_ID: ${DRAWIO_MSGRAPH_TENANT_ID}
DRAWIO_GITLAB_ID: ${DRAWIO_GITLAB_ID}
DRAWIO_GITLAB_SECRET: ${DRAWIO_GITLAB_SECRET}
DRAWIO_GITLAB_URL: ${DRAWIO_GITLAB_URL}
DRAWIO_CLOUD_CONVERT_APIKEY: ${DRAWIO_CLOUD_CONVERT_APIKEY}
networks:
dokploy-network:
external: true
volumes:
fonts_volume:

View File

@@ -0,0 +1,31 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const secretKeyBase = generateBase64(64);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 8080,
serviceName: "drawio",
},
];
const envs = [
`DRAWIO_HOST=${mainDomain}`,
`DRAWIO_BASE_URL=https://${mainDomain}`,
`DRAWIO_SERVER_URL=https://${mainDomain}/`,
];
return {
envs,
domains,
};
}

View File

@@ -0,0 +1,51 @@
services:
app:
image: kimai/kimai2:apache-2.26.0
restart: unless-stopped
environment:
APP_ENV: prod
DATABASE_URL: mysql://kimai:${KI_MYSQL_PASSWORD:-kimai}@db/kimai
TRUSTED_PROXIES: localhost
APP_SECRET: ${KI_APP_SECRET}
MAILER_FROM: ${KI_MAILER_FROM:-admin@kimai.local}
MAILER_URL: ${KI_MAILER_URL:-null://null}
ADMINMAIL: ${KI_ADMINMAIL:-admin@kimai.local}
ADMINPASS: ${KI_ADMINPASS}
volumes:
- kimai-data:/opt/kimai/var
depends_on:
db:
condition: service_healthy
networks:
- dokploy-network
db:
image: mariadb:10.11
restart: unless-stopped
environment:
- MYSQL_DATABASE=kimai
- MYSQL_USER=kimai
- MYSQL_PASSWORD=${KI_MYSQL_PASSWORD}
- MYSQL_ROOT_PASSWORD=${KI_MYSQL_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --innodb-buffer-pool-size=256M
- --innodb-flush-log-at-trx-commit=2
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "kimai", "-p${KI_MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- dokploy-network
networks:
dokploy-network:
external: true
volumes:
kimai-data:
mysql-data:

View File

@@ -0,0 +1,37 @@
import {
type DomainSchema,
type Schema,
type Template,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const domain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: domain,
port: 8001,
serviceName: "app",
},
];
const adminPassword = generatePassword(32);
const mysqlPassword = generatePassword(32);
const mysqlRootPassword = generatePassword(32);
const appSecret = generatePassword(32);
const envs = [
`KI_HOST=${domain}`,
"KI_ADMINMAIL=admin@kimai.local",
`KI_ADMINPASS=${adminPassword}`,
`KI_MYSQL_ROOT_PASSWORD=${mysqlRootPassword}`,
`KI_MYSQL_PASSWORD=${mysqlPassword}`,
`KI_APP_SECRET=${appSecret}`,
];
return {
envs,
domains,
};
}

View File

@@ -1,7 +1,7 @@
version: "3.8"
services:
pocketbase:
image: spectado/pocketbase:0.22.12
image: spectado/pocketbase:0.23.3
restart: unless-stopped
volumes:
- /etc/dokploy/templates/${HASH}/data:/pb_data

View File

@@ -107,6 +107,21 @@ export const templates: TemplateData[] = [
tags: ["database"],
load: () => import("./baserow/index").then((m) => m.generate),
},
{
id: "budibase",
name: "Budibase",
version: "3.2.25",
description:
"Budibase is an open-source low-code platform that saves engineers 100s of hours building forms, portals, and approval apps, securely.",
logo: "budibase.svg",
links: {
github: "https://github.com/Budibase/budibase",
website: "https://budibase.com/",
docs: "https://docs.budibase.com/docs/",
},
tags: ["database", "low-code", "nocode", "applications"],
load: () => import("./budibase/index").then((m) => m.generate),
},
{
id: "ghost",
name: "Ghost",
@@ -380,7 +395,7 @@ export const templates: TemplateData[] = [
{
id: "umami",
name: "Umami",
version: "v2.12.1",
version: "v2.14.0",
description:
"Umami is a simple, fast, privacy-focused alternative to Google Analytics.",
logo: "umami.png",
@@ -936,8 +951,8 @@ export const templates: TemplateData[] = [
logo: "ryot.png",
links: {
github: "https://github.com/IgnisDa/ryot",
website: "https://ryot.dev/",
docs: "https://ryot.dev/docs/getting-started",
website: "https://ryot.io/",
docs: "https://docs.ryot.io/",
},
tags: ["media", "tracking", "self-hosted"],
load: () => import("./ryot/index").then((m) => m.generate),
@@ -972,4 +987,64 @@ export const templates: TemplateData[] = [
tags: ["event"],
load: () => import("./ontime/index").then((m) => m.generate),
},
{
id: "triggerdotdev",
name: "Trigger.dev",
version: "v3",
description:
"Trigger is a platform for building event-driven applications.",
logo: "triggerdotdev.svg",
links: {
github: "https://github.com/triggerdotdev/trigger.dev",
website: "https://trigger.dev/",
docs: "https://trigger.dev/docs",
},
tags: ["event-driven", "applications"],
load: () => import("./triggerdotdev/index").then((m) => m.generate),
},
{
id: "browserless",
name: "Browserless",
version: "2.23.0",
description:
"Browserless allows remote clients to connect and execute headless work, all inside of docker. It supports the standard, unforked Puppeteer and Playwright libraries, as well offering REST-based APIs for common actions like data collection, PDF generation and more.",
logo: "browserless.svg",
links: {
github: "https://github.com/browserless/browserless",
website: "https://www.browserless.io/",
docs: "https://docs.browserless.io/",
},
tags: ["browser", "automation"],
load: () => import("./browserless/index").then((m) => m.generate),
},
{
id: "drawio",
name: "draw.io",
version: "24.7.17",
description:
"draw.io is a configurable diagramming/whiteboarding visualization application.",
logo: "drawio.svg",
links: {
github: "https://github.com/jgraph/drawio",
website: "https://draw.io/",
docs: "https://www.drawio.com/doc/",
},
tags: ["drawing", "diagrams"],
load: () => import("./drawio/index").then((m) => m.generate),
},
{
id: "kimai",
name: "Kimai",
version: "2.26.0",
description:
"Kimai is a web-based multi-user time-tracking application. Works great for everyone: freelancers, companies, organizations - everyone can track their times, generate reports, create invoices and do so much more.",
logo: "kimai.svg",
links: {
github: "https://github.com/kimai/kimai",
website: "https://www.kimai.org",
docs: "https://www.kimai.org/documentation",
},
tags: ["invoice", "business", "finance"],
load: () => import("./kimai/index").then((m) => m.generate),
},
];

View File

@@ -0,0 +1,107 @@
x-webapp-env: &webapp-env
LOGIN_ORIGIN: &trigger-url ${TRIGGER_PROTOCOL:-http}://${TRIGGER_DOMAIN:-localhost:3040}
APP_ORIGIN: *trigger-url
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: &trigger-otel ${TRIGGER_PROTOCOL:-http}://${TRIGGER_DOMAIN:-localhost:3040}/otel
ELECTRIC_ORIGIN: http://electric:3000
x-worker-env: &worker-env
PLATFORM_HOST: webapp
PLATFORM_WS_PORT: 3030
SECURE_CONNECTION: "false"
OTEL_EXPORTER_OTLP_ENDPOINT: *trigger-otel
volumes:
postgres-data:
redis-data:
networks:
webapp:
services:
webapp:
image: ghcr.io/triggerdotdev/trigger.dev:${TRIGGER_IMAGE_TAG:-v3}
restart: ${RESTART_POLICY:-unless-stopped}
env_file:
- .env
environment:
<<: *webapp-env
ports:
- 3000
depends_on:
- postgres
- redis
networks:
- webapp
postgres:
image: postgres:${POSTGRES_IMAGE_TAG:-16}
restart: ${RESTART_POLICY:-unless-stopped}
volumes:
- postgres-data:/var/lib/postgresql/data/
env_file:
- .env
networks:
- webapp
ports:
- 5432
command:
- -c
- wal_level=logical
redis:
image: redis:${REDIS_IMAGE_TAG:-7}
restart: ${RESTART_POLICY:-unless-stopped}
volumes:
- redis-data:/data
networks:
- webapp
ports:
- 6379
docker-provider:
image: ghcr.io/triggerdotdev/provider/docker:${TRIGGER_IMAGE_TAG:-v3}
restart: ${RESTART_POLICY:-unless-stopped}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
user: root
networks:
- webapp
depends_on:
- webapp
ports:
- 9020
env_file:
- .env
environment:
<<: *worker-env
PLATFORM_SECRET: $PROVIDER_SECRET
coordinator:
image: ghcr.io/triggerdotdev/coordinator:${TRIGGER_IMAGE_TAG:-v3}
restart: ${RESTART_POLICY:-unless-stopped}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
user: root
networks:
- webapp
depends_on:
- webapp
ports:
- 9020
env_file:
- .env
environment:
<<: *worker-env
PLATFORM_SECRET: $COORDINATOR_SECRET
electric:
image: electricsql/electric:${ELECTRIC_IMAGE_TAG:-latest}
restart: ${RESTART_POLICY:-unless-stopped}
environment:
DATABASE_URL: ${DATABASE_URL}?sslmode=disable
networks:
- webapp
depends_on:
- postgres
ports:
- 3000

View File

@@ -0,0 +1,93 @@
import { Secrets } from "@/components/ui/secrets";
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const triggerDomain = generateRandomDomain(schema);
const magicLinkSecret = generateBase64(16);
const sessionSecret = generateBase64(16);
const encryptionKey = generateBase64(32);
const providerSecret = generateBase64(32);
const coordinatorSecret = generateBase64(32);
const dbPassword = generateBase64(24);
const dbUser = "triggeruser";
const dbName = "triggerdb";
const domains: DomainSchema[] = [
{
host: triggerDomain,
port: 3000,
serviceName: "webapp",
},
];
const envs = [
"NODE_ENV=production",
"RUNTIME_PLATFORM=docker-compose",
"V3_ENABLED=true",
"# Domain configuration",
`TRIGGER_DOMAIN=${triggerDomain}`,
"TRIGGER_PROTOCOL=http",
"# Database configuration with secure credentials",
`POSTGRES_USER=${dbUser}`,
`POSTGRES_PASSWORD=${dbPassword}`,
`POSTGRES_DB=${dbName}`,
`DATABASE_URL=postgresql://${dbUser}:${dbPassword}@postgres:5432/${dbName}`,
"# Secrets",
`MAGIC_LINK_SECRET=${magicLinkSecret}`,
`SESSION_SECRET=${sessionSecret}`,
`ENCRYPTION_KEY=${encryptionKey}`,
`PROVIDER_SECRET=${providerSecret}`,
`COORDINATOR_SECRET=${coordinatorSecret}`,
"# TRIGGER_TELEMETRY_DISABLED=1",
"INTERNAL_OTEL_TRACE_DISABLED=1",
"INTERNAL_OTEL_TRACE_LOGGING_ENABLED=0",
"DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT=300",
"DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT=100",
"DIRECT_URL=${DATABASE_URL}",
"REDIS_HOST=redis",
"REDIS_PORT=6379",
"REDIS_TLS_DISABLED=true",
"# If this is set, emails that are not specified won't be able to log in",
'# WHITELISTED_EMAILS="authorized@yahoo.com|authorized@gmail.com"',
"# Accounts with these emails will become admins when signing up and get access to the admin panel",
'# ADMIN_EMAILS="admin@example.com|another-admin@example.com"',
"# If this is set, your users will be able to log in via GitHub",
"# AUTH_GITHUB_CLIENT_ID=",
"# AUTH_GITHUB_CLIENT_SECRET=",
"# E-mail settings",
"# Ensure the FROM_EMAIL matches what you setup with Resend.com",
"# If these are not set, emails will be printed to the console",
"# FROM_EMAIL=",
"# REPLY_TO_EMAIL=",
"# RESEND_API_KEY=",
"# Worker settings",
"HTTP_SERVER_PORT=9020",
"COORDINATOR_HOST=127.0.0.1",
"COORDINATOR_PORT=${HTTP_SERVER_PORT}",
"# REGISTRY_HOST=${DEPLOY_REGISTRY_HOST}",
"# REGISTRY_NAMESPACE=${DEPLOY_REGISTRY_NAMESPACE}",
];
return {
envs,
domains,
};
}

View File

@@ -1,6 +1,6 @@
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-v2.13.2
image: ghcr.io/umami-software/umami:postgresql-v2.14.0
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"]

View File

@@ -1,23 +1,10 @@
import type { Languages } from "@/lib/languages";
import Cookies from "js-cookie";
const SUPPORTED_LOCALES = [
"en",
"pl",
"ru",
"fr",
"de",
"tr",
"zh-Hant",
"zh-Hans",
"fa",
] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
export default function useLocale() {
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as Locale;
const currentLocale = (Cookies.get("DOKPLOY_LOCALE") ?? "en") as Languages;
const setLocale = (locale: Locale) => {
const setLocale = (locale: Languages) => {
Cookies.set("DOKPLOY_LOCALE", locale, { expires: 365 });
window.location.reload();
};

View File

@@ -5,11 +5,19 @@ export function getLocale(cookies: NextApiRequestCookies) {
return locale;
}
// libs/i18n.js
import { Languages } from "@/lib/languages";
import { serverSideTranslations as originalServerSideTranslations } from "next-i18next/serverSideTranslations";
import nextI18NextConfig from "../next-i18next.config.cjs";
export const serverSideTranslations = (
locale: string,
namespaces = ["common"],
) => originalServerSideTranslations(locale, namespaces, nextI18NextConfig);
) =>
originalServerSideTranslations(locale, namespaces, {
fallbackLng: "en",
keySeparator: false,
i18n: {
defaultLocale: "en",
locales: Object.values(Languages),
localeDetection: false,
},
});

View File

@@ -5,6 +5,7 @@
"scripts": {
"dokploy:setup": "pnpm --filter=dokploy run setup",
"dokploy:dev": "pnpm --filter=dokploy run dev",
"dokploy:dev:turbopack": "pnpm --filter=dokploy run dev-turbopack",
"dokploy:build": "pnpm --filter=dokploy run build",
"dokploy:start": "pnpm --filter=dokploy run start",
"test": "pnpm --filter=dokploy run test",

View File

@@ -22,9 +22,10 @@ import { redirects } from "./redirects";
import { registry } from "./registry";
import { security } from "./security";
import { server } from "./server";
import { applicationStatus } from "./shared";
import { applicationStatus, certificateType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { previewDeployments } from "./preview-deployments";
export const sourceType = pgEnum("sourceType", [
"docker",
@@ -114,6 +115,19 @@ export const applications = pgTable("application", {
.unique(),
description: text("description"),
env: text("env"),
previewEnv: text("previewEnv"),
previewBuildArgs: text("previewBuildArgs"),
previewWildcard: text("previewWildcard"),
previewPort: integer("previewPort").default(3000),
previewHttps: boolean("previewHttps").notNull().default(false),
previewPath: text("previewPath").default("/"),
previewCertificateType: certificateType("certificateType")
.notNull()
.default("none"),
previewLimit: integer("previewLimit").default(3),
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false,
),
buildArgs: text("buildArgs"),
memoryReservation: integer("memoryReservation"),
memoryLimit: integer("memoryLimit"),
@@ -178,6 +192,7 @@ export const applications = pgTable("application", {
.notNull()
.default("idle"),
buildType: buildType("buildType").notNull().default("nixpacks"),
herokuVersion: text("herokuVersion").default("24"),
publishDirectory: text("publishDirectory"),
createdAt: text("createdAt")
.notNull()
@@ -239,6 +254,7 @@ export const applicationsRelations = relations(
fields: [applications.serverId],
references: [server.serverId],
}),
previewDeployments: many(previewDeployments),
}),
);
@@ -348,6 +364,7 @@ const createSchema = createInsertSchema(applications, {
subtitle: z.string().optional(),
dockerImage: z.string().optional(),
username: z.string().optional(),
isPreviewDeploymentsActive: z.boolean().optional(),
password: z.string().optional(),
registryUrl: z.string().optional(),
customGitSSHKeyId: z.string().optional(),
@@ -368,6 +385,7 @@ const createSchema = createInsertSchema(applications, {
"nixpacks",
"static",
]),
herokuVersion: z.string().optional(),
publishDirectory: z.string().optional(),
owner: z.string(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
@@ -378,6 +396,14 @@ const createSchema = createInsertSchema(applications, {
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
previewPort: z.number().optional(),
previewEnv: z.string().optional(),
previewBuildArgs: z.string().optional(),
previewWildcard: z.string().optional(),
previewLimit: z.number().optional(),
previewHttps: z.boolean().optional(),
previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none"]).optional(),
});
export const apiCreateApplication = createSchema.pick({
@@ -408,6 +434,7 @@ export const apiSaveBuildType = createSchema
dockerfile: true,
dockerContextPath: true,
dockerBuildStage: true,
herokuVersion: true,
})
.required()
.merge(createSchema.pick({ publishDirectory: true }));

View File

@@ -1,11 +1,18 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { is, relations } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
pgEnum,
pgTable,
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 { server } from "./server";
import { previewDeployments } from "./preview-deployments";
export const deploymentStatus = pgEnum("deploymentStatus", [
"running",
@@ -32,6 +39,11 @@ export const deployments = pgTable("deployment", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
isPreviewDeployment: boolean("isPreviewDeployment").default(false),
previewDeploymentId: text("previewDeploymentId").references(
(): AnyPgColumn => previewDeployments.previewDeploymentId,
{ onDelete: "cascade" },
),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
@@ -50,6 +62,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.serverId],
references: [server.serverId],
}),
previewDeployment: one(previewDeployments, {
fields: [deployments.previewDeploymentId],
references: [previewDeployments.previewDeploymentId],
}),
}));
const schema = createInsertSchema(deployments, {
@@ -59,6 +75,7 @@ const schema = createInsertSchema(deployments, {
applicationId: z.string(),
composeId: z.string(),
description: z.string().optional(),
previewDeploymentId: z.string(),
});
export const apiCreateDeployment = schema
@@ -68,11 +85,24 @@ export const apiCreateDeployment = schema
logPath: true,
applicationId: true,
description: true,
previewDeploymentId: true,
})
.extend({
applicationId: z.string().min(1),
});
export const apiCreateDeploymentPreview = schema
.pick({
title: true,
status: true,
logPath: true,
description: true,
previewDeploymentId: true,
})
.extend({
previewDeploymentId: z.string().min(1),
});
export const apiCreateDeploymentCompose = schema
.pick({
title: true,

View File

@@ -1,5 +1,6 @@
import { relations } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
integer,
pgEnum,
@@ -14,8 +15,13 @@ import { domain } from "../validations/domain";
import { applications } from "./application";
import { compose } from "./compose";
import { certificateType } from "./shared";
import { previewDeployments } from "./preview-deployments";
export const domainType = pgEnum("domainType", ["compose", "application"]);
export const domainType = pgEnum("domainType", [
"compose",
"application",
"preview",
]);
export const domains = pgTable("domain", {
domainId: text("domainId")
@@ -39,6 +45,10 @@ export const domains = pgTable("domain", {
() => applications.applicationId,
{ onDelete: "cascade" },
),
previewDeploymentId: text("previewDeploymentId").references(
(): AnyPgColumn => previewDeployments.previewDeploymentId,
{ onDelete: "cascade" },
),
certificateType: certificateType("certificateType").notNull().default("none"),
});
@@ -51,6 +61,10 @@ export const domainsRelations = relations(domains, ({ one }) => ({
fields: [domains.composeId],
references: [compose.composeId],
}),
previewDeployment: one(previewDeployments, {
fields: [domains.previewDeploymentId],
references: [previewDeployments.previewDeploymentId],
}),
}));
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
@@ -65,6 +79,7 @@ export const apiCreateDomain = createSchema.pick({
composeId: true,
serviceName: true,
domainType: true,
previewDeploymentId: true,
});
export const apiFindDomain = createSchema

View File

@@ -29,3 +29,4 @@ export * from "./github";
export * from "./gitlab";
export * from "./server";
export * from "./utils";
export * from "./preview-deployments";

View File

@@ -0,0 +1,74 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { applications } from "./application";
import { domains } from "./domain";
import { deployments } from "./deployment";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { generateAppName } from "./utils";
import { applicationStatus } from "./shared";
export const previewDeployments = pgTable("preview_deployments", {
previewDeploymentId: text("previewDeploymentId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
branch: text("branch").notNull(),
pullRequestId: text("pullRequestId").notNull(),
pullRequestNumber: text("pullRequestNumber").notNull(),
pullRequestURL: text("pullRequestURL").notNull(),
pullRequestTitle: text("pullRequestTitle").notNull(),
pullRequestCommentId: text("pullRequestCommentId").notNull(),
previewStatus: applicationStatus("previewStatus").notNull().default("idle"),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("preview"))
.unique(),
applicationId: text("applicationId")
.notNull()
.references(() => applications.applicationId, {
onDelete: "cascade",
}),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade",
}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
expiresAt: text("expiresAt"),
});
export const previewDeploymentsRelations = relations(
previewDeployments,
({ one, many }) => ({
deployments: many(deployments),
domain: one(domains, {
fields: [previewDeployments.domainId],
references: [domains.domainId],
}),
application: one(applications, {
fields: [previewDeployments.applicationId],
references: [applications.applicationId],
}),
}),
);
export const createSchema = createInsertSchema(previewDeployments, {
applicationId: z.string(),
});
export const apiCreatePreviewDeployment = createSchema
.pick({
applicationId: true,
domainId: true,
branch: true,
pullRequestId: true,
pullRequestNumber: true,
pullRequestURL: true,
pullRequestTitle: true,
})
.extend({
applicationId: z.string().min(1),
// deploymentId: z.string().min(1),
});

View File

@@ -20,6 +20,7 @@ export * from "./services/mount";
export * from "./services/certificate";
export * from "./services/redirect";
export * from "./services/security";
export * from "./services/preview-deployment";
export * from "./services/port";
export * from "./services/redis";
export * from "./services/compose";
@@ -40,6 +41,7 @@ export * from "./setup/redis-setup";
export * from "./setup/server-setup";
export * from "./setup/setup";
export * from "./setup/traefik-setup";
export * from "./setup/server-validate";
export * from "./utils/backups/index";
export * from "./utils/backups/mariadb";

View File

@@ -28,6 +28,7 @@ import {
getCustomGitCloneCommand,
} from "@dokploy/server/utils/providers/git";
import {
authGithub,
cloneGithubRepository,
getGithubCloneCommand,
} from "@dokploy/server/utils/providers/github";
@@ -40,8 +41,23 @@ import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
import { createDeployment, updateDeploymentStatus } from "./deployment";
import {
createDeployment,
createDeploymentPreview,
updateDeploymentStatus,
} from "./deployment";
import { validUniqueServerAppName } from "./project";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
} from "./preview-deployment";
import {
createPreviewDeploymentComment,
getIssueComment,
issueCommentExists,
updateIssueComment,
} from "./github";
import { type Domain, getDomainHost } from "./domain";
export type Application = typeof applications.$inferSelect;
export const createApplication = async (
@@ -100,6 +116,7 @@ export const findApplicationById = async (applicationId: string) => {
github: true,
bitbucket: true,
server: true,
previewDeployments: true,
},
});
if (!application) {
@@ -168,7 +185,10 @@ export const deployApplication = async ({
try {
if (application.sourceType === "github") {
await cloneGithubRepository(application, deployment.logPath);
await cloneGithubRepository({
...application,
logPath: deployment.logPath,
});
await buildApplication(application, deployment.logPath);
} else if (application.sourceType === "gitlab") {
await cloneGitlabRepository(application, deployment.logPath);
@@ -276,7 +296,11 @@ export const deployRemoteApplication = async ({
if (application.serverId) {
let command = "set -e;";
if (application.sourceType === "github") {
command += await getGithubCloneCommand(application, deployment.logPath);
command += await getGithubCloneCommand({
...application,
serverId: application.serverId,
logPath: deployment.logPath,
});
} else if (application.sourceType === "gitlab") {
command += await getGitlabCloneCommand(application, deployment.logPath);
} else if (application.sourceType === "bitbucket") {
@@ -348,6 +372,225 @@ export const deployRemoteApplication = async ({
return true;
};
export const deployPreviewApplication = async ({
applicationId,
titleLog = "Preview Deployment",
descriptionLog = "",
previewDeploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
previewDeploymentId: string;
}) => {
const application = await findApplicationById(applicationId);
const deployment = await createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
});
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
await updatePreviewDeployment(previewDeploymentId, {
createdAt: new Date().toISOString(),
});
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
const issueParams = {
owner: application?.owner || "",
repository: application?.repository || "",
issue_number: previewDeployment.pullRequestNumber,
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
githubId: application?.githubId || "",
};
try {
const commentExists = await issueCommentExists({
...issueParams,
});
if (!commentExists) {
const result = await createPreviewDeploymentComment({
...issueParams,
previewDomain,
appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Pull request comment not found",
});
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
}
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
application.appName = previewDeployment.appName;
application.env = application.previewEnv;
application.buildArgs = application.previewBuildArgs;
if (application.sourceType === "github") {
await cloneGithubRepository({
...application,
appName: previewDeployment.appName,
branch: previewDeployment.branch,
logPath: deployment.logPath,
});
await buildApplication(application, deployment.logPath);
}
// 4eef09efc46009187d668cf1c25f768d0bde4f91
const successComment = getIssueComment(
application.name,
"success",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${successComment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "done",
});
} catch (error) {
const comment = getIssueComment(application.name, "error", previewDomain);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${comment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "error",
});
throw error;
}
return true;
};
export const deployRemotePreviewApplication = async ({
applicationId,
titleLog = "Preview Deployment",
descriptionLog = "",
previewDeploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
previewDeploymentId: string;
}) => {
const application = await findApplicationById(applicationId);
const deployment = await createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
});
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
await updatePreviewDeployment(previewDeploymentId, {
createdAt: new Date().toISOString(),
});
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
const issueParams = {
owner: application?.owner || "",
repository: application?.repository || "",
issue_number: previewDeployment.pullRequestNumber,
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
githubId: application?.githubId || "",
};
try {
const commentExists = await issueCommentExists({
...issueParams,
});
if (!commentExists) {
const result = await createPreviewDeploymentComment({
...issueParams,
previewDomain,
appName: previewDeployment.appName,
githubId: application?.githubId || "",
previewDeploymentId,
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Pull request comment not found",
});
}
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
}
const buildingComment = getIssueComment(
application.name,
"running",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
});
application.appName = previewDeployment.appName;
application.env = application.previewEnv;
application.buildArgs = application.previewBuildArgs;
if (application.serverId) {
let command = "set -e;";
if (application.sourceType === "github") {
command += await getGithubCloneCommand({
...application,
serverId: application.serverId,
logPath: deployment.logPath,
});
}
command += getBuildCommand(application, deployment.logPath);
await execAsyncRemote(application.serverId, command);
await mechanizeDockerContainer(application);
}
const successComment = getIssueComment(
application.name,
"success",
previewDomain,
);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${successComment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "done",
});
} catch (error) {
const comment = getIssueComment(application.name, "error", previewDomain);
await updateIssueComment({
...issueParams,
body: `### Dokploy Preview Deployment\n\n${comment}`,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
await updatePreviewDeployment(previewDeploymentId, {
previewStatus: "error",
});
throw error;
}
return true;
};
export const rebuildRemoteApplication = async ({
applicationId,
titleLog = "Rebuild deployment",

View File

@@ -214,7 +214,11 @@ export const deployCompose = async ({
try {
if (compose.sourceType === "github") {
await cloneGithubRepository(compose, deployment.logPath, true);
await cloneGithubRepository({
...compose,
logPath: deployment.logPath,
type: "compose",
});
} else if (compose.sourceType === "gitlab") {
await cloneGitlabRepository(compose, deployment.logPath, true);
} else if (compose.sourceType === "bitbucket") {
@@ -314,11 +318,12 @@ export const deployRemoteCompose = async ({
let command = "set -e;";
if (compose.sourceType === "github") {
command += await getGithubCloneCommand(
compose,
deployment.logPath,
true,
);
command += await getGithubCloneCommand({
...compose,
logPath: deployment.logPath,
type: "compose",
serverId: compose.serverId,
});
} else if (compose.sourceType === "gitlab") {
command += await getGitlabCloneCommand(
compose,
@@ -463,6 +468,36 @@ export const removeCompose = async (compose: Compose) => {
return true;
};
export const startCompose = async (composeId: string) => {
const compose = await findComposeById(composeId);
try {
const { COMPOSE_PATH } = paths(!!compose.serverId);
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(
compose.serverId,
`cd ${join(COMPOSE_PATH, compose.appName, "code")} && docker compose -p ${compose.appName} up -d`,
);
} else {
await execAsync(`docker compose -p ${compose.appName} up -d`, {
cwd: join(COMPOSE_PATH, compose.appName, "code"),
});
}
}
await updateCompose(composeId, {
composeStatus: "done",
});
} catch (error) {
await updateCompose(composeId, {
composeStatus: "idle",
});
throw error;
}
return true;
};
export const stopCompose = async (composeId: string) => {
const compose = await findComposeById(composeId);
try {

View File

@@ -5,13 +5,14 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateDeployment,
type apiCreateDeploymentCompose,
type apiCreateDeploymentPreview,
type apiCreateDeploymentServer,
deployments,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq, isNull } from "drizzle-orm";
import {
type Application,
findApplicationById,
@@ -21,6 +22,11 @@ import { type Compose, findComposeById, updateCompose } from "./compose";
import { type Server, findServerById } from "./server";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import {
findPreviewDeploymentById,
type PreviewDeployment,
updatePreviewDeployment,
} from "./preview-deployment";
export type Deployment = typeof deployments.$inferSelect;
@@ -101,6 +107,74 @@ export const createDeployment = async (
}
};
export const createDeploymentPreview = async (
deployment: Omit<
typeof apiCreateDeploymentPreview._type,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const previewDeployment = await findPreviewDeploymentById(
deployment.previewDeploymentId,
);
try {
await removeLastTenPreviewDeploymenById(
deployment.previewDeploymentId,
previewDeployment?.application?.serverId,
);
const appName = `${previewDeployment.appName}`;
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
if (previewDeployment?.application?.serverId) {
const server = await findServerById(
previewDeployment?.application?.serverId,
);
const command = `
mkdir -p ${LOGS_PATH}/${appName};
echo "Initializing deployment" >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
} else {
await fsPromises.mkdir(path.join(LOGS_PATH, appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment");
}
const deploymentCreate = await db
.insert(deployments)
.values({
title: deployment.title || "Deployment",
status: "running",
logPath: logFilePath,
description: deployment.description || "",
previewDeploymentId: deployment.previewDeploymentId,
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
return deploymentCreate[0];
} catch (error) {
await updatePreviewDeployment(deployment.previewDeploymentId, {
previewStatus: "error",
});
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
};
export const createDeploymentCompose = async (
deployment: Omit<
typeof apiCreateDeploymentCompose._type,
@@ -257,6 +331,41 @@ const removeLastTenComposeDeployments = async (
}
};
export const removeLastTenPreviewDeploymenById = async (
previewDeploymentId: string,
serverId: string | null,
) => {
const deploymentList = await db.query.deployments.findMany({
where: eq(deployments.previewDeploymentId, previewDeploymentId),
orderBy: desc(deployments.createdAt),
});
if (deploymentList.length > 10) {
const deploymentsToDelete = deploymentList.slice(10);
if (serverId) {
let command = "";
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
command += `
rm -rf ${logPath};
`;
await removeDeployment(oldDeployment.deploymentId);
}
await execAsyncRemote(serverId, command);
} else {
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (existsSync(logPath)) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
}
};
export const removeDeployments = async (application: Application) => {
const { appName, applicationId } = application;
const { LOGS_PATH } = paths(!!application.serverId);
@@ -269,6 +378,30 @@ export const removeDeployments = async (application: Application) => {
await removeDeploymentsByApplicationId(applicationId);
};
export const removeDeploymentsByPreviewDeploymentId = async (
previewDeployment: PreviewDeployment,
serverId: string | null,
) => {
const { appName } = previewDeployment;
const { LOGS_PATH } = paths(!!serverId);
const logsPath = path.join(LOGS_PATH, appName);
if (serverId) {
await execAsyncRemote(serverId, `rm -rf ${logsPath}`);
} else {
await removeDirectoryIfExistsContent(logsPath);
}
await db
.delete(deployments)
.where(
eq(
deployments.previewDeploymentId,
previewDeployment.previewDeploymentId,
),
)
.returning();
};
export const removeDeploymentsByComposeId = async (compose: Compose) => {
const { appName } = compose;
const { LOGS_PATH } = paths(!!compose.serverId);

View File

@@ -110,7 +110,7 @@ export const getContainersByAppNameMatch = async (
const command =
appType === "docker-compose"
? `${cmd} --filter='label=com.docker.compose.project=${appName}'`
: `${cmd} | grep ${appName}`;
: `${cmd} | grep '^.*Name: ${appName}'`;
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);

View File

@@ -134,3 +134,7 @@ export const removeDomainById = async (domainId: string) => {
return result[0];
};
export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};

View File

@@ -6,6 +6,8 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { authGithub } from "../utils/providers/github";
import { updatePreviewDeployment } from "./preview-deployment";
export type Github = typeof github.$inferSelect;
export const createGithub = async (
@@ -72,3 +74,119 @@ export const updateGithub = async (
.returning()
.then((response) => response[0]);
};
export const getIssueComment = (
appName: string,
status: "success" | "error" | "running" | "initializing",
previewDomain: string,
) => {
let statusMessage = "";
if (status === "success") {
statusMessage = "✅ Done";
} else if (status === "error") {
statusMessage = "❌ Failed";
} else if (status === "initializing") {
statusMessage = "🔄 Building";
} else {
statusMessage = "🔄 Building";
}
const finished = `
| Name | Status | Preview | Updated (UTC) |
|------------|--------------|-------------------------------------|-----------------------|
| ${appName} | ${statusMessage} | [Preview URL](${previewDomain}) | ${new Date().toISOString()} |
`;
return finished;
};
interface CommentExists {
owner: string;
repository: string;
comment_id: number;
githubId: string;
}
export const issueCommentExists = async ({
owner,
repository,
comment_id,
githubId,
}: CommentExists) => {
const github = await findGithubById(githubId);
const octokit = authGithub(github);
try {
await octokit.rest.issues.getComment({
owner: owner || "",
repo: repository || "",
comment_id: comment_id,
});
return true;
} catch (error) {
return false;
}
};
interface Comment {
owner: string;
repository: string;
issue_number: string;
body: string;
comment_id: number;
githubId: string;
}
export const updateIssueComment = async ({
owner,
repository,
issue_number,
body,
comment_id,
githubId,
}: Comment) => {
const github = await findGithubById(githubId);
const octokit = authGithub(github);
await octokit.rest.issues.updateComment({
owner: owner || "",
repo: repository || "",
issue_number: issue_number,
body,
comment_id: comment_id,
});
};
interface CommentCreate {
appName: string;
owner: string;
repository: string;
issue_number: string;
previewDomain: string;
githubId: string;
previewDeploymentId: string;
}
export const createPreviewDeploymentComment = async ({
owner,
repository,
issue_number,
previewDomain,
appName,
githubId,
previewDeploymentId,
}: CommentCreate) => {
const github = await findGithubById(githubId);
const octokit = authGithub(github);
const runningComment = getIssueComment(
appName,
"initializing",
previewDomain,
);
const issue = await octokit.rest.issues.createComment({
owner: owner || "",
repo: repository || "",
issue_number: Number.parseInt(issue_number),
body: `### Dokploy Preview Deployment\n\n${runningComment}`,
});
return await updatePreviewDeployment(previewDeploymentId, {
pullRequestCommentId: `${issue.data.id}`,
}).then((response) => response[0]);
};

View File

@@ -0,0 +1,283 @@
import { db } from "@dokploy/server/db";
import {
type apiCreatePreviewDeployment,
deployments,
previewDeployments,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import { slugify } from "../setup/server-setup";
import { findApplicationById } from "./application";
import { createDomain } from "./domain";
import { generatePassword, generateRandomDomain } from "../templates/utils";
import { manageDomain } from "../utils/traefik/domain";
import {
removeDeployments,
removeDeploymentsByPreviewDeploymentId,
} from "./deployment";
import { removeDirectoryCode } from "../utils/filesystem/directory";
import { removeTraefikConfig } from "../utils/traefik/application";
import { removeService } from "../utils/docker/utils";
import { authGithub } from "../utils/providers/github";
import { getIssueComment, type Github } from "./github";
import { findAdminById } from "./admin";
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
export const findPreviewDeploymentById = async (
previewDeploymentId: string,
) => {
const application = await db.query.previewDeployments.findFirst({
where: eq(previewDeployments.previewDeploymentId, previewDeploymentId),
with: {
domain: true,
application: {
with: {
server: true,
project: true,
},
},
},
});
if (!application) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Preview Deployment not found",
});
}
return application;
};
export const findApplicationByPreview = async (applicationId: string) => {
const application = await db.query.applications.findFirst({
with: {
previewDeployments: {
where: eq(previewDeployments.applicationId, applicationId),
},
project: true,
domains: true,
deployments: true,
mounts: true,
redirects: true,
security: true,
ports: true,
registry: true,
gitlab: true,
github: true,
bitbucket: true,
server: true,
},
});
if (!application) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Applicationnot found",
});
}
return application;
};
export const removePreviewDeployment = async (previewDeploymentId: string) => {
try {
const application = await findApplicationByPreview(previewDeploymentId);
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
const deployment = await db
.delete(previewDeployments)
.where(eq(previewDeployments.previewDeploymentId, previewDeploymentId))
.returning();
application.appName = previewDeployment.appName;
const cleanupOperations = [
async () =>
await removeDeploymentsByPreviewDeploymentId(
previewDeployment,
application.serverId,
),
async () =>
await removeDirectoryCode(application.appName, application.serverId),
async () =>
await removeTraefikConfig(application.appName, application.serverId),
async () =>
await removeService(application?.appName, application.serverId),
];
for (const operation of cleanupOperations) {
try {
await operation();
} catch (error) {}
}
return deployment[0];
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to delete this preview deployment",
});
}
};
// testing-tesoitnmg-ddq0ul-preview-ihl44o
export const updatePreviewDeployment = async (
previewDeploymentId: string,
previewDeploymentData: Partial<PreviewDeployment>,
) => {
const application = await db
.update(previewDeployments)
.set({
...previewDeploymentData,
})
.where(eq(previewDeployments.previewDeploymentId, previewDeploymentId))
.returning();
return application;
};
export const findPreviewDeploymentsByApplicationId = async (
applicationId: string,
) => {
const deploymentsList = await db.query.previewDeployments.findMany({
where: eq(previewDeployments.applicationId, applicationId),
orderBy: desc(previewDeployments.createdAt),
with: {
deployments: {
orderBy: desc(deployments.createdAt),
},
domain: true,
},
});
return deploymentsList;
};
export const createPreviewDeployment = async (
schema: typeof apiCreatePreviewDeployment._type,
) => {
const application = await findApplicationById(schema.applicationId);
const appName = `preview-${application.appName}-${generatePassword(6)}`;
const generateDomain = await generateWildcardDomain(
application.previewWildcard || "*.traefik.me",
appName,
application.server?.ipAddress || "",
application.project.adminId,
);
const octokit = authGithub(application?.github as Github);
const runningComment = getIssueComment(
application.name,
"initializing",
generateDomain,
);
const issue = await octokit.rest.issues.createComment({
owner: application?.owner || "",
repo: application?.repository || "",
issue_number: Number.parseInt(schema.pullRequestNumber),
body: `### Dokploy Preview Deployment\n\n${runningComment}`,
});
const previewDeployment = await db
.insert(previewDeployments)
.values({
...schema,
appName: appName,
pullRequestCommentId: `${issue.data.id}`,
})
.returning()
.then((value) => value[0]);
if (!previewDeployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the preview deployment",
});
}
const newDomain = await createDomain({
host: generateDomain,
path: application.previewPath,
port: application.previewPort,
https: application.previewHttps,
certificateType: application.previewCertificateType,
domainType: "preview",
previewDeploymentId: previewDeployment.previewDeploymentId,
});
application.appName = appName;
await manageDomain(application, newDomain);
await db
.update(previewDeployments)
.set({
domainId: newDomain.domainId,
})
.where(
eq(
previewDeployments.previewDeploymentId,
previewDeployment.previewDeploymentId,
),
);
return previewDeployment;
};
export const findPreviewDeploymentsByPullRequestId = async (
pullRequestId: string,
) => {
const previewDeploymentResult = await db.query.previewDeployments.findMany({
where: eq(previewDeployments.pullRequestId, pullRequestId),
});
return previewDeploymentResult;
};
export const findPreviewDeploymentByApplicationId = async (
applicationId: string,
pullRequestId: string,
) => {
const previewDeploymentResult = await db.query.previewDeployments.findFirst({
where: and(
eq(previewDeployments.applicationId, applicationId),
eq(previewDeployments.pullRequestId, pullRequestId),
),
});
return previewDeploymentResult;
};
const generateWildcardDomain = async (
baseDomain: string,
appName: string,
serverIp: string,
adminId: string,
): Promise<string> => {
if (!baseDomain.startsWith("*.")) {
throw new Error('The base domain must start with "*."');
}
const hash = `${appName}`;
if (baseDomain.includes("traefik.me")) {
let ip = "";
if (process.env.NODE_ENV === "development") {
ip = "127.0.0.1";
}
if (serverIp) {
ip = serverIp;
}
if (!ip) {
const admin = await findAdminById(adminId);
ip = admin?.serverIp || "";
}
const slugIp = ip.replaceAll(".", "-");
return baseDomain.replace(
"*",
`${hash}${slugIp === "" ? "" : `-${slugIp}`}`,
);
}
return baseDomain.replace("*", hash);
};

View File

@@ -74,25 +74,106 @@ const installRequirements = async (serverId: string, logPath: string) => {
client
.once("ready", () => {
const bashCommand = `
set -e;
# Thanks to coolify <3
DOCKER_VERSION=27.0.3
OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
CURRENT_USER=$USER
echo "Installing requirements for: OS: $OS_TYPE"
if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo ❌"
exit
fi
${validatePorts()}
# Check if the OS is manjaro, if so, change it to arch
if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then
OS_TYPE="arch"
fi
# Check if the OS is Asahi Linux, if so, change it to fedora
if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then
OS_TYPE="fedora"
fi
# Check if the OS is popOS, if so, change it to ubuntu
if [ "$OS_TYPE" = "pop" ]; then
OS_TYPE="ubuntu"
fi
# Check if the OS is linuxmint, if so, change it to ubuntu
if [ "$OS_TYPE" = "linuxmint" ]; then
OS_TYPE="ubuntu"
fi
#Check if the OS is zorin, if so, change it to ubuntu
if [ "$OS_TYPE" = "zorin" ]; then
OS_TYPE="ubuntu"
fi
if [ "$OS_TYPE" = "arch" ] || [ "$OS_TYPE" = "archarm" ]; then
OS_VERSION="rolling"
else
OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
fi
case "$OS_TYPE" in
arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine) ;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now."
exit
;;
esac
echo -e "---------------------------------------------"
echo "| Operating System | $OS_TYPE $OS_VERSION"
echo "| Docker | $DOCKER_VERSION"
echo -e "---------------------------------------------\n"
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
command_exists() {
command -v "$@" > /dev/null 2>&1
}
${installUtilities()}
echo -e "2. Validating ports. "
${validatePorts()}
echo -e "3. Installing RClone. "
${installRClone()}
echo -e "4. Installing Docker. "
${installDocker()}
echo -e "5. Setting up Docker Swarm"
${setupSwarm()}
echo -e "6. Setting up Network"
${setupNetwork()}
echo -e "7. Setting up Directories"
${setupMainDirectory()}
${setupDirectories()}
echo -e "8. Setting up Traefik"
${createTraefikConfig()}
echo -e "9. Setting up Middlewares"
${createDefaultMiddlewares()}
echo -e "10. Setting up Traefik Instance"
${createTraefikInstance()}
echo -e "11. Installing Nixpacks"
${installNixpacks()}
echo -e "12. Installing Buildpacks"
${installBuildpacks()}
`;
client.exec(bashCommand, (err, stream) => {
if (err) {
writeStream.write(err);
@@ -204,17 +285,12 @@ const setupNetwork = () => `
echo "Network dokploy-network already exists ✅"
else
# Create the dokploy-network if it doesn't exist
docker network create --driver overlay --attachable dokploy-network
echo "Network created ✅"
fi
`;
const installDocker = () => `
if command_exists docker; then
echo "Docker already installed ✅"
else
echo "Installing Docker ✅"
curl -sSL https://get.docker.com | sh -s -- --version 27.2.0
if docker network create --driver overlay --attachable dokploy-network; then
echo "Network created ✅"
else
echo "Failed to create dokploy-network ❌" >&2
exit 1
fi
fi
`;
@@ -230,6 +306,155 @@ const validatePorts = () => `
fi
`;
const installUtilities = () => `
case "$OS_TYPE" in
arch)
pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true
;;
alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null
apk add curl wget git jq openssl >/dev/null
;;
ubuntu | debian | raspbian)
apt-get update -y >/dev/null
apt-get install -y curl wget git jq openssl >/dev/null
;;
centos | fedora | rhel | ol | rocky | almalinux | amzn)
if [ "$OS_TYPE" = "amzn" ]; then
dnf install -y wget git jq openssl >/dev/null
else
if ! command -v dnf >/dev/null; then
yum install -y dnf >/dev/null
fi
if ! command -v curl >/dev/null; then
dnf install -y curl >/dev/null
fi
dnf install -y wget git jq openssl unzip >/dev/null
fi
;;
sles | opensuse-leap | opensuse-tumbleweed)
zypper refresh >/dev/null
zypper install -y curl wget git jq openssl >/dev/null
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
exit
;;
esac
`;
const installDocker = () => `
# Detect if docker is installed via snap
if [ -x "$(command -v snap)" ]; then
SNAP_DOCKER_INSTALLED=$(snap list docker >/dev/null 2>&1 && echo "true" || echo "false")
if [ "$SNAP_DOCKER_INSTALLED" = "true" ]; then
echo " - Docker is installed via snap."
echo " Please note that Dokploy does not support Docker installed via snap."
echo " Please remove Docker with snap (snap remove docker) and reexecute this script."
exit 1
fi
fi
echo -e "3. Check Docker Installation. "
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
case "$OS_TYPE" in
"almalinux")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
"alpine")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
service docker start >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with apk. Try to install it manually."
echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information."
exit 1
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
exit 1
fi
;;
"amzn")
dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=/usr/local/lib/docker
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with dnf. Try to install it manually."
echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information."
exit 1
fi
;;
"fedora")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1
fi
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
;;
*)
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then
echo "Docker automated installation is not supported on Ubuntu 24.10 (non-LTS release)."
echo "Please install Docker manually."
exit 1
fi
curl -s https://releases.rancher.com/install-docker/$DOCKER_VERSION.sh | sh 2>&1
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version $DOCKER_VERSION 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker installation failed."
echo " Maybe your OS is not supported?"
echo " - Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
fi
if [ "$OS_TYPE" = "rocky" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
fi
if [ "$OS_TYPE" = "centos" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
fi
esac
echo " - Docker installed successfully."
else
echo " - Docker is installed."
fi
`;
const createTraefikConfig = () => {
const config = getDefaultServerTraefikConfig();
@@ -260,7 +485,12 @@ const createDefaultMiddlewares = () => {
};
export const installRClone = () => `
curl https://rclone.org/install.sh | sudo bash
if command_exists rclone; then
echo "RClone already installed ✅"
else
curl https://rclone.org/install.sh | sudo bash
echo "RClone installed successfully ✅"
fi
`;
export const createTraefikInstance = () => {

View File

@@ -0,0 +1,144 @@
import { Client } from "ssh2";
import { findServerById } from "../services/server";
export const validateDocker = () => `
if command_exists docker; then
echo "$(docker --version | awk '{print $3}' | sed 's/,//') true"
else
echo "0.0.0 false"
fi
`;
export const validateRClone = () => `
if command_exists rclone; then
echo "$(rclone --version | head -n 1 | awk '{print $2}') true"
else
echo "0.0.0 false"
fi
`;
export const validateSwarm = () => `
if docker info --format '{{.Swarm.LocalNodeState}}' | grep -q 'active'; then
echo true
else
echo false
fi
`;
export const validateNixpacks = () => `
if command_exists nixpacks; then
echo "$(nixpacks --version | awk '{print $2}') true"
else
echo "0.0.0 false"
fi
`;
export const validateBuildpacks = () => `
if command_exists pack; then
echo "$(pack --version | awk '{print $1}') true"
else
echo "0.0.0 false"
fi
`;
export const validateMainDirectory = () => `
if [ -d "/etc/dokploy" ]; then
echo true
else
echo false
fi
`;
export const validateDokployNetwork = () => `
if docker network ls | grep -q 'dokploy-network'; then
echo true
else
echo false
fi
`;
export const serverValidate = async (serverId: string) => {
const client = new Client();
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error("No SSH Key found");
}
return new Promise<string>((resolve, reject) => {
client
.once("ready", () => {
const bashCommand = `
command_exists() {
command -v "$@" > /dev/null 2>&1
}
dockerVersionEnabled=$(${validateDocker()})
rcloneVersionEnabled=$(${validateRClone()})
nixpacksVersionEnabled=$(${validateNixpacks()})
buildpacksVersionEnabled=$(${validateBuildpacks()})
dockerVersion=$(echo $dockerVersionEnabled | awk '{print $1}')
dockerEnabled=$(echo $dockerVersionEnabled | awk '{print $2}')
rcloneVersion=$(echo $rcloneVersionEnabled | awk '{print $1}')
rcloneEnabled=$(echo $rcloneVersionEnabled | awk '{print $2}')
nixpacksVersion=$(echo $nixpacksVersionEnabled | awk '{print $1}')
nixpacksEnabled=$(echo $nixpacksVersionEnabled | awk '{print $2}')
buildpacksVersion=$(echo $buildpacksVersionEnabled | awk '{print $1}')
buildpacksEnabled=$(echo $buildpacksVersionEnabled | awk '{print $2}')
isDokployNetworkInstalled=$(${validateDokployNetwork()})
isSwarmInstalled=$(${validateSwarm()})
isMainDirectoryInstalled=$(${validateMainDirectory()})
echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled}"
`;
client.exec(bashCommand, (err, stream) => {
if (err) {
reject(err);
return;
}
let output = "";
stream
.on("close", () => {
client.end();
try {
const result = JSON.parse(output.trim());
resolve(result);
} catch (parseError) {
reject(
new Error(
`Failed to parse output: ${parseError instanceof Error ? parseError.message : parseError}`,
),
);
}
})
.on("data", (data: string) => {
output += data;
})
.stderr.on("data", (data) => {});
});
})
.on("error", (err) => {
client.end();
if (err.level === "client-authentication") {
reject(
new Error(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
),
);
} else {
reject(new Error(`SSH connection error: ${err.message}`));
}
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};

View File

@@ -22,7 +22,7 @@ export const buildHeroku = async (
"--path",
buildAppDirectory,
"--builder",
"heroku/builder:24",
`heroku/builder:${application.herokuVersion || "24"}`,
];
for (const env of envVariables) {
@@ -58,7 +58,7 @@ export const getHerokuCommand = (
"--path",
buildAppDirectory,
"--builder",
"heroku/builder:24",
`heroku/builder:${application.herokuVersion || "24"}`,
];
for (const env of envVariables) {

View File

@@ -17,6 +17,7 @@ import { buildHeroku, getHerokuCommand } from "./heroku";
import { buildNixpacks, getNixpacksCommand } from "./nixpacks";
import { buildPaketo, getPaketoCommand } from "./paketo";
import { buildStatic, getStaticCommand } from "./static";
import { nanoid } from "nanoid";
// NIXPACKS codeDirectory = where is the path of the code directory
// HEROKU codeDirectory = where is the path of the code directory
@@ -33,6 +34,7 @@ export type ApplicationNested = InferResultType<
project: true;
}
>;
export const buildApplication = async (
application: ApplicationNested,
logPath: string,

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