feat: visual deployment queue — show queued state in deployment list

- 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
This commit is contained in:
Yash Kumar 2026-09-11 19:42:00 +05:30
parent 853ca33659
commit 7fc7718bc0
32 changed files with 9853 additions and 358 deletions

View File

@ -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({

View File

@ -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(

View File

@ -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);
});

View File

@ -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();

View File

@ -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") && (
<KillBuild id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
{(type === "application" || type === "compose") &&
deployments?.some((d) => d.status === "queued") && (
<CancelQueues id={id} type={type} />
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
@ -286,7 +286,9 @@ export const ShowDeployments = ({
deployment.deploymentId,
);
const canDelete =
deployment.status === "done" || deployment.status === "error";
deployment.status === "done" ||
deployment.status === "error" ||
deployment.status === "cancelled";
return (
<div
@ -506,6 +508,7 @@ export const ShowDeployments = ({
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
status={activeLog?.status ?? undefined}
/>
</CardContent>
</Card>

View File

@ -83,7 +83,10 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
isLoading={
data?.applicationStatus === "running" ||
data?.applicationStatus === "queued"
}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
@ -165,7 +168,10 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
isLoading={
data?.applicationStatus === "running" ||
data?.applicationStatus === "queued"
}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>

View File

@ -61,7 +61,10 @@ export const ComposeActions = ({ composeId }: Props) => {
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
isLoading={
data?.composeStatus === "running" ||
data?.composeStatus === "queued"
}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>

View File

@ -39,7 +39,7 @@ export type Services = {
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
status?: "idle" | "queued" | "running" | "done" | "error";
};
interface DuplicateProjectProps {

View File

@ -52,7 +52,7 @@ export type Services = {
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
status?: "idle" | "queued" | "running" | "done" | "error";
};
export const extractServices = (data: Environment | undefined) => {

View File

@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
interface Props {
status:
| "queued"
| "running"
| "error"
| "done"
@ -54,6 +55,11 @@ export const StatusTooltip = ({ status, className }: Props) => {
className={cn("size-3.5 rounded-full bg-yellow-500", className)}
/>
)}
{status === "queued" && (
<div
className={cn("size-3.5 rounded-full bg-blue-500", className)}
/>
)}
</TooltipTrigger>
<TooltipContent align="center">
<span>
@ -62,6 +68,7 @@ export const StatusTooltip = ({ status, className }: Props) => {
{status === "done" && "Done"}
{status === "running" && "Running"}
{status === "cancelled" && "Cancelled"}
{status === "queued" && "Queued"}
</span>
</TooltipContent>
</Tooltip>

View File

@ -0,0 +1,2 @@
ALTER TYPE "public"."deploymentStatus" ADD VALUE IF NOT EXISTS 'queued' BEFORE 'running';--> statement-breakpoint
ALTER TYPE "public"."applicationStatus" ADD VALUE IF NOT EXISTS 'queued' BEFORE 'running';

File diff suppressed because it is too large Load Diff

View File

@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1789065932792,
"tag": "0197_strong_thunderbolt_ross",
"breakpoints": true
}
]
}

View File

@ -9,7 +9,7 @@ import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { applications } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { enqueueApplicationDeployment } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
/**
@ -275,14 +275,7 @@ export default async function handler(
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueApplicationDeployment(jobData);
}
} catch (error) {
logWebhookError("Error deploying Application:", error);

View File

@ -4,7 +4,7 @@ import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { compose } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { enqueueComposeDeployment } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import {
extractBranchName,
@ -198,14 +198,7 @@ export default async function handler(
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueComposeDeployment(jobData);
}
} catch (error) {
logWebhookError("Error deploying Compose:", error);

View File

@ -15,7 +15,11 @@ import { and, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import {
enqueueApplicationDeployment,
enqueueComposeDeployment,
enqueuePreviewDeployment,
} from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import {
extractCommitMessage,
@ -129,30 +133,30 @@ export default async function handler(
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
try {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await enqueueApplicationDeployment(jobData);
} catch (error) {
console.error(
`Failed to queue tag deployment for application ${app.applicationId}:`,
error,
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
// Find compose apps configured to deploy on tag
@ -168,31 +172,30 @@ export default async function handler(
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
try {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await enqueueComposeDeployment(jobData);
} catch (error) {
console.error(
`Failed to queue tag deployment for compose ${composeApp.composeId}:`,
error,
);
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
@ -242,39 +245,39 @@ export default async function handler(
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
try {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
const shouldDeployPaths = shouldDeploy(
app.watchPaths,
normalizedCommits,
);
const shouldDeployPaths = shouldDeploy(
app.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
continue;
if (!shouldDeployPaths) {
continue;
}
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await enqueueApplicationDeployment(jobData);
} catch (error) {
console.error(
`Failed to queue push deployment for application ${app.applicationId}:`,
error,
);
}
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const composeApps = await db.query.compose.findMany({
@ -290,39 +293,38 @@ export default async function handler(
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
try {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
const shouldDeployPaths = shouldDeploy(
composeApp.watchPaths,
normalizedCommits,
);
const shouldDeployPaths = shouldDeploy(
composeApp.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
continue;
if (!shouldDeployPaths) {
continue;
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await enqueueComposeDeployment(jobData);
} catch (error) {
console.error(
`Failed to queue push deployment for compose ${composeApp.composeId}:`,
error,
);
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
@ -471,71 +473,68 @@ export default async function handler(
}
for (const app of secureApps) {
// check for labels
if (app?.previewLabels && app?.previewLabels?.length > 0) {
let hasLabel = false;
const labels = githubBody?.pull_request?.labels;
for (const label of labels) {
if (app?.previewLabels?.includes(label.name)) {
hasLabel = true;
break;
try {
if (app?.previewLabels && app?.previewLabels?.length > 0) {
let hasLabel = false;
const labels = githubBody?.pull_request?.labels;
for (const label of labels) {
if (app?.previewLabels?.includes(label.name)) {
hasLabel = true;
break;
}
}
if (!hasLabel) continue;
}
if (!hasLabel) continue;
}
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
const previewDeploymentResult =
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
let previewDeploymentId =
previewDeploymentResult?.previewDeploymentId || "";
if (!previewDeploymentResult && shouldCreateDeployment) {
// The limit only applies to new previews, existing ones must
// still be redeployed when the pull request is updated.
const previewLimit = app?.previewLimit ?? 3;
if ((app?.previewDeployments?.length ?? 0) >= previewLimit) {
console.warn(
`⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`,
);
continue;
}
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
previewDeploymentId = previewDeployment.previewDeploymentId;
}
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
};
if (previewDeploymentId) {
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
if (!previewDeploymentResult && shouldCreateDeployment) {
const previewLimit = app?.previewLimit ?? 3;
if ((app?.previewDeployments?.length ?? 0) >= previewLimit) {
console.warn(
`⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`,
);
continue;
}
const previewDeployment = await createPreviewDeployment({
applicationId: app.applicationId as string,
branch: prBranch,
pullRequestId: prId,
pullRequestNumber: prNumber,
pullRequestTitle: prTitle,
pullRequestURL: prURL,
});
continue;
previewDeploymentId = previewDeployment.previewDeploymentId;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: "Preview Deployment",
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application-preview",
server: !!app.serverId,
previewDeploymentId,
};
if (previewDeploymentId) {
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
continue;
}
await enqueuePreviewDeployment(jobData);
}
} catch (error) {
console.error(
`Failed to queue preview deployment for application ${app.applicationId}:`,
error,
);
}
}

View File

@ -127,7 +127,7 @@ export type Services = {
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
status?: "idle" | "running" | "done" | "error" | "queued";
lastDeployDate?: Date | null;
icon?: string | null;
};

View File

@ -1,4 +1,5 @@
import {
cancelAllQueuedDeploymentsByApplicationId,
clearOldDeployments,
createApplication,
createDomain,
@ -69,14 +70,15 @@ import {
apiSaveGitProvider,
apiUpdateApplication,
applications,
deployments,
environments,
projects,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByApplication,
enqueueApplicationDeployment,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
@ -232,11 +234,7 @@ export const applicationRouter = createTRPCRouter({
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{ removeOnComplete: true, removeOnFail: true },
);
await enqueueApplicationDeployment(jobData);
}
await audit(ctx, {
@ -472,14 +470,7 @@ export const applicationRouter = createTRPCRouter({
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueApplicationDeployment(jobData);
await audit(ctx, {
action: "rebuild",
resourceType: "application",
@ -839,14 +830,7 @@ export const applicationRouter = createTRPCRouter({
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueApplicationDeployment(jobData);
await audit(ctx, {
action: "deploy",
resourceType: "application",
@ -861,7 +845,17 @@ export const applicationRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["cancel"],
});
await cancelAllQueuedDeploymentsByApplicationId(input.applicationId);
await cleanQueuesByApplication(input.applicationId);
const hasRunning = await db.query.deployments.findFirst({
where: and(
eq(deployments.applicationId, input.applicationId),
eq(deployments.status, "running"),
),
});
if (!hasRunning) {
await updateApplicationStatus(input.applicationId, "idle");
}
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
@ -953,14 +947,7 @@ export const applicationRouter = createTRPCRouter({
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueApplicationDeployment(jobData);
await audit(ctx, {
action: "deploy",
resourceType: "application",

View File

@ -1,6 +1,7 @@
import { join } from "node:path";
import {
addDomainToCompose,
cancelAllQueuedDeploymentsByComposeId,
clearOldDeployments,
cloneCompose,
createCommand,
@ -68,14 +69,15 @@ import {
apiSaveEnvironmentVariablesCompose,
apiUpdateCompose,
compose as composeTable,
deployments,
environments,
projects,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import {
cleanQueuesByCompose,
enqueueComposeDeployment,
killDockerBuild,
myQueue,
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
@ -283,7 +285,17 @@ export const composeRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await cancelAllQueuedDeploymentsByComposeId(input.composeId);
await cleanQueuesByCompose(input.composeId);
const hasRunning = await db.query.deployments.findFirst({
where: and(
eq(deployments.composeId, input.composeId),
eq(deployments.status, "running"),
),
});
if (!hasRunning) {
await updateCompose(input.composeId, { composeStatus: "idle" });
}
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
@ -443,14 +455,7 @@ export const composeRouter = createTRPCRouter({
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueComposeDeployment(jobData);
await audit(ctx, {
action: "deploy",
resourceType: "compose",
@ -492,14 +497,7 @@ export const composeRouter = createTRPCRouter({
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueueComposeDeployment(jobData);
await audit(ctx, {
action: "deploy",
resourceType: "compose",

View File

@ -10,7 +10,7 @@ import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { apiFindAllByApplication } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { enqueuePreviewDeployment } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@ -100,14 +100,7 @@ export const previewDeploymentRouter = createTRPCRouter({
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
await enqueuePreviewDeployment(jobData);
await audit(ctx, {
action: "redeploy",
resourceType: "previewDeployment",

View File

@ -7,6 +7,7 @@ import {
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updateDeploymentStatus,
updatePreviewDeployment,
} from "@dokploy/server";
import type { InMemoryJob } from "./in-memory-queue";
@ -18,31 +19,38 @@ import type { InMemoryJob } from "./in-memory-queue";
export const processDeploymentJob = async (job: InMemoryJob) => {
try {
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (!job.data.deploymentId) {
await updateApplicationStatus(job.data.applicationId, "running");
}
if (job.data.type === "redeploy") {
await rebuildApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
deploymentId: job.data.deploymentId,
});
} else if (job.data.type === "deploy") {
await deployApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
deploymentId: job.data.deploymentId,
});
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (!job.data.deploymentId) {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
}
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
freshVolumes: job.data.freshVolumes,
deploymentId: job.data.deploymentId,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
@ -50,12 +58,15 @@ export const processDeploymentJob = async (job: InMemoryJob) => {
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
freshVolumes: job.data.freshVolumes,
deploymentId: job.data.deploymentId,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (!job.data.deploymentId) {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
}
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
@ -63,6 +74,7 @@ export const processDeploymentJob = async (job: InMemoryJob) => {
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
deploymentId: job.data.deploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
@ -70,10 +82,27 @@ export const processDeploymentJob = async (job: InMemoryJob) => {
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
deploymentId: job.data.deploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
console.error("Deployment job failed", error);
try {
if (job.data.deploymentId) {
await updateDeploymentStatus(job.data.deploymentId, "error");
}
if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "error");
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, { composeStatus: "error" });
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "error",
});
}
} catch (cleanupError) {
console.error("Failed to update status after job failure", cleanupError);
}
}
};

View File

@ -49,6 +49,9 @@ export const getGroup = (data: DeploymentJob): string => {
if (data.applicationType === "compose") {
return `compose:${data.composeId}`;
}
if (data.applicationType === "application-preview") {
return `preview:${data.previewDeploymentId}`;
}
return `application:${data.applicationId}`;
};

View File

@ -7,6 +7,7 @@ type DeployJob =
type: "deploy" | "redeploy";
applicationType: "application";
serverId?: string;
deploymentId?: string;
}
| {
composeId: string;
@ -17,6 +18,7 @@ type DeployJob =
applicationType: "compose";
serverId?: string;
freshVolumes?: boolean;
deploymentId?: string;
}
| {
applicationId: string;
@ -27,6 +29,7 @@ type DeployJob =
applicationType: "application-preview";
previewDeploymentId: string;
serverId?: string;
deploymentId?: string;
};
export type DeploymentJob = DeployJob;

View File

@ -1,4 +1,13 @@
import { IS_CLOUD } from "@dokploy/server";
import {
createDeployment,
createDeploymentCompose,
createDeploymentPreview,
IS_CLOUD,
updateApplicationStatus,
updateCompose,
updateDeploymentStatus,
updatePreviewDeployment,
} from "@dokploy/server";
import {
execAsync,
execAsyncRemote,
@ -79,13 +88,19 @@ export const startDeploymentWorker = () => myQueue.run();
export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter(
(job) => (job.data as any)?.applicationId === applicationId,
(job) =>
job.data.applicationType === "application" &&
job.data.applicationId === applicationId,
);
};
export const getJobsByComposeId = async (composeId: string) => {
const jobs = await myQueue.getJobs();
return jobs.filter((job) => (job.data as any)?.composeId === composeId);
return jobs.filter(
(job) =>
job.data.applicationType === "compose" &&
job.data.composeId === composeId,
);
};
if (!IS_CLOUD) {
@ -97,7 +112,9 @@ if (!IS_CLOUD) {
export const cleanQueuesByApplication = async (applicationId: string) => {
const removed = myQueue.removeWaiting(
(data) => (data as any)?.applicationId === applicationId,
(data) =>
data.applicationType === "application" &&
data.applicationId === applicationId,
);
if (removed > 0) {
console.log(
@ -108,7 +125,8 @@ export const cleanQueuesByApplication = async (applicationId: string) => {
export const cleanQueuesByCompose = async (composeId: string) => {
const removed = myQueue.removeWaiting(
(data) => (data as any)?.composeId === composeId,
(data) =>
data.applicationType === "compose" && data.composeId === composeId,
);
if (removed > 0) {
console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`);
@ -147,4 +165,81 @@ export const killDockerBuild = async (
}
};
const enqueueDeployment = async (
jobData: DeploymentJob,
createRecord: () => Promise<{ deploymentId: string }>,
resetStatus: () => Promise<unknown>,
) => {
try {
const deployment = await createRecord();
jobData.deploymentId = deployment.deploymentId;
return myQueue.add(
"deployments",
{ ...jobData },
{ removeOnComplete: true, removeOnFail: true },
);
} catch (error) {
if (jobData.deploymentId) {
await updateDeploymentStatus(jobData.deploymentId, "cancelled").catch(
console.error,
);
}
await resetStatus().catch(console.error);
throw error;
}
};
export const enqueueApplicationDeployment = async (jobData: DeploymentJob) => {
if (jobData.applicationType !== "application") return;
await updateApplicationStatus(jobData.applicationId, "queued");
return enqueueDeployment(
jobData,
() =>
createDeployment({
applicationId: jobData.applicationId,
title: jobData.titleLog,
description: jobData.descriptionLog,
status: "queued",
}),
() => updateApplicationStatus(jobData.applicationId, "idle"),
);
};
export const enqueueComposeDeployment = async (jobData: DeploymentJob) => {
if (jobData.applicationType !== "compose") return;
await updateCompose(jobData.composeId, { composeStatus: "queued" });
return enqueueDeployment(
jobData,
() =>
createDeploymentCompose({
composeId: jobData.composeId,
title: jobData.titleLog,
description: jobData.descriptionLog,
status: "queued",
}),
() => updateCompose(jobData.composeId, { composeStatus: "idle" }),
);
};
export const enqueuePreviewDeployment = async (jobData: DeploymentJob) => {
if (jobData.applicationType !== "application-preview") return;
await updatePreviewDeployment(jobData.previewDeploymentId, {
previewStatus: "queued",
});
return enqueueDeployment(
jobData,
() =>
createDeploymentPreview({
previewDeploymentId: jobData.previewDeploymentId,
title: jobData.titleLog,
description: jobData.descriptionLog,
status: "queued",
}),
() =>
updatePreviewDeployment(jobData.previewDeploymentId, {
previewStatus: "idle",
}),
);
};
export { myQueue };

View File

@ -343,7 +343,7 @@ const createSchema = createInsertSchema(applications, {
.enum(["github", "docker", "git", "gitlab", "bitbucket", "gitea", "drop"])
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
applicationStatus: z.enum(["idle", "queued", "running", "done", "error"]),
buildType: z.enum([
"dockerfile",
"heroku_buildpacks",

View File

@ -193,7 +193,9 @@ const createSchema = createInsertSchema(compose, {
.enum(["git", "github", "gitlab", "bitbucket", "gitea", "raw"])
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
composeStatus: z
.enum(["idle", "queued", "running", "done", "error"])
.optional(),
icon: z
.string()
.max(2 * 1024 * 1024, "Icon must be less than 2MB")

View File

@ -18,6 +18,7 @@ import { schedules } from "./schedule";
import { server } from "./server";
import { volumeBackups } from "./volume-backups";
export const deploymentStatus = pgEnum("deploymentStatus", [
"queued",
"running",
"done",
"error",
@ -118,7 +119,7 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
const schema = createInsertSchema(deployments, {
title: z.string().min(1),
status: z.string().default("running"),
status: z.enum(deploymentStatus.enumValues).default("running"),
logPath: z.string().min(1),
applicationId: z.string(),
composeId: z.string(),

View File

@ -3,6 +3,7 @@ import { z } from "zod";
export const applicationStatus = pgEnum("applicationStatus", [
"idle",
"queued",
"running",
"done",
"error",

View File

@ -35,6 +35,7 @@ import { getDokployUrl } from "./admin";
import {
createDeployment,
createDeploymentPreview,
resolveQueuedDeployment,
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
@ -179,10 +180,12 @@ export const deployApplication = async ({
applicationId,
titleLog = "Manual deployment",
descriptionLog = "",
deploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
deploymentId?: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
@ -192,11 +195,18 @@ export const deployApplication = async ({
};
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
applicationId: applicationId,
title: titleLog,
description: descriptionLog,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeployment({
applicationId: applicationId,
title: titleLog,
description: descriptionLog,
}),
() => updateApplicationStatus(applicationId, "running"),
() => updateApplicationStatus(applicationId, "idle"),
);
if (!deployment) return;
try {
let command = "set -e;";
@ -297,20 +307,29 @@ export const rebuildApplication = async ({
applicationId,
titleLog = "Rebuild deployment",
descriptionLog = "",
deploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
deploymentId?: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
applicationId: applicationId,
title: titleLog,
description: descriptionLog,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeployment({
applicationId: applicationId,
title: titleLog,
description: descriptionLog,
}),
() => updateApplicationStatus(applicationId, "running"),
() => updateApplicationStatus(applicationId, "idle"),
);
if (!deployment) return;
try {
let command = "set -e;";
@ -364,19 +383,34 @@ export const deployPreviewApplication = async ({
titleLog = "Preview Deployment",
descriptionLog = "",
previewDeploymentId,
deploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
previewDeploymentId: string;
deploymentId?: string;
}) => {
const application = await findApplicationById(applicationId);
const deployment = await createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
}),
() =>
updatePreviewDeployment(previewDeploymentId, {
previewStatus: "running",
}),
() =>
updatePreviewDeployment(previewDeploymentId, {
previewStatus: "idle",
}),
);
if (!deployment) return;
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
@ -484,21 +518,36 @@ export const rebuildPreviewApplication = async ({
titleLog = "Rebuild Preview Deployment",
descriptionLog = "",
previewDeploymentId,
deploymentId,
}: {
applicationId: string;
titleLog: string;
descriptionLog: string;
previewDeploymentId: string;
deploymentId?: string;
}) => {
const application = await findApplicationById(applicationId);
const previewDeployment =
await findPreviewDeploymentById(previewDeploymentId);
const deployment = await createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeploymentPreview({
title: titleLog,
description: descriptionLog,
previewDeploymentId: previewDeploymentId,
}),
() =>
updatePreviewDeployment(previewDeploymentId, {
previewStatus: "running",
}),
() =>
updatePreviewDeployment(previewDeploymentId, {
previewStatus: "idle",
}),
);
if (!deployment) return;
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
const issueParams = {

View File

@ -39,6 +39,7 @@ import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
import {
createDeploymentCompose,
resolveQueuedDeployment,
updateDeployment,
updateDeploymentStatus,
} from "./deployment";
@ -229,11 +230,13 @@ export const deployCompose = async ({
composeId,
titleLog = "Manual deployment",
descriptionLog = "",
deploymentId,
freshVolumes = false,
}: {
composeId: string;
titleLog: string;
descriptionLog: string;
deploymentId?: string;
freshVolumes?: boolean;
}) => {
const compose = await findComposeById(composeId);
@ -241,11 +244,18 @@ export const deployCompose = async ({
const buildLink = `${await getDokployUrl()}/dashboard/project/${
compose.environment.projectId
}/environment/${compose.environmentId}/services/compose/${compose.composeId}?tab=deployments`;
const deployment = await createDeploymentCompose({
composeId: composeId,
title: titleLog,
description: descriptionLog,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeploymentCompose({
composeId: composeId,
title: titleLog,
description: descriptionLog,
}),
() => updateCompose(composeId, { composeStatus: "running" }),
() => updateCompose(composeId, { composeStatus: "idle" }),
);
if (!deployment) return;
try {
const entity = {
@ -371,20 +381,29 @@ export const rebuildCompose = async ({
composeId,
titleLog = "Rebuild deployment",
descriptionLog = "",
deploymentId,
freshVolumes = false,
}: {
composeId: string;
titleLog: string;
descriptionLog: string;
deploymentId?: string;
freshVolumes?: boolean;
}) => {
const compose = await findComposeById(composeId);
const deployment = await createDeploymentCompose({
composeId: composeId,
title: titleLog,
description: descriptionLog,
});
const deployment = await resolveQueuedDeployment(
deploymentId,
() =>
createDeploymentCompose({
composeId: composeId,
title: titleLog,
description: descriptionLog,
}),
() => updateCompose(composeId, { composeStatus: "running" }),
() => updateCompose(composeId, { composeStatus: "idle" }),
);
if (!deployment) return;
try {
let command = "set -e;";

View File

@ -120,10 +120,36 @@ export const findDeploymentByApplicationId = async (applicationId: string) => {
return deployment;
};
export const QUEUED_LOG_MESSAGE = "Waiting for worker to pick job...";
export const resolveQueuedDeployment = async <
T extends { deploymentId: string },
>(
deploymentId: string | undefined,
createNew: () => Promise<T>,
setRunning: () => Promise<unknown>,
resetStatus: () => Promise<unknown>,
): Promise<T | null> => {
if (!deploymentId) {
return createNew();
}
try {
const deployment = await findDeploymentById(deploymentId);
await updateDeploymentStatus(deployment.deploymentId, "running");
await setRunning();
return deployment as unknown as T;
} catch (error) {
console.error(`Deployment ${deploymentId} lookup failed, skipping`, error);
await updateDeploymentStatus(deploymentId, "error").catch(console.error);
await resetStatus().catch(console.error);
return null;
}
};
export const createDeployment = async (
deployment: Omit<
z.infer<typeof apiCreateDeployment>,
"deploymentId" | "createdAt" | "status" | "logPath"
"deploymentId" | "createdAt" | "logPath"
>,
) => {
const application = await findApplicationById(deployment.applicationId);
@ -136,16 +162,21 @@ export const createDeployment = async (
const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${application.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, application.appName, fileName);
const initialLog =
deployment.status === "queued"
? QUEUED_LOG_MESSAGE
: "Initializing deployment";
if (serverId) {
const server = await findServerById(serverId);
const command = `
mkdir -p ${LOGS_PATH}/${application.appName};
echo "Initializing deployment" >> ${logFilePath};
echo '${initialLog}' >> ${logFilePath};
echo "Building on ${serverId ? "Build Server" : "Dokploy Server"}" >> ${logFilePath};
`;
@ -154,7 +185,7 @@ export const createDeployment = async (
await fsPromises.mkdir(path.join(LOGS_PATH, application.appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment\n");
await fsPromises.writeFile(logFilePath, `${initialLog}\n`);
}
const deploymentCreate = await db
@ -162,10 +193,11 @@ export const createDeployment = async (
.values({
applicationId: deployment.applicationId,
title: deployment.title || "Deployment",
status: "running",
status: deployment.status || "running",
logPath: logFilePath,
description: deployment.description || "",
startedAt: new Date().toISOString(),
startedAt:
deployment.status === "queued" ? undefined : new Date().toISOString(),
...(application.buildServerId && {
buildServerId: application.buildServerId,
}),
@ -204,7 +236,7 @@ export const createDeployment = async (
export const createDeploymentPreview = async (
deployment: Omit<
z.infer<typeof apiCreateDeploymentPreview>,
"deploymentId" | "createdAt" | "status" | "logPath"
"deploymentId" | "createdAt" | "logPath"
>,
) => {
const previewDeployment = await findPreviewDeploymentById(
@ -218,10 +250,15 @@ export const createDeploymentPreview = async (
try {
const appName = `${previewDeployment.appName}`;
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
const initialLog =
deployment.status === "queued"
? QUEUED_LOG_MESSAGE
: "Initializing deployment";
if (previewDeployment?.application?.serverId) {
const server = await findServerById(
previewDeployment?.application?.serverId,
@ -229,7 +266,7 @@ export const createDeploymentPreview = async (
const command = `
mkdir -p ${LOGS_PATH}/${appName};
echo "Initializing deployment" >> ${logFilePath};
echo '${initialLog}' >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
@ -237,18 +274,19 @@ export const createDeploymentPreview = async (
await fsPromises.mkdir(path.join(LOGS_PATH, appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment");
await fsPromises.writeFile(logFilePath, `${initialLog}\n`);
}
const deploymentCreate = await db
.insert(deployments)
.values({
title: deployment.title || "Deployment",
status: "running",
status: deployment.status || "running",
logPath: logFilePath,
description: deployment.description || "",
previewDeploymentId: deployment.previewDeploymentId,
startedAt: new Date().toISOString(),
startedAt:
deployment.status === "queued" ? undefined : new Date().toISOString(),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
@ -286,7 +324,7 @@ export const createDeploymentPreview = async (
export const createDeploymentCompose = async (
deployment: Omit<
z.infer<typeof apiCreateDeploymentCompose>,
"deploymentId" | "createdAt" | "status" | "logPath"
"deploymentId" | "createdAt" | "logPath"
>,
) => {
const compose = await findComposeById(deployment.composeId);
@ -297,16 +335,21 @@ export const createDeploymentCompose = async (
);
try {
const { LOGS_PATH } = paths(!!compose.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${compose.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, compose.appName, fileName);
const initialLog =
deployment.status === "queued"
? QUEUED_LOG_MESSAGE
: "Initializing deployment";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
const command = `
mkdir -p ${LOGS_PATH}/${compose.appName};
echo "Initializing deployment\n" >> ${logFilePath};
echo '${initialLog}' >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
@ -314,7 +357,7 @@ echo "Initializing deployment\n" >> ${logFilePath};
await fsPromises.mkdir(path.join(LOGS_PATH, compose.appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing deployment\n");
await fsPromises.writeFile(logFilePath, `${initialLog}\n`);
}
const deploymentCreate = await db
@ -323,9 +366,10 @@ echo "Initializing deployment\n" >> ${logFilePath};
composeId: deployment.composeId,
title: deployment.title || "Deployment",
description: deployment.description || "",
status: "running",
status: deployment.status || "running",
logPath: logFilePath,
startedAt: new Date().toISOString(),
startedAt:
deployment.status === "queued" ? undefined : new Date().toISOString(),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
@ -381,7 +425,7 @@ export const createDeploymentBackup = async (
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
try {
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${backup.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, backup.appName, fileName);
@ -468,7 +512,7 @@ export const createDeploymentSchedule = async (
);
try {
const { SCHEDULES_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(SCHEDULES_PATH, schedule.appName, fileName);
@ -546,7 +590,7 @@ export const createDeploymentVolumeBackup = async (
);
try {
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(
VOLUME_BACKUPS_PATH,
@ -965,8 +1009,13 @@ export const updateDeploymentStatus = async (
.update(deployments)
.set({
status: deploymentStatus,
...(deploymentStatus === "running" && {
startedAt: new Date().toISOString(),
}),
finishedAt:
deploymentStatus === "done" || deploymentStatus === "error"
deploymentStatus === "done" ||
deploymentStatus === "error" ||
deploymentStatus === "cancelled"
? new Date().toISOString()
: null,
})
@ -976,6 +1025,42 @@ export const updateDeploymentStatus = async (
return application;
};
export const cancelAllQueuedDeploymentsByApplicationId = async (
applicationId: string,
) => {
return db
.update(deployments)
.set({
status: "cancelled",
finishedAt: new Date().toISOString(),
})
.where(
and(
eq(deployments.applicationId, applicationId),
eq(deployments.status, "queued"),
),
)
.returning();
};
export const cancelAllQueuedDeploymentsByComposeId = async (
composeId: string,
) => {
return db
.update(deployments)
.set({
status: "cancelled",
finishedAt: new Date().toISOString(),
})
.where(
and(
eq(deployments.composeId, composeId),
eq(deployments.status, "queued"),
),
)
.returning();
};
export const createServerDeployment = async (
deployment: Omit<
z.infer<typeof apiCreateDeploymentServer>,
@ -987,7 +1072,7 @@ export const createServerDeployment = async (
const server = await findServerById(deployment.serverId);
await removeLastFiveDeployments(deployment.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss.SSS");
const fileName = `${server.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, server.appName, fileName);
await fsPromises.mkdir(path.join(LOGS_PATH, server.appName), {

View File

@ -2,9 +2,10 @@ import {
applications,
compose,
deployments,
previewDeployments,
schedules,
} from "@dokploy/server/db/schema";
import { eq, inArray } from "drizzle-orm";
import { eq, inArray, or } from "drizzle-orm";
import { db } from "../../db/index";
export const initCancelDeployments = async () => {
@ -14,12 +15,18 @@ export const initCancelDeployments = async () => {
const runningDeployments = await db
.select({
deploymentId: deployments.deploymentId,
previewDeploymentId: deployments.previewDeploymentId,
scheduleId: deployments.scheduleId,
scheduleType: schedules.scheduleType,
})
.from(deployments)
.leftJoin(schedules, eq(deployments.scheduleId, schedules.scheduleId))
.where(eq(deployments.status, "running"));
.where(
or(
eq(deployments.status, "running"),
eq(deployments.status, "queued"),
),
);
const deploymentIdsToCancel = runningDeployments
.filter(
@ -38,6 +45,7 @@ export const initCancelDeployments = async () => {
.update(deployments)
.set({
status: "cancelled",
finishedAt: new Date().toISOString(),
})
.where(inArray(deployments.deploymentId, deploymentIdsToCancel))
.returning();
@ -71,6 +79,26 @@ export const initCancelDeployments = async () => {
.where(inArray(compose.composeId, composeIds));
}
const previewDeploymentIds = [
...new Set(
result
.map((deployment) => deployment.previewDeploymentId)
.filter((id): id is string => !!id),
),
];
if (previewDeploymentIds.length > 0) {
await db
.update(previewDeployments)
.set({ previewStatus: "idle" })
.where(
inArray(
previewDeployments.previewDeploymentId,
previewDeploymentIds,
),
);
}
console.log(`Cancelled ${result.length} deployments`);
} catch (error) {
console.error(error);