From 7fc7718bc097e2f2f8bb998fa94d91e83ac75b8a Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Fri, 11 Sep 2026 19:42:00 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20visual=20deployment=20queue=20=E2=80=94?= =?UTF-8?q?=20show=20queued=20state=20in=20deployment=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "queued" status to deploymentStatus, applicationStatus enums - Create deployment records at enqueue time for immediate UI visibility - Show blue dot for queued deployments, loading state on deploy buttons - Cancel queued deployments cleanly with proper DB + queue cleanup - Extract shared helpers: resolveQueuedDeployment, enqueueDeployment - Use millisecond-precision timestamps in log filenames to prevent collisions - Fix preview deployment grouping in in-memory queue - Add per-app try/catch in GitHub webhook handler --- .../deploy/application.command.test.ts | 3 +- .../__test__/deploy/application.real.test.ts | 3 +- .../deploy/github-webhook-handler.test.ts | 57 +- .../deployments/show-deployment.tsx | 28 +- .../deployments/show-deployments.tsx | 15 +- .../dashboard/application/general/show.tsx | 10 +- .../dashboard/compose/general/actions.tsx | 5 +- .../dashboard/project/duplicate-project.tsx | 2 +- .../settings/users/add-permissions.tsx | 2 +- .../components/shared/status-tooltip.tsx | 7 + .../drizzle/0197_strong_thunderbolt_ross.sql | 2 + apps/dokploy/drizzle/meta/0197_snapshot.json | 9172 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + .../pages/api/deploy/[refreshToken].ts | 11 +- .../api/deploy/compose/[refreshToken].ts | 11 +- apps/dokploy/pages/api/deploy/github.ts | 327 +- .../environment/[environmentId].tsx | 2 +- .../dokploy/server/api/routers/application.ts | 47 +- apps/dokploy/server/api/routers/compose.ts | 32 +- .../server/api/routers/preview-deployment.ts | 11 +- .../server/queues/deployments-queue.ts | 45 +- apps/dokploy/server/queues/in-memory-queue.ts | 3 + apps/dokploy/server/queues/queue-types.ts | 3 + apps/dokploy/server/queues/queueSetup.ts | 105 +- packages/server/src/db/schema/application.ts | 2 +- packages/server/src/db/schema/compose.ts | 4 +- packages/server/src/db/schema/deployment.ts | 3 +- packages/server/src/db/schema/shared.ts | 1 + packages/server/src/services/application.ts | 89 +- packages/server/src/services/compose.ts | 39 +- packages/server/src/services/deployment.ts | 131 +- .../src/utils/startup/cancel-deployments.ts | 32 +- 32 files changed, 9853 insertions(+), 358 deletions(-) create mode 100644 apps/dokploy/drizzle/0197_strong_thunderbolt_ross.sql create mode 100644 apps/dokploy/drizzle/meta/0197_snapshot.json diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index 1a33489b5..6223c0ee1 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -61,6 +61,7 @@ vi.mock("@dokploy/server/services/admin", () => ({ vi.mock("@dokploy/server/services/deployment", () => ({ createDeployment: vi.fn(), + resolveQueuedDeployment: vi.fn(), updateDeploymentStatus: vi.fn(), updateDeployment: vi.fn(), })); @@ -152,7 +153,7 @@ describe("deployApplication - Command Generation Tests", () => { vi.mocked(adminService.getDokployUrl).mockResolvedValue( "http://localhost:3000", ); - vi.mocked(deploymentService.createDeployment).mockResolvedValue( + vi.mocked(deploymentService.resolveQueuedDeployment).mockResolvedValue( createMockDeployment() as any, ); vi.mocked(execProcess.execAsync).mockResolvedValue({ diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 4adff6f07..258523404 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -62,6 +62,7 @@ vi.mock("@dokploy/server/services/admin", () => ({ vi.mock("@dokploy/server/services/deployment", () => ({ createDeployment: vi.fn(), + resolveQueuedDeployment: vi.fn(), updateDeploymentStatus: vi.fn(), updateDeployment: vi.fn(), })); @@ -195,7 +196,7 @@ describe( vi.mocked(adminService.getDokployUrl).mockResolvedValue( "http://localhost:3000", ); - vi.mocked(deploymentService.createDeployment).mockResolvedValue( + vi.mocked(deploymentService.resolveQueuedDeployment).mockResolvedValue( currentDeployment as any, ); vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue( diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index 6b61c97d7..85025d9cd 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({ applicationsFindMany: vi.fn(), composeFindMany: vi.fn(), queueAdd: vi.fn(), + enqueueApplicationDeployment: vi.fn(), + enqueueComposeDeployment: vi.fn(), + enqueuePreviewDeployment: vi.fn(), verify: vi.fn(), shouldDeploy: vi.fn(), createPreviewDeployment: vi.fn(), @@ -88,6 +91,9 @@ vi.mock("@/server/queues/queueSetup", () => ({ myQueue: { add: mocks.queueAdd, }, + enqueueApplicationDeployment: mocks.enqueueApplicationDeployment, + enqueueComposeDeployment: mocks.enqueueComposeDeployment, + enqueuePreviewDeployment: mocks.enqueuePreviewDeployment, })); vi.mock("@/server/utils/deploy", () => ({ @@ -171,7 +177,9 @@ describe("GitHub app webhook auto-deploy", () => { mocks.verify.mockResolvedValue(true); mocks.shouldDeploy.mockReturnValue(true); mocks.composeFindMany.mockResolvedValue([]); - mocks.queueAdd.mockResolvedValue({ id: "job-id" }); + mocks.enqueueApplicationDeployment.mockResolvedValue({ id: "job-id" }); + mocks.enqueueComposeDeployment.mockResolvedValue({ id: "job-id" }); + mocks.enqueuePreviewDeployment.mockResolvedValue({ id: "job-id" }); mocks.applicationsFindMany.mockImplementation(({ where }) => { const matches = @@ -209,17 +217,12 @@ describe("GitHub app webhook auto-deploy", () => { res, ); - expect(mocks.queueAdd).toHaveBeenCalledWith( - "deployments", + expect(mocks.enqueueApplicationDeployment).toHaveBeenCalledWith( expect.objectContaining({ applicationId: "application-id", applicationType: "application", type: "deploy", }), - expect.objectContaining({ - removeOnComplete: true, - removeOnFail: true, - }), ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" }); @@ -253,17 +256,12 @@ describe("GitHub app webhook auto-deploy", () => { await handler(createPushRequest("main"), res); - expect(mocks.queueAdd).toHaveBeenCalledWith( - "deployments", + expect(mocks.enqueueComposeDeployment).toHaveBeenCalledWith( expect.objectContaining({ applicationType: "compose", composeId: "compose-id", type: "deploy", }), - expect.objectContaining({ - removeOnComplete: true, - removeOnFail: true, - }), ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" }); @@ -295,18 +293,13 @@ describe("GitHub app webhook auto-deploy", () => { await handler(createTagRequest("v1.0.0"), res); - expect(mocks.queueAdd).toHaveBeenCalledWith( - "deployments", + expect(mocks.enqueueApplicationDeployment).toHaveBeenCalledWith( expect.objectContaining({ applicationId: "application-id", applicationType: "application", titleLog: "Tag created: v1.0.0", type: "deploy", }), - expect.objectContaining({ - removeOnComplete: true, - removeOnFail: true, - }), ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ @@ -319,7 +312,9 @@ describe("GitHub app webhook auto-deploy", () => { await handler(createPushRequest("feature"), res); - expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(mocks.enqueueApplicationDeployment).not.toHaveBeenCalled(); + expect(mocks.enqueueComposeDeployment).not.toHaveBeenCalled(); + expect(mocks.enqueuePreviewDeployment).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); @@ -389,7 +384,9 @@ describe("GitHub app webhook preview deployments", () => { githubWebhookSecret: "webhook-secret", }); mocks.verify.mockResolvedValue(true); - mocks.queueAdd.mockResolvedValue({ id: "job-id" }); + mocks.enqueueApplicationDeployment.mockResolvedValue({ id: "job-id" }); + mocks.enqueueComposeDeployment.mockResolvedValue({ id: "job-id" }); + mocks.enqueuePreviewDeployment.mockResolvedValue({ id: "job-id" }); mocks.createPreviewDeployment.mockResolvedValue({ previewDeploymentId: "new-preview-id", }); @@ -411,18 +408,13 @@ describe("GitHub app webhook preview deployments", () => { await handler(createPullRequestRequest("synchronize"), res); expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); - expect(mocks.queueAdd).toHaveBeenCalledWith( - "deployments", + expect(mocks.enqueuePreviewDeployment).toHaveBeenCalledWith( expect.objectContaining({ applicationId: "application-id", applicationType: "application-preview", previewDeploymentId: "existing-preview-0", type: "deploy", }), - expect.objectContaining({ - removeOnComplete: true, - removeOnFail: true, - }), ); expect(res.status).toHaveBeenCalledWith(200); }); @@ -439,7 +431,9 @@ describe("GitHub app webhook preview deployments", () => { await handler(createPullRequestRequest("opened"), res); expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); - expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(mocks.enqueueApplicationDeployment).not.toHaveBeenCalled(); + expect(mocks.enqueueComposeDeployment).not.toHaveBeenCalled(); + expect(mocks.enqueuePreviewDeployment).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); }); @@ -462,18 +456,13 @@ describe("GitHub app webhook preview deployments", () => { pullRequestNumber: 42, }), ); - expect(mocks.queueAdd).toHaveBeenCalledWith( - "deployments", + expect(mocks.enqueuePreviewDeployment).toHaveBeenCalledWith( expect.objectContaining({ applicationId: "application-id", applicationType: "application-preview", previewDeploymentId: "new-preview-id", type: "deploy", }), - expect.objectContaining({ - removeOnComplete: true, - removeOnFail: true, - }), ); expect(res.status).toHaveBeenCalledWith(200); }); diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index f71a3ec10..0c81b4565 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { QUEUED_LOG_MESSAGE } from "@dokploy/server"; import { TerminalLine } from "../../docker/logs/terminal-line"; import { type LogLine, parseLogs } from "../../docker/logs/utils"; @@ -21,6 +22,7 @@ interface Props { onClose: () => void; serverId?: string; errorMessage?: string; + status?: string; } export const ShowDeployment = ({ logPath, @@ -28,6 +30,7 @@ export const ShowDeployment = ({ onClose, serverId, errorMessage, + status, }: Props) => { const [data, setData] = useState(""); const [showExtraLogs, setShowExtraLogs] = useState(false); @@ -99,8 +102,31 @@ export const ShowDeployment = ({ }); } + if (status === "cancelled" && filteredLogsResult.length > 0) { + const isOnlyQueuedLog = + filteredLogsResult.some((log) => + log.message.includes(QUEUED_LOG_MESSAGE), + ) && + !filteredLogsResult.some((log) => log.message.includes("Building on")); + if (isOnlyQueuedLog) { + filteredLogsResult = [ + { + message: "Deployment was cancelled while queued.", + rawTimestamp: null, + timestamp: null, + }, + ]; + } else { + filteredLogsResult.push({ + message: "Deployment cancelled.", + rawTimestamp: null, + timestamp: null, + }); + } + } + setFilteredLogs(filteredLogsResult); - }, [data, showExtraLogs]); + }, [data, showExtraLogs, status]); useEffect(() => { scrollToBottom(); diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index b3be371ca..4b825892d 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -133,8 +133,7 @@ export const ShowDeployments = ({ const mostRecentDeployment = deployments[0]; if ( - !mostRecentDeployment || - mostRecentDeployment.status !== "running" || + mostRecentDeployment?.status !== "running" || !mostRecentDeployment.startedAt ) { return null; @@ -165,9 +164,10 @@ export const ShowDeployments = ({ {(type === "application" || type === "compose") && ( )} - {(type === "application" || type === "compose") && ( - - )} + {(type === "application" || type === "compose") && + deployments?.some((d) => d.status === "queued") && ( + + )} {type === "application" && (