mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: add preview deployments #379
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("createDomainLabels", () => {
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
isPreviewDeployment: false,
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@@ -33,6 +33,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: "",
|
||||
|
||||
@@ -14,6 +14,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 +104,8 @@ const baseDomain: Domain = {
|
||||
composeId: "",
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 1,
|
||||
isPreviewDeployment: false,
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
60
apps/dokploy/drizzle/0047_red_stephen_strange.sql
Normal file
60
apps/dokploy/drizzle/0047_red_stephen_strange.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
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,
|
||||
"appName" text NOT NULL,
|
||||
"applicationId" text NOT NULL,
|
||||
"domainId" text,
|
||||
"deploymentId" 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 "isPreviewDeployment" 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 "preview_deployments" ADD CONSTRAINT "preview_deployments_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") 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 $$;
|
||||
1
apps/dokploy/drizzle/0048_clumsy_matthew_murdock.sql
Normal file
1
apps/dokploy/drizzle/0048_clumsy_matthew_murdock.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "preview_deployments" ADD COLUMN "previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL;
|
||||
3
apps/dokploy/drizzle/0049_useful_mole_man.sql
Normal file
3
apps/dokploy/drizzle/0049_useful_mole_man.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "preview_deployments" DROP CONSTRAINT "preview_deployments_deploymentId_deployment_deploymentId_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "preview_deployments" DROP COLUMN IF EXISTS "deploymentId";
|
||||
4237
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
4237
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4245
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
4245
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4226
apps/dokploy/drizzle/meta/0049_snapshot.json
Normal file
4226
apps/dokploy/drizzle/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -330,6 +330,27 @@
|
||||
"when": 1732851191048,
|
||||
"tag": "0046_purple_sleeper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "6",
|
||||
"when": 1733089956329,
|
||||
"tag": "0047_red_stephen_strange",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "6",
|
||||
"when": 1733091544421,
|
||||
"tag": "0048_clumsy_matthew_murdock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "6",
|
||||
"when": 1733091820570,
|
||||
"tag": "0049_useful_mole_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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),
|
||||
),
|
||||
});
|
||||
|
||||
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),
|
||||
),
|
||||
});
|
||||
|
||||
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 apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
eq(applications.sourceType, "github"),
|
||||
eq(applications.autoDeploy, true),
|
||||
eq(applications.repository, repository),
|
||||
eq(applications.branch, branch),
|
||||
eq(applications.isPreviewDeploymentsActive, true),
|
||||
),
|
||||
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" });
|
||||
}
|
||||
|
||||
// Genera el dominio random
|
||||
// Crea el pull request entity
|
||||
// Crea el deployment entity
|
||||
// Luego lo que sigue...., unicamente github es soportado por ahora3
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
54
apps/dokploy/server/api/routers/preview-deployment.ts
Normal file
54
apps/dokploy/server/api/routers/preview-deployment.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
@@ -18,8 +21,11 @@ export const deploymentWorker = new Worker(
|
||||
"deployments",
|
||||
async (job: Job<DeploymentJob>) => {
|
||||
try {
|
||||
console.log(job.data);
|
||||
|
||||
if (job.data.applicationType === "application") {
|
||||
await updateApplicationStatus(job.data.applicationId, "running");
|
||||
|
||||
if (job.data.server) {
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildRemoteApplication({
|
||||
@@ -83,6 +89,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
@@ -239,6 +253,7 @@ export const applicationsRelations = relations(
|
||||
fields: [applications.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
previewDeployments: many(previewDeployments),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -348,6 +363,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(),
|
||||
@@ -378,6 +394,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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -28,6 +34,7 @@ export const domains = pgTable("domain", {
|
||||
path: text("path").default("/"),
|
||||
serviceName: text("serviceName"),
|
||||
domainType: domainType("domainType").default("application"),
|
||||
isPreviewDeployment: boolean("isPreviewDeployment").default(false), // TODO: remove
|
||||
uniqueConfigKey: serial("uniqueConfigKey"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@@ -39,6 +46,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 +62,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 +80,7 @@ export const apiCreateDomain = createSchema.pick({
|
||||
composeId: true,
|
||||
serviceName: true,
|
||||
domainType: true,
|
||||
previewDeploymentId: true,
|
||||
});
|
||||
|
||||
export const apiFindDomain = createSchema
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./server";
|
||||
export * from "./utils";
|
||||
export * from "./preview-deployments";
|
||||
74
packages/server/src/db/schema/preview-deployments.ts
Normal file
74
packages/server/src/db/schema/preview-deployments.ts
Normal 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),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,18 @@ 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 { getIssueComment, updateIssueComment } from "./github";
|
||||
import { type Domain, getDomainHost } from "./domain";
|
||||
export type Application = typeof applications.$inferSelect;
|
||||
|
||||
export const createApplication = async (
|
||||
@@ -100,6 +111,7 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
github: true,
|
||||
bitbucket: true,
|
||||
server: true,
|
||||
previewDeployments: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
@@ -168,7 +180,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 +291,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 +367,183 @@ 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 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 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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authGithub } from "../utils/providers/github";
|
||||
|
||||
export type Github = typeof github.$inferSelect;
|
||||
export const createGithub = async (
|
||||
@@ -72,3 +73,56 @@ 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 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,
|
||||
});
|
||||
};
|
||||
|
||||
285
packages/server/src/services/preview-deployment.ts
Normal file
285
packages/server/src/services/preview-deployment.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
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;
|
||||
|
||||
console.log(application);
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const buildNixpacks = async (
|
||||
application: ApplicationNested,
|
||||
writeStream: WriteStream,
|
||||
) => {
|
||||
const { env, appName, publishDirectory, serverId } = application;
|
||||
const { env, appName, publishDirectory } = application;
|
||||
|
||||
const buildAppDirectory = getBuildAppDirectory(application);
|
||||
const buildContainerId = `${appName}-${nanoid(10)}`;
|
||||
|
||||
@@ -74,11 +74,22 @@ export type ApplicationWithGithub = InferResultType<
|
||||
>;
|
||||
|
||||
export type ComposeWithGithub = InferResultType<"compose", { github: true }>;
|
||||
export const cloneGithubRepository = async (
|
||||
entity: ApplicationWithGithub | ComposeWithGithub,
|
||||
logPath: string,
|
||||
isCompose = false,
|
||||
) => {
|
||||
|
||||
interface CloneGithubRepository {
|
||||
appName: string;
|
||||
owner: string | null;
|
||||
branch: string | null;
|
||||
githubId: string | null;
|
||||
repository: string | null;
|
||||
logPath: string;
|
||||
type?: "application" | "compose";
|
||||
}
|
||||
export const cloneGithubRepository = async ({
|
||||
logPath,
|
||||
type = "application",
|
||||
...entity
|
||||
}: CloneGithubRepository) => {
|
||||
const isCompose = type === "compose";
|
||||
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths();
|
||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
||||
const { appName, repository, owner, branch, githubId } = entity;
|
||||
@@ -145,13 +156,13 @@ export const cloneGithubRepository = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getGithubCloneCommand = async (
|
||||
entity: ApplicationWithGithub | ComposeWithGithub,
|
||||
logPath: string,
|
||||
isCompose = false,
|
||||
) => {
|
||||
export const getGithubCloneCommand = async ({
|
||||
logPath,
|
||||
type = "application",
|
||||
...entity
|
||||
}: CloneGithubRepository & { serverId: string }) => {
|
||||
const { appName, repository, owner, branch, githubId, serverId } = entity;
|
||||
|
||||
const isCompose = type === "compose";
|
||||
if (!serverId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
|
||||
Reference in New Issue
Block a user