diff --git a/README.md b/README.md index 8faf22a35..d60962cff 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
Hostinger LX Aer + + + +
diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index b597b3aa4..cbfebbfac 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -48,6 +48,7 @@ const baseApp: ApplicationNested = { dockerBuildStage: "", isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, previewCertificateType: "none", previewCustomCertResolver: null, previewEnv: null, @@ -73,6 +74,7 @@ const baseApp: ApplicationNested = { }, }, buildArgs: null, + buildSecrets: null, buildPath: "/", gitlabPathNamespace: "", buildType: "nixpacks", @@ -133,6 +135,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, rollbackActive: false, + stopGracePeriodSwarm: null, }; describe("unzipDrop using real zip files", () => { diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts new file mode 100644 index 000000000..6eb5d1831 --- /dev/null +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { mechanizeDockerContainer } from "@dokploy/server/utils/builders"; + +type MockCreateServiceOptions = { + StopGracePeriod?: number; + [key: string]: unknown; +}; + +const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = + vi.hoisted(() => { + const inspect = vi.fn<[], Promise>(); + const getService = vi.fn(() => ({ inspect })); + const createService = vi.fn<[MockCreateServiceOptions], Promise>( + async () => undefined, + ); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + })); + return { + inspectMock: inspect, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + }; + }); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "dockerfile", + env: null, + mounts: [], + cpuLimit: null, + memoryLimit: null, + memoryReservation: null, + cpuReservation: null, + command: null, + ports: [], + sourceType: "docker", + dockerImage: "example:latest", + registry: null, + environment: { + project: { env: null }, + env: null, + }, + replicas: 1, + stopGracePeriodSwarm: 0n, + serverId: "server-id", + ...overrides, + }) as unknown as ApplicationNested; + +describe("mechanizeDockerContainer", () => { + beforeEach(() => { + inspectMock.mockReset(); + inspectMock.mockRejectedValue(new Error("service not found")); + getServiceMock.mockClear(); + createServiceMock.mockClear(); + getRemoteDockerMock.mockClear(); + getRemoteDockerMock.mockResolvedValue({ + getService: getServiceMock, + createService: createServiceMock, + }); + }); + + it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { + const application = createApplication({ stopGracePeriodSwarm: 0n }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.StopGracePeriod).toBe(0); + expect(typeof settings.StopGracePeriod).toBe("number"); + }); + + it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => { + const application = createApplication({ stopGracePeriodSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings).not.toHaveProperty("StopGracePeriod"); + }); +}); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts index 1144b65fe..3ae92ae20 100644 --- a/apps/dokploy/__test__/templates/helpers.template.test.ts +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -228,5 +228,58 @@ describe("helpers functions", () => { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", ); }); + + it("should handle JWT payload with newlines and whitespace by trimming them", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithNewlines = `{ + "role": "anon", + "iss": "supabase", + "exp": ${expiry} +} +`; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithNewlines, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("anon"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); + + it("should handle JWT payload with leading and trailing whitespace", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithWhitespace, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("service_role"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); }); }); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 5be96e473..7b39eeb4f 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -25,8 +25,10 @@ const baseApp: ApplicationNested = { registryUrl: "", watchPaths: [], buildArgs: null, + buildSecrets: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, triggerType: "push", previewCertificateType: "none", previewEnv: null, @@ -111,6 +113,7 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + stopGracePeriodSwarm: null, }; const baseDomain: Domain = { diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 9e10f43ec..4227eeb44 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -25,6 +25,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -176,10 +177,18 @@ const addSwarmSettings = z.object({ modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(), labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(), networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(), + stopGracePeriodSwarm: z.bigint().nullable(), }); type AddSwarmSettings = z.infer; +const hasStopGracePeriodSwarm = ( + value: unknown, +): value is { stopGracePeriodSwarm: bigint | number | string | null } => + typeof value === "object" && + value !== null && + "stopGracePeriodSwarm" in value; + interface Props { id: string; type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; @@ -224,12 +233,22 @@ export const AddSwarmSettings = ({ id, type }: Props) => { modeSwarm: null, labelsSwarm: null, networkSwarm: null, + stopGracePeriodSwarm: null, }, resolver: zodResolver(addSwarmSettings), }); useEffect(() => { if (data) { + const stopGracePeriodValue = hasStopGracePeriodSwarm(data) + ? data.stopGracePeriodSwarm + : null; + const normalizedStopGracePeriod = + stopGracePeriodValue === null || stopGracePeriodValue === undefined + ? null + : typeof stopGracePeriodValue === "bigint" + ? stopGracePeriodValue + : BigInt(stopGracePeriodValue); form.reset({ healthCheckSwarm: data.healthCheckSwarm ? JSON.stringify(data.healthCheckSwarm, null, 2) @@ -255,6 +274,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { networkSwarm: data.networkSwarm ? JSON.stringify(data.networkSwarm, null, 2) : null, + stopGracePeriodSwarm: normalizedStopGracePeriod, }); } }, [form, form.reset, data]); @@ -275,6 +295,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { modeSwarm: data.modeSwarm, labelsSwarm: data.labelsSwarm, networkSwarm: data.networkSwarm, + stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null, }) .then(async () => { toast.success("Swarm settings updated"); @@ -352,9 +373,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"], - "Interval" : 10000, - "Timeout" : 10000, - "StartPeriod" : 10000, + "Interval" : 10000000000, + "Timeout" : 10000000000, + "StartPeriod" : 10000000000, "Retries" : 10 }`} className="h-[12rem] font-mono" @@ -407,9 +428,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Condition" : "on-failure", - "Delay" : 10000, + "Delay" : 10000000000, "MaxAttempts" : 10, - "Window" : 10000 + "Window" : 10000000000 } `} className="h-[12rem] font-mono" {...field} @@ -529,9 +550,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Parallelism" : 1, - "Delay" : 10000, + "Delay" : 10000000000, "FailureAction" : "continue", - "Monitor" : 10000, + "Monitor" : 10000000000, "MaxFailureRatio" : 10, "Order" : "start-first" }`} @@ -587,9 +608,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => { language="json" placeholder={`{ "Parallelism" : 1, - "Delay" : 10000, + "Delay" : 10000000000, "FailureAction" : "continue", - "Monitor" : 10000, + "Monitor" : 10000000000, "MaxFailureRatio" : 10, "Order" : "start-first" }`} @@ -774,7 +795,57 @@ export const AddSwarmSettings = ({ id, type }: Props) => { )} /> - + ( + + Stop Grace Period (nanoseconds) + + + + + Duration in nanoseconds + + + + + +
+														{`Enter duration in nanoseconds:
+														• 30000000000 - 30 seconds
+														• 120000000000 - 2 minutes  
+														• 3600000000000 - 1 hour
+														• 0 - no grace period`}
+													
+
+
+
+
+ + + field.onChange( + e.target.value ? BigInt(e.target.value) : null, + ) + } + /> + +
+										
+									
+
+ )} + /> + {serverId && (
{ }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return (
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 78edb1aaa..48e978880 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -12,6 +12,7 @@ import { api } from "@/utils/api"; const addEnvironmentSchema = z.object({ env: z.string(), buildArgs: z.string(), + buildSecrets: z.string(), }); type EnvironmentSchema = z.infer; @@ -37,6 +38,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { defaultValues: { env: "", buildArgs: "", + buildSecrets: "", }, resolver: zodResolver(addEnvironmentSchema), }); @@ -44,15 +46,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => { // Watch form values const currentEnv = form.watch("env"); const currentBuildArgs = form.watch("buildArgs"); + const currentBuildSecrets = form.watch("buildSecrets"); const hasChanges = currentEnv !== (data?.env || "") || - currentBuildArgs !== (data?.buildArgs || ""); + currentBuildArgs !== (data?.buildArgs || "") || + currentBuildSecrets !== (data?.buildSecrets || ""); useEffect(() => { if (data) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", + buildSecrets: data.buildSecrets || "", }); } }, [data, form]); @@ -61,6 +66,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { mutateAsync({ env: formData.env, buildArgs: formData.buildArgs, + buildSecrets: formData.buildSecrets, applicationId, }) .then(async () => { @@ -76,9 +82,25 @@ export const ShowEnvironment = ({ applicationId }: Props) => { form.reset({ env: data?.env || "", buildArgs: data?.buildArgs || "", + buildSecrets: data?.buildSecrets || "", }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading]); + return (
@@ -104,13 +126,36 @@ export const ShowEnvironment = ({ applicationId }: Props) => { {data?.buildType === "dockerfile" && ( - Available only at build-time. See documentation  + Arguments are available only at build-time. See + documentation  + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + {data?.buildType === "dockerfile" && ( + + Secrets are specially designed for sensitive information and + are only available at build-time. See documentation  + diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 6f6db5dd1..1f54ddd58 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules || false, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 61690e740..e9be3a2f5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => { const router = useRouter(); const { mutateAsync, isLoading } = - api.application.saveGitProdiver.useMutation(); + api.application.saveGitProvider.useMutation(); const form = useForm({ defaultValues: { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 9a4b92ce1..80d6850ca 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index cb7209f8a..d6f65caf3 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index 16c916d93..862d5f87a 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -46,6 +46,7 @@ const schema = z .object({ env: z.string(), buildArgs: z.string(), + buildSecrets: z.string(), wildcardDomain: z.string(), port: z.number(), previewLimit: z.number(), @@ -109,6 +110,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { form.reset({ env: data.previewEnv || "", buildArgs: data.previewBuildArgs || "", + buildSecrets: data.previewBuildSecrets || "", wildcardDomain: data.previewWildcard || "*.traefik.me", port: data.previewPort || 3000, previewLabels: data.previewLabels || [], @@ -127,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { updateApplication({ previewEnv: formData.env, previewBuildArgs: formData.buildArgs, + previewBuildSecrets: formData.buildSecrets, previewWildcard: formData.wildcardDomain, previewPort: formData.port, previewLabels: formData.previewLabels, @@ -467,13 +470,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { {data?.buildType === "dockerfile" && ( - Available only at build-time. See documentation  + Arguments are available only at build-time. See + documentation  + here + + . + + } + placeholder="NPM_TOKEN=xyz" + /> + )} + {data?.buildType === "dockerfile" && ( + + Secrets are specially designed for sensitive information + and are only available at build-time. See + documentation  + 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/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3209b6e03..26bfa9421 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -6,6 +6,7 @@ import { Terminal, Trash2, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -33,6 +34,9 @@ interface Props { } export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { + const [runningSchedules, setRunningSchedules] = useState>( + new Set(), + ); const { data: schedules, isLoading: isLoadingSchedules, @@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { enabled: !!id, }, ); - const utils = api.useUtils(); - const { mutateAsync: deleteSchedule, isLoading: isDeleting } = api.schedule.delete.useMutation(); + const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); - const { mutateAsync: runManually, isLoading } = - api.schedule.runManually.useMutation(); + const handleRunManually = async (scheduleId: string) => { + setRunningSchedules((prev) => new Set(prev).add(scheduleId)); + try { + await runManually({ scheduleId }); + toast.success("Schedule run successfully"); + await refetchSchedules(); + } catch { + toast.error("Error running schedule"); + } finally { + setRunningSchedules((prev) => { + const newSet = new Set(prev); + newSet.delete(scheduleId); + return newSet; + }); + } + }; return ( @@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { Schedule tasks to run automatically at specified intervals.
- {schedules && schedules.length > 0 && ( )} @@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { {isLoadingSchedules ? ( -
+
Loading scheduled tasks... @@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { return (
-
+
-
+

{schedule.name} @@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { )}

{schedule.command && ( -
- - +
+ + {schedule.command}
)}
-
{ serverId={serverId || undefined} > - @@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { type="button" variant="ghost" size="icon" - isLoading={isLoading} - onClick={async () => { - toast.success("Schedule run successfully"); - - await runManually({ - scheduleId: schedule.scheduleId, - }) - .then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchSchedules(); - }) - .catch(() => { - toast.error("Error running schedule"); - }); - }} + disabled={runningSchedules.has(schedule.scheduleId)} + onClick={() => + handleRunManually(schedule.scheduleId) + } > - + {runningSchedules.has(schedule.scheduleId) ? ( + + ) : ( + + )} Run Manual Schedule - - { 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} /> { push( `/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`, ); - toast.success("deleted successfully"); + toast.success("Service deleted successfully"); setIsOpen(false); }) .catch(() => { diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index 870444be7..1bbeb880e 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -195,6 +195,7 @@ export const ComposeActions = ({ composeId }: Props) => { + - + */} {environment.name !== "production" && (
- + {canDeleteEnvironments && ( + + )}
)}
@@ -285,13 +294,15 @@ export const AdvancedEnvironmentSelector = ({ })} - setIsCreateDialogOpen(true)} - > - - Create Environment - + {canCreateEnvironments && ( + setIsCreateDialogOpen(true)} + > + + Create Environment + + )} diff --git a/apps/dokploy/components/dashboard/project/environment-variables.tsx b/apps/dokploy/components/dashboard/project/environment-variables.tsx index 8cafc8073..e833fa779 100644 --- a/apps/dokploy/components/dashboard/project/environment-variables.tsx +++ b/apps/dokploy/components/dashboard/project/environment-variables.tsx @@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { .finally(() => {}); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading, isOpen]); + return ( diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index 86dfd2433..cb6245f08 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { .finally(() => {}); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isLoading, isOpen]); + return ( diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 12ee3b211..783c5bb32 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -96,8 +96,30 @@ export const ShowProjects = () => { 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/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index a0138b3c3..d36d53af2 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { {(isError || isErrorConnection) && ( - + {connectionError?.message || error?.message} )} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index c923419c7..e5fee3a9d 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1,18 +1,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { - AlertTriangle, - Mail, - MessageCircleMore, - PenBoxIcon, - PlusIcon, -} from "lucide-react"; +import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { DiscordIcon, + GotifyIcon, LarkIcon, + NtfyIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -141,11 +137,11 @@ export const notificationsMap = { label: "Email", }, gotify: { - icon: , + icon: , label: "Gotify", }, ntfy: { - icon: , + icon: , label: "ntfy", }, }; @@ -222,10 +218,10 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); useEffect(() => { - if (type === "email") { + if (type === "email" && fields.length === 0) { append(""); } - }, [type, append]); + }, [type, append, fields.length]); useEffect(() => { if (notification) { @@ -443,7 +439,6 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: dockerCleanup, notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", - serverThreshold: serverThreshold, }); } else if (data.type === "lark") { promise = larkMutation.mutateAsync({ diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 2e02c6ef0..5b7a2220e 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -1,8 +1,10 @@ -import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react"; +import { Bell, Loader2, Mail, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DiscordIcon, + GotifyIcon, LarkIcon, + NtfyIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -86,12 +88,12 @@ export const ShowNotifications = () => { )} {notification.notificationType === "gotify" && (
- +
)} {notification.notificationType === "ntfy" && (
- +
)} {notification.notificationType === "lark" && ( diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index e630ec4f8..22da8ed44 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Fingerprint, QrCode } from "lucide-react"; +import copy from "copy-to-clipboard"; +import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react"; import QRCode from "qrcode"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -29,6 +30,12 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; @@ -54,6 +61,26 @@ type TwoFactorSetupData = { type PasswordForm = z.infer; type PinForm = z.infer; +const USERNAME_PLACEHOLDER = "%username%"; +const DATE_PLACEHOLDER = "%date%"; +const BACKUP_CODES_PLACEHOLDER = "%backupCodes%"; + +const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES + +Points to note +-------------- +# Each code can be used only once. +# Do not share these codes with anyone. + +Generated codes +--------------- +Username: ${USERNAME_PLACEHOLDER} +Generated on: ${DATE_PLACEHOLDER} + + +${BACKUP_CODES_PLACEHOLDER} +`; + export const Enable2FA = () => { const utils = api.useUtils(); const [data, setData] = useState(null); @@ -62,6 +89,7 @@ export const Enable2FA = () => { const [step, setStep] = useState<"password" | "verify">("password"); const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [otpValue, setOtpValue] = useState(""); + const { data: currentUser } = api.user.get.useQuery(); const handleVerifySubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -178,6 +206,54 @@ export const Enable2FA = () => { } }; + const handleDownloadBackupCodes = () => { + if (!backupCodes || backupCodes.length === 0) { + toast.error("No backup codes to download."); + return; + } + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + const blob = new Blob([backupCodesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopyBackupCodes = () => { + const date = new Date(); + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + copy(backupCodesText); + toast.success("Backup codes copied to clipboard"); + }; + return ( @@ -264,6 +340,7 @@ export const Enable2FA = () => { Scan this QR code with your authenticator app + {/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */} 2FA QR Code { {backupCodes && backupCodes.length > 0 && (
-

Backup Codes

+
+

Backup Codes

+
+ + + + + + +

Copy

+
+
+
+ + + + + + + +

Download

+
+
+
+
+
{backupCodes.map((code, index) => ( { onValueChange={(e) => { field.onChange(e); }} - defaultValue={field.value} - value={field.value} + defaultValue={ + field.value?.startsWith("data:") + ? "upload" + : field.value + } + value={ + field.value?.startsWith("data:") + ? "upload" + : field.value + } className="flex flex-row flex-wrap gap-2 max-xl:justify-center" > @@ -279,6 +287,71 @@ export const ProfileForm = () => { + + + + + +
+ document + .getElementById("avatar-upload") + ?.click() + } + > + {field.value?.startsWith("data:") ? ( + Custom avatar + ) : ( + + + + )} +
+ { + const file = e.target.files?.[0]; + if (file) { + // max file size 2mb + if (file.size > 2 * 1024 * 1024) { + toast.error( + "Image size must be less than 2MB", + ); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target + ?.result as string; + field.onChange(result); + }; + reader.readAsDataURL(file); + } + }} + /> +
+
{availableAvatars.map((image) => ( diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index a918da98f..fb4d01547 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -1,6 +1,6 @@ import type { findEnvironmentById } from "@dokploy/server/index"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -161,11 +161,13 @@ const addPermissions = z.object({ canCreateServices: z.boolean().optional().default(false), canDeleteProjects: z.boolean().optional().default(false), canDeleteServices: z.boolean().optional().default(false), + canDeleteEnvironments: z.boolean().optional().default(false), canAccessToTraefikFiles: z.boolean().optional().default(false), canAccessToDocker: z.boolean().optional().default(false), canAccessToAPI: z.boolean().optional().default(false), canAccessToSSHKeys: z.boolean().optional().default(false), canAccessToGitProviders: z.boolean().optional().default(false), + canCreateEnvironments: z.boolean().optional().default(false), }); type AddPermissions = z.infer; @@ -175,6 +177,7 @@ interface Props { } export const AddUserPermissions = ({ userId }: Props) => { + const [isOpen, setIsOpen] = useState(false); const { data: projects } = api.project.all.useQuery(); const { data, refetch } = api.user.one.useQuery( @@ -192,13 +195,25 @@ export const AddUserPermissions = ({ userId }: Props) => { const form = useForm({ defaultValues: { accessedProjects: [], + accessedEnvironments: [], accessedServices: [], + canDeleteEnvironments: false, + canCreateProjects: false, + canCreateServices: false, + canDeleteProjects: false, + canDeleteServices: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + canCreateEnvironments: false, }, resolver: zodResolver(addPermissions), }); useEffect(() => { - if (data) { + if (data && isOpen) { form.reset({ accessedProjects: data.accessedProjects || [], accessedEnvironments: data.accessedEnvironments || [], @@ -207,14 +222,16 @@ export const AddUserPermissions = ({ userId }: Props) => { canCreateServices: data.canCreateServices, canDeleteProjects: data.canDeleteProjects, canDeleteServices: data.canDeleteServices, + canDeleteEnvironments: data.canDeleteEnvironments || false, canAccessToTraefikFiles: data.canAccessToTraefikFiles, canAccessToDocker: data.canAccessToDocker, canAccessToAPI: data.canAccessToAPI, canAccessToSSHKeys: data.canAccessToSSHKeys, canAccessToGitProviders: data.canAccessToGitProviders, + canCreateEnvironments: data.canCreateEnvironments, }); } - }, [form, form.formState.isSubmitSuccessful, form.reset, data]); + }, [form, form.reset, data, isOpen]); const onSubmit = async (data: AddPermissions) => { await mutateAsync({ @@ -223,6 +240,7 @@ export const AddUserPermissions = ({ userId }: Props) => { canCreateProjects: data.canCreateProjects, canDeleteServices: data.canDeleteServices, canDeleteProjects: data.canDeleteProjects, + canDeleteEnvironments: data.canDeleteEnvironments, canAccessToTraefikFiles: data.canAccessToTraefikFiles, accessedProjects: data.accessedProjects || [], accessedEnvironments: data.accessedEnvironments || [], @@ -231,17 +249,19 @@ export const AddUserPermissions = ({ userId }: Props) => { canAccessToAPI: data.canAccessToAPI, canAccessToSSHKeys: data.canAccessToSSHKeys, canAccessToGitProviders: data.canAccessToGitProviders, + canCreateEnvironments: data.canCreateEnvironments, }) .then(async () => { toast.success("Permissions updated"); refetch(); + setIsOpen(false); }) .catch(() => { toast.error("Error updating the permissions"); }); }; return ( - + { )} /> + ( + +
+ Create Environments + + Allow the user to create environments + +
+ + + +
+ )} + /> + ( + +
+ Delete Environments + + Allow the user to delete environments + +
+ + + +
+ )} + /> { resolver: zodResolver(addServerDomain), }); const https = form.watch("https"); + const domain = form.watch("domain") || ""; + const host = data?.user?.host || ""; + const hasChanged = domain !== host; useEffect(() => { if (data) { form.reset({ @@ -119,6 +123,19 @@ export const WebDomain = () => {
+ {/* Warning for GitHub webhook URL changes */} + {hasChanged && ( + +
+

⚠️ Important: URL Change Impact

+

+ If you change the Dokploy Server URL make sure to update + your Github Apps to keep the auto-deploy working and preview + deployments working. +

+
+
+ )} { +export const DockerTerminalModal = ({ + children, + appName, + serverId, + appType, +}: Props) => { const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, + appType, serverId, }, { enabled: !!appName, }, ); + const [containerId, setContainerId] = useState(); const [mainDialogOpen, setMainDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); @@ -83,7 +90,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { {children} event.preventDefault()} > @@ -92,7 +99,6 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { Easy way to access to docker container -