diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts new file mode 100644 index 0000000000..94c95c950a --- /dev/null +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -0,0 +1,104 @@ +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack"; +import { describe, expect, it } from "vitest"; + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "railpack", + sourceType: "git", + buildPath: "/", + railpackVersion: "0.15.4", + env: "TEST_VAR=one", + cleanCache: false, + environment: { + project: { + env: "", + }, + env: "", + }, + ...overrides, + }) as unknown as ApplicationNested; + +const getSecretsHash = (command: string) => { + const match = command.match(/secrets-hash=([a-f0-9]{64})/); + if (!match?.[1]) { + throw new Error("secrets-hash build arg was not found"); + } + + return match[1]; +}; + +describe("getRailpackCommand", () => { + it("includes secrets-hash without clean cache", () => { + const command = getRailpackCommand(createApplication()); + + expect(command).toContain("--build-arg secrets-hash="); + expect(command).not.toContain("cache-key="); + }); + + it("includes cache-key only when clean cache is enabled", () => { + const command = getRailpackCommand( + createApplication({ + cleanCache: true, + }), + ); + + expect(command).toContain("--build-arg secrets-hash="); + expect(command).toContain("--build-arg cache-key="); + }); + + it("changes secrets-hash when an environment value changes", () => { + const firstCommand = getRailpackCommand( + createApplication({ + env: "TEST_VAR=one", + }), + ); + const secondCommand = getRailpackCommand( + createApplication({ + env: "TEST_VAR=two", + }), + ); + + expect(getSecretsHash(firstCommand)).not.toEqual( + getSecretsHash(secondCommand), + ); + }); + + it("changes secrets-hash when referenced project or environment values change", () => { + const firstCommand = getRailpackCommand( + createApplication({ + env: [ + "PROJECT_VALUE=${{project.SHARED_VALUE}}", + "ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}", + ].join("\n"), + environment: { + project: { + env: "SHARED_VALUE=one", + }, + env: "SHARED_VALUE=alpha", + }, + } as Partial), + ); + const secondCommand = getRailpackCommand( + createApplication({ + env: [ + "PROJECT_VALUE=${{project.SHARED_VALUE}}", + "ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}", + ].join("\n"), + environment: { + project: { + env: "SHARED_VALUE=two", + }, + env: "SHARED_VALUE=beta", + }, + } as Partial), + ); + + expect(getSecretsHash(firstCommand)).not.toEqual( + getSecretsHash(secondCommand), + ); + }); +}); diff --git a/apps/dokploy/__test__/env/rollback-environment.test.ts b/apps/dokploy/__test__/env/rollback-environment.test.ts new file mode 100644 index 0000000000..7d71059e05 --- /dev/null +++ b/apps/dokploy/__test__/env/rollback-environment.test.ts @@ -0,0 +1,108 @@ +import { prepareEnvironmentVariables } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +const projectEnv = ` +ENVIRONMENT=staging +DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db +`; + +const environmentEnv = ` +NODE_ENV=production +POSTGRES_HOST=postgres.internal +POSTGRES_PORT=5432 +REDIS_URL=redis://redis.internal:6379 +`; + +const serviceEnv = ` +NODE_ENV=\${{environment.NODE_ENV}} +REDIS_URL=\${{environment.REDIS_URL}} +PORT=3000 +`; + +/** + * A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps + * the service env, the environment env and the project env captured at deploy time. + */ +const fullContext = { + env: serviceEnv, + environment: { + env: environmentEnv, + project: { + env: projectEnv, + }, + }, +}; + +describe("prepareEnvironmentVariables for application rollback", () => { + it("resolves environment variables from the rollback snapshot", () => { + const result = prepareEnvironmentVariables( + fullContext.env, + fullContext.environment.project.env, + fullContext.environment.env, + ); + + expect(result).toEqual([ + "NODE_ENV=production", + "REDIS_URL=redis://redis.internal:6379", + "PORT=3000", + ]); + }); + + it("resolves project and environment variables together on rollback", () => { + const rollbackEnv = ` +DATABASE_URL=\${{project.DATABASE_URL}} +POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app +ENVIRONMENT=\${{project.ENVIRONMENT}} +`; + + const result = prepareEnvironmentVariables( + rollbackEnv, + projectEnv, + environmentEnv, + ); + + expect(result).toEqual([ + "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", + "POSTGRES_URL=postgres://postgres.internal:5432/app", + "ENVIRONMENT=staging", + ]); + }); + + it("throws when the environment env of the snapshot is not passed", () => { + expect(() => + prepareEnvironmentVariables(fullContext.env, projectEnv), + ).toThrow("Invalid environment variable: environment.NODE_ENV"); + }); + + it("maintains precedence: service > environment > project on rollback", () => { + const conflictingProjectEnv = ` +NODE_ENV=project +API_URL=https://project.api.com +`; + + const conflictingEnvironmentEnv = ` +NODE_ENV=environment +API_URL=https://environment.api.com +`; + + const rollbackEnv = ` +NODE_ENV=service +PROJECT_API_URL=\${{project.API_URL}} +ENVIRONMENT_API_URL=\${{environment.API_URL}} +SELF_REFERENCE=\${{NODE_ENV}} +`; + + const result = prepareEnvironmentVariables( + rollbackEnv, + conflictingProjectEnv, + conflictingEnvironmentEnv, + ); + + expect(result).toEqual([ + "NODE_ENV=service", + "PROJECT_API_URL=https://project.api.com", + "ENVIRONMENT_API_URL=https://environment.api.com", + "SELF_REFERENCE=service", + ]); + }); +}); diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts index 3f58ac439a..844458fd24 100644 --- a/apps/dokploy/__test__/requests/request.test.ts +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -55,6 +55,28 @@ describe("processLogs", () => { expect(result.data).toHaveLength(2); }); + it("should not throw when filtering by hostname and an entry has no RequestHost", () => { + const entryWithoutRequestHost = sampleLogEntry.replace( + /"RequestHost":"[^"]*",/, + "", + ); + + const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`; + + expect(() => + parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"), + ).not.toThrow(); + + const result = parseRawConfig( + mixedEntries, + undefined, + undefined, + "traefik.me", + ); + expect(result.totalCount).toBe(1); + expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me"); + }); + it("should filter out Dokploy dashboard requests", () => { const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`; diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 48c8ec981e..1a531e24cf 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -63,6 +63,9 @@ export const ShowDeployments = ({ const [activeLog, setActiveLog] = useState< RouterOutputs["deployment"]["all"][number] | null >(null); + const [removingDeploymentIds, setRemovingDeploymentIds] = useState< + Set + >(new Set()); const { data: deployments, isPending: isLoadingDeployments } = api.deployment.allByType.useQuery( { @@ -81,7 +84,7 @@ export const ShowDeployments = ({ api.rollback.rollback.useMutation(); const { mutateAsync: killProcess, isPending: isKillingProcess } = api.deployment.killProcess.useMutation(); - const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + const { mutateAsync: removeDeployment } = api.deployment.removeDeployment.useMutation(); // Cancel deployment mutations @@ -408,6 +411,11 @@ export const ShowDeployments = ({ description="Are you sure you want to delete this deployment? This action cannot be undone." type="default" onClick={async () => { + setRemovingDeploymentIds((deploymentIds) => { + const nextDeploymentIds = new Set(deploymentIds); + nextDeploymentIds.add(deployment.deploymentId); + return nextDeploymentIds; + }); try { await removeDeployment({ deploymentId: deployment.deploymentId, @@ -415,13 +423,25 @@ export const ShowDeployments = ({ toast.success("Deployment deleted successfully"); } catch (error) { toast.error("Error deleting deployment"); + } finally { + setRemovingDeploymentIds((deploymentIds) => { + const nextDeploymentIds = new Set( + deploymentIds, + ); + nextDeploymentIds.delete( + deployment.deploymentId, + ); + return nextDeploymentIds; + }); } }} > ))} @@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 3fd0592890..99b997d503 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index a43b257b80..8da817c804 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { className="flex items-center gap-1" > {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); field.onChange(newPaths); }} - /> + > + + ))} @@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 29df617b1d..c842655953 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -438,8 +438,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { { + if (!value) { + return; + } + field.onChange(value); + }} value={field.value} > @@ -479,14 +483,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -538,14 +546,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index 13a1cafc8e..c101d091b0 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 53f5ae03e1..0c13bddb71 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -316,7 +316,7 @@ export const RestoreBackup = ({ Restore Backup - + diff --git a/apps/dokploy/components/ui/badge.tsx b/apps/dokploy/components/ui/badge.tsx index bcd0ffe05e..ef7e697dd4 100644 --- a/apps/dokploy/components/ui/badge.tsx +++ b/apps/dokploy/components/ui/badge.tsx @@ -5,7 +5,7 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + "group/badge inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg:not(.cursor-pointer)]:pointer-events-none [&>svg]:size-3!", { variants: { variant: { diff --git a/apps/dokploy/components/ui/dropdown-menu.tsx b/apps/dokploy/components/ui/dropdown-menu.tsx index bf06bb708f..dcb1520f28 100644 --- a/apps/dokploy/components/ui/dropdown-menu.tsx +++ b/apps/dokploy/components/ui/dropdown-menu.tsx @@ -1,21 +1,37 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; -import type * as React from "react"; +import * as React from "react"; import { markNestedPopupClosed } from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function DropdownMenu({ + open, + defaultOpen, onOpenChange, ...props }: React.ComponentProps) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState( + defaultOpen ?? false, + ); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : uncontrolledOpen; + return ( { - if (!open) { + open={isOpen} + onOpenChange={(nextOpen) => { + // Radix closes menus on window blur, unmounting any dialog rendered inside + if (!nextOpen && !document.hasFocus()) { + return; + } + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + if (!nextOpen) { markNestedPopupClosed(); } - onOpenChange?.(open); + onOpenChange?.(nextOpen); }} {...props} /> diff --git a/apps/dokploy/components/ui/sidebar.tsx b/apps/dokploy/components/ui/sidebar.tsx index 6c9ca131a8..0972949e35 100644 --- a/apps/dokploy/components/ui/sidebar.tsx +++ b/apps/dokploy/components/ui/sidebar.tsx @@ -464,7 +464,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", { variants: { variant: { @@ -473,8 +473,8 @@ const sidebarMenuButtonVariants = cva( "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]", }, size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", + default: "h-8 text-sm group-data-[collapsible=icon]:p-2!", + sm: "h-7 text-xs group-data-[collapsible=icon]:p-2!", lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", }, }, diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index f6b4aeb2d5..cca13dea74 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.29.13", + "version": "v0.29.14", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/apps/dokploy/pages/_error.tsx b/apps/dokploy/pages/_error.tsx index 8fd5107966..4b1eeff4bd 100644 --- a/apps/dokploy/pages/_error.tsx +++ b/apps/dokploy/pages/_error.tsx @@ -5,12 +5,11 @@ import { buttonVariants } from "@/components/ui/button"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; interface Props { - statusCode: number; - error?: Error; + statusCode?: number; } -export default function Custom404({ statusCode, error }: Props) { - const displayStatusCode = statusCode || 400; +export default function ErrorPage({ statusCode }: Props) { + const displayStatusCode = statusCode || 500; const { config: whitelabeling } = useWhitelabelingPublic(); const appName = whitelabeling?.appName || "Dokploy"; const logoUrl = whitelabeling?.logoUrl || undefined; @@ -45,12 +44,6 @@ export default function Custom404({ statusCode, error }: Props) { {errorDescription}

)} - {error && ( -
-

{error.message}

-
- )} -
{ - const statusCode = res ? res.statusCode : err ? err.statusCode : 404; - return { statusCode, error: err }; +ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => { + const statusCode = res ? res.statusCode : err ? (err.statusCode ?? 500) : 404; + return { statusCode }; }; diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index bb6eb06d37..9cf6142d25 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -119,9 +119,11 @@ export default async function handler( } // If webhook doesn't provide image info, we'll use the configured image (old behavior) } else if (sourceType === "github") { - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( application.watchPaths, @@ -150,21 +152,29 @@ export default async function handler( let normalizedCommits: string[] = []; if (provider === "github") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } else if (provider === "gitlab") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } else if (provider === "gitea") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } else if (provider === "soft-serve") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } const shouldDeployPaths = shouldDeploy( @@ -179,9 +189,11 @@ export default async function handler( } else if (sourceType === "gitlab") { const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( application.watchPaths, @@ -225,9 +237,11 @@ export default async function handler( } else if (sourceType === "gitea") { const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( application.watchPaths, diff --git a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts index 85a379eb3e..0d83c10174 100644 --- a/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/compose/[refreshToken].ts @@ -54,9 +54,11 @@ export default async function handler( if (sourceType === "github") { const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( composeResult.watchPaths, @@ -74,9 +76,11 @@ export default async function handler( } } else if (sourceType === "gitlab") { const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( composeResult.watchPaths, @@ -125,17 +129,23 @@ export default async function handler( let normalizedCommits: string[] = []; if (provider === "github") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } else if (provider === "gitlab") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } else if (provider === "gitea") { - normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); } const shouldDeployPaths = shouldDeploy( @@ -150,9 +160,11 @@ export default async function handler( } else if (sourceType === "gitea") { const branchName = extractBranchName(req.headers, req.body); - const normalizedCommits = req.body?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = req.body?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const shouldDeployPaths = shouldDeploy( composeResult.watchPaths, diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts index f03bf786ec..4fc75fc50c 100644 --- a/apps/dokploy/pages/api/deploy/github.ts +++ b/apps/dokploy/pages/api/deploy/github.ts @@ -223,9 +223,11 @@ export default async function handler( const deploymentTitle = extractCommitMessage(req.headers, req.body); const deploymentHash = extractHash(req.headers, req.body); const owner = getGithubRepositoryOwner(githubBody); - const normalizedCommits = githubBody?.commits?.flatMap( - (commit: any) => commit.modified, - ); + const normalizedCommits = githubBody?.commits?.flatMap((commit: any) => [ + ...(commit.added || []), + ...(commit.modified || []), + ...(commit.removed || []), + ]); const apps = await db.query.applications.findMany({ where: and( diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index e1bbedcb9e..b6e2a3ee43 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -4,6 +4,7 @@ import { deleteAllMiddlewares, findApplicationById, findEnvironmentById, + findPreviewDeploymentsByApplicationId, findProjectById, getAccessibleServerIds, getApplicationStats, @@ -16,6 +17,7 @@ import { removeDeployments, removeDirectoryCode, removeMonitoringDirectory, + removePreviewDeployment, removeService, removeTraefikConfig, startService, @@ -234,6 +236,15 @@ export const applicationRouter = createTRPCRouter({ }); } + const previewDeploymentsList = + await findPreviewDeploymentsByApplicationId(input.applicationId); + + for (const previewDeployment of previewDeploymentsList) { + try { + await removePreviewDeployment(previewDeployment.previewDeploymentId); + } catch (_) {} + } + const result = await db .delete(applications) .where(eq(applications.applicationId, input.applicationId)) diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index 9f0c16b1ee..c770717902 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -315,13 +315,17 @@ export const scheduleRouter = createTRPCRouter({ await checkPermission(ctx, { schedule: ["create"] }); } try { - await runCommand(input.scheduleId); + const deployment = await runCommand(input.scheduleId); await audit(ctx, { action: "run", resourceType: "schedule", resourceId: input.scheduleId, }); - return true; + return { + status: deployment.status, + deploymentId: deployment.deploymentId, + logPath: deployment.logPath, + }; } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 50c516bd17..d212474c75 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -269,6 +269,12 @@ @apply bg-background text-foreground; } + /* Cursor pointer on buttons */ + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } + /* Custom scrollbar styling */ ::-webkit-scrollbar { width: 0.3125rem; diff --git a/apps/schedules/src/utils.ts b/apps/schedules/src/utils.ts index 7caa106afc..85de85f9a9 100644 --- a/apps/schedules/src/utils.ts +++ b/apps/schedules/src/utils.ts @@ -180,6 +180,9 @@ export const initializeJobs = async () => { where: eq(schedules.enabled, true), with: { application: { + columns: { + applicationId: true, + }, with: { server: true, }, @@ -227,6 +230,9 @@ export const initializeJobs = async () => { where: eq(volumeBackups.enabled, true), with: { application: { + columns: { + applicationId: true, + }, with: { server: true, }, diff --git a/packages/server/src/db/schema/rollbacks.ts b/packages/server/src/db/schema/rollbacks.ts index ec27d683f8..5e3d407768 100644 --- a/packages/server/src/db/schema/rollbacks.ts +++ b/packages/server/src/db/schema/rollbacks.ts @@ -1,4 +1,5 @@ import type { Application } from "@dokploy/server/services/application"; +import type { Environment } from "@dokploy/server/services/environment"; import type { Mount } from "@dokploy/server/services/mount"; import type { Port } from "@dokploy/server/services/port"; import type { Project } from "@dokploy/server/services/project"; @@ -27,7 +28,7 @@ export const rollbacks = pgTable("rollback", { .$defaultFn(() => new Date().toISOString()), fullContext: jsonb("fullContext").$type< Application & { - environment: { + environment: Environment & { project: Project; }; mounts: Mount[]; diff --git a/packages/server/src/services/port.ts b/packages/server/src/services/port.ts index 3ba1b73766..bcc5831a3a 100644 --- a/packages/server/src/services/port.ts +++ b/packages/server/src/services/port.ts @@ -30,12 +30,8 @@ export const finPortById = async (portId: string) => { where: eq(ports.portId, portId), with: { application: { - with: { - environment: { - with: { - project: true, - }, - }, + columns: { + applicationId: true, }, }, }, diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index 20f64259bc..cc3d3dc020 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -17,7 +17,7 @@ import { manageDomain } from "../utils/traefik/domain"; import { findApplicationById } from "./application"; import { removeDeploymentsByPreviewDeploymentId } from "./deployment"; import { createDomain } from "./domain"; -import { type Github, getIssueComment } from "./github"; +import { findGithubById, getIssueComment } from "./github"; import { getWebServerSettings } from "./web-server-settings"; export type PreviewDeployment = typeof previewDeployments.$inferSelect; @@ -142,7 +142,17 @@ export const createPreviewDeployment = async ( org?.ownerId || "", ); - const octokit = authGithub(application?.github as Github); + if (!application.githubId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Github Account not configured correctly", + }); + } + + // `findApplicationById` redacts `githubPrivateKey` from the `github` + // relation, so the provider must be refetched to authenticate. + const githubProvider = await findGithubById(application.githubId); + const octokit = authGithub(githubProvider); const runningComment = getIssueComment( application.name, diff --git a/packages/server/src/services/rollbacks.ts b/packages/server/src/services/rollbacks.ts index 97fa4b1c5f..b3ccc5a5f8 100644 --- a/packages/server/src/services/rollbacks.ts +++ b/packages/server/src/services/rollbacks.ts @@ -19,6 +19,7 @@ import { execAsync, execAsyncRemote } from "../utils/process/execAsync"; import { getRemoteDocker } from "../utils/servers/remote-docker"; import { type Application, findApplicationById } from "./application"; import { findDeploymentById } from "./deployment"; +import type { Environment } from "./environment"; import type { Mount } from "./mount"; import type { Port } from "./port"; import type { Project } from "./project"; @@ -105,19 +106,7 @@ export const findRollbackById = async (rollbackId: string) => { const result = await db.query.rollbacks.findFirst({ where: eq(rollbacks.rollbackId, rollbackId), with: { - deployment: { - with: { - application: { - with: { - environment: { - with: { - project: true, - }, - }, - }, - }, - }, - }, + deployment: true, }, }); @@ -213,7 +202,7 @@ const rollbackApplication = async ( image: string, serverId?: string | null, fullContext?: Application & { - environment: { + environment: Environment & { project: Project; }; mounts: Mount[]; @@ -275,6 +264,7 @@ const rollbackApplication = async ( const envVariables = prepareEnvironmentVariables( env, fullContext.environment.project.env, + fullContext.environment.env, ); let rollbackImage = image; diff --git a/packages/server/src/services/schedule.ts b/packages/server/src/services/schedule.ts index c94973e024..59b75cc0da 100644 --- a/packages/server/src/services/schedule.ts +++ b/packages/server/src/services/schedule.ts @@ -81,6 +81,11 @@ export const findScheduleById = async (scheduleId: string) => { where: eq(schedules.scheduleId, scheduleId), with: { application: { + columns: { + applicationId: true, + appName: true, + serverId: true, + }, with: { environment: { with: { diff --git a/packages/server/src/services/volume-backups.ts b/packages/server/src/services/volume-backups.ts index abd29df1f4..359ccf5f0d 100644 --- a/packages/server/src/services/volume-backups.ts +++ b/packages/server/src/services/volume-backups.ts @@ -13,6 +13,11 @@ export const findVolumeBackupById = async (volumeBackupId: string) => { where: eq(volumeBackups.volumeBackupId, volumeBackupId), with: { application: { + columns: { + applicationId: true, + appName: true, + serverId: true, + }, with: { environment: { with: { diff --git a/packages/server/src/utils/access-log/utils.ts b/packages/server/src/utils/access-log/utils.ts index 9b472ee27d..ddaef3ec42 100644 --- a/packages/server/src/utils/access-log/utils.ts +++ b/packages/server/src/utils/access-log/utils.ts @@ -120,7 +120,7 @@ export function parseRawConfig( if (search) { parsedLogs = parsedLogs.filter((log) => - log.RequestHost.toLowerCase().includes(search.toLowerCase()), + (log.RequestHost ?? "").toLowerCase().includes(search.toLowerCase()), ); } diff --git a/packages/server/src/utils/builders/railpack.ts b/packages/server/src/utils/builders/railpack.ts index b12296a566..b393f44603 100644 --- a/packages/server/src/utils/builders/railpack.ts +++ b/packages/server/src/utils/builders/railpack.ts @@ -53,14 +53,9 @@ export const getRailpackCommand = (application: ApplicationNested) => { "build", "--builder", builderName, - ...(cacheKey - ? [ - "--build-arg", - `secrets-hash=${secretsHash}`, - "--build-arg", - `cache-key=${cacheKey}`, - ] - : []), + "--build-arg", + `secrets-hash=${secretsHash}`, + ...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []), "--build-arg", `BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`, "-f", diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index 8107839d8b..e81f9a5823 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -55,25 +55,46 @@ export const runCommand = async (scheduleId: string) => { description: "Schedule", }); - if (scheduleType === "application" || scheduleType === "compose") { - let containerId = ""; - let serverId = ""; - if (scheduleType === "application" && application) { - const container = await getServiceContainer( - application.appName, - application.serverId, - ); - containerId = container?.Id || ""; - serverId = application.serverId || ""; - } - if (scheduleType === "compose" && compose) { - const container = await getComposeContainer(compose, serviceName || ""); - containerId = container?.Id || ""; - serverId = compose.serverId || ""; - } + try { + if (scheduleType === "application" || scheduleType === "compose") { + let containerId = ""; + let serverId = ""; + if (scheduleType === "application" && application) { + const container = await getServiceContainer( + application.appName, + application.serverId, + ); + containerId = container?.Id || ""; + serverId = application.serverId || ""; + } + if (scheduleType === "compose" && compose) { + const container = await getComposeContainer(compose, serviceName || ""); + containerId = container?.Id || ""; + serverId = compose.serverId || ""; + } + + if (!containerId) { + const target = + scheduleType === "compose" + ? `service '${serviceName}' of compose '${compose?.name}'` + : `application '${application?.appName}'`; + const message = `Container not found for ${target}, make sure the service is running`; + if (serverId) { + await execAsyncRemote( + serverId, + `echo ${quote([`❌ ${message}`])} >> ${quote([deployment.logPath])}`, + ); + } else { + const writeStream = createWriteStream(deployment.logPath, { + flags: "a", + }); + writeStream.write(`❌ ${message}\n`); + writeStream.end(); + } + throw new Error(message); + } - if (serverId) { - try { + if (serverId) { await execAsyncRemote( serverId, ` @@ -86,47 +107,45 @@ export const runCommand = async (scheduleId: string) => { echo "✅ Command executed successfully" >> ${quote([deployment.logPath])}; `, ); - } catch (error) { - await updateDeploymentStatus(deployment.deploymentId, "error"); - throw error; - } - } else { - const writeStream = createWriteStream(deployment.logPath, { flags: "a" }); + } else { + const writeStream = createWriteStream(deployment.logPath, { + flags: "a", + }); - try { - if (IS_CLOUD) { + try { + if (IS_CLOUD) { + writeStream.write( + "This feature is not available in the cloud version.", + ); + writeStream.end(); + await updateDeploymentStatus(deployment.deploymentId, "error"); + return { ...deployment, status: "error" as const }; + } writeStream.write( - "This feature is not available in the cloud version.", + `docker exec ${containerId} ${shellType} -c ${command}\n`, + ); + await spawnAsync( + "docker", + ["exec", containerId, shellType, "-c", command], + (data) => { + if (writeStream.writable) { + writeStream.write(data); + } + }, ); + + writeStream.write("✅ Command executed successfully\n"); writeStream.end(); - return; + } catch (error) { + writeStream.write("❌ Command failed\n"); + writeStream.write( + error instanceof Error ? error.message : "Unknown error", + ); + writeStream.end(); + throw error; } - writeStream.write( - `docker exec ${containerId} ${shellType} -c ${command}\n`, - ); - await spawnAsync( - "docker", - ["exec", containerId, shellType, "-c", command], - (data) => { - if (writeStream.writable) { - writeStream.write(data); - } - }, - ); - - writeStream.write("✅ Command executed successfully\n"); - } catch (error) { - writeStream.write("❌ Command failed\n"); - writeStream.write( - error instanceof Error ? error.message : "Unknown error", - ); - writeStream.end(); - await updateDeploymentStatus(deployment.deploymentId, "error"); - throw error; } - } - } else if (scheduleType === "dokploy-server") { - try { + } else if (scheduleType === "dokploy-server") { const writeStream = createWriteStream(deployment.logPath, { flags: "a" }); const { SCHEDULES_PATH } = paths(); const fullPath = path.join(SCHEDULES_PATH, appName || ""); @@ -151,18 +170,13 @@ export const runCommand = async (scheduleId: string) => { cwd: fullPath, }, ); - } catch (error) { - await updateDeploymentStatus(deployment.deploymentId, "error"); - throw error; - } - } else if (scheduleType === "server") { - try { + } else if (scheduleType === "server") { const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); const command = ` set -e echo "Running script" >> ${deployment.logPath}; - bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { + bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { echo "❌ Command failed" >> ${deployment.logPath}; exit 1; } @@ -177,10 +191,11 @@ export const runCommand = async (scheduleId: string) => { }); } }); - } catch (error) { - await updateDeploymentStatus(deployment.deploymentId, "error"); - throw error; } + await updateDeploymentStatus(deployment.deploymentId, "done"); + return { ...deployment, status: "done" as const }; + } catch { + await updateDeploymentStatus(deployment.deploymentId, "error"); + return { ...deployment, status: "error" as const }; } - await updateDeploymentStatus(deployment.deploymentId, "done"); };