Compare commits

..

11 Commits

Author SHA1 Message Date
Mauricio Siu
24729f35ec fix(traefik): remove error toast on dashboard action failure 2025-09-07 14:03:10 -06:00
Mauricio Siu
3eaeaa1db4 chore(package): bump version from v0.25.0 to v0.25.1 2025-09-07 13:31:38 -06:00
Mauricio Siu
de4a00f1e9 Merge pull request #2556 from Dokploy/2552-traefik-container-no-auto-start
feat(settings): add error handling for unsupported resource types in …
2025-09-07 13:31:03 -06:00
Mauricio Siu
2f5cd620c5 feat(settings): add error handling for unsupported resource types in Traefik setup
- Introduced error handling for unsupported resource types in `readPorts` and `writeTraefikSetup` functions.
- Enhanced `initializeStandaloneTraefik` to include image pulling with error logging for better debugging.
2025-09-07 13:26:19 -06:00
Mauricio Siu
1763000070 Merge pull request #2545 from Dokploy/feat/clean-build-queue-on-build
feat(deployment): add cancellation functionality queue for deployments
2025-09-06 22:12:44 -06:00
Mauricio Siu
3519913886 fix(deployment): update stuck build notification time from 9 to 10 minutes 2025-09-06 22:09:48 -06:00
Mauricio Siu
63e578f13c refactor(deployment): update cancellation input schemas for applications and composes
- Removed the previous cancellation schemas for deployments.
- Replaced them with a unified input schema for finding applications and composes during cancellation requests.
- Ensured that the cancellation logic now utilizes the new input structure for better consistency.
2025-09-06 22:08:59 -06:00
autofix-ci[bot]
d80ada7c00 [autofix.ci] apply automated fixes 2025-09-07 04:06:08 +00:00
Mauricio Siu
766cd20e90 feat(deployment): improve stuck deployment detection and update status
- Enhanced the stuck deployment check to only consider the most recent deployment.
- Updated the logic to correctly identify if the most recent deployment has been running for more than 9 minutes.
- Added functionality to update the deployment status to "done" upon application and compose cancellation.
2025-09-06 22:05:39 -06:00
Mauricio Siu
4e69c70697 feat(deployment): add cancellation functionality for deployments
- Introduced a new endpoint for cancelling deployments, allowing users to cancel both application and compose deployments.
- Implemented validation schemas for cancellation requests.
- Enhanced the deployment dashboard to provide a cancellation option for stuck deployments.
- Updated server-side logic to handle cancellation requests and send appropriate events.
2025-09-06 21:53:15 -06:00
Mauricio Siu
3b7d009841 fix(search-command): remove console log for project debugging 2025-09-06 20:36:01 -06:00
16 changed files with 326 additions and 31 deletions

View File

@@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator";
import { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono";
import { logger } from "./logger.js";
import { type DeployJob, deployJobSchema } from "./schema.js";
import {
cancelDeploymentSchema,
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
},
],
retries: 0,
cancelOn: [
{
event: "deployment/cancelled",
if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId",
timeout: "1h", // Allow cancellation for up to 1 hour
},
],
},
{ event: "deployment/requested" },
@@ -119,6 +130,48 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
}
});
app.post(
"/cancel-deployment",
zValidator("json", cancelDeploymentSchema),
async (c) => {
const data = c.req.valid("json");
logger.info("Received cancel deployment request", data);
try {
// Send cancellation event to Inngest
await inngest.send({
name: "deployment/cancelled",
data,
});
const identifier =
data.applicationType === "application"
? `applicationId: ${data.applicationId}`
: `composeId: ${data.composeId}`;
logger.info("Deployment cancellation event sent", {
...data,
identifier,
});
return c.json({
message: "Deployment cancellation requested",
applicationType: data.applicationType,
});
} catch (error) {
logger.error("Failed to send deployment cancellation event", error);
return c.json(
{
message: "Failed to cancel deployment",
error: error instanceof Error ? error.message : String(error),
},
500,
);
}
},
);
app.get("/health", async (c) => {
return c.json({ status: "ok" });
});

View File

@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
]);
export type DeployJob = z.infer<typeof deployJobSchema>;
export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
applicationType: z.literal("application"),
}),
z.object({
composeId: z.string(),
applicationType: z.literal("compose"),
}),
]);
export type CancelDeploymentJob = z.infer<typeof cancelDeploymentSchema>;

View File

@@ -1,6 +1,7 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -61,12 +62,48 @@ export const ShowDeployments = ({
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
// Cancel deployment mutations
const {
mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp,
} = api.application.cancelDeployment.useMutation();
const {
mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
if (!isCloud || !deployments || deployments.length === 0) return null;
const now = Date.now();
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
// Get the most recent deployment (first in the list since they're sorted by date)
const mostRecentDeployment = deployments[0];
if (
!mostRecentDeployment ||
mostRecentDeployment.status !== "running" ||
!mostRecentDeployment.startedAt
) {
return null;
}
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
const elapsed = now - startTime;
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
}, [isCloud, deployments]);
useEffect(() => {
setUrl(document.location.origin);
}, []);
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{stuckDeployment && (type === "application" || type === "compose") && (
<AlertBlock
type="warning"
className="flex-col items-start w-full p-4"
>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium text-sm mb-1">
Build appears to be stuck
</div>
<p className="text-sm">
Hey! Looks like the build has been running for more than 10
minutes. Would you like to cancel this deployment?
</p>
</div>
<Button
variant="destructive"
size="sm"
className="w-fit"
isLoading={
type === "application" ? isCancellingApp : isCancellingCompose
}
onClick={async () => {
try {
if (type === "application") {
await cancelApplicationDeployment({
applicationId: id,
});
} else if (type === "compose") {
await cancelComposeDeployment({
composeId: id,
});
}
toast.success("Deployment cancellation requested");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to cancel deployment",
);
}
}}
>
Cancel Deployment
</Button>
</div>
</AlertBlock>
)}
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>

View File

@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet,
you must set a port and ensure that the port is not being used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet,
you must set a port and ensure that the port is not being used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet,
you must set a port and ensure that the port is not being used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet,
you must set a port and ensure that the port is not being used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable through the internet,
you must set a port and ensure that the port is not being used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -89,7 +89,6 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
console.log("project", project);
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);

View File

@@ -97,11 +97,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
);
refetchDashboard();
})
.catch(() => {
toast.error(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
});
.catch(() => {});
}}
className="w-full cursor-pointer space-x-3"
>

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.0",
"version": "v0.25.1",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -24,6 +24,7 @@ import {
unzipDrop,
updateApplication,
updateApplicationStatus,
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
// uploadFileSchema
@@ -58,7 +59,7 @@ import {
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { uploadFileSchema } from "@/utils/schema";
export const applicationRouter = createTRPCRouter({
@@ -896,4 +897,55 @@ export const applicationRouter = createTRPCRouter({
return updatedApplication;
}),
cancelDeployment: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to cancel this deployment",
});
}
if (IS_CLOUD && application.serverId) {
try {
await updateApplicationStatus(input.applicationId, "idle");
if (application.deployments[0]) {
await updateDeploymentStatus(
application.deployments[0].deploymentId,
"done",
);
}
await cancelDeployment({
applicationId: input.applicationId,
applicationType: "application",
});
return {
success: true,
message: "Deployment cancellation requested",
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to cancel deployment",
});
}
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment cancellation only available in cloud version",
});
}),
});

View File

@@ -29,6 +29,7 @@ import {
startCompose,
stopCompose,
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import {
type CompleteTemplate,
@@ -58,7 +59,7 @@ import {
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
@@ -928,4 +929,57 @@ export const composeRouter = createTRPCRouter({
});
}
}),
cancelDeployment: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to cancel this deployment",
});
}
if (IS_CLOUD && compose.serverId) {
try {
await updateCompose(input.composeId, {
composeStatus: "idle",
});
if (compose.deployments[0]) {
await updateDeploymentStatus(
compose.deployments[0].deploymentId,
"done",
);
}
await cancelDeployment({
composeId: input.composeId,
applicationType: "compose",
});
return {
success: true,
message: "Deployment cancellation requested",
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to cancel deployment",
});
}
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment cancellation only available in cloud version",
});
}),
});

View File

@@ -23,3 +23,30 @@ export const deploy = async (jobData: DeploymentJob) => {
throw error;
}
};
type CancelDeploymentData =
| { applicationId: string; applicationType: "application" }
| { composeId: string; applicationType: "compose" };
export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
try {
const result = await fetch(`${process.env.SERVER_URL}/cancel-deployment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.API_KEY || "NO-DEFINED",
},
body: JSON.stringify(cancelData),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to cancel deployment");
}
const data = await result.json();
return data;
} catch (error) {
throw error;
}
};

View File

@@ -342,6 +342,8 @@ export const readPorts = async (
command = `docker service inspect ${resourceName} --format '{{json .Spec.EndpointSpec.Ports}}'`;
} else if (resourceType === "standalone") {
command = `docker container inspect ${resourceName} --format '{{json .NetworkSettings.Ports}}'`;
} else {
throw new Error("Resource type not found");
}
let result = "";
if (serverId) {
@@ -397,17 +399,20 @@ export const writeTraefikSetup = async (input: TraefikOptions) => {
"dokploy-traefik",
input.serverId,
);
if (resourceType === "service") {
await initializeTraefikService({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
} else {
} else if (resourceType === "standalone") {
await initializeStandaloneTraefik({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
} else {
throw new Error("Traefik resource type not found");
}
};

View File

@@ -87,16 +87,27 @@ export const initializeStandaloneTraefik = async ({
};
const docker = await getRemoteDocker(serverId);
try {
await docker.pull(imageName);
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Traefik Image Pulled ✅");
} catch (error) {
console.log("Traefik Image Not Found: Pulling ", error);
}
try {
const container = docker.getContainer(containerName);
await container.remove({ force: true });
await new Promise((resolve) => setTimeout(resolve, 5000));
} catch {}
await docker.createContainer(settings);
const newContainer = docker.getContainer(containerName);
await newContainer.start();
console.log("Traefik Started ✅");
try {
await docker.createContainer(settings);
const newContainer = docker.getContainer(containerName);
await newContainer.start();
console.log("Traefik Started ✅");
} catch (error) {
console.log("Traefik Not Found: Starting ", error);
}
};
export const initializeTraefikService = async ({