From 26182f8fe29b5c0adba1de1073ef9c4b9e8fda0e Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 23 Sep 2025 21:50:57 +0200 Subject: [PATCH 1/7] Add canceledAt to the deployment db schema --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 3 +++ 2 files changed, 5 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql diff --git a/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql new file mode 100644 index 0000000000..c279f9dd32 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "canceledAt" TIMESTAMP(3), +ADD COLUMN "canceledReason" TEXT; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c3c26ba507..e7e47b0707 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1772,6 +1772,9 @@ model WorkerDeployment { failedAt DateTime? errorData Json? + canceledAt DateTime? + canceledReason String? + // This is GitMeta type git Json? From bf5f0eb87542d4f2584f33e5054b1872fdb64d69 Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 23 Sep 2025 21:51:45 +0200 Subject: [PATCH 2/7] Expose an api endpoint to cancel deployments --- ...api.v1.deployments.$deploymentId.cancel.ts | 70 ++++++++++++++++ .../app/v3/services/deployment.server.ts | 80 ++++++++++++++++++- .../app/v3/services/failDeployment.server.ts | 8 +- packages/core/src/v3/schemas/api.ts | 6 ++ 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts new file mode 100644 index 0000000000..31de6dba43 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts @@ -0,0 +1,70 @@ +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { CancelDeploymentRequestBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { authenticateRequest } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +const ParamsSchema = z.object({ + deploymentId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid params" }, { status: 400 }); + } + + const authenticationResult = await authenticateRequest(request, { + apiKey: true, + organizationAccessToken: false, + personalAccessToken: false, + }); + + if (!authenticationResult || !authenticationResult.result.ok) { + logger.info("Invalid or missing api key", { url: request.url }); + return json({ error: "Invalid or Missing API key" }, { status: 401 }); + } + + const { environment: authenticatedEnv } = authenticationResult.result; + const { deploymentId } = parsedParams.data; + + const rawBody = await request.json(); + const body = CancelDeploymentRequestBody.safeParse(rawBody); + + if (!body.success) { + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); + } + + const deploymentService = new DeploymentService(); + + return await deploymentService + .cancelDeployment(authenticatedEnv, deploymentId, { + canceledReason: body.data.reason, + }) + .match( + () => { + return new Response(null, { status: 204 }); + }, + (error) => { + switch (error.type) { + case "deployment_not_found": + return json({ error: "Deployment not found" }, { status: 404 }); + case "deployment_cannot_be_cancelled": + return json( + { error: "Deployment is already in a final state and cannot be canceled" }, + { status: 409 } + ); + case "other": + default: + error.type satisfies "other"; + return json({ error: "Internal server error" }, { status: 500 }); + } + } + ); +} diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index d3411d9f8f..59db1591f6 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -1,11 +1,12 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService } from "./baseService.server"; import { errAsync, fromPromise, okAsync } from "neverthrow"; -import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database"; -import { type ExternalBuildData, logger, type GitMeta } from "@trigger.dev/core/v3"; +import { type WorkerDeployment } from "@trigger.dev/database"; +import { logger, type GitMeta } from "@trigger.dev/core/v3"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { env } from "~/env.server"; import { createRemoteImageBuild } from "../remoteImageBuilder.server"; +import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server"; export class DeploymentService extends BaseService { /** @@ -143,4 +144,79 @@ export class DeploymentService extends BaseService { .andThen(extendTimeout) .map(() => undefined); } + + /** + * Cancels a deployment that is not yet in a final state. + * + * Only acts when the current status is not final. Not idempotent. + * + * @param authenticatedEnv The environment which the deployment belongs to. + * @param friendlyId The friendly deployment ID. + * @param data Cancelation reason. + */ + public cancelDeployment( + authenticatedEnv: AuthenticatedEnvironment, + friendlyId: string, + data: Partial> + ) { + const getDeployment = () => + fromPromise( + this._prisma.workerDeployment.findFirst({ + where: { + friendlyId, + environmentId: authenticatedEnv.id, + }, + select: { + status: true, + id: true, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ).andThen((deployment) => { + if (!deployment) { + return errAsync({ type: "deployment_not_found" as const }); + } + return okAsync(deployment); + }); + + const validateDeployment = (deployment: Pick) => { + if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) { + logger.warn("Attempted cancelling deployment in a final state", { + deployment, + }); + return errAsync({ type: "deployment_cannot_be_cancelled" as const }); + } + + return okAsync(deployment); + }; + + const cancelDeployment = (deployment: Pick) => + fromPromise( + this._prisma.workerDeployment.updateMany({ + where: { + id: deployment.id, + status: { + notIn: FINAL_DEPLOYMENT_STATUSES, // status could've changed in the meantime, we're not locking the row + }, + }, + data: { + status: "CANCELED", + canceledAt: new Date(), + canceledReason: data.canceledReason, + }, + }), + (error) => ({ + type: "other" as const, + cause: error, + }) + ); + + return getDeployment() + .andThen(validateDeployment) + .andThen(cancelDeployment) + .map(() => undefined); + } } diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index b486a0f67e..79234b8310 100644 --- a/apps/webapp/app/v3/services/failDeployment.server.ts +++ b/apps/webapp/app/v3/services/failDeployment.server.ts @@ -1,11 +1,11 @@ import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server"; import { BaseService } from "./baseService.server"; import { logger } from "~/services/logger.server"; -import { WorkerDeploymentStatus } from "@trigger.dev/database"; -import { FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { type WorkerDeploymentStatus } from "@trigger.dev/database"; +import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; -const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [ +export const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [ "CANCELED", "DEPLOYED", "FAILED", diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 1a6242b0c1..f8e12f62cc 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -385,6 +385,12 @@ export const ProgressDeploymentRequestBody = z.object({ export type ProgressDeploymentRequestBody = z.infer; +export const CancelDeploymentRequestBody = z.object({ + reason: z.string().max(200, "Reason must be less than 200 characters").optional(), +}); + +export type CancelDeploymentRequestBody = z.infer; + export const ExternalBuildData = z.object({ buildId: z.string(), buildToken: z.string(), From 2be969f75ab6c68c2c6635e91231313bf8ccc4ab Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 23 Sep 2025 21:55:10 +0200 Subject: [PATCH 3/7] Show the canceled status description in the dashboard --- apps/webapp/app/components/runs/v3/DeploymentStatus.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index 1eae4c548a..a2a6d199ab 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -137,6 +137,7 @@ export const deploymentStatuses: WorkerDeploymentStatus[] = [ "DEPLOYED", "FAILED", "TIMED_OUT", + "CANCELED", ]; export function deploymentStatusDescription(status: WorkerDeploymentStatus): string { From 4cfdf8b4ec0d37b854d2840acbf8daf03fbca1cb Mon Sep 17 00:00:00 2001 From: myftija Date: Tue, 23 Sep 2025 22:47:13 +0200 Subject: [PATCH 4/7] Enable canceling deployments from the dashboard --- .../runs/v3/RollbackDeploymentDialog.tsx | 102 ---------- .../route.tsx | 191 +++++++++++++++++- ...deployments.$deploymentShortCode.cancel.ts | 125 ++++++++++++ .../app/v3/services/deployment.server.ts | 8 +- 4 files changed, 310 insertions(+), 116 deletions(-) delete mode 100644 apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx create mode 100644 apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts diff --git a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx deleted file mode 100644 index 50df478098..0000000000 --- a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useNavigation } from "@remix-run/react"; -import { Button } from "~/components/primitives/Buttons"; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, -} from "~/components/primitives/Dialog"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; - -type RollbackDeploymentDialogProps = { - projectId: string; - deploymentShortCode: string; - redirectPath: string; -}; - -export function RollbackDeploymentDialog({ - projectId, - deploymentShortCode, - redirectPath, -}: RollbackDeploymentDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`; - const isLoading = navigation.formAction === formAction; - - return ( - - Rollback to this deployment? - - This deployment will become the default for all future runs. Tasks triggered but not - included in this deploy will remain queued until you roll back to or create a new deployment - with these tasks included. - - - - - -
- -
-
-
- ); -} - -export function PromoteDeploymentDialog({ - projectId, - deploymentShortCode, - redirectPath, -}: RollbackDeploymentDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; - const isLoading = navigation.formAction === formAction; - - return ( - - Promote this deployment? - - This deployment will become the default for all future runs not explicitly tied to a - specific deployment. - - - - - -
- -
-
-
- ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 204ffbc58b..18a643f935 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -1,5 +1,18 @@ -import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid"; -import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react"; +import { + ArrowPathIcon, + ArrowUturnLeftIcon, + BookOpenIcon, + NoSymbolIcon, +} from "@heroicons/react/20/solid"; +import { + Form, + type MetaFunction, + Outlet, + useLocation, + useNavigate, + useNavigation, + useParams, +} from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { CogIcon, GitBranchIcon } from "lucide-react"; import { useEffect } from "react"; @@ -15,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; -import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { + Dialog, + DialogDescription, + DialogContent, + DialogHeader, + DialogTrigger, + DialogFooter, +} from "~/components/primitives/Dialog"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; @@ -39,10 +60,6 @@ import { deploymentStatusDescription, deploymentStatuses, } from "~/components/runs/v3/DeploymentStatus"; -import { - PromoteDeploymentDialog, - RollbackDeploymentDialog, -} from "~/components/runs/v3/RollbackDeploymentDialog"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -63,6 +80,7 @@ import { createSearchParams } from "~/utils/searchParams"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { env } from "~/env.server"; +import { DialogClose } from "@radix-ui/react-dialog"; export const meta: MetaFunction = () => { return [ @@ -395,7 +413,10 @@ function DeploymentActionsCell({ compareDeploymentVersions(deployment.version, currentDeployment.version) === -1; const canBePromoted = canBeMadeCurrent && !canBeRolledBack; - if (!canBeRolledBack && !canBePromoted) { + const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"]; + const canBeCanceled = !finalStatuses.includes(deployment.status); + + if (!canBeRolledBack && !canBePromoted && !canBeCanceled) { return ( {""} @@ -419,7 +440,7 @@ function DeploymentActionsCell({ fullWidth textAlignLeft > - Rollback… + Rollback - Promote… + Promote )} + {canBeCanceled && ( + + + + + + + )} } /> ); } + +type RollbackDeploymentDialogProps = { + projectId: string; + deploymentShortCode: string; + redirectPath: string; +}; + +function RollbackDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`; + const isLoading = navigation.formAction === formAction; + + return ( + + Rollback to this deployment? + + This deployment will become the default for all future runs. Tasks triggered but not + included in this deploy will remain queued until you roll back to or create a new deployment + with these tasks included. + + + + + +
+ +
+
+
+ ); +} + +function PromoteDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const isLoading = navigation.formAction === formAction; + + return ( + + Promote this deployment? + + This deployment will become the default for all future runs not explicitly tied to a + specific deployment. + + + + + +
+ +
+
+
+ ); +} + +function CancelDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const isLoading = navigation.formAction === formAction; + + return ( + + Cancel this deployment? + Canceling a deployment cannot be undone. Are you sure? + + + + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts new file mode 100644 index 0000000000..fb833ea958 --- /dev/null +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -0,0 +1,125 @@ +import { parse } from "@conform-to/zod"; +import { type ActionFunction, json } from "@remix-run/node"; +import { errAsync, fromPromise, okAsync } from "neverthrow"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { DeploymentService } from "~/v3/services/deployment.server"; + +export const promoteSchema = z.object({ + redirectUrl: z.string(), +}); + +const ParamSchema = z.object({ + projectId: z.string(), + deploymentShortCode: z.string(), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { projectId, deploymentShortCode } = ParamSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema: promoteSchema }); + + if (!submission.value) { + return json(submission); + } + + const verifyProjectMembership = fromPromise( + prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + select: { + id: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((project) => { + if (!project) { + return errAsync({ type: "project_not_found" as const }); + } + return okAsync(project); + }); + + const findDeploymentFriendlyId = ({ id }: { id: string }) => + fromPromise( + prisma.workerDeployment.findUnique({ + select: { + friendlyId: true, + projectId: true, + }, + where: { + projectId_shortCode: { + projectId: id, + shortCode: deploymentShortCode, + }, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((deployment) => { + if (!deployment) { + return errAsync({ type: "deployment_not_found" as const }); + } + return okAsync(deployment); + }); + + const deploymentService = new DeploymentService(); + const result = await verifyProjectMembership + .andThen(findDeploymentFriendlyId) + .andThen((deployment) => + deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId) + ); + + if (result.isErr()) { + logger.error( + `Failed to promote deployment: ${result.error.type}`, + result.error.type === "other" + ? { + cause: result.error.cause, + } + : undefined + ); + + switch (result.error.type) { + case "project_not_found": + return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found"); + case "deployment_not_found": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + case "deployment_cannot_be_cancelled": + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Deployment not found" + ); + case "other": + default: + result.error.type satisfies "other"; + return redirectWithErrorMessage( + submission.value.redirectUrl, + request, + "Internal server error" + ); + } + } + + return redirectWithSuccessMessage( + submission.value.redirectUrl, + request, + `Canceled deployment ${deploymentShortCode}.` + ); +}; diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index 59db1591f6..b183c8497f 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -155,16 +155,16 @@ export class DeploymentService extends BaseService { * @param data Cancelation reason. */ public cancelDeployment( - authenticatedEnv: AuthenticatedEnvironment, + authenticatedEnv: Pick, friendlyId: string, - data: Partial> + data?: Partial> ) { const getDeployment = () => fromPromise( this._prisma.workerDeployment.findFirst({ where: { friendlyId, - environmentId: authenticatedEnv.id, + projectId: authenticatedEnv.projectId, }, select: { status: true, @@ -205,7 +205,7 @@ export class DeploymentService extends BaseService { data: { status: "CANCELED", canceledAt: new Date(), - canceledReason: data.canceledReason, + canceledReason: data?.canceledReason, }, }), (error) => ({ From 209242f643d83dab6ccc694182d651214dd2585b Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 24 Sep 2025 10:27:06 +0200 Subject: [PATCH 5/7] Show cancelation reason in the deployment details --- .../v3/DeploymentPresenter.server.ts | 6 ++++ .../route.tsx | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index 21f2b79a81..8387269cb6 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -103,6 +103,9 @@ export class DeploymentPresenter { deployedAt: true, createdAt: true, startedAt: true, + installedAt: true, + canceledAt: true, + canceledReason: true, git: true, promotions: { select: { @@ -147,8 +150,11 @@ export class DeploymentPresenter { status: deployment.status, createdAt: deployment.createdAt, startedAt: deployment.startedAt, + installedAt: deployment.installedAt, builtAt: deployment.builtAt, deployedAt: deployment.deployedAt, + canceledAt: deployment.canceledAt, + canceledReason: deployment.canceledReason, tasks: deployment.worker?.tasks, label: deployment.promotions?.[0]?.label, environment: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 0805338ed7..63c0fc41a6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -158,6 +158,22 @@ export default function Page() { /> + {deployment.canceledAt && ( + + Canceled at + + <> + UTC + + + + )} + {deployment.canceledReason && ( + + Cancelation reason + {deployment.canceledReason} + + )} Tasks {deployment.tasks ? deployment.tasks.length : "–"} @@ -200,6 +216,18 @@ export default function Page() { )} + + Installed at + + {deployment.installedAt ? ( + <> + UTC + + ) : ( + "–" + )} + + Built at From 8e853167dd9e936fc8140475ec9e4ac0951108db Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 24 Sep 2025 10:33:01 +0200 Subject: [PATCH 6/7] Make verifyProjectMembership a function for consistency --- ...deployments.$deploymentShortCode.cancel.ts | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts index fb833ea958..f5d6063168 100644 --- a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -28,29 +28,30 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } - const verifyProjectMembership = fromPromise( - prisma.project.findFirst({ - where: { - id: projectId, - organization: { - members: { - some: { - userId, + const verifyProjectMembership = () => + fromPromise( + prisma.project.findFirst({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, }, }, }, - }, - select: { - id: true, - }, - }), - (error) => ({ type: "other" as const, cause: error }) - ).andThen((project) => { - if (!project) { - return errAsync({ type: "project_not_found" as const }); - } - return okAsync(project); - }); + select: { + id: true, + }, + }), + (error) => ({ type: "other" as const, cause: error }) + ).andThen((project) => { + if (!project) { + return errAsync({ type: "project_not_found" as const }); + } + return okAsync(project); + }); const findDeploymentFriendlyId = ({ id }: { id: string }) => fromPromise( @@ -75,7 +76,7 @@ export const action: ActionFunction = async ({ request, params }) => { }); const deploymentService = new DeploymentService(); - const result = await verifyProjectMembership + const result = await verifyProjectMembership() .andThen(findDeploymentFriendlyId) .andThen((deployment) => deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId) From 45078355762549145bef75ae2d6e5b90cfbf6915 Mon Sep 17 00:00:00 2001 From: myftija Date: Wed, 24 Sep 2025 11:05:16 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Apply=20some=20good=20=F0=9F=90=B0=20sugges?= =?UTF-8?q?tions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../route.tsx | 7 ++----- .../api.v1.deployments.$deploymentId.cancel.ts | 8 +++++--- .../api.v1.deployments.$deploymentId.progress.ts | 6 +++--- ...tId.deployments.$deploymentShortCode.cancel.ts | 15 +++++++++++---- apps/webapp/app/v3/services/deployment.server.ts | 14 +++++++++++++- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 18a643f935..7f1f94dc31 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -594,7 +594,7 @@ function CancelDeploymentDialog({ }: RollbackDeploymentDialogProps) { const navigation = useNavigation(); - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`; const isLoading = navigation.formAction === formAction; return ( @@ -605,10 +605,7 @@ function CancelDeploymentDialog({ -
+