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..1045856c2 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,48 @@ 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 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 = 10 * 60 * 1000; // 9 minutes in milliseconds + + // Get the most recent deployment (first in the list since they're sorted by date) + const mostRecentDeployment = deployments[0]; + + if ( + !mostRecentDeployment || + mostRecentDeployment.status !== "running" || + !mostRecentDeployment.startedAt + ) { + return null; + } + + const startTime = new Date(mostRecentDeployment.startedAt).getTime(); + const elapsed = now - startTime; + + return elapsed > NINE_MINUTES ? mostRecentDeployment : null; + }, [isCloud, deployments]); useEffect(() => { setUrl(document.location.origin); }, []); @@ -94,6 +131,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 10 + minutes. Would you like to cancel this deployment? +

+
+ +
+
+ )} {refreshToken && (
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 diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index bddae6887..45fe15c6f 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 @@ -58,7 +59,7 @@ 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"; export const applicationRouter = createTRPCRouter({ @@ -896,4 +897,55 @@ export const applicationRouter = createTRPCRouter({ return updatedApplication; }), + + cancelDeployment: protectedProcedure + .input(apiFindOneApplication) + .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"); + + if (application.deployments[0]) { + await updateDeploymentStatus( + application.deployments[0].deploymentId, + "done", + ); + } + + 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..64f844faf 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, @@ -58,7 +59,7 @@ 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"; @@ -928,4 +929,57 @@ export const composeRouter = createTRPCRouter({ }); } }), + + cancelDeployment: protectedProcedure + .input(apiFindCompose) + .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", + }); + + if (compose.deployments[0]) { + await updateDeploymentStatus( + compose.deployments[0].deploymentId, + "done", + ); + } + + 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; + } +};