diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index e403c8be5..8273d0e2b 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -7,7 +7,7 @@ import { RefreshCw, } from "lucide-react"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { type Control, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -57,6 +57,7 @@ export const commonCronExpressions = [ { label: "Every month on the 1st at midnight", value: "0 0 1 * *" }, { label: "Every 15 minutes", value: "*/15 * * * *" }, { label: "Every weekday at midnight", value: "0 0 * * 1-5" }, + { label: "Custom", value: "custom" }, ]; const formSchema = z @@ -115,10 +116,91 @@ interface Props { scheduleType?: "application" | "compose" | "server" | "dokploy-server"; } +export const ScheduleFormField = ({ + name, + formControl, +}: { + name: string; + formControl: Control; +}) => { + const [selectedOption, setSelectedOption] = useState(""); + + return ( + ( + + + Schedule + + + + + + +

Cron expression format: minute hour day month weekday

+

Example: 0 0 * * * (daily at midnight)

+
+
+
+
+
+ +
+ + { + const value = e.target.value; + const commonExpression = commonCronExpressions.find( + (expression) => expression.value === value, + ); + if (commonExpression) { + setSelectedOption(commonExpression.value); + } else { + setSelectedOption("custom"); + } + field.onChange(e); + }} + /> + +
+
+ + Choose a predefined schedule or enter a custom cron expression + + +
+ )} + /> + ); +}; + export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); - const utils = api.useUtils(); const form = useForm>({ resolver: zodResolver(formSchema), @@ -377,63 +459,9 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { )} /> - ( - - - Schedule - - - - - - -

- Cron expression format: minute hour day month - weekday -

-

Example: 0 0 * * * (daily at midnight)

-
-
-
-
-
- -
- - - -
-
- - Choose a predefined schedule or enter a custom cron - expression - - -
- )} + formControl={form.control} /> {(scheduleTypeForm === "application" || diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index f00b91a9d..804b4c39b 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -1,11 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { - DatabaseZap, - Info, - PenBoxIcon, - PlusCircle, - RefreshCw, -} from "lucide-react"; +import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -47,7 +41,7 @@ import { import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import type { CacheType } from "../domains/handle-domain"; -import { commonCronExpressions } from "../schedules/handle-schedules"; +import { ScheduleFormField } from "../schedules/handle-schedules"; const formSchema = z .object({ @@ -306,64 +300,9 @@ export const HandleVolumeBackups = ({ )} /> - - ( - - - Schedule - - - - - - -

- Cron expression format: minute hour day month - weekday -

-

Example: 0 0 * * * (daily at midnight)

-
-
-
-
-
- -
- - - -
-
- - Choose a predefined schedule or enter a custom cron - expression - - -
- )} + formControl={form.control} /> { ); const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const form = useForm({ defaultValues: { @@ -53,6 +54,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => { } }, [form, form.reset, data]); + useEffect(() => { + if (data?.composeFile !== undefined) { + setHasUnsavedChanges(composeFile !== data.composeFile); + } + }, [composeFile, data?.composeFile]); + const onSubmit = async (data: AddComposeFile) => { const { valid, error } = validateAndFormatYAML(data.composeFile); if (!valid) { @@ -71,6 +78,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { }) .then(async () => { toast.success("Compose config Updated"); + setHasUnsavedChanges(false); refetch(); await utils.compose.getConvertedCompose.invalidate({ composeId, @@ -99,6 +107,19 @@ export const ComposeFileEditor = ({ composeId }: Props) => { return ( <>
+
+
+

Compose File

+

+ Configure your Docker Compose file for this service. + {hasUnsavedChanges && ( + + (You have unsaved changes) + + )} +

+
+
- { - return ( - - - Schedule - - - - - - -

- Cron expression format: minute hour day month - weekday -

-

Example: 0 0 * * * (daily at midnight)

-
-
-
-
-
- -
- - - -
-
- - Choose a predefined schedule or enter a custom cron - expression - - -
- ); - }} - /> + + + { new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); break; case "services": { - const aTotalServices = a.environments.length; - const bTotalServices = b.environments.length; + const aTotalServices = a.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + const bTotalServices = b.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); comparison = aTotalServices - bTotalServices; break; } diff --git a/apps/dokploy/components/shared/status-tooltip.tsx b/apps/dokploy/components/shared/status-tooltip.tsx index 23c823c6f..f8c57b8ac 100644 --- a/apps/dokploy/components/shared/status-tooltip.tsx +++ b/apps/dokploy/components/shared/status-tooltip.tsx @@ -7,7 +7,14 @@ import { import { cn } from "@/lib/utils"; interface Props { - status: "running" | "error" | "done" | "idle" | undefined | null; + status: + | "running" + | "error" + | "done" + | "idle" + | "cancelled" + | undefined + | null; className?: string; } @@ -34,6 +41,14 @@ export const StatusTooltip = ({ status, className }: Props) => { className={cn("size-3.5 rounded-full bg-green-500", className)} /> )} + {status === "cancelled" && ( +
+ )} {status === "running" && (
{ {status === "error" && "Error"} {status === "done" && "Done"} {status === "running" && "Running"} + {status === "cancelled" && "Cancelled"} diff --git a/apps/dokploy/drizzle/0113_complete_rafael_vega.sql b/apps/dokploy/drizzle/0113_complete_rafael_vega.sql new file mode 100644 index 000000000..5e24ab585 --- /dev/null +++ b/apps/dokploy/drizzle/0113_complete_rafael_vega.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."deploymentStatus" ADD VALUE 'cancelled'; \ No newline at end of file diff --git a/apps/dokploy/drizzle/meta/0113_snapshot.json b/apps/dokploy/drizzle/meta/0113_snapshot.json index 5907fbdb1..023b05f4c 100644 --- a/apps/dokploy/drizzle/meta/0113_snapshot.json +++ b/apps/dokploy/drizzle/meta/0113_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f859f128-7326-4ec9-b5d8-78b518c77fa5", + "id": "8310936d-e9ff-408b-ae6d-2292aac02523", "prevId": "61ac43a1-aa28-42a3-a4ab-5f006bdacbb4", "version": "7", "dialect": "postgresql", @@ -6493,7 +6493,8 @@ "values": [ "running", "done", - "error" + "error", + "cancelled" ] }, "public.domainType": { diff --git a/apps/dokploy/drizzle/meta/_journal.json b/apps/dokploy/drizzle/meta/_journal.json index 0d77ab3ba..e262ed472 100644 --- a/apps/dokploy/drizzle/meta/_journal.json +++ b/apps/dokploy/drizzle/meta/_journal.json @@ -796,15 +796,8 @@ { "idx": 113, "version": "7", - "when": 1758913506515, - "tag": "0113_keen_wasp", - "breakpoints": true - }, - { - "idx": 114, - "version": "7", - "when": 1758914193619, - "tag": "0114_smart_shape", + "when": 1758960816504, + "tag": "0113_complete_rafael_vega", "breakpoints": true } ] diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 463db904e..2c5f2ed27 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.25.3", + "version": "v0.25.4", "private": true, "license": "Apache-2.0", "type": "module", diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index 592172a3d..f8d0154f5 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -226,6 +226,7 @@ const Service = ( General Environment Domains + Deployments Preview Deployments @@ -233,7 +234,6 @@ const Service = ( Volume Backups - Deployments Logs {((data?.serverId && isCloud) || !data?.server) && ( Monitoring diff --git a/apps/dokploy/server/server.ts b/apps/dokploy/server/server.ts index 194a96665..eaf562695 100644 --- a/apps/dokploy/server/server.ts +++ b/apps/dokploy/server/server.ts @@ -8,6 +8,7 @@ import { initializeNetwork, initSchedules, initVolumeBackupsCronJobs, + initCancelDeployments, sendDokployRestartNotifications, setupDirectories, } from "@dokploy/server"; @@ -52,6 +53,7 @@ void app.prepare().then(async () => { await migration(); await initCronJobs(); await initSchedules(); + await initCancelDeployments(); await initVolumeBackupsCronJobs(); await sendDokployRestartNotifications(); } diff --git a/packages/server/src/db/schema/deployment.ts b/packages/server/src/db/schema/deployment.ts index ce1ff2159..d6b0ddbcc 100644 --- a/packages/server/src/db/schema/deployment.ts +++ b/packages/server/src/db/schema/deployment.ts @@ -21,6 +21,7 @@ export const deploymentStatus = pgEnum("deploymentStatus", [ "running", "done", "error", + "cancelled", ]); export const deployments = pgTable("deployment", { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c845dc0bd..a0dac2e00 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -68,6 +68,7 @@ export * from "./utils/backups/postgres"; export * from "./utils/backups/utils"; export * from "./utils/backups/web-server"; export * from "./utils/builders/compose"; +export * from "./utils/startup/cancell-deployments"; export * from "./utils/builders/docker-file"; export * from "./utils/builders/drop"; export * from "./utils/builders/heroku"; diff --git a/packages/server/src/services/cdn.ts b/packages/server/src/services/cdn.ts index 9d4d865cd..1e19652c1 100644 --- a/packages/server/src/services/cdn.ts +++ b/packages/server/src/services/cdn.ts @@ -603,6 +603,21 @@ const BUNNY_CDN_IPS = new Set([ "89.187.184.176", ]); +// Arvancloud IP ranges +// https://www.arvancloud.ir/fa/ips.txt +const ARVANCLOUD_IP_RANGES = [ + "185.143.232.0/22", + "188.229.116.16/29", + "94.101.182.0/27", + "2.144.3.128/28", + "89.45.48.64/28", + "37.32.16.0/27", + "37.32.17.0/27", + "37.32.18.0/27", + "37.32.19.0/27", + "185.215.232.0/22", +]; + const CDN_PROVIDERS: CDNProvider[] = [ { name: "cloudflare", @@ -627,6 +642,14 @@ const CDN_PROVIDERS: CDNProvider[] = [ warningMessage: "Domain is behind Fastly - actual IP is masked by CDN proxy", }, + { + name: "arvancloud", + displayName: "Arvancloud", + checkIp: (ip: string) => + ARVANCLOUD_IP_RANGES.some((range) => isIPInCIDR(ip, range)), + warningMessage: + "Domain is behind Arvancloud - actual IP is masked by CDN proxy", + }, ]; export const detectCDNProvider = (ip: string): CDNProvider | null => { diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index bfe8d01d0..1436c52cc 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -227,7 +227,7 @@ export const deployCompose = async ({ const buildLink = `${await getDokployUrl()}/dashboard/project/${ compose.environment.projectId - }/services/compose/${compose.composeId}?tab=deployments`; + }/environment/${compose.environmentId}/services/compose/${compose.composeId}?tab=deployments`; const deployment = await createDeploymentCompose({ composeId: composeId, title: titleLog, @@ -335,7 +335,7 @@ export const deployRemoteCompose = async ({ const buildLink = `${await getDokployUrl()}/dashboard/project/${ compose.environment.projectId - }/services/compose/${compose.composeId}?tab=deployments`; + }/environment/${compose.environmentId}/services/compose/${compose.composeId}?tab=deployments`; const deployment = await createDeploymentCompose({ composeId: composeId, title: titleLog, diff --git a/packages/server/src/utils/providers/bitbucket.ts b/packages/server/src/utils/providers/bitbucket.ts index ca76a67fa..ed6cd8c31 100644 --- a/packages/server/src/utils/providers/bitbucket.ts +++ b/packages/server/src/utils/providers/bitbucket.ts @@ -31,29 +31,51 @@ export const getBitbucketCloneUrl = ( apiToken?: string | null; bitbucketUsername?: string | null; appPassword?: string | null; + bitbucketEmail?: string | null; + bitbucketWorkspaceName?: string | null; } | null, repoClone: string, ) => { if (!bitbucketProvider) { throw new Error("Bitbucket provider is required"); } - return bitbucketProvider.apiToken - ? `https://x-token-auth:${bitbucketProvider.apiToken}@${repoClone}` - : `https://${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}@${repoClone}`; + + if (bitbucketProvider.apiToken) { + return `https://x-bitbucket-api-token-auth:${bitbucketProvider.apiToken}@${repoClone}`; + } + + // For app passwords, use username:app_password format + if (!bitbucketProvider.bitbucketUsername || !bitbucketProvider.appPassword) { + throw new Error( + "Username and app password are required when not using API token", + ); + } + return `https://${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}@${repoClone}`; }; export const getBitbucketHeaders = (bitbucketProvider: Bitbucket) => { if (bitbucketProvider.apiToken) { - // For API tokens, use HTTP Basic auth with email and token - // According to Bitbucket docs: email:token for API calls - const email = - bitbucketProvider.bitbucketEmail || bitbucketProvider.bitbucketUsername; + // According to Bitbucket official docs, for API calls with API tokens: + // "You will need both your Atlassian account email and an API token" + // Use: {atlassian_account_email}:{api_token} + + if (!bitbucketProvider.bitbucketEmail) { + throw new Error( + "Atlassian account email is required when using API token for API calls", + ); + } + return { - Authorization: `Basic ${Buffer.from(`${email}:${bitbucketProvider.apiToken}`).toString("base64")}`, + Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketEmail}:${bitbucketProvider.apiToken}`).toString("base64")}`, }; } // For app passwords, use HTTP Basic auth with username and app password + if (!bitbucketProvider.bitbucketUsername || !bitbucketProvider.appPassword) { + throw new Error( + "Username and app password are required when not using API token", + ); + } return { Authorization: `Basic ${Buffer.from(`${bitbucketProvider.bitbucketUsername}:${bitbucketProvider.appPassword}`).toString("base64")}`, }; diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 874e2679e..db6dcbb78 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -99,6 +99,19 @@ export const refreshGiteaToken = async (giteaProviderId: string) => { } }; +const buildGiteaCloneUrl = ( + giteaUrl: string, + accessToken: string, + owner: string, + repository: string, +) => { + const protocol = giteaUrl.startsWith("http://") ? "http" : "https"; + const baseUrl = giteaUrl.replace(/^https?:\/\//, ""); + const repoClone = `${owner}/${repository}.git`; + const cloneUrl = `${protocol}://oauth2:${accessToken}@${baseUrl}/${repoClone}`; + return cloneUrl; +}; + export type ApplicationWithGitea = InferResultType< "applications", { gitea: true } @@ -148,9 +161,13 @@ export const getGiteaCloneCommand = async ( const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH; const outputPath = join(basePath, appName, "code"); - const baseUrl = gitea?.giteaUrl.replace(/^https?:\/\//, ""); const repoClone = `${giteaOwner}/${giteaRepository}.git`; - const cloneUrl = `https://oauth2:${gitea?.accessToken}@${baseUrl}/${repoClone}`; + const cloneUrl = buildGiteaCloneUrl( + gitea?.giteaUrl!, + gitea?.accessToken!, + giteaOwner!, + giteaRepository!, + ); const cloneCommand = ` rm -rf ${outputPath}; @@ -205,8 +222,12 @@ export const cloneGiteaRepository = async ( await recreateDirectory(outputPath); const repoClone = `${giteaOwner}/${giteaRepository}.git`; - const baseUrl = giteaProvider.giteaUrl.replace(/^https?:\/\//, ""); - const cloneUrl = `https://oauth2:${giteaProvider.accessToken}@${baseUrl}/${repoClone}`; + const cloneUrl = buildGiteaCloneUrl( + giteaProvider.giteaUrl, + giteaProvider.accessToken!, + giteaOwner!, + giteaRepository!, + ); writeStream.write(`\nCloning Repo ${repoClone} to ${outputPath}...\n`); @@ -269,9 +290,12 @@ export const cloneRawGiteaRepository = async (entity: Compose) => { const outputPath = join(basePath, appName, "code"); await recreateDirectory(outputPath); - const repoClone = `${giteaOwner}/${giteaRepository}.git`; - const baseUrl = giteaProvider.giteaUrl.replace(/^https?:\/\//, ""); - const cloneUrl = `https://oauth2:${giteaProvider.accessToken}@${baseUrl}/${repoClone}`; + const cloneUrl = buildGiteaCloneUrl( + giteaProvider.giteaUrl, + giteaProvider.accessToken!, + giteaOwner!, + giteaRepository!, + ); try { await spawnAsync("git", [ @@ -317,9 +341,13 @@ export const cloneRawGiteaRepositoryRemote = async (compose: Compose) => { const giteaProvider = await findGiteaById(giteaId); const basePath = COMPOSE_PATH; const outputPath = join(basePath, appName, "code"); - const repoClone = `${giteaOwner}/${giteaRepository}.git`; - const baseUrl = giteaProvider.giteaUrl.replace(/^https?:\/\//, ""); - const cloneUrl = `https://oauth2:${giteaProvider.accessToken}@${baseUrl}/${repoClone}`; + const cloneUrl = buildGiteaCloneUrl( + giteaProvider.giteaUrl, + giteaProvider.accessToken!, + giteaOwner!, + giteaRepository!, + ); + try { const command = ` rm -rf ${outputPath}; diff --git a/packages/server/src/utils/startup/cancell-deployments.ts b/packages/server/src/utils/startup/cancell-deployments.ts new file mode 100644 index 000000000..5c0a871f1 --- /dev/null +++ b/packages/server/src/utils/startup/cancell-deployments.ts @@ -0,0 +1,21 @@ +import { deployments } from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/index"; + +export const initCancelDeployments = async () => { + try { + console.log("Setting up cancel deployments...."); + + const result = await db + .update(deployments) + .set({ + status: "cancelled", + }) + .where(eq(deployments.status, "running")) + .returning(); + + console.log(`Cancelled ${result.length} deployments`); + } catch (error) { + console.error(error); + } +};