diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d45c3dac0..e210811b0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ Before submitting this PR, please make sure that: - [ ] You created a dedicated branch based on the `canary` branch. - [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request -- [ ] You have tested this PR in your local instance. +- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you. ## Issues related (if applicable) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c1f832db..6ac16b14e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute. -Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues. +Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues. We have a few guidelines to follow when contributing to this project: @@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project: - [Development](#development) - [Build](#build) - [Pull Request](#pull-request) +- [Important Considerations](#important-considerations-for-pull-requests) ## Commit Convention @@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0. - If your pull request fixes an open issue, please reference the issue in the pull request description. - Once your pull request is merged, you will be automatically added as a contributor to the project. -**Important Considerations for Pull Requests:** +### Important Considerations for Pull Requests +- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code. - **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects. - **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task. - **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`). diff --git a/Dockerfile b/Dockerfile index 5d7bb6770..262862ca6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 -CMD [ "pnpm", "start" ] + +HEALTHCHECK --interval=10s --timeout=3s --retries=10 \ + CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 + + CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] diff --git a/README.md b/README.md index e97735597..927e6ebc6 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,8 @@
- -
- Special thanks to: -
-
- - Tuple's sponsorship image - - -### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy) -[Available for MacOS & Windows](https://tuple.app/dokploy)
- -
- - Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. - ## ✨ Features Dokploy includes multiple features to make your life easier. @@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). -## ♥️ Sponsors - -🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features. - -[Dokploy Open Collective](https://opencollective.com/dokploy) [Github Sponsors](https://github.com/sponsors/Siumauricio) -## Sponsors - -| Sponsor | Logo | Supporter Level | -|---------|:----:|----------------| -| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | Hostinger | 🎖 Hero Sponsor | -| [LX Aer](https://www.lxaer.com/?ref=dokploy) | LX Aer | 🎖 Hero Sponsor | -| [LinkDR](https://linkdr.com/?ref=dokploy) | LinkDR | 🎖 Hero Sponsor | -| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | LambdaTest | 🎖 Hero Sponsor | -| [Awesome Tools](https://awesome.tools/) | Awesome Tools | 🎖 Hero Sponsor | -| [Supafort](https://supafort.com/?ref=dokploy) | Supafort.com | 🥇 Premium Supporter | -| [Agentdock](https://agentdock.ai/?ref=dokploy) | agentdock.ai | 🥇 Premium Supporter | -| [AmericanCloud](https://americancloud.com/?ref=dokploy) | AmericanCloud | 🥈 Elite Contributor | -| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | Tolgee | 🥈 Elite Contributor | -| [Cloudblast](https://cloudblast.io/?ref=dokploy) | Cloudblast.io | 🥉 Supporting Member | -| [Synexa](https://synexa.ai/?ref=dokploy) | Synexa | 🥉 Supporting Member | - -### Community Backers 🤝 - -#### Organizations: - -[Sponsors on Open Collective](https://opencollective.com/dokploy) - -#### Individuals: - -[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy) - ### Contributors 🤝 diff --git a/apps/api/package.json b/apps/api/package.json index 0f4b1044f..70c8aaac8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,7 +14,7 @@ "@hono/node-server": "^1.14.3", "@hono/zod-validator": "0.3.0", "dotenv": "^16.4.5", - "hono": "^4.7.10", + "hono": "^4.11.7", "pino": "9.4.0", "pino-pretty": "11.2.2", "react": "18.2.0", @@ -23,7 +23,7 @@ "zod": "^3.25.32" }, "devDependencies": { - "@types/node": "^20.17.51", + "@types/node": "^20.16.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "tsx": "^4.16.2", diff --git a/apps/dokploy/__test__/compose/domain/network-service.test.ts b/apps/dokploy/__test__/compose/domain/network-service.test.ts index b8d03c751..83fe8a166 100644 --- a/apps/dokploy/__test__/compose/domain/network-service.test.ts +++ b/apps/dokploy/__test__/compose/domain/network-service.test.ts @@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest"; describe("addDokployNetworkToService", () => { it("should add network to an empty array", () => { const result = addDokployNetworkToService([]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should not add duplicate network to an array", () => { const result = addDokployNetworkToService(["dokploy-network"]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should add network to an existing array with other networks", () => { const result = addDokployNetworkToService(["other-network"]); - expect(result).toEqual(["other-network", "dokploy-network"]); + expect(result).toEqual(["other-network", "dokploy-network", "default"]); }); it("should add network to an object if networks is an object", () => { const result = addDokployNetworkToService({ "other-network": {} }); - expect(result).toEqual({ "other-network": {}, "dokploy-network": {} }); + expect(result).toEqual({ + "other-network": {}, + "dokploy-network": {}, + default: {}, + }); + }); + + it("should not duplicate default network when already present", () => { + const result = addDokployNetworkToService(["default", "dokploy-network"]); + expect(result).toEqual(["default", "dokploy-network"]); }); }); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 85b9b2c61..dc795fd35 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -147,6 +147,7 @@ const baseApp: ApplicationNested = { dockerContextPath: null, rollbackActive: false, stopGracePeriodSwarm: null, + ulimitsSwarm: 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 index c12a272bc..fb448e3af 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -6,6 +6,7 @@ type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; @@ -13,11 +14,11 @@ type MockCreateServiceOptions = { const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = vi.hoisted(() => { - const inspect = vi.fn<[], Promise>(); + const inspect = vi.fn<() => Promise>(); const getService = vi.fn(() => ({ inspect })); - const createService = vi.fn<[MockCreateServiceOptions], Promise>( - async () => undefined, - ); + const createService = vi.fn< + (opts: MockCreateServiceOptions) => Promise + >(async () => undefined); const getRemoteDocker = vi.fn(async () => ({ getService, createService, @@ -57,6 +58,7 @@ const createApplication = ( }, replicas: 1, stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; @@ -80,7 +82,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -97,7 +101,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -106,4 +112,50 @@ describe("mechanizeDockerContainer", () => { "StopGracePeriod", ); }); + + it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { + const ulimits = [ + { Name: "nofile", Soft: 10000, Hard: 20000 }, + { Name: "nproc", Soft: 4096, Hard: 8192 }, + ]; + const application = createApplication({ ulimitsSwarm: ulimits }); + + 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.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); + }); + + it("omits Ulimits when ulimitsSwarm is null", async () => { + const application = createApplication({ ulimitsSwarm: 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.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); + + it("omits Ulimits when ulimitsSwarm is an empty array", async () => { + const application = createApplication({ ulimitsSwarm: [] }); + + 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.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); }); diff --git a/apps/dokploy/__test__/setup.ts b/apps/dokploy/__test__/setup.ts new file mode 100644 index 000000000..5af01d147 --- /dev/null +++ b/apps/dokploy/__test__/setup.ts @@ -0,0 +1,40 @@ +import { vi } from "vitest"; + +/** + * Mock the DB module so tests that import from @dokploy/server (barrel) + * never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs). + * Without this, loading the server barrel pulls in lib/auth and db, which + * connect to localhost:5432 and cause ECONNREFUSED. + */ +vi.mock("@dokploy/server/db", () => { + const chain = () => chain; + chain.set = () => chain; + chain.where = () => chain; + chain.values = () => chain; + chain.returning = () => Promise.resolve([{}]); + chain.then = undefined; + + const tableMock = { + findFirst: vi.fn(() => Promise.resolve(undefined)), + findMany: vi.fn(() => Promise.resolve([])), + insert: vi.fn(() => Promise.resolve([{}])), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + }; + const createQueryMock = () => tableMock; + + return { + db: { + select: vi.fn(() => chain), + insert: vi.fn(() => ({ + values: () => ({ returning: () => Promise.resolve([{}]) }), + })), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + query: new Proxy({} as Record, { + get: () => tableMock, + }), + }, + dbUrl: "postgres://mock:mock@localhost:5432/mock", + }; +}); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 0e6e529b0..bcbc74623 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -125,6 +125,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts index 7270b828a..65eb374ea 100644 --- a/apps/dokploy/__test__/vitest.config.ts +++ b/apps/dokploy/__test__/vitest.config.ts @@ -7,10 +7,15 @@ export default defineConfig({ include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__ exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"], pool: "forks", + setupFiles: [path.resolve(__dirname, "setup.ts")], }, define: { "process.env": { NODE: "test", + GITHUB_CLIENT_ID: "test", + GITHUB_CLIENT_SECRET: "test", + GOOGLE_CLIENT_ID: "test", + GOOGLE_CLIENT_SECRET: "test", }, }, plugins: [ 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 ee427feca..4c6fc60c7 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 @@ -22,6 +22,7 @@ import { HealthCheckForm, LabelsForm, ModeForm, + NetworkForm, PlacementForm, RestartPolicyForm, RollbackConfigForm, @@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [ docDescription: "Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).", }, + { + id: "network", + label: "Network", + description: "Configure network attachments", + docDescription: + "Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.", + }, { id: "labels", label: "Labels", @@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { )} {activeMenu === "mode" && } + {activeMenu === "network" && } {activeMenu === "labels" && } {activeMenu === "stop-grace-period" && ( diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts index 2f07be53d..df972102d 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form"; export { HealthCheckForm } from "./health-check-form"; export { LabelsForm } from "./labels-form"; export { ModeForm } from "./mode-form"; +export { NetworkForm } from "./network-form"; export { PlacementForm } from "./placement-form"; export { RestartPolicyForm } from "./restart-policy-form"; export { RollbackConfigForm } from "./rollback-config-form"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx new file mode 100644 index 000000000..f2c640cfe --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -0,0 +1,313 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const driverOptEntrySchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const networkFormSchema = z.object({ + networks: z + .array( + z.object({ + Target: z.string().optional(), + Aliases: z.string().optional(), + DriverOptsEntries: z.array(driverOptEntrySchema).optional(), + }), + ) + .optional(), +}); + +interface NetworkFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const NetworkForm = ({ id, type }: NetworkFormProps) => { + const [isLoading, setIsLoading] = useState(false); + + const queryMap = { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + }; + const { data, refetch } = queryMap[type] + ? queryMap[type]() + : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + + const mutationMap = { + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + application: () => api.application.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + }; + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.mongo.update.useMutation(); + + const form = useForm>({ + resolver: zodResolver(networkFormSchema), + defaultValues: { + networks: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "networks", + }); + + useEffect(() => { + if (data?.networkSwarm && Array.isArray(data.networkSwarm)) { + const networkEntries = data.networkSwarm.map((network) => ({ + Target: network.Target || "", + Aliases: network.Aliases?.join(", ") || "", + DriverOptsEntries: network.DriverOpts + ? Object.entries(network.DriverOpts).map(([key, value]) => ({ + key, + value: value ?? "", + })) + : [], + })); + form.reset({ networks: networkEntries }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + const networksArray = + formData.networks + ?.filter((network) => network.Target) + .map((network) => { + const entries = (network.DriverOptsEntries ?? []).filter( + (e) => e.key.trim() !== "", + ); + const driverOpts = + entries.length > 0 + ? Object.fromEntries( + entries.map((e) => [e.key.trim(), e.value]), + ) + : undefined; + return { + Target: network.Target, + Aliases: network.Aliases + ? network.Aliases.split(",").map((alias) => alias.trim()) + : undefined, + DriverOpts: driverOpts, + }; + }) || []; + + // If no networks, send null to clear the database + const networksToSend = networksArray.length > 0 ? networksArray : null; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + networkSwarm: networksToSend, + }); + + toast.success("Network configuration updated successfully"); + refetch(); + } catch { + toast.error("Error updating network configuration"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ Networks + + Configure network attachments for your service + +
+ {fields.map((field, index) => ( +
+ ( + + Network Name + + + + + The name of the network to attach to + + + + )} + /> + ( + + Aliases (optional) + + + + + Comma-separated list of network aliases + + + + )} + /> +
+ Driver options (optional) + + e.g. com.docker.network.driver.mtu, + com.docker.network.driver.host_binding + + {( + form.watch(`networks.${index}.DriverOptsEntries`) ?? [] + ).map((_, optIndex) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+ +
+ ))} + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index aea30e49b..8978d346a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -21,10 +21,18 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { createConverter, NumberInputWithSteps, } from "@/components/ui/number-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tooltip, TooltipContent, @@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => { : `${formatNumber(mb)} MB`; }); +const ulimitSchema = z.object({ + Name: z.string().min(1, "Name is required"), + Soft: z.coerce.number().int().min(-1, "Must be >= -1"), + Hard: z.coerce.number().int().min(-1, "Must be >= -1"), +}); + const addResourcesSchema = z.object({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: z.string().optional(), cpuReservation: z.string().optional(), + ulimitsSwarm: z.array(ulimitSchema).optional(), }); +const ULIMIT_PRESETS = [ + { value: "nofile", label: "nofile (Open Files)" }, + { value: "nproc", label: "nproc (Processes)" }, + { value: "memlock", label: "memlock (Locked Memory)" }, + { value: "stack", label: "stack (Stack Size)" }, + { value: "core", label: "core (Core File Size)" }, + { value: "cpu", label: "cpu (CPU Time)" }, + { value: "data", label: "data (Data Segment)" }, + { value: "fsize", label: "fsize (File Size)" }, + { value: "locks", label: "locks (File Locks)" }, + { value: "msgqueue", label: "msgqueue (Message Queues)" }, + { value: "nice", label: "nice (Nice Priority)" }, + { value: "rtprio", label: "rtprio (Real-time Priority)" }, + { value: "sigpending", label: "sigpending (Pending Signals)" }, +]; + export type ServiceType = | "postgres" | "mongo" @@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: "", memoryLimit: "", memoryReservation: "", + ulimitsSwarm: [], }, resolver: zodResolver(addResourcesSchema), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "ulimitsSwarm", + }); + useEffect(() => { if (data) { form.reset({ @@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: data?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); @@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, memoryReservation: formData.memoryReservation || null, + ulimitsSwarm: + formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0 + ? formData.ulimitsSwarm + : null, }) .then(async () => { toast.success("Resources Updated"); @@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => { }} /> + + {/* Ulimits Section */} +
+
+
+ Ulimits + + + + + + +

+ Set resource limits for the container. Each ulimit has + a soft limit (warning threshold) and hard limit + (maximum allowed). Use -1 for unlimited. +

+
+
+
+
+ +
+ + {fields.length > 0 && ( +
+ {fields.map((field, index) => ( +
+ ( + + Type + + + + )} + /> + ( + + + Soft Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + ( + + + Hard Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + +
+ ))} +
+ )} + + {fields.length === 0 && ( +

+ No ulimits configured. Click "Add Ulimit" to set + resource limits. +

+ )} +
+
- + +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

+ Check to save configs with Go templating (e.g.{" "} + {"{{range}}"}). +

+
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index 2198f4a97..3f7943252 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo: GiteaRepository) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. 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 80d6850ca..1fa42b9c0 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 @@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. 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 6197fc49f..f5ba24e4c 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 @@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index e5dff075e..941ddef50 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -34,6 +34,7 @@ export const DockerLogs = dynamic( export const badgeStateColor = (state: string) => { switch (state) { case "running": + case "ready": return "green"; case "exited": case "shutdown": @@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))} @@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services?.find((c) => c.containerId === containerId)?.error} +
+ )} { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index fce562285..5e546d050 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 5ad950e4c..b52fa2097 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index 98c2afa11..9f9babb3e 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 98c6c0470..16dd1f246 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))} @@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services.find((c) => c.containerId === containerId)?.error} +
+ )} {container.state} + {container.status ? ` ${container.status}` : ""} ))} Containers ({data?.length}) diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx index 8c848a0dc..7e740a1b7 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx @@ -7,6 +7,7 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -16,6 +17,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config"; @@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, ); const [canEdit, setCanEdit] = useState(true); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { mutateAsync, isLoading, error, isError } = api.settings.updateTraefikFile.useMutation(); @@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, [form, form.reset, data]); const onSubmit = async (data: UpdateServerMiddlewareConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: error || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: error || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -153,14 +158,37 @@ routers: /> )} -
- +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

+ Traefik supports Go templating in dynamic configs (e.g.{" "} + {"{{range}}"}). Configs using + templates will fail standard YAML validation. Check this to save + without validation. +

+
+ +
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx index 8745db286..86fe71ed4 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx @@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; diff --git a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx index d30061db5..acc74066f 100644 --- a/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx +++ b/apps/dokploy/components/dashboard/mongo/general/show-external-mongo-credentials.tsx @@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; diff --git a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx index dfaa36f6b..6e6cbe018 100644 --- a/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx +++ b/apps/dokploy/components/dashboard/mysql/general/show-external-mysql-credentials.tsx @@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx index 46b3772a0..1d34c010a 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx @@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 8234593e1..07ad68144 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -430,7 +430,7 @@ export const ShowProjects = () => { ) : null} - +
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
- + {project.description} diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx index 8edd92389..9511af628 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx @@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; diff --git a/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx index 4cb6bd50e..f474c376d 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitea/add-gitea-provider.tsx @@ -21,6 +21,7 @@ import { FormControl, FormField, FormItem, + FormDescription, FormLabel, FormMessage, } from "@/components/ui/form"; @@ -39,6 +40,10 @@ const Schema = z.object({ giteaUrl: z.string().min(1, { message: "Gitea URL is required", }), + giteaInternalUrl: z + .union([z.string().url(), z.literal("")]) + .optional() + .transform((v) => (v === "" ? undefined : v)), clientId: z.string().min(1, { message: "Client ID is required", }), @@ -70,6 +75,7 @@ export const AddGiteaProvider = () => { redirectUri: webhookUrl, name: "", giteaUrl: "https://gitea.com", + giteaInternalUrl: "", }, resolver: zodResolver(Schema), }); @@ -83,6 +89,7 @@ export const AddGiteaProvider = () => { redirectUri: webhookUrl, name: "", giteaUrl: "https://gitea.com", + giteaInternalUrl: "", }); }, [form, webhookUrl, isOpen]); @@ -95,6 +102,7 @@ export const AddGiteaProvider = () => { name: data.name, redirectUri: data.redirectUri, giteaUrl: data.giteaUrl, + giteaInternalUrl: data.giteaInternalUrl || undefined, organizationName: data.organizationName, })) as unknown as GiteaProviderResponse; @@ -223,6 +231,29 @@ export const AddGiteaProvider = () => { )} /> + ( + + Internal URL (Optional) + + + + + Use when Gitea runs on the same instance as Dokploy. + Used for OAuth token exchange to reach Gitea via + internal network (e.g. Docker service name). + + + + )} + /> + (v === "" ? undefined : v)), clientId: z.string().min(1, "Client ID is required"), clientSecret: z.string().min(1, "Client Secret is required"), }); @@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => { defaultValues: { name: "", giteaUrl: "https://gitea.com", + giteaInternalUrl: "", clientId: "", clientSecret: "", }, @@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => { form.reset({ name: gitea.gitProvider?.name || "", giteaUrl: gitea.giteaUrl || "https://gitea.com", + giteaInternalUrl: gitea.giteaInternalUrl || "", clientId: gitea.clientId || "", clientSecret: gitea.clientSecret || "", }); @@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => { gitProviderId: gitea?.gitProvider?.gitProviderId || "", name: values.name, giteaUrl: values.giteaUrl, + giteaInternalUrl: values.giteaInternalUrl ?? null, clientId: values.clientId, clientSecret: values.clientSecret, }) @@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => { )} /> + ( + + Internal URL (Optional) + + + + + Use when Gitea runs on the same instance as Dokploy. Used + for OAuth token exchange to reach Gitea via internal network + (e.g. Docker service name). + + + + )} + /> (v === "" ? undefined : v)), applicationId: z.string().min(1, { message: "Application ID is required", }), @@ -66,6 +71,7 @@ export const AddGitlabProvider = () => { redirectUri: webhookUrl, name: "", gitlabUrl: "https://gitlab.com", + gitlabInternalUrl: "", }, resolver: zodResolver(Schema), }); @@ -80,6 +86,7 @@ export const AddGitlabProvider = () => { redirectUri: webhookUrl, name: "", gitlabUrl: "https://gitlab.com", + gitlabInternalUrl: "", }); }, [form, isOpen]); @@ -92,6 +99,7 @@ export const AddGitlabProvider = () => { name: data.name || "", redirectUri: data.redirectUri || "", gitlabUrl: data.gitlabUrl || "https://gitlab.com", + gitlabInternalUrl: data.gitlabInternalUrl || undefined, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -192,6 +200,29 @@ export const AddGitlabProvider = () => { )} /> + ( + + Internal URL (Optional) + + + + + Use when GitLab runs on the same instance as Dokploy. + Used for OAuth token exchange to reach GitLab via + internal network (e.g. Docker service name). + + + + )} + /> + (v === "" ? undefined : v)), groupName: z.string().optional(), }); @@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { groupName: "", name: "", gitlabUrl: "https://gitlab.com", + gitlabInternalUrl: "", }, resolver: zodResolver(Schema), }); @@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { groupName: gitlab?.groupName || "", name: gitlab?.gitProvider.name || "", gitlabUrl: gitlab?.gitlabUrl || "", + gitlabInternalUrl: gitlab?.gitlabInternalUrl || "", }); }, [form, isOpen]); @@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { groupName: data.groupName || "", name: data.name || "", gitlabUrl: data.gitlabUrl || "", + gitlabInternalUrl: data.gitlabInternalUrl ?? null, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { )} /> + ( + + Internal URL (Optional) + + + + + Use when GitLab runs on the same instance as Dokploy. + Used for OAuth token exchange to reach GitLab via + internal network (e.g. Docker service name). + + + + )} + /> + ([]); + const [accountsLoading, setAccountsLoading] = useState(true); + const [linkingProvider, setLinkingProvider] = useState( + null, + ); + const [unlinkingProviderId, setUnlinkingProviderId] = useState( + null, + ); + + const fetchAccounts = useCallback(async () => { + setAccountsLoading(true); + try { + const { data } = await authClient.listAccounts(); + const list = Array.isArray(data) + ? data + : ((data && typeof data === "object" && "accounts" in data + ? (data as { accounts?: AccountItem[] }).accounts + : null) ?? []); + setAccounts(Array.isArray(list) ? list : []); + } catch { + setAccounts([]); + } finally { + setAccountsLoading(false); + } + }, []); + + useEffect(() => { + fetchAccounts(); + }, [fetchAccounts]); + + const linkedProviderIds = new Set(accounts.map((a) => a.providerId)); + const socialAccounts = accounts.filter((a) => + TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider), + ); + + const handleLinkSocial = async (provider: SocialProvider) => { + setLinkingProvider(provider); + try { + const { error } = await authClient.linkSocial({ + provider, + callbackURL: LINKING_CALLBACK_URL, + }); + if (error) { + toast.error(error.message ?? "Failed to link account"); + setLinkingProvider(null); + return; + } + } catch (err) { + toast.error( + "Failed to link account", + err instanceof Error ? { description: err.message } : undefined, + ); + setLinkingProvider(null); + } + }; + + const handleUnlink = async (providerId: string, accountId?: string) => { + setUnlinkingProviderId(providerId); + try { + const { error } = await authClient.unlinkAccount({ + providerId, + ...(accountId && { accountId }), + }); + if (error) { + toast.error(error.message ?? "Failed to unlink account"); + return; + } + toast.success("Account unlinked"); + await fetchAccounts(); + } catch (err) { + toast.error( + "Failed to unlink account", + err instanceof Error ? { description: err.message } : undefined, + ); + } finally { + setUnlinkingProviderId(null); + } + }; + + const canUnlink = accounts.length > 1; + + return ( + +
+ +
+
+ + + Linking account + + + Link your Google or GitHub account to sign in with them. + +
+
+
+ + {/* Linked accounts */} +
+

Linked accounts

+ {accountsLoading ? ( +
+ + Loading... +
+ ) : socialAccounts.length === 0 ? ( +

+ No social accounts linked yet. +

+ ) : ( +
    + {socialAccounts.map((acc) => ( +
  • + + {providerLabel(acc.providerId)} + + {canUnlink && ( + + )} +
  • + ))} +
+ )} +
+ +

+ Click a provider below to link it to your account. You will be + redirected to complete the flow. +

+
+ {!linkedProviderIds.has("google") && ( + + )} + {!linkedProviderIds.has("github") && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index a8c8a543d..7b477c92b 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -16,6 +16,7 @@ import { LarkIcon, NtfyIcon, PushoverIcon, + ResendIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -97,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [ .min(1, { message: "At least one email is required" }), }) .merge(notificationBaseSchema), + z + .object({ + type: z.literal("resend"), + apiKey: z.string().min(1, { message: "API Key is required" }), + fromAddress: z + .string() + .min(1, { message: "From Address is required" }) + .email({ message: "Email is invalid" }), + toAddresses: z + .array( + z.string().min(1, { message: "Email is required" }).email({ + message: "Email is invalid", + }), + ) + .min(1, { message: "At least one email is required" }), + }) + .merge(notificationBaseSchema), z .object({ type: z.literal("gotify"), @@ -169,6 +187,10 @@ export const notificationsMap = { icon: , label: "Email", }, + resend: { + icon: , + label: "Resend", + }, gotify: { icon: , label: "Gotify", @@ -214,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { api.notification.testDiscordConnection.useMutation(); const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } = api.notification.testEmailConnection.useMutation(); + const { mutateAsync: testResendConnection, isLoading: isLoadingResend } = + api.notification.testResendConnection.useMutation(); const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } = api.notification.testGotifyConnection.useMutation(); const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } = @@ -242,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const emailMutation = notificationId ? api.notification.updateEmail.useMutation() : api.notification.createEmail.useMutation(); + const resendMutation = notificationId + ? api.notification.updateResend.useMutation() + : api.notification.createResend.useMutation(); const gotifyMutation = notificationId ? api.notification.updateGotify.useMutation() : api.notification.createGotify.useMutation(); @@ -281,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); useEffect(() => { - if (type === "email" && fields.length === 0) { + if ((type === "email" || type === "resend") && fields.length === 0) { append(""); } }, [type, append, fields.length]); @@ -349,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "resend") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + apiKey: notification.resend?.apiKey, + toAddresses: notification.resend?.toAddresses, + fromAddress: notification.resend?.fromAddress, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } else if (notification.notificationType === "gotify") { form.reset({ appBuildError: notification.appBuildError, @@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { telegram: telegramMutation, discord: discordMutation, email: emailMutation, + resend: resendMutation, gotify: gotifyMutation, ntfy: ntfyMutation, lark: larkMutation, @@ -525,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => { emailId: notification?.emailId || "", serverThreshold: serverThreshold, }); + } else if (data.type === "resend") { + promise = resendMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + resendId: notification?.resendId || "", + serverThreshold: serverThreshold, + }); } else if (data.type === "gotify") { promise = gotifyMutation.mutateAsync({ appBuildError: appBuildError, @@ -1042,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} + {type === "resend" && ( + <> + ( + + API Key + + + + + + )} + /> + + ( + + From Address + + + + + + )} + /> + +
+ To Addresses + + {fields.map((field, index) => ( +
+ ( + + + + + + + + )} + /> + +
+ ))} + {type === "resend" && + "toAddresses" in form.formState.errors && ( +
+ {form.formState?.errors?.toAddresses?.root?.message} +
+ )} +
+ + + + )} + {type === "gotify" && ( <> { isLoadingTelegram || isLoadingDiscord || isLoadingEmail || + isLoadingResend || isLoadingGotify || isLoadingNtfy || isLoadingLark || @@ -1667,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { fromAddress: data.fromAddress, toAddresses: data.toAddresses, }); + } else if (data.type === "resend") { + await testResendConnection({ + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + }); } else if (data.type === "gotify") { await testGotifyConnection({ serverUrl: data.serverUrl, diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 06ffd91e4..a3c1377ae 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -5,6 +5,7 @@ import { GotifyIcon, LarkIcon, NtfyIcon, + ResendIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -36,7 +37,7 @@ export const ShowNotifications = () => {
Add your providers to receive notifications, like Discord, Slack, - Telegram, Email, Lark. + Telegram, Email, Resend, Lark.
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "resend" && ( +
+ +
+ )} {notification.notificationType === "gotify" && (
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx index 2bafe7e64..42b73cf59 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx @@ -23,6 +23,8 @@ export const ShowDokployActions = () => { const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation(); const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation(); + const { mutateAsync: cleanAllDeploymentQueue } = + api.settings.cleanAllDeploymentQueue.useMutation(); return ( @@ -87,6 +89,21 @@ export const ShowDokployActions = () => { Clean Redis + { + await cleanAllDeploymentQueue() + .then(() => { + toast.success("Deployment queue cleaned"); + }) + .catch(() => { + toast.error("Error cleaning deployment queue"); + }); + }} + > + Clean all deployment queue + + { diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index aebba8877..5b4d751ff 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -12,6 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; @@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => { serverId, }); + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik dashboard updated successfully", + onSuccess: () => { + refetchDashboard(); + }, + }); + + const { + execute: executeReloadWithHealthCheck, + isExecuting: isReloadHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik Reloaded", + }); + return (
} onClick={async () => { - await toggleDashboard({ - enableDashboard: !haveTraefikDashboardPortEnabled, - serverId: serverId, - }) - .then(async () => { - toast.success( - `${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`, - ); - refetchDashboard(); - }) - .catch((error) => { - const errorMessage = - error?.message || - "Failed to toggle dashboard. Please check if port 8080 is available."; - toast.error(errorMessage); - }); + try { + await executeWithHealthCheck(() => + toggleDashboard({ + enableDashboard: !haveTraefikDashboardPortEnabled, + serverId: serverId, + }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + } }} - disabled={toggleDashboardIsLoading} + disabled={toggleDashboardIsLoading || isHealthCheckExecuting} type="default" > { const { mutateAsync, isLoading, error, isError } = api.settings.writeTraefikEnv.useMutation(); + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + successMessage: "Traefik Env Updated", + }); + const form = useForm({ defaultValues: { env: data || "", @@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { }, [form, form.reset, data]); const onSubmit = async (data: Schema) => { - await mutateAsync({ - env: data.env, - serverId, - }) - .then(async () => { - toast.success("Traefik Env Updated"); - }) - .catch(() => { - toast.error("Error updating the Traefik env"); - }); + try { + await executeWithHealthCheck(() => + mutateAsync({ + env: data.env, + serverId, + }), + ); + } catch { + toast.error("Error updating the Traefik env"); + } }; // Add keyboard shortcut for Ctrl+S/Cmd+S @@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare diff --git a/apps/dokploy/components/icons/notification-icons.tsx b/apps/dokploy/components/icons/notification-icons.tsx index 87bb6c0ae..05f66146a 100644 --- a/apps/dokploy/components/icons/notification-icons.tsx +++ b/apps/dokploy/components/icons/notification-icons.tsx @@ -257,3 +257,23 @@ export const PushoverIcon = ({ className }: Props) => { ); }; + +export const ResendIcon = ({ className }: Props) => { + return ( + + + + + ); +}; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index d256a5119..52f8b5bfb 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -18,8 +18,10 @@ import { Forward, GalleryVerticalEnd, GitBranch, + Key, KeyRound, Loader2, + LogIn, type LucideIcon, Package, PieChart, @@ -396,6 +398,23 @@ const MENU: Menu = { // Only enabled for admins in cloud environments isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), }, + { + isSingle: true, + title: "License", + url: "/dashboard/settings/license", + icon: Key, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "SSO", + url: "/dashboard/settings/sso", + icon: LogIn, + // Enabled for admins in both cloud and self-hosted (enterprise) + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), + }, ], help: [ diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx new file mode 100644 index 000000000..988eeae05 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; + +export function SignInWithGithub() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "github", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with GitHub", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx new file mode 100644 index 000000000..bff0e69ab --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; + +export function SignInWithGoogle() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "google", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with Google", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx new file mode 100644 index 000000000..875813fcb --- /dev/null +++ b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Loader2, Lock } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; + +interface EnterpriseFeatureLockedProps { + /** Optional title override */ + title?: string; + /** Optional description override */ + description?: string; + /** Optional custom CTA label */ + ctaLabel?: string; + /** Optional CTA href (default: /dashboard/settings/license) */ + ctaHref?: string; + /** Compact variant (less padding, smaller icon) */ + compact?: boolean; +} + +/** + * Displays a locked state for enterprise features when the user has no valid license. + * Use standalone or via EnterpriseFeatureGate. + */ +export function EnterpriseFeatureLocked({ + title = "Enterprise feature", + description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.", + ctaLabel = "Go to License", + ctaHref = "/dashboard/settings/license", + compact = false, +}: EnterpriseFeatureLockedProps) { + return ( + + +
+
+ +
+
+ {title} + + {description} + +
+
+
+ +
+ +
+
+
+ ); +} + +interface EnterpriseFeatureGateProps { + children: React.ReactNode; + /** Props for the locked state when license is invalid */ + lockedProps?: Omit; + /** Show loading spinner while checking license */ + fallback?: React.ReactNode; +} + +/** + * Renders children only when the instance has a valid enterprise license. + * Otherwise shows EnterpriseFeatureLocked. + */ +export function EnterpriseFeatureGate({ + children, + lockedProps, + fallback, +}: EnterpriseFeatureGateProps) { + const { data: haveValidLicense, isLoading } = + api.licenseKey.haveValidLicenseKey.useQuery(); + + if (isLoading) { + if (fallback) return <>{fallback}; + return ( +
+ + + Checking license... + +
+ ); + } + + if (!haveValidLicense) { + return ; + } + + return <>{children}; +} diff --git a/apps/dokploy/components/proprietary/license-keys/license-key.tsx b/apps/dokploy/components/proprietary/license-keys/license-key.tsx new file mode 100644 index 000000000..a1b29c6cd --- /dev/null +++ b/apps/dokploy/components/proprietary/license-keys/license-key.tsx @@ -0,0 +1,237 @@ +import { Key, Loader2, ShieldCheck } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +export function LicenseKeySettings() { + const utils = api.useUtils(); + const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery(); + const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } = + api.licenseKey.updateEnterpriseSettings.useMutation(); + const { mutateAsync: activateLicenseKey, isLoading: isActivating } = + api.licenseKey.activate.useMutation(); + const { mutateAsync: validateLicenseKey, isLoading: isValidating } = + api.licenseKey.validate.useMutation(); + const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } = + api.licenseKey.deactivate.useMutation(); + const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } = + api.licenseKey.haveValidLicenseKey.useQuery(); + const [licenseKey, setLicenseKey] = useState(""); + + useEffect(() => { + if (data?.licenseKey) { + setLicenseKey(data.licenseKey); + } + }, [data?.licenseKey]); + + const enabled = !!data?.enableEnterpriseFeatures; + + return ( +
+ {isCheckingLicenseKey ? ( +
+ + + Checking license key... + +
+ ) : ( + <> +
+
+
+ + License Key +
+ + {enabled && ( +
+ + {enabled ? "Enabled" : "Disabled"} + + { + try { + await updateEnterpriseSettings({ + enableEnterpriseFeatures: next, + }); + await utils.licenseKey.getEnterpriseSettings.invalidate(); + toast.success("Enterprise features updated"); + } catch (error) { + console.error(error); + toast.error("Failed to update enterprise features"); + } + }} + /> +
+ )} +
+ +

+ To unlock extra features you need an enterprise license key. + Contact us{" "} + + here + + . +

+
+ {enabled ? ( + <> +
+
+ + setLicenseKey(e.target.value)} + /> +
+
+ {haveValidLicenseKey && ( + { + try { + await deactivateLicenseKey(); + await utils.licenseKey.getEnterpriseSettings.invalidate(); + await utils.licenseKey.haveValidLicenseKey.invalidate(); + setLicenseKey(""); + toast.success("License key deactivated"); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : "Failed to deactivate license key", + ); + } + }} + disabled={isDeactivating || !haveValidLicenseKey} + > + + + )} + {haveValidLicenseKey && ( + + )} + {!haveValidLicenseKey && ( + + )} +
+
+ + ) : ( +
+
+
+ +
+
+

Enterprise Features

+

+ Unlock advanced capabilities like SSO, Audit logs, + whitelabeling and more. +

+
+
+ + +
+ )} + + )} +
+ ); +} diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx new file mode 100644 index 000000000..77a68a55a --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import type { FieldArrayPath } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const DEFAULT_SCOPES = ["openid", "email", "profile"]; + +const domainsArraySchema = z + .array(z.string().trim()) + .superRefine((arr, ctx) => { + const filled = arr.filter((s) => s.length > 0); + if (filled.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one domain is required", + path: [], + }); + } + }); + +const scopesArraySchema = z.array(z.string().trim()); + +const oidcProviderSchema = z.object({ + providerId: z.string().min(1, "Provider ID is required").trim(), + issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(), + domains: domainsArraySchema, + clientId: z.string().min(1, "Client ID is required").trim(), + clientSecret: z.string().min(1, "Client secret is required"), + scopes: scopesArraySchema, +}); + +type OidcProviderForm = z.infer; + +interface RegisterOidcDialogProps { + children: React.ReactNode; +} + +const formDefaultValues = { + providerId: "", + issuer: "", + domains: [""], + clientId: "", + clientSecret: "", + scopes: [...DEFAULT_SCOPES], +}; + +export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) { + const utils = api.useUtils(); + const [open, setOpen] = useState(false); + const { mutateAsync, isLoading } = api.sso.register.useMutation(); + + const form = useForm({ + resolver: zodResolver(oidcProviderSchema), + defaultValues: formDefaultValues, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "domains" as FieldArrayPath, + }); + + const { + fields: scopeFields, + append: appendScope, + remove: removeScope, + } = useFieldArray({ + control: form.control, + name: "scopes" as FieldArrayPath, + }); + + const isSubmitting = form.formState.isSubmitting; + + const onSubmit = async (data: OidcProviderForm) => { + try { + const scopes = data.scopes.filter(Boolean).length + ? data.scopes.filter(Boolean) + : DEFAULT_SCOPES; + + const isAzure = data.issuer.includes("login.microsoftonline.com"); + const mapping = isAzure + ? { + id: "sub", + email: "preferred_username", + emailVerified: "email_verified", + name: "name", + } + : { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "preferred_username", + image: "picture", + }; + await mutateAsync({ + providerId: data.providerId, + issuer: data.issuer, + domains: data.domains, + oidcConfig: { + clientId: data.clientId, + clientSecret: data.clientSecret, + scopes, + pkce: true, + mapping, + }, + }); + + toast.success("OIDC provider registered successfully"); + form.reset(formDefaultValues); + setOpen(false); + await utils.sso.listProviders.invalidate(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to register SSO provider", + ); + } + }; + + return ( + + {children} + + + Register OIDC provider + + Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, + Google Workspace, Auth0, Keycloak). Discovery will fill endpoints + from the issuer URL when possible. + + +
+ + ( + + Provider ID + + + + + Unique identifier; used in callback URL path. + + + + )} + /> + ( + + Issuer URL + + + + + Discovery document is fetched from{" "} + + {"{issuer}"}/.well-known/openid-configuration + + + + + )} + /> +
+
+ Domains + +
+

+ Email domains that use this provider (sign-in by email and org + assignment; subdomains matched automatically). +

+ {fields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} + {(() => { + const err = form.formState.errors.domains; + const msg = + typeof err?.message === "string" + ? err.message + : (err as { root?: { message?: string } } | undefined)?.root + ?.message; + return msg ? ( +

{msg}

+ ) : null; + })()} +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client secret + + + + + + )} + /> +
+
+ Scopes (optional) + +
+ + OIDC scopes to request (e.g. openid, email, profile). If empty, + openid, email and profile are used. + + {scopeFields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} +
+ + + + + + +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx new file mode 100644 index 000000000..4835eb6b8 --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const domainsArraySchema = z + .array(z.string().trim()) + .superRefine((arr, ctx) => { + const filled = arr.filter((s) => s.length > 0); + if (filled.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one domain is required", + path: [], + }); + } + }); + +const samlProviderSchema = z.object({ + providerId: z.string().min(1, "Provider ID is required").trim(), + issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(), + domains: domainsArraySchema, + entryPoint: z + .string() + .min(1, "IdP SSO URL is required") + .url("Invalid URL") + .trim(), + cert: z.string().min(1, "IdP signing certificate is required"), + idpMetadataXml: z.string().optional(), +}); + +type SamlProviderForm = z.infer; + +interface RegisterSamlDialogProps { + children: React.ReactNode; +} + +const formDefaultValues: SamlProviderForm = { + providerId: "", + issuer: "", + domains: [""], + entryPoint: "", + cert: "", + idpMetadataXml: "", +}; + +export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) { + const utils = api.useUtils(); + const [open, setOpen] = useState(false); + const { mutateAsync, isLoading } = api.sso.register.useMutation(); + + const [baseURL, setBaseURL] = useState(""); + + useEffect(() => { + if (typeof window !== "undefined") { + setBaseURL(window.location.origin); + } + }, []); + + const form = useForm({ + resolver: zodResolver(samlProviderSchema), + defaultValues: formDefaultValues, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "domains" as FieldArrayPath, + }); + + const isSubmitting = form.formState.isSubmitting; + + const onSubmit = async (data: SamlProviderForm) => { + try { + // maybe add the /saml/metadata endpoint to the baseURL + const baseURLWithMetadata = `${baseURL}/saml/metadata`; + const generateSpMetadata = (providerId: string) => { + return ` + + + + +`; + }; + + await mutateAsync({ + providerId: data.providerId, + issuer: data.issuer, + domains: data.domains, + samlConfig: { + entryPoint: data.entryPoint, + cert: data.cert, + callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`, + audience: baseURL, + idpMetadata: data.idpMetadataXml?.trim() + ? { metadata: data.idpMetadataXml.trim() } + : undefined, + spMetadata: { + metadata: generateSpMetadata(data.providerId), + }, + mapping: { + id: "nameID", + email: "email", + name: "displayName", + firstName: "givenName", + lastName: "surname", + }, + }, + }); + + toast.success("SAML provider registered successfully"); + form.reset(formDefaultValues); + setOpen(false); + await utils.sso.listProviders.invalidate(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to register SAML provider", + ); + } + }; + + return ( + + {children} + + + Register SAML provider + + Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, + OneLogin). You need the IdP's SSO URL and signing certificate. + + +
+ + ( + + Provider ID + + + + + + )} + /> + ( + + Issuer URL + + + + + + )} + /> +
+
+ Domains + +
+ + Email domains that use this provider (sign-in by email and org + assignment; subdomains matched automatically). + + {fields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} + {(() => { + const err = form.formState.errors.domains; + const msg = + typeof err?.message === "string" + ? err.message + : (err as { root?: { message?: string } } | undefined)?.root + ?.message; + return msg ? ( +

{msg}

+ ) : null; + })()} +
+ ( + + IdP SSO URL (Entry point) + + + + + Single Sign-On URL from your IdP's SAML setup. + + + + )} + /> + ( + + IdP signing certificate (X.509) + +