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] 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 aa735833..8ddb56de 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 f87d0ee3..5a435595 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 13694a28..c1c31994 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 bddae688..dea6a711 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 2f998418..ecd68c0f 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 df8fc804..f4591e3b 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; + } +};