Skip to content

Commit 1763000

Browse files
authored
Merge pull request #2545 from Dokploy/feat/clean-build-queue-on-build
feat(deployment): add cancellation functionality queue for deployments
2 parents 3b7d009 + 3519913 commit 1763000

File tree

11 files changed

+303
-19
lines changed

11 files changed

+303
-19
lines changed

apps/api/src/index.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator";
55
import { Inngest } from "inngest";
66
import { serve as serveInngest } from "inngest/hono";
77
import { logger } from "./logger.js";
8-
import { type DeployJob, deployJobSchema } from "./schema.js";
8+
import {
9+
cancelDeploymentSchema,
10+
type DeployJob,
11+
deployJobSchema,
12+
} from "./schema.js";
913
import { deploy } from "./utils.js";
1014

1115
const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
2731
},
2832
],
2933
retries: 0,
34+
cancelOn: [
35+
{
36+
event: "deployment/cancelled",
37+
if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId",
38+
timeout: "1h", // Allow cancellation for up to 1 hour
39+
},
40+
],
3041
},
3142
{ event: "deployment/requested" },
3243

@@ -119,6 +130,48 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
119130
}
120131
});
121132

133+
app.post(
134+
"/cancel-deployment",
135+
zValidator("json", cancelDeploymentSchema),
136+
async (c) => {
137+
const data = c.req.valid("json");
138+
logger.info("Received cancel deployment request", data);
139+
140+
try {
141+
// Send cancellation event to Inngest
142+
143+
await inngest.send({
144+
name: "deployment/cancelled",
145+
data,
146+
});
147+
148+
const identifier =
149+
data.applicationType === "application"
150+
? `applicationId: ${data.applicationId}`
151+
: `composeId: ${data.composeId}`;
152+
153+
logger.info("Deployment cancellation event sent", {
154+
...data,
155+
identifier,
156+
});
157+
158+
return c.json({
159+
message: "Deployment cancellation requested",
160+
applicationType: data.applicationType,
161+
});
162+
} catch (error) {
163+
logger.error("Failed to send deployment cancellation event", error);
164+
return c.json(
165+
{
166+
message: "Failed to cancel deployment",
167+
error: error instanceof Error ? error.message : String(error),
168+
},
169+
500,
170+
);
171+
}
172+
},
173+
);
174+
122175
app.get("/health", async (c) => {
123176
return c.json({ status: "ok" });
124177
});

apps/api/src/schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
3232
]);
3333

3434
export type DeployJob = z.infer<typeof deployJobSchema>;
35+
36+
export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [
37+
z.object({
38+
applicationId: z.string(),
39+
applicationType: z.literal("application"),
40+
}),
41+
z.object({
42+
composeId: z.string(),
43+
applicationType: z.literal("compose"),
44+
}),
45+
]);
46+
47+
export type CancelDeploymentJob = z.infer<typeof cancelDeploymentSchema>;

apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
2-
import React, { useEffect, useState } from "react";
2+
import React, { useEffect, useMemo, useState } from "react";
33
import { toast } from "sonner";
4+
import { AlertBlock } from "@/components/shared/alert-block";
45
import { DateTooltip } from "@/components/shared/date-tooltip";
56
import { DialogAction } from "@/components/shared/dialog-action";
67
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -61,12 +62,48 @@ export const ShowDeployments = ({
6162
},
6263
);
6364

65+
const { data: isCloud } = api.settings.isCloud.useQuery();
66+
6467
const { mutateAsync: rollback, isLoading: isRollingBack } =
6568
api.rollback.rollback.useMutation();
6669
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
6770
api.deployment.killProcess.useMutation();
6871

72+
// Cancel deployment mutations
73+
const {
74+
mutateAsync: cancelApplicationDeployment,
75+
isLoading: isCancellingApp,
76+
} = api.application.cancelDeployment.useMutation();
77+
const {
78+
mutateAsync: cancelComposeDeployment,
79+
isLoading: isCancellingCompose,
80+
} = api.compose.cancelDeployment.useMutation();
81+
6982
const [url, setUrl] = React.useState("");
83+
84+
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
85+
const stuckDeployment = useMemo(() => {
86+
if (!isCloud || !deployments || deployments.length === 0) return null;
87+
88+
const now = Date.now();
89+
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
90+
91+
// Get the most recent deployment (first in the list since they're sorted by date)
92+
const mostRecentDeployment = deployments[0];
93+
94+
if (
95+
!mostRecentDeployment ||
96+
mostRecentDeployment.status !== "running" ||
97+
!mostRecentDeployment.startedAt
98+
) {
99+
return null;
100+
}
101+
102+
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
103+
const elapsed = now - startTime;
104+
105+
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
106+
}, [isCloud, deployments]);
70107
useEffect(() => {
71108
setUrl(document.location.origin);
72109
}, []);
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
94131
</div>
95132
</CardHeader>
96133
<CardContent className="flex flex-col gap-4">
134+
{stuckDeployment && (type === "application" || type === "compose") && (
135+
<AlertBlock
136+
type="warning"
137+
className="flex-col items-start w-full p-4"
138+
>
139+
<div className="flex flex-col gap-3">
140+
<div>
141+
<div className="font-medium text-sm mb-1">
142+
Build appears to be stuck
143+
</div>
144+
<p className="text-sm">
145+
Hey! Looks like the build has been running for more than 10
146+
minutes. Would you like to cancel this deployment?
147+
</p>
148+
</div>
149+
<Button
150+
variant="destructive"
151+
size="sm"
152+
className="w-fit"
153+
isLoading={
154+
type === "application" ? isCancellingApp : isCancellingCompose
155+
}
156+
onClick={async () => {
157+
try {
158+
if (type === "application") {
159+
await cancelApplicationDeployment({
160+
applicationId: id,
161+
});
162+
} else if (type === "compose") {
163+
await cancelComposeDeployment({
164+
composeId: id,
165+
});
166+
}
167+
toast.success("Deployment cancellation requested");
168+
} catch (error) {
169+
toast.error(
170+
error instanceof Error
171+
? error.message
172+
: "Failed to cancel deployment",
173+
);
174+
}
175+
}}
176+
>
177+
Cancel Deployment
178+
</Button>
179+
</div>
180+
</AlertBlock>
181+
)}
97182
{refreshToken && (
98183
<div className="flex flex-col gap-2 text-sm">
99184
<span>

apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
102102
<CardHeader>
103103
<CardTitle className="text-xl">External Credentials</CardTitle>
104104
<CardDescription>
105-
In order to make the database reachable through the internet,
106-
you must set a port and ensure that the port is not being used by another
107-
application or database
105+
In order to make the database reachable through the internet, you
106+
must set a port and ensure that the port is not being used by
107+
another application or database
108108
</CardDescription>
109109
</CardHeader>
110110
<CardContent className="flex w-full flex-col gap-4">

apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
102102
<CardHeader>
103103
<CardTitle className="text-xl">External Credentials</CardTitle>
104104
<CardDescription>
105-
In order to make the database reachable through the internet,
106-
you must set a port and ensure that the port is not being used by another
107-
application or database
105+
In order to make the database reachable through the internet, you
106+
must set a port and ensure that the port is not being used by
107+
another application or database
108108
</CardDescription>
109109
</CardHeader>
110110
<CardContent className="flex w-full flex-col gap-4">

apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
102102
<CardHeader>
103103
<CardTitle className="text-xl">External Credentials</CardTitle>
104104
<CardDescription>
105-
In order to make the database reachable through the internet,
106-
you must set a port and ensure that the port is not being used by another
107-
application or database
105+
In order to make the database reachable through the internet, you
106+
must set a port and ensure that the port is not being used by
107+
another application or database
108108
</CardDescription>
109109
</CardHeader>
110110
<CardContent className="flex w-full flex-col gap-4">

apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
104104
<CardHeader>
105105
<CardTitle className="text-xl">External Credentials</CardTitle>
106106
<CardDescription>
107-
In order to make the database reachable through the internet,
108-
you must set a port and ensure that the port is not being used by another
109-
application or database
107+
In order to make the database reachable through the internet, you
108+
must set a port and ensure that the port is not being used by
109+
another application or database
110110
</CardDescription>
111111
</CardHeader>
112112
<CardContent className="flex w-full flex-col gap-4">

apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
9696
<CardHeader>
9797
<CardTitle className="text-xl">External Credentials</CardTitle>
9898
<CardDescription>
99-
In order to make the database reachable through the internet,
100-
you must set a port and ensure that the port is not being used by another
101-
application or database
99+
In order to make the database reachable through the internet, you
100+
must set a port and ensure that the port is not being used by
101+
another application or database
102102
</CardDescription>
103103
</CardHeader>
104104
<CardContent className="flex w-full flex-col gap-4">

apps/dokploy/server/api/routers/application.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
unzipDrop,
2525
updateApplication,
2626
updateApplicationStatus,
27+
updateDeploymentStatus,
2728
writeConfig,
2829
writeConfigRemote,
2930
// uploadFileSchema
@@ -58,7 +59,7 @@ import {
5859
} from "@/server/db/schema";
5960
import type { DeploymentJob } from "@/server/queues/queue-types";
6061
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
61-
import { deploy } from "@/server/utils/deploy";
62+
import { cancelDeployment, deploy } from "@/server/utils/deploy";
6263
import { uploadFileSchema } from "@/utils/schema";
6364

6465
export const applicationRouter = createTRPCRouter({
@@ -896,4 +897,55 @@ export const applicationRouter = createTRPCRouter({
896897

897898
return updatedApplication;
898899
}),
900+
901+
cancelDeployment: protectedProcedure
902+
.input(apiFindOneApplication)
903+
.mutation(async ({ input, ctx }) => {
904+
const application = await findApplicationById(input.applicationId);
905+
if (
906+
application.environment.project.organizationId !==
907+
ctx.session.activeOrganizationId
908+
) {
909+
throw new TRPCError({
910+
code: "UNAUTHORIZED",
911+
message: "You are not authorized to cancel this deployment",
912+
});
913+
}
914+
915+
if (IS_CLOUD && application.serverId) {
916+
try {
917+
await updateApplicationStatus(input.applicationId, "idle");
918+
919+
if (application.deployments[0]) {
920+
await updateDeploymentStatus(
921+
application.deployments[0].deploymentId,
922+
"done",
923+
);
924+
}
925+
926+
await cancelDeployment({
927+
applicationId: input.applicationId,
928+
applicationType: "application",
929+
});
930+
931+
return {
932+
success: true,
933+
message: "Deployment cancellation requested",
934+
};
935+
} catch (error) {
936+
throw new TRPCError({
937+
code: "INTERNAL_SERVER_ERROR",
938+
message:
939+
error instanceof Error
940+
? error.message
941+
: "Failed to cancel deployment",
942+
});
943+
}
944+
}
945+
946+
throw new TRPCError({
947+
code: "BAD_REQUEST",
948+
message: "Deployment cancellation only available in cloud version",
949+
});
950+
}),
899951
});

0 commit comments

Comments
 (0)