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/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 31dbc48fb..bfdc8c48b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -24,14 +24,14 @@ jobs: - name: Install Nixpacks if: matrix.job == 'test' run: | - export NIXPACKS_VERSION=1.39.0 + export NIXPACKS_VERSION=1.41.0 curl -sSL https://nixpacks.com/install.sh | bash echo "Nixpacks installed $NIXPACKS_VERSION" - + - name: Install Railpack if: matrix.job == 'test' run: | - export RAILPACK_VERSION=0.15.0 + export RAILPACK_VERSION=0.15.4 curl -sSL https://railpack.com/install.sh | bash echo "Railpack installed $RAILPACK_VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38a36345e..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 @@ -148,7 +149,7 @@ curl -sSL https://railpack.com/install.sh | sh ```bash # Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack +curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack ``` ## Pull Request @@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/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 ae8c997f8..262862ca6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,18 +51,22 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash -ARG NIXPACKS_VERSION=1.39.0 +ARG NIXPACKS_VERSION=1.41.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ && pnpm install -g tsx # Install Railpack -ARG RAILPACK_VERSION=0.2.2 +ARG RAILPACK_VERSION=0.15.4 RUN curl -sSL https://railpack.com/install.sh | bash # Install buildpacks -COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack +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/Dockerfile.cloud b/Dockerfile.cloud index ee42cd2bd..a0de32021 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash RUN pnpm install -g tsx EXPOSE 3000 -CMD [ "pnpm", "start" ] \ No newline at end of file +CMD [ "pnpm", "start" ] diff --git a/Dockerfile.schedule b/Dockerfile.schedule index ecb125e09..ce1f96edf 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -35,4 +35,5 @@ COPY --from=build /prod/schedules/dist ./dist COPY --from=build /prod/schedules/package.json ./package.json COPY --from=build /prod/schedules/node_modules ./node_modules -CMD HOSTNAME=0.0.0.0 && pnpm start \ No newline at end of file +ENV HOSTNAME=0.0.0.0 +CMD ["pnpm", "start"] diff --git a/Dockerfile.server b/Dockerfile.server index ea6b372e8..f5aa25c1e 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -35,4 +35,5 @@ COPY --from=build /prod/api/dist ./dist COPY --from=build /prod/api/package.json ./package.json COPY --from=build /prod/api/node_modules ./node_modules -CMD HOSTNAME=0.0.0.0 && pnpm start \ No newline at end of file +ENV HOSTNAME=0.0.0.0 +CMD ["pnpm", "start"] diff --git a/LICENSE.MD b/LICENSE.MD index 6cbef2c6d..bcef8b36e 100644 --- a/LICENSE.MD +++ b/LICENSE.MD @@ -1,8 +1,13 @@ -# License +Copyright 2026-present Dokploy Technology, Inc. -## Core License (Apache License 2.0) +Portions of this software are licensed as follows: -Copyright 2025 Mauricio Siu. +* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY". +* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below. + +## Apache License 2.0 + +Copyright 2026-present Dokploy Technology, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -## Additional Terms for Specific Features -The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License: - -- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version. -- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent. -- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service. - -For further inquiries or permissions, please contact us directly. diff --git a/LICENSE_PROPRIETARY.md b/LICENSE_PROPRIETARY.md new file mode 100644 index 000000000..0f4957575 --- /dev/null +++ b/LICENSE_PROPRIETARY.md @@ -0,0 +1,11 @@ +The Dokploy Source Available license (DSAL) version 1.0 + +Copyright (c) 2026-present Dokploy Technology, Inc. + +With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License.  Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription.  You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications.  You are not granted any other rights beyond what is expressly stated herein.  Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software. + +This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE. + +For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component. \ No newline at end of file diff --git a/README.md b/README.md index 23fcd0c9d..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,72 +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) - - - - -### Hero Sponsors 🎖 - -
- Hostinger - LX Aer - - - - - - -
- - - - - -### Premium Supporters 🥇 - -
- Supafort.com - agentdock.ai -
- - - - - -### Elite Contributors 🥈 - -
- AmericanCloud - Tolgee -
- -### Supporting Members 🥉 - -
- - Cloudblast.io - - Synexa -
- -### 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 dfc2a355d..70c8aaac8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,9 +13,8 @@ "@dokploy/server": "workspace:*", "@hono/node-server": "^1.14.3", "@hono/zod-validator": "0.3.0", - "@nerimity/mimiqueue": "1.2.3", "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", @@ -24,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/api/src/schema.ts b/apps/api/src/schema.ts index 5a4355956..e2f37cd1c 100644 --- a/apps/api/src/schema.ts +++ b/apps/api/src/schema.ts @@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [ titleLog: z.string().optional(), descriptionLog: z.string().optional(), server: z.boolean().optional(), - type: z.enum(["deploy"]), + type: z.enum(["deploy", "redeploy"]), applicationType: z.literal("application-preview"), serverId: z.string().min(1), }), diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts index 0d0b574fc..13708006a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -4,6 +4,7 @@ import { deployPreviewApplication, rebuildApplication, rebuildCompose, + rebuildPreviewApplication, updateApplicationStatus, updateCompose, updatePreviewDeployment, @@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => { previewStatus: "running", }); if (job.server) { - if (job.type === "deploy") { + if (job.type === "redeploy") { + await rebuildPreviewApplication({ + applicationId: job.applicationId, + titleLog: job.titleLog || "Rebuild Preview Deployment", + descriptionLog: job.descriptionLog || "", + previewDeploymentId: job.previewDeploymentId, + }); + } else if (job.type === "deploy") { await deployPreviewApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Preview Deployment", diff --git a/apps/dokploy/.env.production.example b/apps/dokploy/.env.production.example index 41e934c3a..560faf9e6 100644 --- a/apps/dokploy/.env.production.example +++ b/apps/dokploy/.env.production.example @@ -1,3 +1,2 @@ -DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy" PORT=3000 NODE_ENV=production \ No newline at end of file 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 adbed084c..dc795fd35 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -25,10 +25,11 @@ if (typeof window === "undefined") { } const baseApp: ApplicationNested = { - railpackVersion: "0.2.2", + railpackVersion: "0.15.4", applicationId: "", previewLabels: [], createEnvFile: true, + bitbucketRepositorySlug: "", herokuVersion: "", giteaBranch: "", buildServerId: "", diff --git a/apps/dokploy/__test__/env/environment-access-fallback.test.ts b/apps/dokploy/__test__/env/environment-access-fallback.test.ts new file mode 100644 index 000000000..a4b56393a --- /dev/null +++ b/apps/dokploy/__test__/env/environment-access-fallback.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; + +// Type definitions matching the project structure +type Environment = { + environmentId: string; + name: string; + isDefault: boolean; +}; + +type Project = { + projectId: string; + name: string; + environments: Environment[]; +}; + +/** + * Helper function that selects the appropriate environment for a user + * This matches the logic used in search-command.tsx and show.tsx + */ +function selectAccessibleEnvironment( + project: Project | null | undefined, +): Environment | null { + if (!project || !project.environments || project.environments.length === 0) { + return null; + } + + // Find default environment from accessible environments, or fall back to first accessible environment + const defaultEnvironment = + project.environments.find((environment) => environment.isDefault) || + project.environments[0]; + + return defaultEnvironment || null; +} + +describe("Environment Access Fallback", () => { + describe("selectAccessibleEnvironment", () => { + it("should return default environment when user has access to it", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-prod", + name: "production", + isDefault: true, + }, + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-prod"); + expect(result?.isDefault).toBe(true); + }); + + it("should return first accessible environment when user doesn't have access to default", () => { + // Simulating filtered environments (user only has access to development) + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + // Note: production is not in the list because user doesn't have access + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + { + environmentId: "env-staging", + name: "staging", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-dev"); + expect(result?.name).toBe("development"); + }); + + it("should return first environment when no default is marked but environments exist", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + { + environmentId: "env-staging", + name: "staging", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-dev"); + }); + + it("should return null when project has no accessible environments", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).toBeNull(); + }); + + it("should return null when project is null", () => { + const result = selectAccessibleEnvironment(null); + + expect(result).toBeNull(); + }); + + it("should return null when project is undefined", () => { + const result = selectAccessibleEnvironment(undefined); + + expect(result).toBeNull(); + }); + + it("should handle project with single accessible environment", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-dev"); + }); + + it("should prioritize default environment even when it's not first in the array", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + { + environmentId: "env-staging", + name: "staging", + isDefault: false, + }, + { + environmentId: "env-prod", + name: "production", + isDefault: true, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-prod"); + expect(result?.isDefault).toBe(true); + }); + + it("should handle multiple default environments by returning the first one found", () => { + // Edge case: multiple environments marked as default (shouldn't happen, but test it) + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-prod-1", + name: "production-1", + isDefault: true, + }, + { + environmentId: "env-prod-2", + name: "production-2", + isDefault: true, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.isDefault).toBe(true); + // Should return the first default found + expect(result?.environmentId).toBe("env-prod-1"); + }); + + it("should work correctly when user has access to multiple environments including default", () => { + const project: Project = { + projectId: "proj-1", + name: "Test Project", + environments: [ + { + environmentId: "env-prod", + name: "production", + isDefault: true, + }, + { + environmentId: "env-dev", + name: "development", + isDefault: false, + }, + { + environmentId: "env-staging", + name: "staging", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-prod"); + expect(result?.isDefault).toBe(true); + }); + + it("should handle real-world scenario: user with only development access", () => { + // This simulates the exact bug we're fixing: + // User has access to development but not production (default) + // The filtered environments array only contains development + const project: Project = { + projectId: "proj-1", + name: "My Project", + environments: [ + // Only development is accessible (production was filtered out) + { + environmentId: "env-dev-123", + name: "development", + isDefault: false, + }, + ], + }; + + const result = selectAccessibleEnvironment(project); + + expect(result).not.toBeNull(); + expect(result?.environmentId).toBe("env-dev-123"); + expect(result?.name).toBe("development"); + // Should not be null even though it's not the default + }); + }); + + describe("Environment selection edge cases", () => { + it("should handle project with environments property as undefined", () => { + const project = { + projectId: "proj-1", + name: "Test Project", + environments: undefined, + } as unknown as Project; + + const result = selectAccessibleEnvironment(project); + + expect(result).toBeNull(); + }); + + it("should handle project with null environments array", () => { + const project = { + projectId: "proj-1", + name: "Test Project", + environments: null, + } as unknown as Project; + + const result = selectAccessibleEnvironment(project); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/dokploy/__test__/env/stack-environment.test.ts b/apps/dokploy/__test__/env/stack-environment.test.ts new file mode 100644 index 000000000..13f5adb53 --- /dev/null +++ b/apps/dokploy/__test__/env/stack-environment.test.ts @@ -0,0 +1,184 @@ +import { getEnviromentVariablesObject } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +const projectEnv = ` +ENVIRONMENT=staging +DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db +PORT=3000 +`; + +const environmentEnv = ` +NODE_ENV=development +API_URL=https://api.dev.example.com +REDIS_URL=redis://localhost:6379 +DATABASE_NAME=dev_database +SECRET_KEY=env-secret-123 +`; + +describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => { + it("resolves environment variables correctly for Stack compose", () => { + const serviceEnv = ` +FOO=\${{environment.NODE_ENV}} +BAR=\${{environment.API_URL}} +BAZ=test +`; + + const result = getEnviromentVariablesObject( + serviceEnv, + projectEnv, + environmentEnv, + ); + + expect(result).toEqual({ + FOO: "development", + BAR: "https://api.dev.example.com", + BAZ: "test", + }); + }); + + it("resolves both project and environment variables for Stack compose", () => { + const serviceEnv = ` +ENVIRONMENT=\${{project.ENVIRONMENT}} +NODE_ENV=\${{environment.NODE_ENV}} +API_URL=\${{environment.API_URL}} +DATABASE_URL=\${{project.DATABASE_URL}} +SERVICE_PORT=4000 +`; + + const result = getEnviromentVariablesObject( + serviceEnv, + projectEnv, + environmentEnv, + ); + + expect(result).toEqual({ + ENVIRONMENT: "staging", + NODE_ENV: "development", + API_URL: "https://api.dev.example.com", + DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db", + SERVICE_PORT: "4000", + }); + }); + + it("handles multiple environment references in single value for Stack compose", () => { + const multiRefEnv = ` +HOST=localhost +PORT=5432 +USERNAME=postgres +PASSWORD=secret123 +`; + + const serviceEnv = ` +DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb +`; + + const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv); + + expect(result).toEqual({ + DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb", + }); + }); + + it("throws error for undefined environment variables in Stack compose", () => { + const serviceWithUndefined = ` +UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}} +`; + + expect(() => + getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv), + ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); + }); + + it("allows service variables to override environment variables in Stack compose", () => { + const serviceOverrideEnv = ` +NODE_ENV=production +API_URL=\${{environment.API_URL}} +`; + + const result = getEnviromentVariablesObject( + serviceOverrideEnv, + "", + environmentEnv, + ); + + expect(result).toEqual({ + NODE_ENV: "production", + API_URL: "https://api.dev.example.com", + }); + }); + + it("resolves complex references with project, environment, and service variables for Stack compose", () => { + const complexServiceEnv = ` +FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}} +API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api +SERVICE_NAME=my-service +COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} +`; + + const result = getEnviromentVariablesObject( + complexServiceEnv, + projectEnv, + environmentEnv, + ); + + expect(result).toEqual({ + FULL_DATABASE_URL: + "postgres://postgres:postgres@localhost:5432/project_db/dev_database", + API_ENDPOINT: "https://api.dev.example.com/staging/api", + SERVICE_NAME: "my-service", + COMPLEX_VAR: "my-service-development-staging", + }); + }); + + it("maintains precedence: service > environment > project in Stack compose", () => { + const conflictingProjectEnv = ` +NODE_ENV=production-project +API_URL=https://project.api.com +DATABASE_NAME=project_db +`; + + const conflictingEnvironmentEnv = ` +NODE_ENV=development-environment +API_URL=https://environment.api.com +DATABASE_NAME=env_db +`; + + const serviceWithConflicts = ` +NODE_ENV=service-override +PROJECT_ENV=\${{project.NODE_ENV}} +ENV_VAR=\${{environment.API_URL}} +DB_NAME=\${{environment.DATABASE_NAME}} +`; + + const result = getEnviromentVariablesObject( + serviceWithConflicts, + conflictingProjectEnv, + conflictingEnvironmentEnv, + ); + + expect(result).toEqual({ + NODE_ENV: "service-override", + PROJECT_ENV: "production-project", + ENV_VAR: "https://environment.api.com", + DB_NAME: "env_db", + }); + }); + + it("handles empty environment variables in Stack compose", () => { + const serviceWithEmpty = ` +SERVICE_VAR=test +PROJECT_VAR=\${{project.ENVIRONMENT}} +`; + + const result = getEnviromentVariablesObject( + serviceWithEmpty, + projectEnv, + "", + ); + + expect(result).toEqual({ + SERVICE_VAR: "test", + PROJECT_VAR: "staging", + }); + }); +}); diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index b212fe34f..fb448e3af 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -14,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, @@ -82,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"); } @@ -99,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"); } 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 41e5451c5..bcbc74623 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -3,11 +3,12 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { - railpackVersion: "0.2.2", + railpackVersion: "0.15.4", rollbackActive: false, applicationId: "", previewLabels: [], createEnvFile: true, + bitbucketRepositorySlug: "", herokuVersion: "", giteaRepository: "", giteaOwner: "", 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 739bd87a5..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 @@ -1,205 +1,114 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { HelpCircle, Settings } from "lucide-react"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; +import { Settings } from "lucide-react"; +import { useState } from "react"; import { AlertBlock } from "@/components/shared/alert-block"; -import { CodeEditor } from "@/components/shared/code-editor"; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { api } from "@/utils/api"; +import { cn } from "@/lib/utils"; +import { + EndpointSpecForm, + HealthCheckForm, + LabelsForm, + ModeForm, + NetworkForm, + PlacementForm, + RestartPolicyForm, + RollbackConfigForm, + StopGracePeriodForm, + UpdateConfigForm, +} from "./swarm-forms"; -const HealthCheckSwarmSchema = z - .object({ - Test: z.array(z.string()).optional(), - Interval: z.number().optional(), - Timeout: z.number().optional(), - StartPeriod: z.number().optional(), - Retries: z.number().optional(), - }) - .strict(); - -const RestartPolicySwarmSchema = z - .object({ - Condition: z.string().optional(), - Delay: z.number().optional(), - MaxAttempts: z.number().optional(), - Window: z.number().optional(), - }) - .strict(); - -const PreferenceSchema = z - .object({ - Spread: z.object({ - SpreadDescriptor: z.string(), - }), - }) - .strict(); - -const PlatformSchema = z - .object({ - Architecture: z.string(), - OS: z.string(), - }) - .strict(); - -const PlacementSwarmSchema = z - .object({ - Constraints: z.array(z.string()).optional(), - Preferences: z.array(PreferenceSchema).optional(), - MaxReplicas: z.number().optional(), - Platforms: z.array(PlatformSchema).optional(), - }) - .strict(); - -const UpdateConfigSwarmSchema = z - .object({ - Parallelism: z.number(), - Delay: z.number().optional(), - FailureAction: z.string().optional(), - Monitor: z.number().optional(), - MaxFailureRatio: z.number().optional(), - Order: z.string(), - }) - .strict(); - -const ReplicatedSchema = z - .object({ - Replicas: z.number().optional(), - }) - .strict(); - -const ReplicatedJobSchema = z - .object({ - MaxConcurrent: z.number().optional(), - TotalCompletions: z.number().optional(), - }) - .strict(); - -const ServiceModeSwarmSchema = z - .object({ - Replicated: ReplicatedSchema.optional(), - Global: z.object({}).optional(), - ReplicatedJob: ReplicatedJobSchema.optional(), - GlobalJob: z.object({}).optional(), - }) - .strict(); - -const NetworkSwarmSchema = z.array( - z - .object({ - Target: z.string().optional(), - Aliases: z.array(z.string()).optional(), - DriverOpts: z.object({}).optional(), - }) - .strict(), -); - -const LabelsSwarmSchema = z.record(z.string()); - -const EndpointPortConfigSwarmSchema = z - .object({ - Protocol: z.string().optional(), - TargetPort: z.number().optional(), - PublishedPort: z.number().optional(), - PublishMode: z.string().optional(), - }) - .strict(); - -const EndpointSpecSwarmSchema = z - .object({ - Mode: z.string().optional(), - Ports: z.array(EndpointPortConfigSwarmSchema).optional(), - }) - .strict(); - -const createStringToJSONSchema = (schema: z.ZodTypeAny) => { - return z - .string() - .transform((str, ctx) => { - if (str === null || str === "") { - return null; - } - try { - return JSON.parse(str); - } catch { - ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); - return z.NEVER; - } - }) - .superRefine((data, ctx) => { - if (data === null) { - return; - } - - if (Object.keys(data).length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Object cannot be empty", - }); - return; - } - - const parseResult = schema.safeParse(data); - if (!parseResult.success) { - for (const error of parseResult.error.issues) { - const path = error.path.join("."); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${path} ${error.message}`, - }); - } - } - }); +type MenuItem = { + id: string; + label: string; + description: string; + docDescription: string; }; -const addSwarmSettings = z.object({ - healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(), - restartPolicySwarm: createStringToJSONSchema( - RestartPolicySwarmSchema, - ).nullable(), - placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(), - updateConfigSwarm: createStringToJSONSchema( - UpdateConfigSwarmSchema, - ).nullable(), - rollbackConfigSwarm: createStringToJSONSchema( - UpdateConfigSwarmSchema, - ).nullable(), - modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(), - labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(), - networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(), - stopGracePeriodSwarm: z.bigint().nullable(), - endpointSpecSwarm: createStringToJSONSchema( - EndpointSpecSwarmSchema, - ).nullable(), -}); - -type AddSwarmSettings = z.infer; +const menuItems: MenuItem[] = [ + { + id: "health-check", + label: "Health Check", + description: "Configure health check settings", + docDescription: + "Configure HEALTHCHECK to test a container's health. Determines if a container is healthy by running a command inside the container. Test, Interval, Timeout, StartPeriod, and Retries control health monitoring.", + }, + { + id: "restart-policy", + label: "Restart Policy", + description: "Configure restart policy", + docDescription: + "Configure the restart policy for containers in the service. Condition (none, on-failure, any), Delay (nanoseconds between restarts), MaxAttempts, and Window control restart behavior.", + }, + { + id: "placement", + label: "Placement", + description: "Configure placement constraints", + docDescription: + "Control which nodes service tasks can be scheduled on. Constraints (node.id==xyz), Preferences (spread.node.labels.zone), MaxReplicas, and Platforms specify task placement rules.", + }, + { + id: "update-config", + label: "Update Config", + description: "Configure update strategy", + docDescription: + "Configure how the service should be updated. Parallelism (tasks updated simultaneously), Delay, FailureAction (pause, continue, rollback), Monitor, MaxFailureRatio, and Order (stop-first, start-first) control updates.", + }, + { + id: "rollback-config", + label: "Rollback Config", + description: "Configure rollback strategy", + docDescription: + "Configure automated rollback on update failure. Uses same parameters as UpdateConfig: Parallelism, Delay, FailureAction, Monitor, MaxFailureRatio, and Order.", + }, + { + id: "mode", + label: "Mode", + description: "Configure service mode", + 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", + description: "Configure service labels", + docDescription: + "Add metadata to services using labels. Labels are key-value pairs (e.g., com.example.foo=bar) for organizing and filtering services.", + }, + { + id: "stop-grace-period", + label: "Stop Grace Period", + description: "Configure stop grace period", + docDescription: + "Time to wait before forcefully killing a container. Specified in nanoseconds (e.g., 10000000000 = 10 seconds). Allows containers to shutdown gracefully.", + }, + { + id: "endpoint-spec", + label: "Endpoint Spec", + description: "Configure endpoint specification", + docDescription: + "Configure endpoint mode for service discovery. Mode 'vip' (virtual IP - default) uses a single virtual IP. Mode 'dnsrr' (DNS round-robin) returns DNS entries for all tasks.", + }, +]; const hasStopGracePeriodSwarm = ( value: unknown, @@ -214,137 +123,23 @@ interface Props { } export const AddSwarmSettings = ({ id, type }: Props) => { - 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, isError, error, isLoading } = mutationMap[type] - ? mutationMap[type]() - : api.mongo.update.useMutation(); - - const form = useForm({ - defaultValues: { - healthCheckSwarm: null, - restartPolicySwarm: null, - placementSwarm: null, - updateConfigSwarm: null, - rollbackConfigSwarm: null, - modeSwarm: null, - labelsSwarm: null, - networkSwarm: null, - stopGracePeriodSwarm: null, - endpointSpecSwarm: 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) - : null, - restartPolicySwarm: data.restartPolicySwarm - ? JSON.stringify(data.restartPolicySwarm, null, 2) - : null, - placementSwarm: data.placementSwarm - ? JSON.stringify(data.placementSwarm, null, 2) - : null, - updateConfigSwarm: data.updateConfigSwarm - ? JSON.stringify(data.updateConfigSwarm, null, 2) - : null, - rollbackConfigSwarm: data.rollbackConfigSwarm - ? JSON.stringify(data.rollbackConfigSwarm, null, 2) - : null, - modeSwarm: data.modeSwarm - ? JSON.stringify(data.modeSwarm, null, 2) - : null, - labelsSwarm: data.labelsSwarm - ? JSON.stringify(data.labelsSwarm, null, 2) - : null, - networkSwarm: data.networkSwarm - ? JSON.stringify(data.networkSwarm, null, 2) - : null, - stopGracePeriodSwarm: normalizedStopGracePeriod, - endpointSpecSwarm: data.endpointSpecSwarm - ? JSON.stringify(data.endpointSpecSwarm, null, 2) - : null, - }); - } - }, [form, form.reset, data]); - - const onSubmit = async (data: AddSwarmSettings) => { - await mutateAsync({ - applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - mongoId: id || "", - healthCheckSwarm: data.healthCheckSwarm, - restartPolicySwarm: data.restartPolicySwarm, - placementSwarm: data.placementSwarm, - updateConfigSwarm: data.updateConfigSwarm, - rollbackConfigSwarm: data.rollbackConfigSwarm, - modeSwarm: data.modeSwarm, - labelsSwarm: data.labelsSwarm, - networkSwarm: data.networkSwarm, - stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null, - endpointSpecSwarm: data.endpointSpecSwarm, - }) - .then(async () => { - toast.success("Swarm settings updated"); - refetch(); - }) - .catch(() => { - toast.error("Error updating the swarm settings"); - }); - }; + const [activeMenu, setActiveMenu] = useState("health-check"); + const [open, setOpen] = useState(false); return ( - + - + Swarm Settings - Update certain settings using a json object. + Configure swarm settings for your service. - {isError && {error?.message}}
Changing settings such as placements may cause the logs/monitoring, @@ -352,596 +147,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
-
- - ( - - Health Check - - - - - Check the interface - - - - + {/* Left Column - Menu */} +
+ +
- - - -
-										
-									
-
- )} - /> - - ( - - Restart Policy - - - - - Check the interface - - - - - -
-														{`{
-	Condition?: string | undefined;
-	Delay?: number | undefined;
-	MaxAttempts?: number | undefined;
-	Window?: number | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Placement - - - - - Check the interface - - - - - -
-														{`{
-	Constraints?: string[] | undefined;
-	Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
-	MaxReplicas?: number | undefined;
-	Platforms?:
-		| Array<{
-				Architecture: string;
-				OS: string;
-		  }>
-		| undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Update Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Rollback Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Mode - - - - - Check the interface - - - - - -
-														{`{
-	Replicated?: { Replicas?: number | undefined } | undefined;
-	Global?: {} | undefined;
-	ReplicatedJob?:
-		| {
-				MaxConcurrent?: number | undefined;
-				TotalCompletions?: number | undefined;
-		  }
-		| undefined;
-	GlobalJob?: {} | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - ( - - Network - - - - - Check the interface - - - - - -
-														{`[
-  {
-	"Target" : string | undefined;
-	"Aliases" : string[] | undefined;
-	"DriverOpts" : { [key: string]: string } | undefined;
-  }
-]`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - ( - - Labels - - - - - Check the interface - - - - - -
-														{`{
-	[name: string]: string;
-}`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - ( - - 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, - ) - } - /> - -
-										
-									
-
- )} - /> - ( - - Endpoint Spec - - - - - Check the interface - - - - - -
-														{`{
-	Mode?: string | undefined;
-	Ports?: Array<{
-		Protocol?: string | undefined;
-		TargetPort?: number | undefined;
-		PublishedPort?: number | undefined;
-		PublishMode?: string | undefined;
-	}> | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - - - - + {/* Right Column - Form */} +
+ {activeMenu === "health-check" && ( + + )} + {activeMenu === "restart-policy" && ( + + )} + {activeMenu === "placement" && ( + + )} + {activeMenu === "update-config" && ( + + )} + {activeMenu === "rollback-config" && ( + + )} + {activeMenu === "mode" && } + {activeMenu === "network" && } + {activeMenu === "labels" && } + {activeMenu === "stop-grace-period" && ( + + )} + {activeMenu === "endpoint-spec" && ( + + )} +
+
); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx new file mode 100644 index 000000000..7ee31e5b6 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx @@ -0,0 +1,154 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +export const endpointSpecFormSchema = z.object({ + Mode: z.string().optional(), +}); + +interface EndpointSpecFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { + 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(endpointSpecFormSchema), + defaultValues: { + Mode: undefined, + }, + }); + + useEffect(() => { + if (data?.endpointSpecSwarm) { + const es = data.endpointSpecSwarm; + form.reset({ + Mode: es.Mode, + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = + formData.Mode !== undefined && + formData.Mode !== null && + formData.Mode !== ""; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + endpointSpecSwarm: hasAnyValue ? formData : null, + }); + + toast.success("Endpoint spec updated successfully"); + refetch(); + } catch { + toast.error("Error updating endpoint spec"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Mode + Endpoint mode (vip or dnsrr) + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx new file mode 100644 index 000000000..1e0d032f0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -0,0 +1,270 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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"; + +export const healthCheckFormSchema = z.object({ + Test: z.array(z.string()).optional(), + Interval: z.coerce.number().optional(), + Timeout: z.coerce.number().optional(), + StartPeriod: z.coerce.number().optional(), + Retries: z.coerce.number().optional(), +}); + +interface HealthCheckFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { + 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(healthCheckFormSchema), + defaultValues: { + Test: [], + Interval: undefined, + Timeout: undefined, + StartPeriod: undefined, + Retries: undefined, + }, + }); + + const testCommands = form.watch("Test") || []; + + useEffect(() => { + if (data?.healthCheckSwarm) { + const hc = data.healthCheckSwarm; + form.reset({ + Test: hc.Test || [], + Interval: hc.Interval, + Timeout: hc.Timeout, + StartPeriod: hc.StartPeriod, + Retries: hc.Retries, + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = + (formData.Test && formData.Test.length > 0) || + formData.Interval !== undefined || + formData.Timeout !== undefined || + formData.StartPeriod !== undefined || + formData.Retries !== undefined; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + healthCheckSwarm: hasAnyValue ? formData : null, + }); + + toast.success("Health check updated successfully"); + refetch(); + } catch { + toast.error("Error updating health check"); + } finally { + setIsLoading(false); + } + }; + + const addTestCommand = () => { + form.setValue("Test", [...testCommands, ""]); + }; + + const updateTestCommand = (index: number, value: string) => { + const newCommands = [...testCommands]; + newCommands[index] = value; + form.setValue("Test", newCommands); + }; + + const removeTestCommand = (index: number) => { + form.setValue( + "Test", + testCommands.filter((_: string, i: number) => i !== index), + ); + }; + + return ( +
+ +
+ Test Commands + + Command to run for health check (e.g., ["CMD-SHELL", "curl -f + http://localhost:3000/health"]) + +
+ {testCommands.map((cmd: string, index: number) => ( +
+ updateTestCommand(index, e.target.value)} + placeholder={ + index === 0 + ? "CMD-SHELL" + : "curl -f http://localhost:3000/health" + } + /> + +
+ ))} + +
+
+ + ( + + Interval (nanoseconds) + + Time between health checks (e.g., 10000000000 for 10 seconds) + + + + + + + )} + /> + + ( + + Timeout (nanoseconds) + + Maximum time to wait for health check response + + + + + + + )} + /> + + ( + + Start Period (nanoseconds) + + Initial grace period before health checks begin + + + + + + + )} + /> + + ( + + Retries + + Number of consecutive failures needed to consider container + unhealthy + + + + + + + )} + /> + +
+ + +
+ + + ); +}; 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 new file mode 100644 index 000000000..df972102d --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -0,0 +1,11 @@ +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"; +export { StopGracePeriodForm } from "./stop-grace-period-form"; +export { UpdateConfigForm } from "./update-config-form"; +export { filterEmptyValues, hasValues } from "./utils"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx new file mode 100644 index 000000000..d1681dcd0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx @@ -0,0 +1,200 @@ +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"; + +export const labelsFormSchema = z.object({ + labels: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .optional(), +}); + +interface LabelsFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const LabelsForm = ({ id, type }: LabelsFormProps) => { + 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(labelsFormSchema), + defaultValues: { + labels: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "labels", + }); + + useEffect(() => { + if (data?.labelsSwarm && typeof data.labelsSwarm === "object") { + const labelEntries = Object.entries(data.labelsSwarm).map( + ([key, value]) => ({ + key, + value: value as string, + }), + ); + form.reset({ labels: labelEntries }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + const labelsObject = + formData.labels?.reduce( + (acc, { key, value }) => { + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record, + ) || {}; + + // If no labels, send null to clear the database + const labelsToSend = + Object.keys(labelsObject).length > 0 ? labelsObject : null; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + labelsSwarm: labelsToSend, + }); + + toast.success("Labels updated successfully"); + refetch(); + } catch { + toast.error("Error updating labels"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ Labels + + Add key-value labels to your service + +
+ {fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx new file mode 100644 index 000000000..839f5d519 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface ModeFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const ModeForm = ({ id, type }: ModeFormProps) => { + 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({ + defaultValues: { + type: undefined, + Replicas: undefined, + }, + }); + + const modeType = form.watch("type"); + + useEffect(() => { + if (data?.modeSwarm) { + const mode = data.modeSwarm; + if (mode.Replicated) { + form.reset({ + type: "Replicated", + Replicas: mode.Replicated.Replicas, + }); + } else if (mode.Global) { + form.reset({ + type: "Global", + Replicas: undefined, + }); + } + } + }, [data, form]); + + const onSubmit = async (formData: any) => { + setIsLoading(true); + try { + // If no type is selected, send null to clear the database + if (!formData.type) { + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + modeSwarm: null, + }); + toast.success("Mode updated successfully"); + refetch(); + setIsLoading(false); + return; + } + + const modeData = + formData.type === "Replicated" + ? { Replicated: { Replicas: formData.Replicas } } + : { Global: {} }; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + modeSwarm: modeData, + }); + + toast.success("Mode updated successfully"); + refetch(); + } catch { + toast.error("Error updating mode"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Mode Type + + Choose between replicated or global service mode + + + + + )} + /> + + {modeType === "Replicated" && ( + ( + + Replicas + Number of replicas to run + + + + + + )} + /> + )} + +
+ + +
+ + + ); +}; 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/cluster/swarm-forms/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx new file mode 100644 index 000000000..25a72b3c9 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -0,0 +1,347 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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 PreferenceSchema = z.object({ + SpreadDescriptor: z.string(), +}); + +const PlatformSchema = z.object({ + Architecture: z.string(), + OS: z.string(), +}); + +export const placementFormSchema = z.object({ + Constraints: z.array(z.string()).optional(), + Preferences: z.array(PreferenceSchema).optional(), + MaxReplicas: z.coerce.number().optional(), + Platforms: z.array(PlatformSchema).optional(), +}); + +interface PlacementFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const PlacementForm = ({ id, type }: PlacementFormProps) => { + 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(placementFormSchema), + defaultValues: { + Constraints: [], + Preferences: [], + MaxReplicas: undefined, + Platforms: [], + }, + }); + + const constraints = form.watch("Constraints") || []; + const preferences = form.watch("Preferences") || []; + const platforms = form.watch("Platforms") || []; + + useEffect(() => { + if (data?.placementSwarm) { + const placement = data.placementSwarm; + form.reset({ + Constraints: placement.Constraints || [], + Preferences: + placement.Preferences?.map((p: any) => ({ + SpreadDescriptor: p.Spread?.SpreadDescriptor || "", + })) || [], + MaxReplicas: placement.MaxReplicas, + Platforms: placement.Platforms || [], + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = + (formData.Constraints && formData.Constraints.length > 0) || + (formData.Preferences && formData.Preferences.length > 0) || + (formData.Platforms && formData.Platforms.length > 0) || + formData.MaxReplicas !== undefined; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + placementSwarm: hasAnyValue + ? { + ...formData, + Preferences: formData.Preferences?.map((p) => ({ + Spread: { SpreadDescriptor: p.SpreadDescriptor }, + })), + } + : null, + }); + + toast.success("Placement updated successfully"); + refetch(); + } catch { + toast.error("Error updating placement"); + } finally { + setIsLoading(false); + } + }; + + const addConstraint = () => { + form.setValue("Constraints", [...constraints, ""]); + }; + + const updateConstraint = (index: number, value: string) => { + const newConstraints = [...constraints]; + newConstraints[index] = value; + form.setValue("Constraints", newConstraints); + }; + + const removeConstraint = (index: number) => { + form.setValue( + "Constraints", + constraints.filter((_: string, i: number) => i !== index), + ); + }; + + const addPreference = () => { + form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]); + }; + + const updatePreference = (index: number, value: string) => { + const newPreferences = [...preferences]; + if (newPreferences[index]) { + newPreferences[index].SpreadDescriptor = value; + form.setValue("Preferences", newPreferences); + } + }; + + const removePreference = (index: number) => { + form.setValue( + "Preferences", + preferences.filter((_: any, i: number) => i !== index), + ); + }; + + const addPlatform = () => { + form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]); + }; + + const updatePlatform = ( + index: number, + field: "Architecture" | "OS", + value: string, + ) => { + const newPlatforms = [...platforms]; + if (newPlatforms[index]) { + newPlatforms[index][field] = value; + form.setValue("Platforms", newPlatforms); + } + }; + + const removePlatform = (index: number) => { + form.setValue( + "Platforms", + platforms.filter((_: any, i: number) => i !== index), + ); + }; + + return ( +
+ +
+ Constraints + + Placement constraints (e.g., "node.role==manager") + +
+ {constraints.map((constraint: string, index: number) => ( +
+ updateConstraint(index, e.target.value)} + placeholder="node.role==manager" + /> + +
+ ))} + +
+
+ +
+ Preferences + + Spread preferences for task distribution (e.g., + "node.labels.region") + +
+ {preferences.map((pref: any, index: number) => ( +
+ updatePreference(index, e.target.value)} + placeholder="node.labels.region" + /> + +
+ ))} + +
+
+ + ( + + Max Replicas + + Maximum number of replicas per node + + + + + + + )} + /> + +
+ Platforms + + Target platforms for task scheduling + +
+ {platforms.map((platform: any, index: number) => ( +
+ + updatePlatform(index, "Architecture", e.target.value) + } + placeholder="amd64" + /> + updatePlatform(index, "OS", e.target.value)} + placeholder="linux" + /> + +
+ ))} + +
+
+ +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx new file mode 100644 index 000000000..b7fb649be --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx @@ -0,0 +1,219 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +export const restartPolicyFormSchema = z.object({ + Condition: z.string().optional(), + Delay: z.coerce.number().optional(), + MaxAttempts: z.coerce.number().optional(), + Window: z.coerce.number().optional(), +}); + +interface RestartPolicyFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { + 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(restartPolicyFormSchema), + defaultValues: { + Condition: undefined, + Delay: undefined, + MaxAttempts: undefined, + Window: undefined, + }, + }); + + useEffect(() => { + if (data?.restartPolicySwarm) { + form.reset({ + Condition: data.restartPolicySwarm.Condition, + Delay: data.restartPolicySwarm.Delay, + MaxAttempts: data.restartPolicySwarm.MaxAttempts, + Window: data.restartPolicySwarm.Window, + }); + } + }, [data, form]); + + const onSubmit = async ( + formData: z.infer, + ) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = Object.values(formData).some( + (value) => value !== undefined && value !== null && value !== "", + ); + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + restartPolicySwarm: hasAnyValue ? formData : null, + }); + + toast.success("Restart policy updated successfully"); + refetch(); + } catch { + toast.error("Error updating restart policy"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Condition + When to restart the container + + + + )} + /> + + ( + + Delay (nanoseconds) + + Wait time between restart attempts + + + + + + + )} + /> + + ( + + Max Attempts + + Maximum number of restart attempts + + + + + + + )} + /> + + ( + + Window (nanoseconds) + + Time window to evaluate restart policy + + + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx new file mode 100644 index 000000000..d53215348 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx @@ -0,0 +1,257 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +export const rollbackConfigFormSchema = z.object({ + Parallelism: z.coerce.number().optional(), + Delay: z.coerce.number().optional(), + FailureAction: z.string().optional(), + Monitor: z.coerce.number().optional(), + MaxFailureRatio: z.coerce.number().optional(), + Order: z.string().optional(), +}); + +interface RollbackConfigFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { + 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(rollbackConfigFormSchema), + defaultValues: { + Parallelism: undefined, + Delay: undefined, + FailureAction: undefined, + Monitor: undefined, + MaxFailureRatio: undefined, + Order: undefined, + }, + }); + + useEffect(() => { + if (data?.rollbackConfigSwarm) { + form.reset(data.rollbackConfigSwarm); + } + }, [data, form]); + + const onSubmit = async ( + formData: z.infer, + ) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = Object.values(formData).some( + (value) => value !== undefined && value !== null && value !== "", + ); + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + rollbackConfigSwarm: (hasAnyValue ? formData : null) as any, + }); + + toast.success("Rollback config updated successfully"); + refetch(); + } catch { + toast.error("Error updating rollback config"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Parallelism + + Number of tasks to rollback simultaneously + + + + + + + )} + /> + + ( + + Delay (nanoseconds) + Delay between task rollbacks + + + + + + )} + /> + + ( + + Failure Action + Action on rollback failure + + + + )} + /> + + ( + + Monitor (nanoseconds) + + Duration to monitor for failure after rollback + + + + + + + )} + /> + + ( + + Max Failure Ratio + + Maximum failure ratio tolerated (0-1) + + + + + + + )} + /> + + ( + + Order + Rollback order strategy + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx new file mode 100644 index 000000000..a324da31b --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +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 hasStopGracePeriodSwarm = ( + value: unknown, +): value is { stopGracePeriodSwarm: bigint | number | string | null } => + typeof value === "object" && + value !== null && + "stopGracePeriodSwarm" in value; + +interface StopGracePeriodFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { + 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({ + defaultValues: { + value: null as bigint | null, + }, + }); + + useEffect(() => { + if (hasStopGracePeriodSwarm(data)) { + const value = data.stopGracePeriodSwarm; + const normalizedValue = + value === null || value === undefined + ? null + : typeof value === "bigint" + ? value + : BigInt(value); + form.reset({ + value: normalizedValue, + }); + } + }, [data, form]); + + const onSubmit = async (formData: any) => { + setIsLoading(true); + try { + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + stopGracePeriodSwarm: formData.value, + }); + + toast.success("Stop grace period updated successfully"); + refetch(); + } catch { + toast.error("Error updating stop grace period"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Stop Grace Period (nanoseconds) + + Time to wait before forcefully killing the container +
+ Examples: 30000000000 (30s), 120000000000 (2m) +
+ + + field.onChange( + e.target.value ? BigInt(e.target.value) : null, + ) + } + /> + + +
+ )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx new file mode 100644 index 000000000..4119c41f8 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx @@ -0,0 +1,264 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +export const updateConfigFormSchema = z.object({ + Parallelism: z.coerce.number().optional(), + Delay: z.coerce.number().optional(), + FailureAction: z.string().optional(), + Monitor: z.coerce.number().optional(), + MaxFailureRatio: z.coerce.number().optional(), + Order: z.string().optional(), +}); + +interface UpdateConfigFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { + 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(updateConfigFormSchema), + defaultValues: { + Parallelism: undefined, + Delay: undefined, + FailureAction: undefined, + Monitor: undefined, + MaxFailureRatio: undefined, + Order: undefined, + }, + }); + + useEffect(() => { + if (data?.updateConfigSwarm) { + const config = data.updateConfigSwarm; + form.reset({ + Parallelism: config.Parallelism, + Delay: config.Delay, + FailureAction: config.FailureAction, + Monitor: config.Monitor, + MaxFailureRatio: config.MaxFailureRatio, + Order: config.Order, + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = Object.values(formData).some( + (value) => value !== undefined && value !== null && value !== "", + ); + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + updateConfigSwarm: (hasAnyValue ? formData : null) as any, + }); + + toast.success("Update config updated successfully"); + refetch(); + } catch { + toast.error("Error updating update config"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Parallelism + + Number of tasks to update simultaneously + + + + + + + )} + /> + + ( + + Delay (nanoseconds) + Delay between task updates + + + + + + )} + /> + + ( + + Failure Action + Action on update failure + + + + )} + /> + + ( + + Monitor (nanoseconds) + + Duration to monitor for failure after update + + + + + + + )} + /> + + ( + + Max Failure Ratio + + Maximum failure ratio tolerated (0-1) + + + + + + + )} + /> + + ( + + Order + Update order strategy + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts new file mode 100644 index 000000000..58793c02e --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/utils.ts @@ -0,0 +1,31 @@ +/** + * Filters out undefined, null, and empty string values from form data + * Only returns fields that have actual values + */ +export const filterEmptyValues = ( + formData: Record, +): Record => { + return Object.entries(formData).reduce( + (acc, [key, value]) => { + // Keep arrays even if empty (they might be intentionally cleared) + if (Array.isArray(value)) { + if (value.length > 0) { + acc[key] = value; + } + } + // For other values, filter out undefined, null, and empty strings + else if (value !== undefined && value !== null && value !== "") { + acc[key] = value; + } + return acc; + }, + {} as Record, + ); +}; + +/** + * Checks if filtered data has any values to save + */ +export const hasValues = (data: Record): boolean => { + return Object.keys(data).length > 0; +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx index bf3d5d9bc..928949d9f 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx @@ -24,6 +24,8 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; const UpdateTraefikConfigSchema = z.object({ @@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => { export const UpdateTraefikConfig = ({ applicationId }: Props) => { const [open, setOpen] = useState(false); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( { applicationId, @@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { }, [data]); const onSubmit = async (data: UpdateTraefikConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: (error as string) || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: (error as string) || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { setOpen(open); if (!open) { form.reset(); + setSkipYamlValidation(false); } }} > @@ -169,7 +175,28 @@ routers: - + +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

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

+
+ + ) : ( + + )} + + + Select a Railpack version or choose manual to enter a + custom version.{" "} +
+ View releases + + + + + )} + /> + )}
@@ -364,6 +365,7 @@ export const ShowDeployments = ({ onClick={() => { setActiveLog(deployment); }} + className="w-full sm:w-auto" > View @@ -405,6 +407,7 @@ export const ShowDeployments = ({ variant="secondary" size="sm" isLoading={isRollingBack} + className="w-full sm:w-auto" > Rollback 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 1f54ddd58..01db7febc 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 @@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({ .object({ repo: z.string().min(1, "Repo is required"), owner: z.string().min(1, "Owner is required"), + slug: z.string().optional(), }) .required(), branch: z.string().min(1, "Branch is required"), @@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { repository: { owner: "", repo: "", + slug: "", }, bitbucketId: "", branch: "", @@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { } = api.bitbucket.getBitbucketBranches.useQuery( { owner: repository?.owner, - repo: repository?.repo, + repo: repository?.slug || repository?.repo || "", bitbucketId, }, { - enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId, + enabled: + !!repository?.owner && + !!(repository?.slug || repository?.repo) && + !!bitbucketId, }, ); @@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { repository: { repo: data.bitbucketRepository || "", owner: data.bitbucketOwner || "", + slug: data.bitbucketRepositorySlug || "", }, buildPath: data.bitbucketBuildPath || "/", bitbucketId: data.bitbucketId || "", @@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { await mutateAsync({ bitbucketBranch: data.branch, bitbucketRepository: data.repository.repo, + bitbucketRepositorySlug: data.repository.slug || data.repository.repo, bitbucketOwner: data.repository.owner, bitbucketBuildPath: data.buildPath, bitbucketId: data.bitbucketId, @@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { form.setValue("repository", { owner: "", repo: "", + slug: "", }); form.setValue("branch", ""); }} @@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Repository {field.value.owner && field.value.repo && ( { form.setValue("repository", { owner: repo.owner.username as string, repo: repo.name, + slug: repo.slug, }); form.setValue("branch", ""); }} 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 d6f65caf3..6197fc49f 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 @@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
Repository - {field.value.owner && field.value.repo && ( + {field.value.gitlabPathNamespace && ( { 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} +
+ )} { const { mutateAsync: deletePreviewDeployment, isLoading } = api.previewDeployment.delete.useMutation(); + const { mutateAsync: redeployPreviewDeployment } = + api.previewDeployment.redeploy.useMutation(); + const { data: previewDeployments, refetch: refetchPreviewDeployments, @@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { { applicationId }, { enabled: !!applicationId, + refetchInterval: (data) => + data?.some((d) => d.previewStatus === "running") ? 2000 : false, }, ); @@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { + { + await redeployPreviewDeployment({ + previewDeploymentId: + deployment.previewDeploymentId, + }) + .then(() => { + toast.success( + "Preview deployment rebuild started", + ); + refetchPreviewDeployments(); + }) + .catch(() => { + toast.error( + "Error rebuilding preview deployment", + ); + }); + }} + > + + + { repository: { owner: "", repo: "", + slug: "", }, bitbucketId: "", branch: "", @@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { } = api.bitbucket.getBitbucketBranches.useQuery( { owner: repository?.owner, - repo: repository?.repo, + repo: repository?.slug || repository?.repo || "", bitbucketId, }, { - enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId, + enabled: + !!repository?.owner && + !!(repository?.slug || repository?.repo) && + !!bitbucketId, }, ); @@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { repository: { repo: data.bitbucketRepository || "", owner: data.bitbucketOwner || "", + slug: data.bitbucketRepositorySlug || "", }, composePath: data.composePath, bitbucketId: data.bitbucketId || "", @@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { await mutateAsync({ bitbucketBranch: data.branch, bitbucketRepository: data.repository.repo, + bitbucketRepositorySlug: data.repository.slug || data.repository.repo, bitbucketOwner: data.repository.owner, bitbucketId: data.bitbucketId, composePath: data.composePath, @@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { form.setValue("repository", { owner: "", repo: "", + slug: "", }); form.setValue("branch", ""); }} @@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { Repository {field.value.owner && field.value.repo && ( { form.setValue("repository", { owner: repo.owner.username as string, repo: repo.name, + slug: repo.slug, }); form.setValue("branch", ""); }} 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 933abd1a2..98c2afa11 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 @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import Link from "next/link"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { const repository = form.watch("repository"); const gitlabId = form.watch("gitlabId"); + const gitlabUrl = useMemo(() => { + const url = gitlabProviders?.find( + (provider) => provider.gitlabId === gitlabId, + )?.gitlabUrl; + + const gitlabUrl = url?.replace(/\/$/, ""); + + return gitlabUrl || "https://gitlab.com"; + }, [gitlabId, gitlabProviders]); + const { data: repositories, isLoading: isLoadingRepositories, @@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
Repository - {field.value.owner && field.value.repo && ( + {field.value.gitlabPathNamespace && ( { {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/advanced/show-custom-command.tsx b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx index 5a543b477..d9841716e 100644 --- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx +++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx @@ -129,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => { Docker Image - + 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/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx index 3176b9589..67d00b0d7 100644 --- a/apps/dokploy/components/dashboard/project/add-database.tsx +++ b/apps/dokploy/components/dashboard/project/add-database.tsx @@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record = { mongo: "mongo:7", mariadb: "mariadb:11", mysql: "mysql:8", - postgres: "postgres:15", + postgres: "postgres:18", redis: "redis:7", }; @@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { type="password" placeholder="******************" autoComplete="one-time-code" + enablePasswordGenerator={true} {...field} /> @@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index a618a20ac..8234593e1 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -288,9 +288,12 @@ export const ShowProjects = () => { ) .some(Boolean); - const productionEnvironment = project?.environments.find( - (env) => env.isDefault, - ); + // Find default environment from accessible environments, or fall back to first accessible environment + const accessibleEnvironment = + project?.environments.find((env) => env.isDefault) || + project?.environments?.[0]; + + const hasNoEnvironments = !accessibleEnvironment; return (
{ className="w-full lg:max-w-md" > { + if (hasNoEnvironments) { + e.preventDefault(); + } + }} > {haveServicesWithDomains ? ( @@ -419,7 +431,7 @@ export const ShowProjects = () => { ) : null} - +
@@ -427,9 +439,19 @@ export const ShowProjects = () => {
- + {project.description} + + {hasNoEnvironments && ( +
+ + + You have access to this project but no + environments are available + +
+ )}
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/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index d53fe0037..6fd798955 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -89,7 +89,7 @@ export const SearchCommand = () => { {data?.map((project) => { - // Find default environment, or fall back to first environment + // Find default environment from accessible environments, or fall back to first accessible environment const defaultEnvironment = project.environments.find( (environment) => environment.isDefault, diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx new file mode 100644 index 000000000..67c15ee63 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx @@ -0,0 +1,74 @@ +import { CreditCard, FileText } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { ShowInvoices } from "./show-invoices"; + +const navigationItems = [ + { + name: "Subscription", + href: "/dashboard/settings/billing", + icon: CreditCard, + }, + { + name: "Invoices", + href: "/dashboard/settings/invoices", + icon: FileText, + }, +]; + +export const ShowBillingInvoices = () => { + const router = useRouter(); + + return ( +
+ +
+ + + + Billing + + + Manage your subscription and invoices + + + + + +
+ +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index ac211a1c5..1460244c1 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -4,11 +4,13 @@ import { AlertTriangle, CheckIcon, CreditCard, + FileText, Loader2, MinusIcon, PlusIcon, } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/router"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => { if (count <= 1) return 4.5; return count * 3.5; }; + +const navigationItems = [ + { + name: "Subscription", + href: "/dashboard/settings/billing", + icon: CreditCard, + }, + { + name: "Invoices", + href: "/dashboard/settings/invoices", + icon: FileText, + }, +]; + export const ShowBilling = () => { + const router = useRouter(); const { data: servers } = api.server.count.useQuery(); const { data: admin } = api.user.get.useQuery(); const { data, isLoading } = api.stripe.getProducts.useQuery(); @@ -76,17 +93,41 @@ export const ShowBilling = () => { return (
- -
- + +
+ Billing - Manage your subscription + + Manage your subscription and invoices + - -
+ + + +
{ + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +const formatAmount = (amount: number, currency: string) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); +}; + +const getStatusBadge = (status: Stripe.Invoice.Status | null) => { + const statusConfig: Record< + Stripe.Invoice.Status, + { label: string; variant: "default" | "secondary" | "destructive" } + > = { + paid: { label: "Paid", variant: "default" }, + open: { label: "Open", variant: "secondary" }, + draft: { label: "Draft", variant: "secondary" }, + void: { label: "Void", variant: "destructive" }, + uncollectible: { label: "Uncollectible", variant: "destructive" }, + }; + + if (!status) { + return Unknown; + } + + const config = statusConfig[status] || { + label: status, + variant: "secondary" as const, + }; + + return {config.label}; +}; + +export const ShowInvoices = () => { + const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery(); + + return ( +
+ {isLoading ? ( +
+ + Loading invoices... + + +
+ ) : invoices && invoices.length > 0 ? ( +
+ + + + Invoice + Date + Due Date + Amount + Status + Actions + + + + {invoices.map((invoice) => ( + + + {invoice.number || invoice.id.slice(0, 12)} + + {formatDate(invoice.created)} + {formatDate(invoice.dueDate)} + + {formatAmount(invoice.amountDue, invoice.currency)} + + {getStatusBadge(invoice.status)} + +
+ {invoice.hostedInvoiceUrl && ( + + )} + {invoice.invoicePdf && ( + + )} +
+
+
+ ))} +
+
+
+ ) : ( +
+ +

No invoices found

+

+ Your invoices will appear here once you have a subscription +

+
+ )} +
+ ); +}; 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). + + + + )} + /> + , label: "Email", }, + resend: { + icon: , + label: "Resend", + }, gotify: { icon: , label: "Gotify", @@ -166,6 +199,10 @@ export const notificationsMap = { icon: , label: "ntfy", }, + pushover: { + icon: , + label: "Pushover", + }, custom: { icon: , label: "Custom", @@ -199,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 } = @@ -209,6 +248,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } = api.notification.testCustomConnection.useMutation(); + const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } = + api.notification.testPushoverConnection.useMutation(); + const customMutation = notificationId ? api.notification.updateCustom.useMutation() : api.notification.createCustom.useMutation(); @@ -224,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(); @@ -233,6 +278,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const larkMutation = notificationId ? api.notification.updateLark.useMutation() : api.notification.createLark.useMutation(); + const pushoverMutation = notificationId + ? api.notification.updatePushover.useMutation() + : api.notification.createPushover.useMutation(); const form = useForm({ defaultValues: { @@ -260,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]); @@ -328,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, @@ -393,6 +456,23 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "pushover") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + userKey: notification.pushover?.userKey, + apiToken: notification.pushover?.apiToken, + priority: notification.pushover?.priority, + retry: notification.pushover?.retry ?? undefined, + expire: notification.pushover?.expire ?? undefined, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } } else { form.reset(); @@ -404,10 +484,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { telegram: telegramMutation, discord: discordMutation, email: emailMutation, + resend: resendMutation, gotify: gotifyMutation, ntfy: ntfyMutation, lark: larkMutation, custom: customMutation, + pushover: pushoverMutation, }; const onSubmit = async (data: NotificationSchema) => { @@ -486,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, @@ -559,6 +657,28 @@ export const HandleNotifications = ({ notificationId }: Props) => { notificationId: notificationId || "", customId: notification?.customId || "", }); + } else if (data.type === "pushover") { + if (data.priority === 2 && (data.retry == null || data.expire == null)) { + toast.error("Retry and expire are required for emergency priority (2)"); + return; + } + promise = pushoverMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + userKey: data.userKey, + apiToken: data.apiToken, + priority: data.priority, + retry: data.priority === 2 ? data.retry : undefined, + expire: data.priority === 2 ? data.expire : undefined, + name: data.name, + dockerCleanup: dockerCleanup, + serverThreshold: serverThreshold, + notificationId: notificationId || "", + pushoverId: notification?.pushoverId || "", + }); } if (promise) { @@ -981,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" && ( <> { /> )} + {type === "pushover" && ( + <> + ( + + User Key + + + + + + )} + /> + ( + + API Token + + + + + + )} + /> + ( + + Priority + + { + const value = e.target.value; + if (value === "" || value === "-") { + field.onChange(0); + } else { + const priority = Number.parseInt(value); + if ( + !Number.isNaN(priority) && + priority >= -2 && + priority <= 2 + ) { + field.onChange(priority); + } + } + }} + type="number" + min={-2} + max={2} + /> + + + Message priority (-2 to 2, default: 0, emergency: 2) + + + + )} + /> + {form.watch("priority") === 2 && ( + <> + ( + + Retry (seconds) + + { + const value = e.target.value; + if (value === "") { + field.onChange(undefined); + } else { + const retry = Number.parseInt(value); + if (!Number.isNaN(retry)) { + field.onChange(retry); + } + } + }} + type="number" + min={30} + /> + + + How often (in seconds) to retry. Minimum 30 + seconds. + + + + )} + /> + ( + + Expire (seconds) + + { + const value = e.target.value; + if (value === "") { + field.onChange(undefined); + } else { + const expire = Number.parseInt(value); + if (!Number.isNaN(expire)) { + field.onChange(expire); + } + } + }} + type="number" + min={1} + max={10800} + /> + + + How long to keep retrying (max 10800 seconds / 3 + hours). + + + + )} + /> + + )} + + )}
@@ -1425,10 +1776,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { isLoadingTelegram || isLoadingDiscord || isLoadingEmail || + isLoadingResend || isLoadingGotify || isLoadingNtfy || isLoadingLark || - isLoadingCustom + isLoadingCustom || + isLoadingPushover } variant="secondary" type="button" @@ -1464,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, @@ -1497,6 +1856,22 @@ export const HandleNotifications = ({ notificationId }: Props) => { endpoint: data.endpoint, headers: headersRecord, }); + } else if (data.type === "pushover") { + if ( + data.priority === 2 && + (data.retry == null || data.expire == null) + ) { + throw new Error( + "Retry and expire are required for emergency priority (2)", + ); + } + await testPushoverConnection({ + userKey: data.userKey, + apiToken: data.apiToken, + priority: data.priority, + retry: data.priority === 2 ? data.retry : undefined, + expire: data.priority === 2 ? data.expire : undefined, + }); } toast.success("Connection Success"); } catch (error) { 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/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index ad66aa0fa..461a4c17c 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -41,7 +41,7 @@ const profileSchema = z.object({ password: z.string().nullable(), currentPassword: z.string().nullable(), image: z.string().optional(), - name: z.string().optional(), + firstName: z.string().optional(), lastName: z.string().optional(), allowImpersonation: z.boolean().optional().default(false), }); @@ -91,7 +91,7 @@ export const ProfileForm = () => { image: data?.user?.image || "", currentPassword: "", allowImpersonation: data?.user?.allowImpersonation || false, - name: data?.user?.firstName || "", + firstName: data?.user?.firstName || "", lastName: data?.user?.lastName || "", }, resolver: zodResolver(profileSchema), @@ -106,7 +106,7 @@ export const ProfileForm = () => { image: data?.user?.image || "", currentPassword: form.getValues("currentPassword") || "", allowImpersonation: data?.user?.allowImpersonation, - name: data?.user?.firstName || "", + firstName: data?.user?.firstName || "", lastName: data?.user?.lastName || "", }, { @@ -131,7 +131,7 @@ export const ProfileForm = () => { image: values.image, currentPassword: values.currentPassword || undefined, allowImpersonation: values.allowImpersonation, - name: values.name || undefined, + firstName: values.firstName || undefined, lastName: values.lastName || undefined, }); await refetch(); @@ -141,7 +141,7 @@ export const ProfileForm = () => { password: "", image: values.image, currentPassword: "", - name: values.name || "", + firstName: values.firstName || "", lastName: values.lastName || "", }); } catch (error) { @@ -184,7 +184,7 @@ export const ProfileForm = () => {
( First Name 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" > { ) : ( - { - e.preventDefault(); + size="sm" + onClick={() => { setIsOpen(true); }} > - Setup Server - + Setup Server + )} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 85e7f3ee7..92d6fc5c3 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -6,9 +6,7 @@ import { Loader2, MoreHorizontal, Network, - Pencil, ServerIcon, - Settings, Terminal, Trash2, User, @@ -31,9 +29,7 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -285,7 +281,32 @@ export const ShowServers = () => { {/* Compact Actions */} {isActive && ( -
+
+
+ + + + + +
+

+ Setup Server +

+

+ Configure and initialize your + server with Docker, Traefik, and + other essential services +

+
+
+
+
+ {server.sshKeyId && ( @@ -311,20 +332,6 @@ export const ShowServers = () => { )} - - -
- -
-
- -

Setup Server

-
-
-
diff --git a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx index f1f7bce32..482b98579 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/edit-traefik-env.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => { 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 cc54327a8..05f66146a 100644 --- a/apps/dokploy/components/icons/notification-icons.tsx +++ b/apps/dokploy/components/icons/notification-icons.tsx @@ -231,3 +231,49 @@ export const NtfyIcon = ({ className }: Props) => { ); }; + +export const PushoverIcon = ({ className }: Props) => { + return ( + + + + + + + ); +}; + +export const ResendIcon = ({ className }: Props) => { + return ( + + + + + ); +}; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 45b6a7e3a..0062c223a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -18,9 +18,10 @@ import { Forward, GalleryVerticalEnd, GitBranch, - HeartIcon, + Key, KeyRound, Loader2, + LogIn, type LucideIcon, Package, PieChart, @@ -397,6 +398,24 @@ 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" || auth?.role === "admin"), + }, + { + 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: [ @@ -410,18 +429,6 @@ const MENU: Menu = { url: "https://discord.gg/2tBnJ3jDJc", icon: CircleHelp, }, - { - name: "Sponsor", - url: "https://opencollective.com/dokploy", - icon: ({ className }) => ( - - ), - }, ], } as const; 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..89e429ebd --- /dev/null +++ b/apps/dokploy/components/proprietary/license-keys/license-key.tsx @@ -0,0 +1,232 @@ +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) + +