From 4e69c7069720770db15b94a45985a49847d4c565 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 6 Sep 2025 21:53:15 -0600 Subject: [PATCH 1/5] feat(deployment): add cancellation functionality for deployments - Introduced a new endpoint for cancelling deployments, allowing users to cancel both application and compose deployments. - Implemented validation schemas for cancellation requests. - Enhanced the deployment dashboard to provide a cancellation option for stuck deployments. - Updated server-side logic to handle cancellation requests and send appropriate events. --- apps/api/src/index.ts | 55 ++++++++++++- apps/api/src/schema.ts | 13 +++ .../deployments/show-deployments.tsx | 81 ++++++++++++++++++- .../dokploy/server/api/routers/application.ts | 50 +++++++++++- apps/dokploy/server/api/routers/compose.ts | 52 +++++++++++- apps/dokploy/server/utils/deploy.ts | 27 +++++++ 6 files changed, 274 insertions(+), 4 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index aa7358335..8ddb56dec 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator"; import { Inngest } from "inngest"; import { serve as serveInngest } from "inngest/hono"; import { logger } from "./logger.js"; -import { type DeployJob, deployJobSchema } from "./schema.js"; +import { + cancelDeploymentSchema, + type DeployJob, + deployJobSchema, +} from "./schema.js"; import { deploy } from "./utils.js"; const app = new Hono(); @@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction( }, ], retries: 0, + cancelOn: [ + { + event: "deployment/cancelled", + if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId", + timeout: "1h", // Allow cancellation for up to 1 hour + }, + ], }, { event: "deployment/requested" }, @@ -119,6 +130,48 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { } }); +app.post( + "/cancel-deployment", + zValidator("json", cancelDeploymentSchema), + async (c) => { + const data = c.req.valid("json"); + logger.info("Received cancel deployment request", data); + + try { + // Send cancellation event to Inngest + + await inngest.send({ + name: "deployment/cancelled", + data, + }); + + const identifier = + data.applicationType === "application" + ? `applicationId: ${data.applicationId}` + : `composeId: ${data.composeId}`; + + logger.info("Deployment cancellation event sent", { + ...data, + identifier, + }); + + return c.json({ + message: "Deployment cancellation requested", + applicationType: data.applicationType, + }); + } catch (error) { + logger.error("Failed to send deployment cancellation event", error); + return c.json( + { + message: "Failed to cancel deployment", + error: error instanceof Error ? error.message : String(error), + }, + 500, + ); + } + }, +); + app.get("/health", async (c) => { return c.json({ status: "ok" }); }); diff --git a/apps/api/src/schema.ts b/apps/api/src/schema.ts index f87d0ee32..5a4355956 100644 --- a/apps/api/src/schema.ts +++ b/apps/api/src/schema.ts @@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [ ]); export type DeployJob = z.infer; + +export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [ + z.object({ + applicationId: z.string(), + applicationType: z.literal("application"), + }), + z.object({ + composeId: z.string(), + applicationType: z.literal("compose"), + }), +]); + +export type CancelDeploymentJob = z.infer; diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 13694a283..c1c319949 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,6 +1,7 @@ import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; import { StatusTooltip } from "@/components/shared/status-tooltip"; @@ -61,12 +62,42 @@ export const ShowDeployments = ({ }, ); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { mutateAsync: rollback, isLoading: isRollingBack } = api.rollback.rollback.useMutation(); const { mutateAsync: killProcess, isLoading: isKillingProcess } = api.deployment.killProcess.useMutation(); + // Cancel deployment mutations + const { + mutateAsync: cancelApplicationDeployment, + isLoading: isCancellingApp, + } = api.application.cancelDeployment.useMutation(); + const { + mutateAsync: cancelComposeDeployment, + isLoading: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation(); + const [url, setUrl] = React.useState(""); + + // Check for stuck deployments (more than 9 minutes) + const stuckDeployment = useMemo(() => { + if (!isCloud || !deployments || deployments.length === 0) return null; + + const now = Date.now(); + const NINE_MINUTES = 8 * 60 * 1000; // 9 minutes in milliseconds + + return deployments.find((deployment) => { + if (deployment.status !== "running" || !deployment.startedAt) + return false; + + const startTime = new Date(deployment.startedAt).getTime(); + const elapsed = now - startTime; + + return elapsed > NINE_MINUTES; + }); + }, [isCloud, deployments]); useEffect(() => { setUrl(document.location.origin); }, []); @@ -94,6 +125,54 @@ export const ShowDeployments = ({ + {stuckDeployment && (type === "application" || type === "compose") && ( + +
+
+
+ Build appears to be stuck +
+

+ Hey! Looks like the build has been running for more than 9 + minutes. Would you like to cancel this deployment? +

+
+ +
+
+ )} {refreshToken && (
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index bddae6887..dea6a7116 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -58,9 +58,14 @@ import { } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup"; -import { deploy } from "@/server/utils/deploy"; +import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { uploadFileSchema } from "@/utils/schema"; +// Schema for canceling deployment +const apiCancelDeployment = z.object({ + applicationId: z.string().min(1), +}); + export const applicationRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateApplication) @@ -896,4 +901,47 @@ export const applicationRouter = createTRPCRouter({ return updatedApplication; }), + + cancelDeployment: protectedProcedure + .input(apiCancelDeployment) + .mutation(async ({ input, ctx }) => { + const application = await findApplicationById(input.applicationId); + if ( + application.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to cancel this deployment", + }); + } + + if (IS_CLOUD && application.serverId) { + try { + await updateApplicationStatus(input.applicationId, "idle"); + await cancelDeployment({ + applicationId: input.applicationId, + applicationType: "application", + }); + + return { + success: true, + message: "Deployment cancellation requested", + }; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to cancel deployment", + }); + } + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Deployment cancellation only available in cloud version", + }); + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 2f9984183..ecd68c0ff 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -58,10 +58,15 @@ import { } from "@/server/db/schema"; import type { DeploymentJob } from "@/server/queues/queue-types"; import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; -import { deploy } from "@/server/utils/deploy"; +import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +// Schema for canceling deployment +const apiCancelDeployment = z.object({ + composeId: z.string().min(1), +}); + export const composeRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateCompose) @@ -928,4 +933,49 @@ export const composeRouter = createTRPCRouter({ }); } }), + + cancelDeployment: protectedProcedure + .input(apiCancelDeployment) + .mutation(async ({ input, ctx }) => { + const compose = await findComposeById(input.composeId); + if ( + compose.environment.project.organizationId !== + ctx.session.activeOrganizationId + ) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You are not authorized to cancel this deployment", + }); + } + + if (IS_CLOUD && compose.serverId) { + try { + await updateCompose(input.composeId, { + composeStatus: "idle", + }); + await cancelDeployment({ + composeId: input.composeId, + applicationType: "compose", + }); + + return { + success: true, + message: "Deployment cancellation requested", + }; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to cancel deployment", + }); + } + } + + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Deployment cancellation only available in cloud version", + }); + }), }); diff --git a/apps/dokploy/server/utils/deploy.ts b/apps/dokploy/server/utils/deploy.ts index df8fc8041..f4591e3b3 100644 --- a/apps/dokploy/server/utils/deploy.ts +++ b/apps/dokploy/server/utils/deploy.ts @@ -23,3 +23,30 @@ export const deploy = async (jobData: DeploymentJob) => { throw error; } }; + +type CancelDeploymentData = + | { applicationId: string; applicationType: "application" } + | { composeId: string; applicationType: "compose" }; + +export const cancelDeployment = async (cancelData: CancelDeploymentData) => { + try { + const result = await fetch(`${process.env.SERVER_URL}/cancel-deployment`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + body: JSON.stringify(cancelData), + }); + + if (!result.ok) { + const errorData = await result.json().catch(() => ({})); + throw new Error(errorData.message || "Failed to cancel deployment"); + } + + const data = await result.json(); + return data; + } catch (error) { + throw error; + } +}; From 766cd20e90a0fd65251c282263b72f160d707b5d Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:05:39 -0600 Subject: [PATCH 2/5] feat(deployment): improve stuck deployment detection and update status - Enhanced the stuck deployment check to only consider the most recent deployment. - Updated the logic to correctly identify if the most recent deployment has been running for more than 9 minutes. - Added functionality to update the deployment status to "done" upon application and compose cancellation. --- .../deployments/show-deployments.tsx | 24 ++++++++++++------- .../dokploy/server/api/routers/application.ts | 9 +++++++ apps/dokploy/server/api/routers/compose.ts | 9 +++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index c1c319949..dd384e74f 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -81,22 +81,28 @@ export const ShowDeployments = ({ const [url, setUrl] = React.useState(""); - // Check for stuck deployments (more than 9 minutes) + // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment const stuckDeployment = useMemo(() => { if (!isCloud || !deployments || deployments.length === 0) return null; const now = Date.now(); - const NINE_MINUTES = 8 * 60 * 1000; // 9 minutes in milliseconds + const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds - return deployments.find((deployment) => { - if (deployment.status !== "running" || !deployment.startedAt) - return false; + // Get the most recent deployment (first in the list since they're sorted by date) + const mostRecentDeployment = deployments[0]; - const startTime = new Date(deployment.startedAt).getTime(); - const elapsed = now - startTime; + if ( + !mostRecentDeployment || + mostRecentDeployment.status !== "running" || + !mostRecentDeployment.startedAt + ) { + return null; + } - return elapsed > NINE_MINUTES; - }); + const startTime = new Date(mostRecentDeployment.startedAt).getTime(); + const elapsed = now - startTime; + + return elapsed > NINE_MINUTES ? mostRecentDeployment : null; }, [isCloud, deployments]); useEffect(() => { setUrl(document.location.origin); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index dea6a7116..28094cfb9 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -24,6 +24,7 @@ import { unzipDrop, updateApplication, updateApplicationStatus, + updateDeploymentStatus, writeConfig, writeConfigRemote, // uploadFileSchema @@ -919,6 +920,14 @@ export const applicationRouter = createTRPCRouter({ if (IS_CLOUD && application.serverId) { try { await updateApplicationStatus(input.applicationId, "idle"); + + if (application.deployments[0]) { + await updateDeploymentStatus( + application.deployments[0].deploymentId, + "done", + ); + } + await cancelDeployment({ applicationId: input.applicationId, applicationType: "application", diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index ecd68c0ff..080bd917c 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -29,6 +29,7 @@ import { startCompose, stopCompose, updateCompose, + updateDeploymentStatus, } from "@dokploy/server"; import { type CompleteTemplate, @@ -953,6 +954,14 @@ export const composeRouter = createTRPCRouter({ await updateCompose(input.composeId, { composeStatus: "idle", }); + + if (compose.deployments[0]) { + await updateDeploymentStatus( + compose.deployments[0].deploymentId, + "done", + ); + } + await cancelDeployment({ composeId: input.composeId, applicationType: "compose", From d80ada7c00b2d4c7b701f41db33aee23642dd176 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 7 Sep 2025 04:06:08 +0000 Subject: [PATCH 3/5] [autofix.ci] apply automated fixes --- .../mariadb/general/show-external-mariadb-credentials.tsx | 6 +++--- .../mongo/general/show-external-mongo-credentials.tsx | 6 +++--- .../mysql/general/show-external-mysql-credentials.tsx | 6 +++--- .../postgres/general/show-external-postgres-credentials.tsx | 6 +++--- .../redis/general/show-external-redis-credentials.tsx | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx index ea394d5bf..8745db286 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx @@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { External Credentials - In order to make the database reachable through the internet, - you must set a port and ensure that the port is not being used by another - application or database + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database diff --git a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx index 36b3c6802..d30061db5 100644 --- a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx +++ b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx @@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => { External Credentials - In order to make the database reachable through the internet, - you must set a port and ensure that the port is not being used by another - application or database + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database diff --git a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx index 539d86607..dfaa36f6b 100644 --- a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx +++ b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx @@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => { External Credentials - In order to make the database reachable through the internet, - you must set a port and ensure that the port is not being used by another - application or database + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx index 9a821a6e9..46b3772a0 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx @@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { External Credentials - In order to make the database reachable through the internet, - you must set a port and ensure that the port is not being used by another - application or database + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx index bf9b048b9..8edd92389 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx @@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { External Credentials - In order to make the database reachable through the internet, - you must set a port and ensure that the port is not being used by another - application or database + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database From 63e578f13cff0c0c3b377b6b4463a94db97848e3 Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:08:59 -0600 Subject: [PATCH 4/5] refactor(deployment): update cancellation input schemas for applications and composes - Removed the previous cancellation schemas for deployments. - Replaced them with a unified input schema for finding applications and composes during cancellation requests. - Ensured that the cancellation logic now utilizes the new input structure for better consistency. --- apps/dokploy/server/api/routers/application.ts | 7 +------ apps/dokploy/server/api/routers/compose.ts | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 28094cfb9..45fe15c6f 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -62,11 +62,6 @@ import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup"; import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { uploadFileSchema } from "@/utils/schema"; -// Schema for canceling deployment -const apiCancelDeployment = z.object({ - applicationId: z.string().min(1), -}); - export const applicationRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateApplication) @@ -904,7 +899,7 @@ export const applicationRouter = createTRPCRouter({ }), cancelDeployment: protectedProcedure - .input(apiCancelDeployment) + .input(apiFindOneApplication) .mutation(async ({ input, ctx }) => { const application = await findApplicationById(input.applicationId); if ( diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 080bd917c..64f844faf 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -63,11 +63,6 @@ import { cancelDeployment, deploy } from "@/server/utils/deploy"; import { generatePassword } from "@/templates/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; -// Schema for canceling deployment -const apiCancelDeployment = z.object({ - composeId: z.string().min(1), -}); - export const composeRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateCompose) @@ -936,7 +931,7 @@ export const composeRouter = createTRPCRouter({ }), cancelDeployment: protectedProcedure - .input(apiCancelDeployment) + .input(apiFindCompose) .mutation(async ({ input, ctx }) => { const compose = await findComposeById(input.composeId); if ( From 35199138869b941b10a3612b89fed708ae157a8e Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:09:48 -0600 Subject: [PATCH 5/5] fix(deployment): update stuck build notification time from 9 to 10 minutes --- .../dashboard/application/deployments/show-deployments.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index dd384e74f..1045856c2 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -142,7 +142,7 @@ export const ShowDeployments = ({ Build appears to be stuck

- Hey! Looks like the build has been running for more than 9 + Hey! Looks like the build has been running for more than 10 minutes. Would you like to cancel this deployment?