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.
This commit is contained in:
Mauricio Siu
2025-09-06 21:53:15 -06:00
parent 3b7d009841
commit 4e69c70697
6 changed files with 274 additions and 4 deletions

View File

@@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator";
import { Inngest } from "inngest"; import { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono"; import { serve as serveInngest } from "inngest/hono";
import { logger } from "./logger.js"; 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"; import { deploy } from "./utils.js";
const app = new Hono(); const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
}, },
], ],
retries: 0, 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" }, { 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) => { app.get("/health", async (c) => {
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });

View File

@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
]); ]);
export type DeployJob = z.infer<typeof deployJobSchema>; export type DeployJob = z.infer<typeof deployJobSchema>;
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<typeof cancelDeploymentSchema>;

View File

@@ -1,6 +1,7 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; 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 { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip"; 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 } = const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation(); api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } = const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation(); 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(""); 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(() => { useEffect(() => {
setUrl(document.location.origin); setUrl(document.location.origin);
}, []); }, []);
@@ -94,6 +125,54 @@ export const ShowDeployments = ({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{stuckDeployment && (type === "application" || type === "compose") && (
<AlertBlock
type="warning"
className="flex-col items-start w-full p-4"
>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium text-sm mb-1">
Build appears to be stuck
</div>
<p className="text-sm">
Hey! Looks like the build has been running for more than 9
minutes. Would you like to cancel this deployment?
</p>
</div>
<Button
variant="destructive"
size="sm"
className="w-fit"
isLoading={
type === "application" ? isCancellingApp : isCancellingCompose
}
onClick={async () => {
try {
if (type === "application") {
await cancelApplicationDeployment({
applicationId: id,
});
} else if (type === "compose") {
await cancelComposeDeployment({
composeId: id,
});
}
toast.success("Deployment cancellation requested");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to cancel deployment",
);
}
}}
>
Cancel Deployment
</Button>
</div>
</AlertBlock>
)}
{refreshToken && ( {refreshToken && (
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2 text-sm">
<span> <span>

View File

@@ -58,9 +58,14 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup"; 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"; import { uploadFileSchema } from "@/utils/schema";
// Schema for canceling deployment
const apiCancelDeployment = z.object({
applicationId: z.string().min(1),
});
export const applicationRouter = createTRPCRouter({ export const applicationRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateApplication) .input(apiCreateApplication)
@@ -896,4 +901,47 @@ export const applicationRouter = createTRPCRouter({
return updatedApplication; 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",
});
}),
}); });

View File

@@ -58,10 +58,15 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; 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 { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
// Schema for canceling deployment
const apiCancelDeployment = z.object({
composeId: z.string().min(1),
});
export const composeRouter = createTRPCRouter({ export const composeRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateCompose) .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",
});
}),
}); });

View File

@@ -23,3 +23,30 @@ export const deploy = async (jobData: DeploymentJob) => {
throw error; 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;
}
};