diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b849afc0..d45c3dac0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about. 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 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. ## Issues related (if applicable) diff --git a/.github/sponsors/awesome.png b/.github/sponsors/awesome.png new file mode 100644 index 000000000..0753212ab Binary files /dev/null and b/.github/sponsors/awesome.png differ diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6c74dbc02..bfdc8c48b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -20,6 +20,32 @@ jobs: with: node-version: 20.16.0 cache: "pnpm" + + - name: Install Nixpacks + if: matrix.job == 'test' + run: | + 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.4 + curl -sSL https://railpack.com/install.sh | bash + echo "Railpack installed $RAILPACK_VERSION" + + - name: Add build tools to PATH + if: matrix.job == 'test' + run: echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Initialize Docker Swarm + if: matrix.job == 'test' + run: | + docker swarm init + docker network create --driver overlay dokploy-network || true + echo "✅ Docker Swarm initialized" + - run: pnpm install --frozen-lockfile - run: pnpm server:build - run: pnpm ${{ matrix.job }} diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml new file mode 100644 index 000000000..ddc51355a --- /dev/null +++ b/.github/workflows/sync-openapi-docs.yml @@ -0,0 +1,70 @@ +name: Generate and Sync OpenAPI + +on: + push: + branches: + - canary + - main + paths: + - 'apps/dokploy/server/api/routers/**' + - 'packages/server/src/services/**' + - 'packages/server/src/db/schema/**' + + workflow_dispatch: + +jobs: + generate-and-commit: + name: Generate OpenAPI and commit to Dokploy repo + runs-on: ubuntu-latest + steps: + - name: Checkout Dokploy repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.16.0 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate OpenAPI specification + run: | + pnpm generate:openapi + + # Verifica que se generó correctamente + if [ ! -f openapi.json ]; then + echo "❌ openapi.json not found" + exit 1 + fi + + echo "✅ OpenAPI specification generated successfully" + + - name: Sync to website repository + run: | + # Clona el repositorio de website + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo + + cd website-repo + + # Copia el openapi.json al website (sobrescribe) + mkdir -p apps/docs/public + cp -f ../openapi.json apps/docs/public/openapi.json + + # Configura git + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + + # Agrega y commitea siempre + git add apps/docs/public/openapi.json + git commit -m "chore: sync OpenAPI specification [skip ci]" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + -m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \ + --allow-empty + + git push + + echo "✅ OpenAPI synced to website successfully" + diff --git a/.gitignore b/.gitignore index 5e6e4eb3c..ab2fe76c6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules .env.test.local .env.production.local +openapi.json + # Testing coverage @@ -41,4 +43,7 @@ yarn-error.log* *.pem -.db \ No newline at end of file +.db + +# Development environment +.devcontainer \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38a36345e..4c1f832db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,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 diff --git a/Dockerfile b/Dockerfile index 11310b18e..5d7bb6770 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,23 +46,23 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules # Install docker -RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash +RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash # 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" ] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index 8e4bac215..a0de32021 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server # Deploy only the dokploy app -ARG NEXT_PUBLIC_UMAMI_HOST -ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST +# ARG NEXT_PUBLIC_UMAMI_HOST +# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST -ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID -ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID +# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID +# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY @@ -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 d60962cff..e97735597 100644 --- a/README.md +++ b/README.md @@ -68,51 +68,21 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). [Github Sponsors](https://github.com/sponsors/Siumauricio) - +## Sponsors - - -### Hero Sponsors 🎖 - -
- Hostinger - LX Aer - - - - -
- - - - - -### Premium Supporters 🥇 - -
- Supafort.com - agentdock.ai -
- - - - - -### Elite Contributors 🥈 - -
- AmericanCloud - Tolgee -
- -### Supporting Members 🥉 - -
- - Cloudblast.io - - Synexa -
+| Sponsor | Logo | Supporter Level | +|---------|:----:|----------------| +| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | Hostinger | 🎖 Hero Sponsor | +| [LX Aer](https://www.lxaer.com/?ref=dokploy) | LX Aer | 🎖 Hero Sponsor | +| [LinkDR](https://linkdr.com/?ref=dokploy) | LinkDR | 🎖 Hero Sponsor | +| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | LambdaTest | 🎖 Hero Sponsor | +| [Awesome Tools](https://awesome.tools/) | Awesome Tools | 🎖 Hero Sponsor | +| [Supafort](https://supafort.com/?ref=dokploy) | Supafort.com | 🥇 Premium Supporter | +| [Agentdock](https://agentdock.ai/?ref=dokploy) | agentdock.ai | 🥇 Premium Supporter | +| [AmericanCloud](https://americancloud.com/?ref=dokploy) | AmericanCloud | 🥈 Elite Contributor | +| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | Tolgee | 🥈 Elite Contributor | +| [Cloudblast](https://cloudblast.io/?ref=dokploy) | Cloudblast.io | 🥉 Supporting Member | +| [Synexa](https://synexa.ai/?ref=dokploy) | Synexa | 🥉 Supporting Member | ### Community Backers 🤝 diff --git a/apps/api/package.json b/apps/api/package.json index dfc2a355d..0f4b1044f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,6 @@ "@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", "pino": "9.4.0", 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 ee2ac3e50..13708006a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -1,9 +1,10 @@ import { - deployRemoteApplication, - deployRemoteCompose, - deployRemotePreviewApplication, - rebuildRemoteApplication, - rebuildRemoteCompose, + deployApplication, + deployCompose, + deployPreviewApplication, + rebuildApplication, + rebuildCompose, + rebuildPreviewApplication, updateApplicationStatus, updateCompose, updatePreviewDeployment, @@ -16,13 +17,13 @@ export const deploy = async (job: DeployJob) => { await updateApplicationStatus(job.applicationId, "running"); if (job.server) { if (job.type === "redeploy") { - await rebuildRemoteApplication({ + await rebuildApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Rebuild deployment", descriptionLog: job.descriptionLog || "", }); } else if (job.type === "deploy") { - await deployRemoteApplication({ + await deployApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Manual deployment", descriptionLog: job.descriptionLog || "", @@ -36,13 +37,13 @@ export const deploy = async (job: DeployJob) => { if (job.server) { if (job.type === "redeploy") { - await rebuildRemoteCompose({ + await rebuildCompose({ composeId: job.composeId, titleLog: job.titleLog || "Rebuild deployment", descriptionLog: job.descriptionLog || "", }); } else if (job.type === "deploy") { - await deployRemoteCompose({ + await deployCompose({ composeId: job.composeId, titleLog: job.titleLog || "Manual deployment", descriptionLog: job.descriptionLog || "", @@ -54,8 +55,15 @@ export const deploy = async (job: DeployJob) => { previewStatus: "running", }); if (job.server) { - if (job.type === "deploy") { - await deployRemotePreviewApplication({ + 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", descriptionLog: job.descriptionLog || "", diff --git a/apps/dokploy/.env.example b/apps/dokploy/.env.example index ba57ec7be..8f801196e 100644 --- a/apps/dokploy/.env.example +++ b/apps/dokploy/.env.example @@ -1,3 +1,3 @@ DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy" PORT=3000 -NODE_ENV=development \ No newline at end of file +NODE_ENV=development 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__/cluster/upload.test.ts b/apps/dokploy/__test__/cluster/upload.test.ts new file mode 100644 index 000000000..1ccb9e22d --- /dev/null +++ b/apps/dokploy/__test__/cluster/upload.test.ts @@ -0,0 +1,243 @@ +import type { Registry } from "@dokploy/server"; +import { getRegistryTag } from "@dokploy/server"; +import { describe, expect, it } from "vitest"; + +describe("getRegistryTag", () => { + // Helper to create a mock registry + const createMockRegistry = (overrides: Partial = {}): Registry => { + return { + registryId: "test-registry-id", + registryName: "Test Registry", + username: "myuser", + password: "test-password", + registryUrl: "docker.io", + registryType: "cloud", + imagePrefix: null, + createdAt: new Date().toISOString(), + organizationId: "test-org-id", + ...overrides, + }; + }; + + describe("with username (no imagePrefix)", () => { + it("should handle simple image name without tag", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("docker.io/myuser/nginx"); + }); + + it("should handle image name with tag", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "nginx:latest"); + expect(result).toBe("docker.io/myuser/nginx:latest"); + }); + + it("should handle image name with username already present (no duplication)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("docker.io/myuser/myprivaterepo"); + }); + + it("should handle image name with username and tag already present", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "myuser/myprivaterepo:latest"); + // Should not duplicate username + expect(result).toBe("docker.io/myuser/myprivaterepo:latest"); + }); + + it("should handle complex image name with username", () => { + const registry = createMockRegistry({ username: "siumauricio" }); + const result = getRegistryTag( + registry, + "siumauricio/app-parse-multi-byte-port-e32uh7", + ); + // Should not duplicate username + expect(result).toBe( + "docker.io/siumauricio/app-parse-multi-byte-port-e32uh7", + ); + }); + + it("should handle image name with different username (should not duplicate)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "otheruser/myprivaterepo"); + expect(result).toBe("docker.io/myuser/myprivaterepo"); + }); + + it("should handle image name with full registry URL (no username)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "docker.io/nginx"); + // Should add username since imageName doesn't have one + expect(result).toBe("docker.io/myuser/nginx"); + }); + + it("should handle image name with custom registry URL and username", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "ghcr.io/myuser/repo"); + // Should not duplicate username even if registry URL is different + expect(result).toBe("docker.io/myuser/repo"); + }); + + it("should handle image name with custom registry URL (different username)", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "ghcr.io/otheruser/repo"); + // Should use registry username, not the one in imageName + expect(result).toBe("docker.io/myuser/repo"); + }); + }); + + describe("with imagePrefix", () => { + it("should use imagePrefix instead of username", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("docker.io/myorg/nginx"); + }); + + it("should use imagePrefix with image tag", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "nginx:latest"); + expect(result).toBe("docker.io/myorg/nginx:latest"); + }); + + it("should handle imagePrefix with username already in image name", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + expect(result).toBe("docker.io/myorg/myprivaterepo"); + }); + + it("should handle imagePrefix matching image name prefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + }); + const result = getRegistryTag(registry, "myorg/myprivaterepo"); + // Should not duplicate prefix + expect(result).toBe("docker.io/myorg/myprivaterepo"); + }); + }); + + describe("without registryUrl", () => { + it("should work without registryUrl", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("myuser/nginx"); + }); + + it("should work without registryUrl with imagePrefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + registryUrl: "", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("myorg/nginx"); + }); + + it("should handle username already present without registryUrl", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("myuser/myprivaterepo"); + }); + }); + + describe("with custom registryUrl", () => { + it("should handle custom registry URL", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("ghcr.io/myuser/nginx"); + }); + + it("should handle custom registry URL with imagePrefix", () => { + const registry = createMockRegistry({ + username: "myuser", + imagePrefix: "myorg", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("ghcr.io/myorg/nginx"); + }); + + it("should handle custom registry URL with username already present", () => { + const registry = createMockRegistry({ + username: "myuser", + registryUrl: "ghcr.io", + }); + const result = getRegistryTag(registry, "myuser/myprivaterepo"); + // Should not duplicate username + expect(result).toBe("ghcr.io/myuser/myprivaterepo"); + }); + }); + + describe("edge cases", () => { + it("should handle empty image name", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, ""); + expect(result).toBe("docker.io/myuser/"); + }); + + it("should handle image name with multiple slashes", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "org/suborg/repo"); + expect(result).toBe("docker.io/myuser/repo"); + }); + + it("should handle image name with username at different position", () => { + const registry = createMockRegistry({ username: "myuser" }); + const result = getRegistryTag(registry, "org/myuser/repo"); + expect(result).toBe("docker.io/myuser/repo"); + }); + }); + + describe("special characters in username", () => { + it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => { + const registry = createMockRegistry({ + username: "robot$library+dokploy", + }); + const result = getRegistryTag(registry, "nginx"); + expect(result).toBe("docker.io/robot$library+dokploy/nginx"); + }); + + it("should handle username with $ and other special characters", () => { + const registry = createMockRegistry({ + username: "robot$test+app", + }); + const result = getRegistryTag(registry, "myapp:latest"); + expect(result).toBe("docker.io/robot$test+app/myapp:latest"); + }); + + it("should handle username with multiple $ symbols", () => { + const registry = createMockRegistry({ + username: "user$name$test", + }); + const result = getRegistryTag(registry, "app"); + expect(result).toBe("docker.io/user$name$test/app"); + }); + + it("should handle username with + and - symbols", () => { + const registry = createMockRegistry({ + username: "robot+test-user", + }); + const result = getRegistryTag(registry, "nginx:latest"); + expect(result).toBe("docker.io/robot+test-user/nginx:latest"); + }); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts new file mode 100644 index 000000000..097c916ea --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -0,0 +1,215 @@ +import type { Domain } from "@dokploy/server"; +import { createDomainLabels } from "@dokploy/server"; +import { describe, expect, it } from "vitest"; +import { parse, stringify } from "yaml"; + +/** + * Regression tests for Traefik Host rule label format. + * + * These tests verify that the Host rule is generated with the correct format: + * - Host(`domain.com`) - with opening and closing parentheses + * - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing + * + * Issue: https://github.com/Dokploy/dokploy/issues/3161 + * The bug caused Host rules to be malformed as Host`domain.com`) + * (missing opening parenthesis) which broke all domain routing. + */ +describe("Host rule format regression tests", () => { + const baseDomain: Domain = { + host: "example.com", + port: 8080, + https: false, + uniqueConfigKey: 1, + customCertResolver: null, + certificateType: "none", + applicationId: "", + composeId: "", + domainType: "compose", + serviceName: "test-app", + domainId: "", + path: "/", + createdAt: "", + previewDeploymentId: "", + internalPath: "/", + stripPath: false, + }; + + describe("Host rule format validation", () => { + it("should generate Host rule with correct parentheses format", async () => { + const labels = await createDomainLabels("test-app", baseDomain, "web"); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + // Verify exact format: Host(`domain`) + expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/); + // Ensure opening parenthesis is present after Host + expect(ruleLabel).toContain("Host(`example.com`)"); + // Ensure it does NOT have the malformed format + expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/); + }); + + it("should generate PathPrefix with correct parentheses format", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, path: "/api" }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + // Verify PathPrefix format + expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/); + expect(ruleLabel).toContain("PathPrefix(`/api`)"); + // Ensure opening parenthesis is present + expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/); + }); + + it("should generate combined Host and PathPrefix with correct format", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, path: "/api/v1" }, + "websecure", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + expect(ruleLabel).toBe( + "traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)", + ); + }); + }); + + describe("YAML serialization preserves Host rule format", () => { + it("should preserve Host rule format through YAML stringify/parse", async () => { + const labels = await createDomainLabels("test-app", baseDomain, "web"); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + // Simulate compose file structure + const composeSpec = { + services: { + myapp: { + image: "nginx", + labels: labels, + }, + }, + }; + + // Stringify to YAML + const yamlOutput = stringify(composeSpec, { lineWidth: 1000 }); + + // Parse back + const parsed = parse(yamlOutput) as typeof composeSpec; + const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) => + l.includes(".rule="), + ); + + // Verify format is preserved + expect(parsedRuleLabel).toBe(ruleLabel); + expect(parsedRuleLabel).toContain("Host(`example.com`)"); + expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/); + }); + + it("should preserve complex rule format through YAML serialization", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, path: "/api", https: true }, + "websecure", + ); + + const composeSpec = { + services: { + myapp: { + labels: labels, + }, + }, + }; + + const yamlOutput = stringify(composeSpec, { lineWidth: 1000 }); + const parsed = parse(yamlOutput) as typeof composeSpec; + const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) => + l.includes(".rule="), + ); + + expect(parsedRuleLabel).toContain( + "Host(`example.com`) && PathPrefix(`/api`)", + ); + }); + }); + + describe("Edge cases for domain names", () => { + const domainCases = [ + { name: "simple domain", host: "example.com" }, + { name: "subdomain", host: "app.example.com" }, + { name: "deep subdomain", host: "api.v1.app.example.com" }, + { name: "numeric domain", host: "123.example.com" }, + { name: "hyphenated domain", host: "my-app.example-host.com" }, + { name: "localhost", host: "localhost" }, + { name: "IP address style", host: "192.168.1.100" }, + ]; + + for (const { name, host } of domainCases) { + it(`should generate correct Host rule for ${name}: ${host}`, async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, host }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + expect(ruleLabel).toContain(`Host(\`${host}\`)`); + // Verify parenthesis is present + expect(ruleLabel).toMatch( + new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`), + ); + }); + } + }); + + describe("Multiple domains scenario", () => { + it("should generate correct format for both web and websecure entrypoints", async () => { + const webLabels = await createDomainLabels("test-app", baseDomain, "web"); + const websecureLabels = await createDomainLabels( + "test-app", + baseDomain, + "websecure", + ); + + const webRule = webLabels.find((l) => l.includes(".rule=")); + const websecureRule = websecureLabels.find((l) => l.includes(".rule=")); + + // Both should have correct format + expect(webRule).toContain("Host(`example.com`)"); + expect(websecureRule).toContain("Host(`example.com`)"); + + // Neither should have malformed format + expect(webRule).not.toMatch(/Host`[^`]+`\)/); + expect(websecureRule).not.toMatch(/Host`[^`]+`\)/); + }); + }); + + describe("Special characters in paths", () => { + const pathCases = [ + { name: "simple path", path: "/api" }, + { name: "nested path", path: "/api/v1/users" }, + { name: "path with hyphen", path: "/api-v1" }, + { name: "path with underscore", path: "/api_v1" }, + ]; + + for (const { name, path } of pathCases) { + it(`should generate correct PathPrefix for ${name}: ${path}`, async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, path }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`); + // Verify parenthesis is present + expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/); + }); + } + }); +}); diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts new file mode 100644 index 000000000..be29748eb --- /dev/null +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -0,0 +1,276 @@ +import * as adminService from "@dokploy/server/services/admin"; +import * as applicationService from "@dokploy/server/services/application"; +import { deployApplication } from "@dokploy/server/services/application"; +import * as deploymentService from "@dokploy/server/services/deployment"; +import * as builders from "@dokploy/server/utils/builders"; +import * as notifications from "@dokploy/server/utils/notifications/build-success"; +import * as execProcess from "@dokploy/server/utils/process/execAsync"; +import * as gitProvider from "@dokploy/server/utils/providers/git"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => { + const createChainableMock = (): any => { + const chain = { + set: vi.fn(() => chain), + where: vi.fn(() => chain), + returning: vi.fn().mockResolvedValue([{}] as any), + } as any; + return chain; + }; + + return { + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(() => createChainableMock()), + delete: vi.fn(), + query: { + applications: { + findFirst: vi.fn(), + }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/application", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/application") + >("@dokploy/server/services/application"); + return { + ...actual, + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: vi.fn(), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), + updateDeployment: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/providers/git", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/providers/git") + >("@dokploy/server/utils/providers/git"); + return { + ...actual, + getGitCommitInfo: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn(), + ExecError: class ExecError extends Error {}, +})); + +vi.mock("@dokploy/server/utils/builders", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/utils/builders") + >("@dokploy/server/utils/builders"); + return { + ...actual, + mechanizeDockerContainer: vi.fn(), + getBuildCommand: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/utils/notifications/build-success", () => ({ + sendBuildSuccessNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/notifications/build-error", () => ({ + sendBuildErrorNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/services/rollbacks", () => ({ + createRollback: vi.fn(), +})); + +import { db } from "@dokploy/server/db"; +import { cloneGitRepository } from "@dokploy/server/utils/providers/git"; + +const createMockApplication = (overrides = {}) => ({ + applicationId: "test-app-id", + name: "Test App", + appName: "test-app", + sourceType: "git" as const, + customGitUrl: "https://github.com/Dokploy/examples.git", + customGitBranch: "main", + customGitSSHKeyId: null, + buildType: "nixpacks" as const, + buildPath: "/astro", + env: "NODE_ENV=production", + serverId: null, + rollbackActive: false, + enableSubmodules: false, + environmentId: "env-id", + environment: { + projectId: "project-id", + env: "", + name: "production", + project: { + name: "Test Project", + organizationId: "org-id", + env: "", + }, + }, + domains: [], + ...overrides, +}); + +const createMockDeployment = () => ({ + deploymentId: "deployment-id", + logPath: "/tmp/test-deployment.log", +}); + +describe("deployApplication - Command Generation Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + createMockApplication() as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + createMockApplication() as any, + ); + vi.mocked(adminService.getDokployUrl).mockResolvedValue( + "http://localhost:3000", + ); + vi.mocked(deploymentService.createDeployment).mockResolvedValue( + createMockDeployment() as any, + ); + vi.mocked(execProcess.execAsync).mockResolvedValue({ + stdout: "", + stderr: "", + } as any); + vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue( + undefined as any, + ); + vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue( + undefined as any, + ); + vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue( + {} as any, + ); + vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue( + undefined as any, + ); + vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({ + message: "test commit", + hash: "abc123", + }); + vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any); + }); + + it("should generate correct git clone command for astro example", async () => { + const app = createMockApplication(); + const command = await cloneGitRepository(app); + console.log(command); + + expect(command).toContain("https://github.com/Dokploy/examples.git"); + expect(command).not.toContain("--recurse-submodules"); + expect(command).toContain("--branch main"); + expect(command).toContain("--depth 1"); + expect(command).toContain("git clone"); + }); + + it("should generate git clone with submodules when enabled", async () => { + const app = createMockApplication({ enableSubmodules: true }); + const command = await cloneGitRepository(app); + + expect(command).toContain("--recurse-submodules"); + expect(command).toContain("https://github.com/Dokploy/examples.git"); + }); + + it("should verify nixpacks command is called with correct app", async () => { + const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app"; + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand); + + await deployApplication({ + applicationId: "test-app-id", + titleLog: "Test deployment", + descriptionLog: "", + }); + + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + buildType: "nixpacks", + customGitUrl: "https://github.com/Dokploy/examples.git", + buildPath: "/astro", + }), + ); + + expect(execProcess.execAsync).toHaveBeenCalledWith( + expect.stringContaining("nixpacks build"), + ); + }); + + it("should verify railpack command includes correct parameters", async () => { + const mockApp = createMockApplication({ buildType: "railpack" }); + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + mockApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + mockApp as any, + ); + + const mockRailpackCommand = "railpack prepare /path/to/app"; + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand); + + await deployApplication({ + applicationId: "test-app-id", + titleLog: "Railpack test", + descriptionLog: "", + }); + + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + buildType: "railpack", + }), + ); + + expect(execProcess.execAsync).toHaveBeenCalledWith( + expect.stringContaining("railpack prepare"), + ); + }); + + it("should execute commands in correct order", async () => { + const mockNixpacksCommand = "nixpacks build"; + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand); + + await deployApplication({ + applicationId: "test-app-id", + titleLog: "Test", + descriptionLog: "", + }); + + const execCalls = vi.mocked(execProcess.execAsync).mock.calls; + expect(execCalls.length).toBeGreaterThan(0); + + const fullCommand = execCalls[0]?.[0]; + expect(fullCommand).toContain("set -e"); + expect(fullCommand).toContain("git clone"); + expect(fullCommand).toContain("nixpacks build"); + }); + + it("should include log redirection in command", async () => { + const mockCommand = "nixpacks build"; + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand); + + await deployApplication({ + applicationId: "test-app-id", + titleLog: "Test", + descriptionLog: "", + }); + + const execCalls = vi.mocked(execProcess.execAsync).mock.calls; + const fullCommand = execCalls[0]?.[0]; + + expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1"); + }); +}); diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts new file mode 100644 index 000000000..43ff07836 --- /dev/null +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -0,0 +1,479 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import type { ApplicationNested } from "@dokploy/server"; +import { paths } from "@dokploy/server/constants"; +import { execAsync } from "@dokploy/server/utils/process/execAsync"; +import { format } from "date-fns"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const REAL_TEST_TIMEOUT = 180000; // 3 minutes + +// Mock ONLY database and notifications +vi.mock("@dokploy/server/db", () => { + const createChainableMock = (): any => { + const chain: any = { + set: vi.fn(() => chain), + where: vi.fn(() => chain), + returning: vi.fn().mockResolvedValue([{}]), + }; + return chain; + }; + + return { + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(() => createChainableMock()), + delete: vi.fn(), + query: { + applications: { + findFirst: vi.fn(), + }, + }, + }, + }; +}); + +vi.mock("@dokploy/server/services/application", async () => { + const actual = await vi.importActual< + typeof import("@dokploy/server/services/application") + >("@dokploy/server/services/application"); + return { + ...actual, + findApplicationById: vi.fn(), + updateApplicationStatus: vi.fn(), + }; +}); + +vi.mock("@dokploy/server/services/admin", () => ({ + getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"), +})); + +vi.mock("@dokploy/server/services/deployment", () => ({ + createDeployment: vi.fn(), + updateDeploymentStatus: vi.fn(), + updateDeployment: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/notifications/build-success", () => ({ + sendBuildSuccessNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/notifications/build-error", () => ({ + sendBuildErrorNotifications: vi.fn(), +})); + +vi.mock("@dokploy/server/services/rollbacks", () => ({ + createRollback: vi.fn(), +})); + +// NOT mocked (executed for real): +// - execAsync +// - cloneGitRepository +// - getBuildCommand +// - mechanizeDockerContainer (requires Docker Swarm) + +import { db } from "@dokploy/server/db"; +import * as adminService from "@dokploy/server/services/admin"; +import * as applicationService from "@dokploy/server/services/application"; +import { deployApplication } from "@dokploy/server/services/application"; +import * as deploymentService from "@dokploy/server/services/deployment"; + +const createMockApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + applicationId: "test-app-id", + name: "Real Test App", + appName: `real-test-${Date.now()}`, + sourceType: "git" as const, + customGitUrl: "https://github.com/Dokploy/examples.git", + customGitBranch: "main", + customGitSSHKeyId: null, + customGitBuildPath: "/astro", + buildType: "nixpacks" as const, + env: "NODE_ENV=production", + serverId: null, + rollbackActive: false, + enableSubmodules: false, + environmentId: "env-id", + environment: { + projectId: "project-id", + env: "", + name: "production", + project: { + name: "Test Project", + organizationId: "org-id", + env: "", + }, + }, + domains: [], + mounts: [], + security: [], + redirects: [], + ports: [], + registry: null, + ...overrides, + }) as ApplicationNested; + +const createMockDeployment = async (appName: string) => { + const { LOGS_PATH } = paths(false); // false = local, no remote server + const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); + const fileName = `${appName}-${formattedDateTime}.log`; + const logFilePath = path.join(LOGS_PATH, appName, fileName); + + // Actually create the log directory + await execAsync(`mkdir -p ${path.dirname(logFilePath)}`); + await execAsync(`echo "Initializing deployment" > ${logFilePath}`); + + return { + deploymentId: "deployment-id", + logPath: logFilePath, + }; +}; + +async function cleanupDocker(appName: string) { + try { + await execAsync(`docker stop ${appName} 2>/dev/null || true`); + await execAsync(`docker rm ${appName} 2>/dev/null || true`); + await execAsync(`docker rmi ${appName} 2>/dev/null || true`); + } catch (error) { + console.log("Docker cleanup completed"); + } +} + +async function cleanupFiles(appName: string) { + try { + const { LOGS_PATH, APPLICATIONS_PATH } = paths(false); + + // Clean cloned code directories + const appPath = path.join(APPLICATIONS_PATH, appName); + await execAsync(`rm -rf ${appPath} 2>/dev/null || true`); + + // Clean logs for appName - removes entire folder + const logPath = path.join(LOGS_PATH, appName); + await execAsync(`rm -rf ${logPath} 2>/dev/null || true`); + + console.log(`✅ Cleaned up files and logs for ${appName}`); + } catch (error) { + console.error(`⚠️ Error during cleanup for ${appName}:`, error); + } +} + +describe( + "deployApplication - REAL Execution Tests", + () => { + let currentAppName: string; + let currentDeployment: any; + const allTestAppNames: string[] = []; + + beforeEach(async () => { + vi.clearAllMocks(); + currentAppName = `real-test-${Date.now()}`; + currentDeployment = await createMockDeployment(currentAppName); + allTestAppNames.push(currentAppName); + + const mockApp = createMockApplication({ appName: currentAppName }); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + mockApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + mockApp as any, + ); + vi.mocked(adminService.getDokployUrl).mockResolvedValue( + "http://localhost:3000", + ); + vi.mocked(deploymentService.createDeployment).mockResolvedValue( + currentDeployment as any, + ); + vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue( + undefined as any, + ); + vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue( + {} as any, + ); + vi.mocked(deploymentService.updateDeployment).mockResolvedValue( + {} as any, + ); + }); + + afterEach(async () => { + // ALWAYS cleanup, even if test failed or passed + console.log(`\n🧹 Cleaning up test: ${currentAppName}`); + + // Clean current appName + try { + await cleanupDocker(currentAppName); + await cleanupFiles(currentAppName); + } catch (error) { + console.error("⚠️ Error cleaning current app:", error); + } + + // Clean ALL test folders just in case + try { + const { LOGS_PATH, APPLICATIONS_PATH } = paths(false); + await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`); + await execAsync( + `rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`, + ); + console.log("✅ Cleaned up all test artifacts"); + } catch (error) { + console.error("⚠️ Error cleaning all artifacts:", error); + } + + console.log("✅ Cleanup completed\n"); + }); + + it( + "should REALLY clone git repo and build with nixpacks", + async () => { + console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`); + + const result = await deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Nixpacks Test", + descriptionLog: "Testing real execution", + }); + + expect(result).toBe(true); + + // Verify that Docker image was actually created + const { stdout: dockerImages } = await execAsync( + `docker images ${currentAppName} --format "{{.Repository}}"`, + ); + console.log("dockerImages", dockerImages); + expect(dockerImages.trim()).toBe(currentAppName); + console.log(`✅ Docker image created: ${currentAppName}`); + + // Verify log exists and has content + expect(existsSync(currentDeployment.logPath)).toBe(true); + const { stdout: logContent } = await execAsync( + `cat ${currentDeployment.logPath}`, + ); + expect(logContent).toContain("Cloning"); + expect(logContent).toContain("nixpacks"); + console.log(`✅ Build log created with ${logContent.length} chars`); + + // Verify update functions were called + expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith( + "deployment-id", + "done", + ); + }, + REAL_TEST_TIMEOUT, + ); + + it.skip( + "should REALLY build with railpack (SKIPPED: requires special permissions)", + async () => { + const railpackAppName = `real-railpack-${Date.now()}`; + const railpackApp = createMockApplication({ + appName: railpackAppName, + buildType: "railpack", + railpackVersion: "3", + }); + currentAppName = railpackAppName; + allTestAppNames.push(railpackAppName); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + railpackApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + railpackApp as any, + ); + + console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`); + + const result = await deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Railpack Test", + descriptionLog: "", + }); + + expect(result).toBe(true); + + const { stdout: dockerImages } = await execAsync( + `docker images ${currentAppName} --format "{{.Repository}}"`, + ); + expect(dockerImages.trim()).toBe(currentAppName); + console.log(`✅ Railpack image created: ${currentAppName}`); + + const { stdout: logContent } = await execAsync( + `cat ${currentDeployment.logPath}`, + ); + expect(logContent).toContain("railpack"); + console.log("✅ Railpack build completed"); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "should handle REAL git clone errors", + async () => { + const errorAppName = `real-error-${Date.now()}`; + const errorApp = createMockApplication({ + appName: errorAppName, + customGitUrl: + "https://github.com/invalid/nonexistent-repo-123456.git", + }); + currentAppName = errorAppName; + allTestAppNames.push(errorAppName); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + errorApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + errorApp as any, + ); + + console.log(`\n🚀 Testing real error handling: ${currentAppName}`); + + await expect( + deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Error Test", + descriptionLog: "", + }), + ).rejects.toThrow(); + + // Verify error status was called + expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith( + "deployment-id", + "error", + ); + + // Verify log contains error + const { stdout: logContent } = await execAsync( + `cat ${currentDeployment.logPath}`, + ); + expect(logContent.toLowerCase()).toContain("error"); + console.log("✅ Error handling verified"); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "should REALLY clone with submodules when enabled", + async () => { + const submodulesAppName = `real-submodules-${Date.now()}`; + const submodulesApp = createMockApplication({ + appName: submodulesAppName, + enableSubmodules: true, + }); + currentAppName = submodulesAppName; + allTestAppNames.push(submodulesAppName); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + submodulesApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + submodulesApp as any, + ); + + console.log(`\n🚀 Testing real submodules support: ${currentAppName}`); + + const result = await deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Submodules Test", + descriptionLog: "", + }); + + expect(result).toBe(true); + + // Verify deployment completed successfully + const { stdout: logContent } = await execAsync( + `cat ${currentDeployment.logPath}`, + ); + expect(logContent).toContain("Cloning"); + expect(logContent.length).toBeGreaterThan(100); + console.log("✅ Submodules deployment completed"); + + // Verify image + const { stdout: dockerImages } = await execAsync( + `docker images ${currentAppName} --format "{{.Repository}}"`, + ); + expect(dockerImages.trim()).toBe(currentAppName); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "should verify REAL commit info extraction", + async () => { + console.log(`\n🚀 Testing real commit info: ${currentAppName}`); + + await deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Commit Test", + descriptionLog: "", + }); + + // Verify updateDeployment was called with commit info + expect(deploymentService.updateDeployment).toHaveBeenCalled(); + const updateCall = vi.mocked(deploymentService.updateDeployment).mock + .calls[0]; + + // Real commit info should have title and hash + expect(updateCall?.[1]).toHaveProperty("title"); + expect(updateCall?.[1]).toHaveProperty("description"); + expect(updateCall?.[1]?.description).toContain("Commit:"); + + console.log( + `✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`, + ); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "should REALLY build with Dockerfile", + async () => { + const dockerfileAppName = `real-dockerfile-${Date.now()}`; + const dockerfileApp = createMockApplication({ + appName: dockerfileAppName, + buildType: "dockerfile", + customGitBuildPath: "/deno", + dockerfile: "Dockerfile", + }); + currentAppName = dockerfileAppName; + allTestAppNames.push(dockerfileAppName); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + dockerfileApp as any, + ); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + dockerfileApp as any, + ); + + console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`); + + const result = await deployApplication({ + applicationId: "test-app-id", + titleLog: "Real Dockerfile Test", + descriptionLog: "", + }); + + expect(result).toBe(true); + + // Verify log + const { stdout: logContent } = await execAsync( + `cat ${currentDeployment.logPath}`, + ); + expect(logContent).toContain("Building"); + expect(logContent).toContain(dockerfileAppName); + console.log("✅ Dockerfile build log verified"); + + // Verify image + const { stdout: dockerImages } = await execAsync( + `docker images ${currentAppName} --format "{{.Repository}}"`, + ); + console.log("dockerImages", dockerImages); + expect(dockerImages.trim()).toBe(currentAppName); + console.log(`✅ Docker image created: ${currentAppName}`); + }, + REAL_TEST_TIMEOUT, + ); + }, + REAL_TEST_TIMEOUT, +); diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts index 03805b08d..46be44883 100644 --- a/apps/dokploy/__test__/deploy/github.test.ts +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]"; +import { + extractCommitMessage, + extractImageName, + extractImageTag, + extractImageTagFromRequest, +} from "@/pages/api/deploy/[refreshToken]"; describe("GitHub Webhook Skip CI", () => { const mockGithubHeaders = { @@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => { ); }); }); + +describe("GitHub Packages Docker Image Tag Extraction", () => { + it("should extract tag from container_metadata", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + container_metadata: { + tag: { + name: "v1.0.0", + digest: "sha256:abc123...", + }, + }, + package_url: "ghcr.io/owner/repo:v1.0.0", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBe("v1.0.0"); + }); + + it("should extract tag from package_url when container_metadata tag matches version", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + container_metadata: { + tag: { + name: "sha256:abc123...", + digest: "sha256:abc123...", + }, + }, + package_url: "ghcr.io/owner/repo:latest", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBe("latest"); + }); + + it("should extract tag from package_url when container_metadata is missing", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + package_url: "ghcr.io/owner/repo:1.2.3", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBe("1.2.3"); + }); + + it("should handle different tag formats in package_url", () => { + const headers = { "x-github-event": "registry_package" }; + const testCases = [ + { url: "ghcr.io/owner/repo:latest", expected: "latest" }, + { url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" }, + { url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" }, + { url: "ghcr.io/owner/repo:dev", expected: "dev" }, + ]; + + for (const testCase of testCases) { + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + package_url: testCase.url, + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBe(testCase.expected); + } + }); + + it("should return null for non-registry_package events", () => { + const headers = { "x-github-event": "push" }; + const body = { + registry_package: { + package_version: { + package_url: "ghcr.io/owner/repo:latest", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBeNull(); + }); + + it("should return null when package_version is missing", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: {}, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBeNull(); + }); + + it("should return null when package_url has no tag", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + package_url: "ghcr.io/owner/repo", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBeNull(); + }); + + it("should return null when package_url ends with colon (no tag)", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + package_url: "ghcr.io/owner/repo:", + container_metadata: { + tag: { + name: "", + digest: "sha256:abc123...", + }, + }, + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBeNull(); + }); + + it("should return null when tag name is empty string", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + container_metadata: { + tag: { + name: "", + digest: "sha256:abc123...", + }, + }, + package_url: "ghcr.io/owner/repo:", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBeNull(); + }); + + it("should ignore tag if it matches the version (digest)", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + container_metadata: { + tag: { + name: "sha256:abc123...", + digest: "sha256:abc123...", + }, + }, + package_url: "ghcr.io/owner/repo:latest", + }, + }, + }; + + const tag = extractImageTagFromRequest(headers, body); + expect(tag).toBe("latest"); + }); + + it("should handle registry_package commit message with package_url", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + package_url: "ghcr.io/owner/repo:latest", + }, + }, + }; + + const message = extractCommitMessage(headers, body); + expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest"); + }); + + it("should handle registry_package commit message when package_url is missing", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: { + package_version: { + version: "sha256:abc123...", + }, + }, + }; + + const message = extractCommitMessage(headers, body); + expect(message).toBe("Docker GHCR image pushed"); + }); + + it("should handle registry_package commit message when package_version is missing", () => { + const headers = { "x-github-event": "registry_package" }; + const body = { + registry_package: {}, + }; + + const message = extractCommitMessage(headers, body); + expect(message).toBe("NEW COMMIT"); + }); +}); + +describe("Docker Image Name and Tag Extraction", () => { + describe("extractImageName", () => { + it("should return image name without tag", () => { + expect(extractImageName("my-image:latest")).toBe("my-image"); + expect(extractImageName("my-image:1.0.0")).toBe("my-image"); + expect(extractImageName("ghcr.io/owner/repo:latest")).toBe( + "ghcr.io/owner/repo", + ); + }); + + it("should return full image name when no tag is present", () => { + expect(extractImageName("my-image")).toBe("my-image"); + expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo"); + }); + + it("should handle images with port numbers correctly", () => { + expect(extractImageName("registry:5000/image:tag")).toBe( + "registry:5000/image", + ); + expect(extractImageName("localhost:5000/my-app:latest")).toBe( + "localhost:5000/my-app", + ); + }); + + it("should handle complex image paths", () => { + expect( + extractImageName("myregistryhost:5000/fedora/httpd:version1.0"), + ).toBe("myregistryhost:5000/fedora/httpd"); + expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe( + "registry.example.com:8080/ns/app", + ); + }); + + it("should return null for invalid inputs", () => { + expect(extractImageName(null)).toBeNull(); + expect(extractImageName("")).toBeNull(); + }); + + it("should handle edge cases with multiple colons", () => { + expect(extractImageName("image:tag:extra")).toBe("image:tag"); + expect(extractImageName("registry:5000:invalid")).toBe("registry:5000"); + }); + }); + + describe("extractImageTag", () => { + it("should extract tag from image with tag", () => { + expect(extractImageTag("my-image:latest")).toBe("latest"); + expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0"); + expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3"); + }); + + it("should return 'latest' when no tag is present", () => { + expect(extractImageTag("my-image")).toBe("latest"); + expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest"); + }); + + it("should handle complex image paths with tags", () => { + expect( + extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"), + ).toBe("version1.0"); + expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe( + "v1.2.3", + ); + }); + + it("should return null for invalid inputs", () => { + expect(extractImageTag(null)).toBeNull(); + expect(extractImageTag("")).toBeNull(); + }); + + it("should handle edge cases with multiple colons", () => { + expect(extractImageTag("image:tag:extra")).toBe("extra"); + expect(extractImageTag("registry:5000/image:tag")).toBe("tag"); + }); + + it("should handle numeric tags", () => { + expect(extractImageTag("my-image:123")).toBe("123"); + expect(extractImageTag("my-image:1")).toBe("1"); + }); + }); +}); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index cbfebbfac..85b9b2c61 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -25,11 +25,17 @@ if (typeof window === "undefined") { } const baseApp: ApplicationNested = { - railpackVersion: "0.2.2", + railpackVersion: "0.15.4", applicationId: "", previewLabels: [], + createEnvFile: true, + bitbucketRepositorySlug: "", herokuVersion: "", giteaBranch: "", + buildServerId: "", + buildRegistryId: "", + buildRegistry: null, + args: [], giteaBuildPath: "", previewRequireCollaboratorPermissions: false, giteaId: "", @@ -37,11 +43,15 @@ const baseApp: ApplicationNested = { giteaRepository: "", cleanCache: false, watchPaths: [], + rollbackRegistryId: "", + rollbackRegistry: null, + deployments: [], enableSubmodules: false, applicationStatus: "done", triggerType: "push", appName: "", autoDeploy: true, + endpointSpecSwarm: null, serverId: "", registryUrl: "", branch: null, @@ -59,6 +69,7 @@ const baseApp: ApplicationNested = { previewWildcard: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", 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/environment.test.ts b/apps/dokploy/__test__/env/environment.test.ts index 95d46dcc0..24ef18b00 100644 --- a/apps/dokploy/__test__/env/environment.test.ts +++ b/apps/dokploy/__test__/env/environment.test.ts @@ -1,4 +1,7 @@ -import { prepareEnvironmentVariables } from "@dokploy/server/index"; +import { + prepareEnvironmentVariables, + prepareEnvironmentVariablesForShell, +} from "@dokploy/server/index"; import { describe, expect, it } from "vitest"; const projectEnv = ` @@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}} "IS_DEV=0", ]); }); + + it("handles environment variables with single quotes in values", () => { + const envWithSingleQuotes = ` +ENV_VARIABLE='ENVITONME'NT' +ANOTHER_VAR='value with 'quotes' inside' +SIMPLE_VAR=no-quotes +`; + + const serviceWithSingleQuotes = ` +TEST_VAR=\${{environment.ENV_VARIABLE}} +ANOTHER_TEST=\${{environment.ANOTHER_VAR}} +SIMPLE=\${{environment.SIMPLE_VAR}} +`; + + const resolved = prepareEnvironmentVariables( + serviceWithSingleQuotes, + "", + envWithSingleQuotes, + ); + + expect(resolved).toEqual([ + "TEST_VAR=ENVITONME'NT", + "ANOTHER_TEST=value with 'quotes' inside", + "SIMPLE=no-quotes", + ]); + }); +}); + +describe("prepareEnvironmentVariablesForShell (shell escaping)", () => { + it("escapes single quotes in environment variable values", () => { + const serviceEnv = ` +ENV_VARIABLE='ENVITONME'NT' +ANOTHER_VAR='value with 'quotes' inside' +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // shell-quote should wrap these in double quotes + expect(resolved).toEqual([ + `"ENV_VARIABLE=ENVITONME'NT"`, + `"ANOTHER_VAR=value with 'quotes' inside"`, + ]); + }); + + it("escapes double quotes in environment variable values", () => { + const serviceEnv = ` +MESSAGE="Hello "World"" +QUOTED_PATH="/path/to/"file"" +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // shell-quote wraps in single quotes when there are double quotes inside + expect(resolved).toEqual([ + `'MESSAGE=Hello "World"'`, + `'QUOTED_PATH=/path/to/"file"'`, + ]); + }); + + it("escapes dollar signs in environment variable values", () => { + const serviceEnv = ` +PRICE=$100 +VARIABLE=$HOME/path +TEMPLATE=Hello $USER +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // Dollar signs should be escaped to prevent variable expansion + for (const env of resolved) { + expect(env).toContain("$"); + } + }); + + it("escapes backticks in environment variable values", () => { + const serviceEnv = ` +COMMAND=\`echo "test"\` +NESTED=value with \`backticks\` inside +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // Backticks are escaped/removed by dotenv parsing, but values should be safely quoted + expect(resolved.length).toBe(2); + expect(resolved[0]).toContain("COMMAND"); + expect(resolved[1]).toContain("NESTED"); + }); + + it("handles environment variables with spaces", () => { + const serviceEnv = ` +FULL_NAME="John Doe" +MESSAGE='Hello World' +SENTENCE=This is a test +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // shell-quote uses single quotes for strings with spaces + expect(resolved).toEqual([ + `'FULL_NAME=John Doe'`, + `'MESSAGE=Hello World'`, + `'SENTENCE=This is a test'`, + ]); + }); + + it("handles environment variables with backslashes", () => { + const serviceEnv = ` +WINDOWS_PATH=C:\\Users\\Documents +ESCAPED=value\\with\\backslashes +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // Backslashes should be properly escaped + expect(resolved.length).toBe(2); + for (const env of resolved) { + expect(env).toContain("\\"); + } + }); + + it("handles simple environment variables without special characters", () => { + const serviceEnv = ` +NODE_ENV=production +PORT=3000 +DEBUG=true +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // shell-quote escapes the = sign in some cases + expect(resolved).toEqual([ + "NODE_ENV\\=production", + "PORT\\=3000", + "DEBUG\\=true", + ]); + }); + + it("handles environment variables with mixed special characters", () => { + const serviceEnv = ` +COMPLEX='value with "double" and 'single' quotes' +BASH_COMMAND=echo "$HOME" && echo 'test' +WEIRD=\`echo "$VAR"\` with 'quotes' and "more" +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // All should be escaped, none should throw errors + expect(resolved.length).toBe(3); + // Verify each can be safely used in shell + for (const env of resolved) { + expect(typeof env).toBe("string"); + expect(env.length).toBeGreaterThan(0); + } + }); + + it("handles environment variables with newlines", () => { + const serviceEnv = ` +MULTILINE="line1 +line2 +line3" +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(1); + expect(resolved[0]).toContain("MULTILINE"); + }); + + it("handles empty environment variable values", () => { + const serviceEnv = ` +EMPTY= +EMPTY_QUOTED="" +EMPTY_SINGLE='' +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + // shell-quote escapes the = sign for empty values + expect(resolved).toEqual([ + "EMPTY\\=", + "EMPTY_QUOTED\\=", + "EMPTY_SINGLE\\=", + ]); + }); + + it("handles environment variables with equals signs in values", () => { + const serviceEnv = ` +EQUATION=a=b+c +CONNECTION_STRING=user=admin;password=test +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(2); + expect(resolved[0]).toContain("EQUATION"); + expect(resolved[1]).toContain("CONNECTION_STRING"); + }); + + it("resolves and escapes environment variables together", () => { + const projectEnv = ` +BASE_URL=https://example.com +API_KEY='secret-key-with-quotes' +`; + + const environmentEnv = ` +ENV_NAME=production +DB_PASS='pa$$word' +`; + + const serviceEnv = ` +FULL_URL=\${{project.BASE_URL}}/api +AUTH_KEY=\${{project.API_KEY}} +ENVIRONMENT=\${{environment.ENV_NAME}} +DB_PASSWORD=\${{environment.DB_PASS}} +CUSTOM='value with 'quotes' inside' +`; + + const resolved = prepareEnvironmentVariablesForShell( + serviceEnv, + projectEnv, + environmentEnv, + ); + + expect(resolved.length).toBe(5); + // All resolved values should be properly escaped + for (const env of resolved) { + expect(typeof env).toBe("string"); + } + }); + + it("handles environment variables with semicolons and ampersands", () => { + const serviceEnv = ` +COMMAND=echo "test" && echo "test2" +MULTIPLE=cmd1; cmd2; cmd3 +URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3 +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(3); + // These should be safely escaped to prevent command injection + for (const env of resolved) { + expect(typeof env).toBe("string"); + expect(env.length).toBeGreaterThan(0); + } + }); + + it("handles environment variables with pipes and redirects", () => { + const serviceEnv = ` +PIPE_COMMAND=cat file | grep test +REDIRECT=echo "test" > output.txt +BOTH=cat input.txt | grep pattern > output.txt +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(3); + // Pipes and redirects should be safely quoted + expect(resolved[0]).toContain("PIPE_COMMAND"); + expect(resolved[1]).toContain("REDIRECT"); + expect(resolved[2]).toContain("BOTH"); + // At least one should contain a pipe + const hasPipe = resolved.some((env) => env.includes("|")); + expect(hasPipe).toBe(true); + }); + + it("handles environment variables with parentheses and brackets", () => { + const serviceEnv = ` +MATH=(a+b)*c +ARRAY=[1,2,3] +JSON={"key":"value"} +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(3); + expect(resolved[0]).toContain("("); + expect(resolved[1]).toContain("["); + expect(resolved[2]).toContain("{"); + }); + + it("handles very long environment variable values", () => { + const longValue = "a".repeat(10000); + const serviceEnv = `LONG_VAR=${longValue}`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(1); + expect(resolved[0]).toContain("LONG_VAR"); + expect(resolved[0]?.length).toBeGreaterThan(10000); + }); + + it("handles special unicode characters in environment variables", () => { + const serviceEnv = ` +EMOJI=Hello 🌍 World 🚀 +CHINESE=你好世界 +SPECIAL=café résumé naïve +`; + + const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", ""); + + expect(resolved.length).toBe(3); + expect(resolved[0]).toContain("🌍"); + expect(resolved[1]).toContain("你好"); + expect(resolved[2]).toContain("café"); + }); }); 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__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts index 53ca8d777..3f58ac439 100644 --- a/apps/dokploy/__test__/requests/request.test.ts +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -54,4 +54,22 @@ describe("processLogs", () => { const result = parseRawConfig(entryWithWhitespace); expect(result.data).toHaveLength(2); }); + + it("should filter out Dokploy dashboard requests", () => { + const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`; + + // Test with only Dokploy dashboard entry - should be filtered out + const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry); + expect(resultOnlyDokploy.data).toHaveLength(0); + expect(resultOnlyDokploy.totalCount).toBe(0); + + // Test with mixed entries - Dokploy should be filtered, others should remain + const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`; + const resultMixed = parseRawConfig(mixedEntries); + expect(resultMixed.data).toHaveLength(1); + expect(resultMixed.totalCount).toBe(1); + expect(resultMixed.data[0]?.ServiceName).not.toBe( + "dokploy-service-app@file", + ); + }); }); diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index 6eb5d1831..c12a272bc 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -1,10 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - import type { ApplicationNested } from "@dokploy/server/utils/builders"; import { mechanizeDockerContainer } from "@dokploy/server/utils/builders"; +import { beforeEach, describe, expect, it, vi } from "vitest"; type MockCreateServiceOptions = { - StopGracePeriod?: number; + TaskTemplate?: { + ContainerSpec?: { + StopGracePeriod?: number; + }; + }; [key: string]: unknown; }; @@ -82,8 +85,10 @@ describe("mechanizeDockerContainer", () => { throw new Error("createServiceMock should have been called once"); } const [settings] = call; - expect(settings.StopGracePeriod).toBe(0); - expect(typeof settings.StopGracePeriod).toBe("number"); + expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0); + expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe( + "number", + ); }); it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => { @@ -97,6 +102,8 @@ describe("mechanizeDockerContainer", () => { throw new Error("createServiceMock should have been called once"); } const [settings] = call; - expect(settings).not.toHaveProperty("StopGracePeriod"); + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty( + "StopGracePeriod", + ); }); }); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts index 3ae92ae20..f2af2717b 100644 --- a/apps/dokploy/__test__/templates/helpers.template.test.ts +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -161,6 +161,50 @@ describe("helpers functions", () => { }); }); + describe("Empty string variables", () => { + it("should replace variables with empty string values correctly", () => { + const variables = { + smtp_username: "", + smtp_password: "", + non_empty: "value", + }; + + const result1 = processValue("${smtp_username}", variables, mockSchema); + expect(result1).toBe(""); + + const result2 = processValue("${smtp_password}", variables, mockSchema); + expect(result2).toBe(""); + + const result3 = processValue("${non_empty}", variables, mockSchema); + expect(result3).toBe("value"); + }); + + it("should not replace undefined variables", () => { + const variables = { + defined_var: "", + }; + + const result = processValue("${undefined_var}", variables, mockSchema); + expect(result).toBe("${undefined_var}"); + }); + + it("should handle mixed empty and non-empty variables in template", () => { + const variables = { + smtp_address: "smtp.example.com", + smtp_port: "2525", + smtp_username: "", + smtp_password: "", + }; + + const template = + "SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}"; + const result = processValue(template, variables, mockSchema); + expect(result).toBe( + "SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=", + ); + }); + }); + describe("${jwt}", () => { it("should generate a JWT string", () => { const jwt = processValue("${jwt}", {}, mockSchema); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 6858f0f00..b422279ca 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({ default: fs, })); -import type { FileConfig, User } from "@dokploy/server"; +import type { FileConfig } from "@dokploy/server"; import { createDefaultServerTraefikConfig, loadOrCreateConfig, updateServerTraefik, } from "@dokploy/server"; +import type { webServerSettings } from "@dokploy/server/db/schema"; import { beforeEach, expect, test, vi } from "vitest"; -const baseAdmin: User = { +type WebServerSettings = typeof webServerSettings.$inferSelect; + +const baseSettings: WebServerSettings = { + id: "", https: false, - enablePaidFeatures: false, - allowImpersonation: false, - role: "user", + certificateType: "none", + host: null, + serverIp: null, + letsEncryptEmail: null, + sshPrivateKey: null, + enableDockerCleanup: false, + logCleanupCron: null, metricsConfig: { containers: { refreshRate: 20, @@ -43,30 +51,8 @@ const baseAdmin: User = { cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, - createdAt: new Date(), - serverIp: null, - certificateType: "none", - host: null, - letsEncryptEmail: null, - sshPrivateKey: null, - enableDockerCleanup: false, - logCleanupCron: null, - serversQuantity: 0, - stripeCustomerId: "", - stripeSubscriptionId: "", - banExpires: new Date(), - banned: true, - banReason: "", - email: "", - expirationDate: "", - id: "", - isRegistered: false, - name: "", - createdAt2: new Date().toISOString(), - emailVerified: false, - image: "", + createdAt: null, updatedAt: new Date(), - twoFactorEnabled: false, }; beforeEach(() => { @@ -84,7 +70,7 @@ test("Should read the configuration file", () => { test("Should apply redirect-to-https", () => { updateServerTraefik( { - ...baseAdmin, + ...baseSettings, https: true, certificateType: "letsencrypt", }, @@ -99,7 +85,7 @@ test("Should apply redirect-to-https", () => { }); test("Should change only host when no certificate", () => { - updateServerTraefik(baseAdmin, "example.com"); + updateServerTraefik(baseSettings, "example.com"); const config: FileConfig = loadOrCreateConfig("dokploy"); @@ -109,7 +95,7 @@ test("Should change only host when no certificate", () => { test("Should not touch config without host", () => { const originalConfig: FileConfig = loadOrCreateConfig("dokploy"); - updateServerTraefik(baseAdmin, null); + updateServerTraefik(baseSettings, null); const config: FileConfig = loadOrCreateConfig("dokploy"); @@ -118,11 +104,14 @@ test("Should not touch config without host", () => { test("Should remove websecure if https rollback to http", () => { updateServerTraefik( - { ...baseAdmin, certificateType: "letsencrypt" }, + { ...baseSettings, certificateType: "letsencrypt" }, "example.com", ); - updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com"); + updateServerTraefik( + { ...baseSettings, certificateType: "none" }, + "example.com", + ); const config: FileConfig = loadOrCreateConfig("dokploy"); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 7b39eeb4f..0e6e529b0 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -3,18 +3,28 @@ 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: "", giteaBranch: "", + buildServerId: "", + buildRegistryId: "", + buildRegistry: null, giteaBuildPath: "", giteaId: "", + args: [], + rollbackRegistryId: "", + rollbackRegistry: null, + deployments: [], cleanCache: false, applicationStatus: "done", + endpointSpecSwarm: null, appName: "", autoDeploy: true, enableSubmodules: false, @@ -41,6 +51,7 @@ const baseApp: ApplicationNested = { environmentId: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts index ddc84d6ac..7270b828a 100644 --- a/apps/dokploy/__test__/vitest.config.ts +++ b/apps/dokploy/__test__/vitest.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ NODE: "test", }, }, - plugins: [tsconfigPaths()], + plugins: [ + tsconfigPaths({ + projects: [path.resolve(__dirname, "../tsconfig.json")], + }), + ], resolve: { alias: { "@dokploy/server": path.resolve( 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 4227eeb44..ee427feca 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,186 +1,106 @@ -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, + 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 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(), -}); - -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: "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, @@ -195,132 +115,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, - }, - 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, - }); - } - }, [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, - }) - .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, @@ -328,535 +139,66 @@ 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, - ) - } - /> - -
-										
-									
-
- )} - /> - - - - - + {/* Right Column - Form */} +
+ {activeMenu === "health-check" && ( + + )} + {activeMenu === "restart-policy" && ( + + )} + {activeMenu === "placement" && ( + + )} + {activeMenu === "update-config" && ( + + )} + {activeMenu === "rollback-config" && ( + + )} + {activeMenu === "mode" && } + {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..b2fc49ef3 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -0,0 +1,267 @@ +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 [testCommands, setTestCommands] = useState([]); + + 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, + }, + }); + + 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, + }); + setTestCommands(hc.Test || []); + } + }, [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 = () => { + setTestCommands([...testCommands, ""]); + }; + + const updateTestCommand = (index: number, value: string) => { + const newCommands = [...testCommands]; + newCommands[index] = value; + setTestCommands(newCommands); + }; + + const removeTestCommand = (index: number) => { + setTestCommands(testCommands.filter((_, i) => i !== index)); + }; + + return ( +
+ +
+ Test Commands + + Command to run for health check (e.g., ["CMD-SHELL", "curl -f + http://localhost:3000/health"]) + +
+ {testCommands.map((cmd, index) => ( +
+ 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..ebd00abcd --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -0,0 +1,10 @@ +export { HealthCheckForm } from "./health-check-form"; +export { RestartPolicyForm } from "./restart-policy-form"; +export { PlacementForm } from "./placement-form"; +export { UpdateConfigForm } from "./update-config-form"; +export { RollbackConfigForm } from "./rollback-config-form"; +export { ModeForm } from "./mode-form"; +export { LabelsForm } from "./labels-form"; +export { StopGracePeriodForm } from "./stop-grace-period-form"; +export { EndpointSpecForm } from "./endpoint-spec-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/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx new file mode 100644 index 000000000..b0c354513 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -0,0 +1,342 @@ +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({ + Spread: 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 : 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/general/add-command.tsx b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx index 1bf69394a..a7c5f7288 100644 --- a/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,13 @@ interface Props { const AddRedirectSchema = z.object({ command: z.string(), + args: z + .array( + z.object({ + value: z.string().min(1, "Argument cannot be empty"), + }), + ) + .optional(), }); type AddCommand = z.infer; @@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => { const form = useForm({ defaultValues: { command: "", + args: [], }, resolver: zodResolver(AddRedirectSchema), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "args", + }); + useEffect(() => { - if (data?.command) { + if (data) { form.reset({ command: data?.command || "", + args: data?.args?.map((arg) => ({ value: arg })) || [], }); } - }, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]); + }, [data, form]); const onSubmit = async (data: AddCommand) => { await mutateAsync({ applicationId, command: data?.command, + args: data?.args?.map((arg) => arg.value).filter(Boolean), }) .then(async () => { toast.success("Command Updated"); @@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => { Command - + )} /> + +
+
+ Arguments (Args) + +
+ + {fields.length === 0 && ( +

+ No arguments added yet. Click "Add Argument" to add one. +

+ )} + + {fields.map((field, index) => ( + ( + +
+ + + + +
+ +
+ )} + /> + ))} +
+
+ + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index 3beedcdbc..aea30e49b 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -21,7 +21,10 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; +import { + createConverter, + NumberInputWithSteps, +} from "@/components/ui/number-input"; import { Tooltip, TooltipContent, @@ -30,6 +33,23 @@ import { } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; +const CPU_STEP = 0.25; +const MEMORY_STEP_MB = 256; + +const formatNumber = (value: number, decimals = 2): string => + Number.isInteger(value) ? String(value) : value.toFixed(decimals); + +const cpuConverter = createConverter(1_000_000_000, (cpu) => + cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`, +); + +const memoryConverter = createConverter(1024 * 1024, (mb) => { + if (mb <= 0) return ""; + return mb >= 1024 + ? `${formatNumber(mb / 1024)} GB` + : `${formatNumber(mb)} MB`; +}); + const addResourcesSchema = z.object({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), @@ -51,6 +71,7 @@ interface Props { } type AddResources = z.infer; + export const ShowResources = ({ id, type }: Props) => { const queryMap = { postgres: () => @@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {

Memory hard limit in bytes. Example: 1GB = - 1073741824 bytes + 1073741824 bytes. Use +/- buttons to adjust by + 256 MB.

- @@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {

Memory soft limit in bytes. Example: 256MB = - 268435456 bytes + 268435456 bytes. Use +/- buttons to adjust by 256 + MB.

- @@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {

CPU quota in units of 10^-9 CPUs. Example: 2 - CPUs = 2000000000 + CPUs = 2000000000. Use +/- buttons to adjust by + 0.25 CPU.

- @@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {

CPU shares (relative weight). Example: 1 CPU = - 1000000000 + 1000000000. Use +/- buttons to adjust by 0.25 + CPU.

- + diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx index d7621bc1e..2bfd6bbc0 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx @@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [ z .object({ type: z.literal("volume"), - volumeName: z.string().min(1, "Volume name required"), + volumeName: z + .string() + .min(1, "Volume name required") + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, + "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.", + ), }) .merge(mountSchema), z diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 38d02ec90..44fb050bc 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [ z .object({ type: z.literal("volume"), - volumeName: z.string().min(1, "Volume name required"), + volumeName: z + .string() + .min(1, "Volume name required") + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, + "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.", + ), }) .merge(mountSchema), z diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 1a0ed386d..7f92157f2 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Cog } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -20,8 +20,39 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { api } from "@/utils/api"; +// Railpack versions from https://github.com/railwayapp/railpack/releases +export const RAILPACK_VERSIONS = [ + "0.15.4", + "0.15.3", + "0.15.2", + "0.15.1", + "0.15.0", + "0.14.0", + "0.13.0", + "0.12.0", + "0.11.0", + "0.10.0", + "0.9.2", + "0.9.1", + "0.9.0", + "0.8.0", + "0.7.0", + "0.6.0", + "0.5.0", + "0.4.0", + "0.3.0", + "0.2.2", +] as const; + export enum BuildType { dockerfile = "dockerfile", heroku_buildpacks = "heroku_buildpacks", @@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [ }), z.object({ buildType: z.literal(BuildType.railpack), - railpackVersion: z.string().nullable().default("0.2.2"), + railpackVersion: z.string().nullable().default("0.15.4"), }), z.object({ buildType: z.literal(BuildType.static), @@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { }); const buildType = form.watch("buildType"); + const railpackVersion = form.watch("railpackVersion"); + const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false); useEffect(() => { if (data) { @@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { }; form.reset(resetData(typedData)); + + // Check if railpack version is manual (not in the predefined list) + if ( + data.railpackVersion && + !RAILPACK_VERSIONS.includes(data.railpackVersion as any) + ) { + setIsManualRailpackVersion(true); + } } }, [data, form]); + // Hide builder section when Docker provider is selected + if (data?.sourceType === "docker") { + return null; + } + const onSubmit = async (data: AddTemplate) => { await mutateAsync({ applicationId, @@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { data.buildType === BuildType.static ? data.isStaticSpa : null, railpackVersion: data.buildType === BuildType.railpack - ? data.railpackVersion || "0.2.2" + ? data.railpackVersion || "0.15.4" : null, }) .then(async () => { @@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { /> )} {buildType === BuildType.railpack && ( - ( - - Railpack Version - - - - - - )} - /> + <> + ( + + Railpack Version + + {isManualRailpackVersion ? ( +
+ + +
+ ) : ( + + )} +
+ + Select a Railpack version or choose manual to enter a + custom version.{" "} + + View releases + + + +
+ )} + /> + )}
+ + + + Are you sure to kill the build? + + This will kill the build process + + + + Cancel + { + await mutateAsync({ + applicationId: id || "", + composeId: id || "", + }) + .then(() => { + toast.success("Build killed successfully"); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + Confirm + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1045856c2..cfe747d27 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,4 +1,12 @@ -import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + Clock, + Loader2, + RefreshCcw, + RocketIcon, + Settings, +} from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -17,6 +25,7 @@ import { import { api, type RouterOutputs } from "@/utils/api"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; import { CancelQueues } from "./cancel-queues"; +import { KillBuild } from "./kill-build"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; @@ -80,6 +89,23 @@ export const ShowDeployments = ({ } = api.compose.cancelDeployment.useMutation(); const [url, setUrl] = React.useState(""); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set(), + ); + + const MAX_DESCRIPTION_LENGTH = 200; + + const truncateDescription = (description: string): string => { + if (description.length <= MAX_DESCRIPTION_LENGTH) { + return description; + } + const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); + const lastSpace = truncated.lastIndexOf(" "); + if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { + return `${truncated.slice(0, lastSpace)}...`; + } + return `${truncated}...`; + }; // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment const stuckDeployment = useMemo(() => { @@ -117,7 +143,10 @@ export const ShowDeployments = ({ See the last 10 deployments for this {type}
-
+
+ {(type === "application" || type === "compose") && ( + + )} {(type === "application" || type === "compose") && ( )} @@ -217,122 +246,183 @@ export const ShowDeployments = ({
) : (
- {deployments?.map((deployment, index) => ( -
-
- - {index + 1}. {deployment.status} - - - - {deployment.title} - - {deployment.description && ( - - {deployment.description} + {deployments?.map((deployment, index) => { + const titleText = deployment?.title?.trim() || ""; + const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; + const isExpanded = expandedDescriptions.has( + deployment.deploymentId, + ); + + return ( +
+
+ + {index + 1}. {deployment.status} + - )} -
-
-
- - {deployment.startedAt && deployment.finishedAt && ( - - - {formatDuration( - Math.floor( - (new Date(deployment.finishedAt).getTime() - - new Date(deployment.startedAt).getTime()) / - 1000, - ), - )} - - )} -
-
- {deployment.pid && deployment.status === "running" && ( - { - await killProcess({ - deploymentId: deployment.deploymentId, - }) - .then(() => { - toast.success("Process killed successfully"); - }) - .catch(() => { - toast.error("Error killing process"); - }); - }} - > - - - )} - + {isExpanded ? ( + <> + + Show less + + ) : ( + <> + + Show more + + )} + + )} + {/* Hash (from description) - shown in compact form */} + {deployment.description?.trim() && ( + + {deployment.description} + + )} +
+
+
+
+ + {deployment.startedAt && deployment.finishedAt && ( + + + {formatDuration( + Math.floor( + (new Date(deployment.finishedAt).getTime() - + new Date(deployment.startedAt).getTime()) / + 1000, + ), + )} + + )} +
- {deployment?.rollback && - deployment.status === "done" && - type === "application" && ( +
+ {deployment.pid && deployment.status === "running" && ( { - await rollback({ - rollbackId: deployment.rollback.rollbackId, + await killProcess({ + deploymentId: deployment.deploymentId, }) .then(() => { - toast.success( - "Rollback initiated successfully", - ); + toast.success("Process killed successfully"); }) .catch(() => { - toast.error("Error initiating rollback"); + toast.error("Error killing process"); }); }} > )} + + + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + +

+ Are you sure you want to rollback to this + deployment? +

+ + Please wait a few seconds while the image is + pulled from the registry. Your application + should be running shortly. + +
+ } + type="default" + onClick={async () => { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
-
- ))} + ); + })}
)} setActiveLog(null)} logPath={activeLog?.logPath || ""} diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 9d7a074f9..6af0e1e8c 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache"; export const domain = z .object({ - host: z.string().min(1, { message: "Add a hostname" }), + host: z + .string() + .min(1, { message: "Add a hostname" }) + .refine((val) => val === val.trim(), { + message: "Domain name cannot have leading or trailing spaces", + }) + .transform((val) => val.trim()), path: z.string().min(1).optional(), internalPath: z.string().optional(), stripPath: z.boolean().optional(), @@ -202,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const certificateType = form.watch("certificateType"); const https = form.watch("https"); const domainType = form.watch("domainType"); + const host = form.watch("host"); + const isTraefikMeDomain = host?.includes("traefik.me") || false; useEffect(() => { if (data) { @@ -299,6 +307,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { {isError && {error?.message}} + {type === "compose" && ( + + Whenever you make changes to domains, remember to redeploy your + compose to apply the changes. + + )} +
{ to make your traefik.me domain work. )} + {isTraefikMeDomain && ( + + Note: traefik.me is a public HTTP + service and does not support SSL/HTTPS. HTTPS and + certificate options will not have any effect. + + )} Host
diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 48e978880..cbbd105df 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -5,14 +5,23 @@ import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Form } from "@/components/ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; import { Secrets } from "@/components/ui/secrets"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; const addEnvironmentSchema = z.object({ env: z.string(), buildArgs: z.string(), buildSecrets: z.string(), + createEnvFile: z.boolean(), }); type EnvironmentSchema = z.infer; @@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: "", buildArgs: "", buildSecrets: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); @@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const currentEnv = form.watch("env"); const currentBuildArgs = form.watch("buildArgs"); const currentBuildSecrets = form.watch("buildSecrets"); + const currentCreateEnvFile = form.watch("createEnvFile"); const hasChanges = currentEnv !== (data?.env || "") || currentBuildArgs !== (data?.buildArgs || "") || - currentBuildSecrets !== (data?.buildSecrets || ""); + currentBuildSecrets !== (data?.buildSecrets || "") || + currentCreateEnvFile !== (data?.createEnvFile ?? true); useEffect(() => { if (data) { @@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: data.env || "", buildArgs: data.buildArgs || "", buildSecrets: data.buildSecrets || "", + createEnvFile: data.createEnvFile ?? true, }); } }, [data, form]); @@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: formData.env, buildArgs: formData.buildArgs, buildSecrets: formData.buildSecrets, + createEnvFile: formData.createEnvFile, applicationId, }) .then(async () => { @@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: data?.env || "", buildArgs: data?.buildArgs || "", buildSecrets: data?.buildSecrets || "", + createEnvFile: data?.createEnvFile ?? true, }); }; @@ -167,6 +182,31 @@ export const ShowEnvironment = ({ applicationId }: Props) => { placeholder="NPM_TOKEN=xyz" /> )} + {data?.buildType === "dockerfile" && ( + ( + +
+ Create Environment File + + When enabled, an .env file will be created in the same + directory as your Dockerfile during the build process. + Disable this if you don't want to generate an environment + file. + +
+ + + +
+ )} + /> + )}
{hasChanges && ( + + + { + await redeployPreviewDeployment({ + previewDeploymentId: + deployment.previewDeploymentId, + }) + .then(() => { + toast.success( + "Preview deployment rebuild started", + ); + refetchPreviewDeployments(); + }) + .catch(() => { + toast.error( + "Error rebuilding preview deployment", + ); + }); + }} + > + + { }); const previewHttps = form.watch("previewHttps"); + const wildcardDomain = form.watch("wildcardDomain"); + const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false; useEffect(() => { setIsEnabled(data?.isPreviewDeploymentsActive || false); @@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { previewCertificateType: data.previewCertificateType || "none", previewCustomCertResolver: data.previewCustomCertResolver || "", previewRequireCollaboratorPermissions: - data.previewRequireCollaboratorPermissions || true, + data.previewRequireCollaboratorPermissions ?? true, }); } }, [data]); @@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
+ {isTraefikMeDomain && ( + + Note: traefik.me is a public HTTP service and + does not support SSL/HTTPS. HTTPS and certificate options will + not have any effect. + + )} { + if ( + values.rollbackActive && + (!values.rollbackRegistryId || values.rollbackRegistryId === "none") + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["rollbackRegistryId"], + message: "Registry is required when rollbacks are enabled", + }); + } + }); type FormValues = z.infer; @@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => { const { mutateAsync: updateApplication, isLoading } = api.application.update.useMutation(); + const { data: registries } = api.registry.all.useQuery(); + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { rollbackActive: application?.rollbackActive ?? false, + rollbackRegistryId: application?.rollbackRegistryId || "", }, }); + useEffect(() => { + if (application) { + form.reset({ + rollbackActive: application.rollbackActive ?? false, + rollbackRegistryId: application.rollbackRegistryId || "", + }); + } + }, [application, form]); + const onSubmit = async (data: FormValues) => { await updateApplication({ applicationId, rollbackActive: data.rollbackActive, + rollbackRegistryId: + data.rollbackRegistryId === "none" || !data.rollbackRegistryId + ? null + : data.rollbackRegistryId, }) .then(() => { toast.success("Rollback settings updated"); @@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => { )} /> + {form.watch("rollbackActive") && ( + ( + + Rollback Registry + + {!registries || registries.length === 0 ? ( + + No registries available. Please{" "} + + configure a registry + {" "} + first to enable rollbacks. + + ) : ( + + Select a registry where rollback images will be stored. + + )} + + + )} + /> + )} + diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index 8273d0e2b..e85b1b004 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -1,5 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { + CheckIcon, + ChevronsUpDown, DatabaseZap, Info, PenBoxIcon, @@ -13,6 +15,14 @@ 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 { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Dialog, DialogContent, @@ -31,6 +41,12 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -48,6 +64,7 @@ import { import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import type { CacheType } from "../domains/handle-domain"; +import { getTimezoneLabel, TIMEZONES } from "./timezones"; export const commonCronExpressions = [ { label: "Every minute", value: "* * * * *" }, @@ -75,6 +92,7 @@ const formSchema = z "dokploy-server", ]), script: z.string(), + timezone: z.string().optional(), }) .superRefine((data, ctx) => { if (data.scheduleType === "compose" && !data.serviceName) { @@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: "", scheduleType: scheduleType || "application", script: "", + timezone: undefined, }, }); @@ -251,6 +270,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: schedule.serviceName || "", scheduleType: schedule.scheduleType, script: schedule.script || "", + timezone: schedule.timezone || undefined, }); } }, [form, schedule, scheduleId]); @@ -464,6 +484,89 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { formControl={form.control} /> + ( + + + Timezone + + + + + + +

+ Select a timezone for the schedule. If not + specified, UTC will be used. +

+
+
+
+
+ + + + + + + + + + + No timezone found. + + {Object.entries(TIMEZONES).map( + ([region, zones]) => ( + + {zones.map((tz) => ( + { + field.onChange(tz.value); + }} + > + {tz.value} + + + ))} + + ), + )} + + + + + + + Optional: Choose a timezone for the schedule execution time + + +
+ )} + /> + {(scheduleTypeForm === "application" || scheduleTypeForm === "compose") && ( <> diff --git a/apps/dokploy/components/dashboard/application/schedules/timezones.ts b/apps/dokploy/components/dashboard/application/schedules/timezones.ts new file mode 100644 index 000000000..44891b909 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/schedules/timezones.ts @@ -0,0 +1,458 @@ +// Complete list of IANA timezones grouped by region +export const TIMEZONES: Record< + string, + Array<{ label: string; value: string }> +> = { + Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }], + Africa: [ + { label: "Abidjan", value: "Africa/Abidjan" }, + { label: "Accra", value: "Africa/Accra" }, + { label: "Addis Ababa", value: "Africa/Addis_Ababa" }, + { label: "Algiers", value: "Africa/Algiers" }, + { label: "Asmara", value: "Africa/Asmara" }, + { label: "Bamako", value: "Africa/Bamako" }, + { label: "Bangui", value: "Africa/Bangui" }, + { label: "Banjul", value: "Africa/Banjul" }, + { label: "Bissau", value: "Africa/Bissau" }, + { label: "Blantyre", value: "Africa/Blantyre" }, + { label: "Brazzaville", value: "Africa/Brazzaville" }, + { label: "Bujumbura", value: "Africa/Bujumbura" }, + { label: "Cairo", value: "Africa/Cairo" }, + { label: "Casablanca", value: "Africa/Casablanca" }, + { label: "Ceuta", value: "Africa/Ceuta" }, + { label: "Conakry", value: "Africa/Conakry" }, + { label: "Dakar", value: "Africa/Dakar" }, + { label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" }, + { label: "Djibouti", value: "Africa/Djibouti" }, + { label: "Douala", value: "Africa/Douala" }, + { label: "El Aaiun", value: "Africa/El_Aaiun" }, + { label: "Freetown", value: "Africa/Freetown" }, + { label: "Gaborone", value: "Africa/Gaborone" }, + { label: "Harare", value: "Africa/Harare" }, + { label: "Johannesburg", value: "Africa/Johannesburg" }, + { label: "Juba", value: "Africa/Juba" }, + { label: "Kampala", value: "Africa/Kampala" }, + { label: "Khartoum", value: "Africa/Khartoum" }, + { label: "Kigali", value: "Africa/Kigali" }, + { label: "Kinshasa", value: "Africa/Kinshasa" }, + { label: "Lagos", value: "Africa/Lagos" }, + { label: "Libreville", value: "Africa/Libreville" }, + { label: "Lome", value: "Africa/Lome" }, + { label: "Luanda", value: "Africa/Luanda" }, + { label: "Lubumbashi", value: "Africa/Lubumbashi" }, + { label: "Lusaka", value: "Africa/Lusaka" }, + { label: "Malabo", value: "Africa/Malabo" }, + { label: "Maputo", value: "Africa/Maputo" }, + { label: "Maseru", value: "Africa/Maseru" }, + { label: "Mbabane", value: "Africa/Mbabane" }, + { label: "Mogadishu", value: "Africa/Mogadishu" }, + { label: "Monrovia", value: "Africa/Monrovia" }, + { label: "Nairobi", value: "Africa/Nairobi" }, + { label: "Ndjamena", value: "Africa/Ndjamena" }, + { label: "Niamey", value: "Africa/Niamey" }, + { label: "Nouakchott", value: "Africa/Nouakchott" }, + { label: "Ouagadougou", value: "Africa/Ouagadougou" }, + { label: "Porto-Novo", value: "Africa/Porto-Novo" }, + { label: "Sao Tome", value: "Africa/Sao_Tome" }, + { label: "Tripoli", value: "Africa/Tripoli" }, + { label: "Tunis", value: "Africa/Tunis" }, + { label: "Windhoek", value: "Africa/Windhoek" }, + ], + America: [ + { label: "Adak", value: "America/Adak" }, + { label: "Anchorage", value: "America/Anchorage" }, + { label: "Anguilla", value: "America/Anguilla" }, + { label: "Antigua", value: "America/Antigua" }, + { label: "Araguaina", value: "America/Araguaina" }, + { + label: "Argentina/Buenos Aires", + value: "America/Argentina/Buenos_Aires", + }, + { label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" }, + { label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" }, + { label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" }, + { label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" }, + { label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" }, + { + label: "Argentina/Rio Gallegos", + value: "America/Argentina/Rio_Gallegos", + }, + { label: "Argentina/Salta", value: "America/Argentina/Salta" }, + { label: "Argentina/San Juan", value: "America/Argentina/San_Juan" }, + { label: "Argentina/San Luis", value: "America/Argentina/San_Luis" }, + { label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" }, + { label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" }, + { label: "Aruba", value: "America/Aruba" }, + { label: "Asuncion", value: "America/Asuncion" }, + { label: "Atikokan", value: "America/Atikokan" }, + { label: "Bahia", value: "America/Bahia" }, + { label: "Bahia Banderas", value: "America/Bahia_Banderas" }, + { label: "Barbados", value: "America/Barbados" }, + { label: "Belem", value: "America/Belem" }, + { label: "Belize", value: "America/Belize" }, + { label: "Blanc-Sablon", value: "America/Blanc-Sablon" }, + { label: "Boa Vista", value: "America/Boa_Vista" }, + { label: "Bogota", value: "America/Bogota" }, + { label: "Boise", value: "America/Boise" }, + { label: "Cambridge Bay", value: "America/Cambridge_Bay" }, + { label: "Campo Grande", value: "America/Campo_Grande" }, + { label: "Cancun", value: "America/Cancun" }, + { label: "Caracas", value: "America/Caracas" }, + { label: "Cayenne", value: "America/Cayenne" }, + { label: "Cayman", value: "America/Cayman" }, + { label: "Chicago (Central Time)", value: "America/Chicago" }, + { label: "Chihuahua", value: "America/Chihuahua" }, + { label: "Ciudad Juarez", value: "America/Ciudad_Juarez" }, + { label: "Costa Rica", value: "America/Costa_Rica" }, + { label: "Creston", value: "America/Creston" }, + { label: "Cuiaba", value: "America/Cuiaba" }, + { label: "Curacao", value: "America/Curacao" }, + { label: "Danmarkshavn", value: "America/Danmarkshavn" }, + { label: "Dawson", value: "America/Dawson" }, + { label: "Dawson Creek", value: "America/Dawson_Creek" }, + { label: "Denver (Mountain Time)", value: "America/Denver" }, + { label: "Detroit", value: "America/Detroit" }, + { label: "Dominica", value: "America/Dominica" }, + { label: "Edmonton", value: "America/Edmonton" }, + { label: "Eirunepe", value: "America/Eirunepe" }, + { label: "El Salvador", value: "America/El_Salvador" }, + { label: "Fort Nelson", value: "America/Fort_Nelson" }, + { label: "Fortaleza", value: "America/Fortaleza" }, + { label: "Glace Bay", value: "America/Glace_Bay" }, + { label: "Goose Bay", value: "America/Goose_Bay" }, + { label: "Grand Turk", value: "America/Grand_Turk" }, + { label: "Grenada", value: "America/Grenada" }, + { label: "Guadeloupe", value: "America/Guadeloupe" }, + { label: "Guatemala", value: "America/Guatemala" }, + { label: "Guayaquil", value: "America/Guayaquil" }, + { label: "Guyana", value: "America/Guyana" }, + { label: "Halifax", value: "America/Halifax" }, + { label: "Havana", value: "America/Havana" }, + { label: "Hermosillo", value: "America/Hermosillo" }, + { label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" }, + { label: "Indiana/Knox", value: "America/Indiana/Knox" }, + { label: "Indiana/Marengo", value: "America/Indiana/Marengo" }, + { label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" }, + { label: "Indiana/Tell City", value: "America/Indiana/Tell_City" }, + { label: "Indiana/Vevay", value: "America/Indiana/Vevay" }, + { label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" }, + { label: "Indiana/Winamac", value: "America/Indiana/Winamac" }, + { label: "Inuvik", value: "America/Inuvik" }, + { label: "Iqaluit", value: "America/Iqaluit" }, + { label: "Jamaica", value: "America/Jamaica" }, + { label: "Juneau", value: "America/Juneau" }, + { label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" }, + { label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" }, + { label: "Kralendijk", value: "America/Kralendijk" }, + { label: "La Paz", value: "America/La_Paz" }, + { label: "Lima", value: "America/Lima" }, + { label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" }, + { label: "Lower Princes", value: "America/Lower_Princes" }, + { label: "Maceio", value: "America/Maceio" }, + { label: "Managua", value: "America/Managua" }, + { label: "Manaus", value: "America/Manaus" }, + { label: "Marigot", value: "America/Marigot" }, + { label: "Martinique", value: "America/Martinique" }, + { label: "Matamoros", value: "America/Matamoros" }, + { label: "Mazatlan", value: "America/Mazatlan" }, + { label: "Menominee", value: "America/Menominee" }, + { label: "Merida", value: "America/Merida" }, + { label: "Metlakatla", value: "America/Metlakatla" }, + { label: "Mexico City (Central Mexico)", value: "America/Mexico_City" }, + { label: "Miquelon", value: "America/Miquelon" }, + { label: "Moncton", value: "America/Moncton" }, + { label: "Monterrey", value: "America/Monterrey" }, + { label: "Montevideo", value: "America/Montevideo" }, + { label: "Montserrat", value: "America/Montserrat" }, + { label: "Nassau", value: "America/Nassau" }, + { label: "New York (Eastern Time)", value: "America/New_York" }, + { label: "Nome", value: "America/Nome" }, + { label: "Noronha", value: "America/Noronha" }, + { label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" }, + { label: "North Dakota/Center", value: "America/North_Dakota/Center" }, + { + label: "North Dakota/New Salem", + value: "America/North_Dakota/New_Salem", + }, + { label: "Nuuk", value: "America/Nuuk" }, + { label: "Ojinaga", value: "America/Ojinaga" }, + { label: "Panama", value: "America/Panama" }, + { label: "Paramaribo", value: "America/Paramaribo" }, + { label: "Phoenix", value: "America/Phoenix" }, + { label: "Port-au-Prince", value: "America/Port-au-Prince" }, + { label: "Port of Spain", value: "America/Port_of_Spain" }, + { label: "Porto Velho", value: "America/Porto_Velho" }, + { label: "Puerto Rico", value: "America/Puerto_Rico" }, + { label: "Punta Arenas", value: "America/Punta_Arenas" }, + { label: "Rankin Inlet", value: "America/Rankin_Inlet" }, + { label: "Recife", value: "America/Recife" }, + { label: "Regina", value: "America/Regina" }, + { label: "Resolute", value: "America/Resolute" }, + { label: "Rio Branco", value: "America/Rio_Branco" }, + { label: "Santarem", value: "America/Santarem" }, + { label: "Santiago", value: "America/Santiago" }, + { label: "Santo Domingo", value: "America/Santo_Domingo" }, + { label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" }, + { label: "Scoresbysund", value: "America/Scoresbysund" }, + { label: "Sitka", value: "America/Sitka" }, + { label: "St Barthelemy", value: "America/St_Barthelemy" }, + { label: "St Johns", value: "America/St_Johns" }, + { label: "St Kitts", value: "America/St_Kitts" }, + { label: "St Lucia", value: "America/St_Lucia" }, + { label: "St Thomas", value: "America/St_Thomas" }, + { label: "St Vincent", value: "America/St_Vincent" }, + { label: "Swift Current", value: "America/Swift_Current" }, + { label: "Tegucigalpa", value: "America/Tegucigalpa" }, + { label: "Thule", value: "America/Thule" }, + { label: "Tijuana", value: "America/Tijuana" }, + { label: "Toronto", value: "America/Toronto" }, + { label: "Tortola", value: "America/Tortola" }, + { label: "Vancouver", value: "America/Vancouver" }, + { label: "Whitehorse", value: "America/Whitehorse" }, + { label: "Winnipeg", value: "America/Winnipeg" }, + { label: "Yakutat", value: "America/Yakutat" }, + ], + Antarctica: [ + { label: "Casey", value: "Antarctica/Casey" }, + { label: "Davis", value: "Antarctica/Davis" }, + { label: "DumontDUrville", value: "Antarctica/DumontDUrville" }, + { label: "Macquarie", value: "Antarctica/Macquarie" }, + { label: "Mawson", value: "Antarctica/Mawson" }, + { label: "McMurdo", value: "Antarctica/McMurdo" }, + { label: "Palmer", value: "Antarctica/Palmer" }, + { label: "Rothera", value: "Antarctica/Rothera" }, + { label: "Syowa", value: "Antarctica/Syowa" }, + { label: "Troll", value: "Antarctica/Troll" }, + { label: "Vostok", value: "Antarctica/Vostok" }, + ], + Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }], + Asia: [ + { label: "Aden", value: "Asia/Aden" }, + { label: "Almaty", value: "Asia/Almaty" }, + { label: "Amman", value: "Asia/Amman" }, + { label: "Anadyr", value: "Asia/Anadyr" }, + { label: "Aqtau", value: "Asia/Aqtau" }, + { label: "Aqtobe", value: "Asia/Aqtobe" }, + { label: "Ashgabat", value: "Asia/Ashgabat" }, + { label: "Atyrau", value: "Asia/Atyrau" }, + { label: "Baghdad", value: "Asia/Baghdad" }, + { label: "Bahrain", value: "Asia/Bahrain" }, + { label: "Baku", value: "Asia/Baku" }, + { label: "Bangkok", value: "Asia/Bangkok" }, + { label: "Barnaul", value: "Asia/Barnaul" }, + { label: "Beirut", value: "Asia/Beirut" }, + { label: "Bishkek", value: "Asia/Bishkek" }, + { label: "Brunei", value: "Asia/Brunei" }, + { label: "Chita", value: "Asia/Chita" }, + { label: "Choibalsan", value: "Asia/Choibalsan" }, + { label: "Colombo", value: "Asia/Colombo" }, + { label: "Damascus", value: "Asia/Damascus" }, + { label: "Dhaka", value: "Asia/Dhaka" }, + { label: "Dili", value: "Asia/Dili" }, + { label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" }, + { label: "Dushanbe", value: "Asia/Dushanbe" }, + { label: "Famagusta", value: "Asia/Famagusta" }, + { label: "Gaza", value: "Asia/Gaza" }, + { label: "Hebron", value: "Asia/Hebron" }, + { label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" }, + { label: "Hong Kong", value: "Asia/Hong_Kong" }, + { label: "Hovd", value: "Asia/Hovd" }, + { label: "Irkutsk", value: "Asia/Irkutsk" }, + { label: "Jakarta", value: "Asia/Jakarta" }, + { label: "Jayapura", value: "Asia/Jayapura" }, + { label: "Jerusalem", value: "Asia/Jerusalem" }, + { label: "Kabul", value: "Asia/Kabul" }, + { label: "Kamchatka", value: "Asia/Kamchatka" }, + { label: "Karachi", value: "Asia/Karachi" }, + { label: "Kathmandu", value: "Asia/Kathmandu" }, + { label: "Khandyga", value: "Asia/Khandyga" }, + { label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" }, + { label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" }, + { label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" }, + { label: "Kuching", value: "Asia/Kuching" }, + { label: "Kuwait", value: "Asia/Kuwait" }, + { label: "Macau", value: "Asia/Macau" }, + { label: "Magadan", value: "Asia/Magadan" }, + { label: "Makassar", value: "Asia/Makassar" }, + { label: "Manila", value: "Asia/Manila" }, + { label: "Muscat", value: "Asia/Muscat" }, + { label: "Nicosia", value: "Asia/Nicosia" }, + { label: "Novokuznetsk", value: "Asia/Novokuznetsk" }, + { label: "Novosibirsk", value: "Asia/Novosibirsk" }, + { label: "Omsk", value: "Asia/Omsk" }, + { label: "Oral", value: "Asia/Oral" }, + { label: "Phnom Penh", value: "Asia/Phnom_Penh" }, + { label: "Pontianak", value: "Asia/Pontianak" }, + { label: "Pyongyang", value: "Asia/Pyongyang" }, + { label: "Qatar", value: "Asia/Qatar" }, + { label: "Qostanay", value: "Asia/Qostanay" }, + { label: "Qyzylorda", value: "Asia/Qyzylorda" }, + { label: "Riyadh", value: "Asia/Riyadh" }, + { label: "Sakhalin", value: "Asia/Sakhalin" }, + { label: "Samarkand", value: "Asia/Samarkand" }, + { label: "Seoul", value: "Asia/Seoul" }, + { label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" }, + { label: "Singapore", value: "Asia/Singapore" }, + { label: "Srednekolymsk", value: "Asia/Srednekolymsk" }, + { label: "Taipei", value: "Asia/Taipei" }, + { label: "Tashkent", value: "Asia/Tashkent" }, + { label: "Tbilisi", value: "Asia/Tbilisi" }, + { label: "Tehran", value: "Asia/Tehran" }, + { label: "Thimphu", value: "Asia/Thimphu" }, + { label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" }, + { label: "Tomsk", value: "Asia/Tomsk" }, + { label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" }, + { label: "Urumqi", value: "Asia/Urumqi" }, + { label: "Ust-Nera", value: "Asia/Ust-Nera" }, + { label: "Vientiane", value: "Asia/Vientiane" }, + { label: "Vladivostok", value: "Asia/Vladivostok" }, + { label: "Yakutsk", value: "Asia/Yakutsk" }, + { label: "Yangon", value: "Asia/Yangon" }, + { label: "Yekaterinburg", value: "Asia/Yekaterinburg" }, + { label: "Yerevan", value: "Asia/Yerevan" }, + ], + Atlantic: [ + { label: "Azores", value: "Atlantic/Azores" }, + { label: "Bermuda", value: "Atlantic/Bermuda" }, + { label: "Canary", value: "Atlantic/Canary" }, + { label: "Cape Verde", value: "Atlantic/Cape_Verde" }, + { label: "Faroe", value: "Atlantic/Faroe" }, + { label: "Madeira", value: "Atlantic/Madeira" }, + { label: "Reykjavik", value: "Atlantic/Reykjavik" }, + { label: "South Georgia", value: "Atlantic/South_Georgia" }, + { label: "St Helena", value: "Atlantic/St_Helena" }, + { label: "Stanley", value: "Atlantic/Stanley" }, + ], + Australia: [ + { label: "Adelaide", value: "Australia/Adelaide" }, + { label: "Brisbane", value: "Australia/Brisbane" }, + { label: "Broken Hill", value: "Australia/Broken_Hill" }, + { label: "Darwin", value: "Australia/Darwin" }, + { label: "Eucla", value: "Australia/Eucla" }, + { label: "Hobart", value: "Australia/Hobart" }, + { label: "Lindeman", value: "Australia/Lindeman" }, + { label: "Lord Howe", value: "Australia/Lord_Howe" }, + { label: "Melbourne", value: "Australia/Melbourne" }, + { label: "Perth", value: "Australia/Perth" }, + { label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" }, + ], + Europe: [ + { label: "Amsterdam", value: "Europe/Amsterdam" }, + { label: "Andorra", value: "Europe/Andorra" }, + { label: "Astrakhan", value: "Europe/Astrakhan" }, + { label: "Athens", value: "Europe/Athens" }, + { label: "Belgrade", value: "Europe/Belgrade" }, + { label: "Berlin (Central European Time)", value: "Europe/Berlin" }, + { label: "Bratislava", value: "Europe/Bratislava" }, + { label: "Brussels", value: "Europe/Brussels" }, + { label: "Bucharest", value: "Europe/Bucharest" }, + { label: "Budapest", value: "Europe/Budapest" }, + { label: "Busingen", value: "Europe/Busingen" }, + { label: "Chisinau", value: "Europe/Chisinau" }, + { label: "Copenhagen", value: "Europe/Copenhagen" }, + { label: "Dublin", value: "Europe/Dublin" }, + { label: "Gibraltar", value: "Europe/Gibraltar" }, + { label: "Guernsey", value: "Europe/Guernsey" }, + { label: "Helsinki", value: "Europe/Helsinki" }, + { label: "Isle of Man", value: "Europe/Isle_of_Man" }, + { label: "Istanbul", value: "Europe/Istanbul" }, + { label: "Jersey", value: "Europe/Jersey" }, + { label: "Kaliningrad", value: "Europe/Kaliningrad" }, + { label: "Kirov", value: "Europe/Kirov" }, + { label: "Kyiv", value: "Europe/Kyiv" }, + { label: "Lisbon", value: "Europe/Lisbon" }, + { label: "Ljubljana", value: "Europe/Ljubljana" }, + { label: "London (Greenwich Mean Time)", value: "Europe/London" }, + { label: "Luxembourg", value: "Europe/Luxembourg" }, + { label: "Madrid", value: "Europe/Madrid" }, + { label: "Malta", value: "Europe/Malta" }, + { label: "Mariehamn", value: "Europe/Mariehamn" }, + { label: "Minsk", value: "Europe/Minsk" }, + { label: "Monaco", value: "Europe/Monaco" }, + { label: "Moscow", value: "Europe/Moscow" }, + { label: "Oslo", value: "Europe/Oslo" }, + { label: "Paris (Central European Time)", value: "Europe/Paris" }, + { label: "Podgorica", value: "Europe/Podgorica" }, + { label: "Prague", value: "Europe/Prague" }, + { label: "Riga", value: "Europe/Riga" }, + { label: "Rome", value: "Europe/Rome" }, + { label: "Samara", value: "Europe/Samara" }, + { label: "San Marino", value: "Europe/San_Marino" }, + { label: "Sarajevo", value: "Europe/Sarajevo" }, + { label: "Saratov", value: "Europe/Saratov" }, + { label: "Simferopol", value: "Europe/Simferopol" }, + { label: "Skopje", value: "Europe/Skopje" }, + { label: "Sofia", value: "Europe/Sofia" }, + { label: "Stockholm", value: "Europe/Stockholm" }, + { label: "Tallinn", value: "Europe/Tallinn" }, + { label: "Tirane", value: "Europe/Tirane" }, + { label: "Ulyanovsk", value: "Europe/Ulyanovsk" }, + { label: "Vaduz", value: "Europe/Vaduz" }, + { label: "Vatican", value: "Europe/Vatican" }, + { label: "Vienna", value: "Europe/Vienna" }, + { label: "Vilnius", value: "Europe/Vilnius" }, + { label: "Volgograd", value: "Europe/Volgograd" }, + { label: "Warsaw", value: "Europe/Warsaw" }, + { label: "Zagreb", value: "Europe/Zagreb" }, + { label: "Zurich", value: "Europe/Zurich" }, + ], + Indian: [ + { label: "Antananarivo", value: "Indian/Antananarivo" }, + { label: "Chagos", value: "Indian/Chagos" }, + { label: "Christmas", value: "Indian/Christmas" }, + { label: "Cocos", value: "Indian/Cocos" }, + { label: "Comoro", value: "Indian/Comoro" }, + { label: "Kerguelen", value: "Indian/Kerguelen" }, + { label: "Mahe", value: "Indian/Mahe" }, + { label: "Maldives", value: "Indian/Maldives" }, + { label: "Mauritius", value: "Indian/Mauritius" }, + { label: "Mayotte", value: "Indian/Mayotte" }, + { label: "Reunion", value: "Indian/Reunion" }, + ], + Pacific: [ + { label: "Apia", value: "Pacific/Apia" }, + { label: "Auckland", value: "Pacific/Auckland" }, + { label: "Bougainville", value: "Pacific/Bougainville" }, + { label: "Chatham", value: "Pacific/Chatham" }, + { label: "Chuuk", value: "Pacific/Chuuk" }, + { label: "Easter", value: "Pacific/Easter" }, + { label: "Efate", value: "Pacific/Efate" }, + { label: "Fakaofo", value: "Pacific/Fakaofo" }, + { label: "Fiji", value: "Pacific/Fiji" }, + { label: "Funafuti", value: "Pacific/Funafuti" }, + { label: "Galapagos", value: "Pacific/Galapagos" }, + { label: "Gambier", value: "Pacific/Gambier" }, + { label: "Guadalcanal", value: "Pacific/Guadalcanal" }, + { label: "Guam", value: "Pacific/Guam" }, + { label: "Honolulu", value: "Pacific/Honolulu" }, + { label: "Kanton", value: "Pacific/Kanton" }, + { label: "Kiritimati", value: "Pacific/Kiritimati" }, + { label: "Kosrae", value: "Pacific/Kosrae" }, + { label: "Kwajalein", value: "Pacific/Kwajalein" }, + { label: "Majuro", value: "Pacific/Majuro" }, + { label: "Marquesas", value: "Pacific/Marquesas" }, + { label: "Midway", value: "Pacific/Midway" }, + { label: "Nauru", value: "Pacific/Nauru" }, + { label: "Niue", value: "Pacific/Niue" }, + { label: "Norfolk", value: "Pacific/Norfolk" }, + { label: "Noumea", value: "Pacific/Noumea" }, + { label: "Pago Pago", value: "Pacific/Pago_Pago" }, + { label: "Palau", value: "Pacific/Palau" }, + { label: "Pitcairn", value: "Pacific/Pitcairn" }, + { label: "Pohnpei", value: "Pacific/Pohnpei" }, + { label: "Port Moresby", value: "Pacific/Port_Moresby" }, + { label: "Rarotonga", value: "Pacific/Rarotonga" }, + { label: "Saipan", value: "Pacific/Saipan" }, + { label: "Tahiti", value: "Pacific/Tahiti" }, + { label: "Tarawa", value: "Pacific/Tarawa" }, + { label: "Tongatapu", value: "Pacific/Tongatapu" }, + { label: "Wake", value: "Pacific/Wake" }, + { label: "Wallis", value: "Pacific/Wallis" }, + ], +}; + +// Helper to get display label for a timezone value +export function getTimezoneLabel(value: string | undefined): string { + if (!value) return "UTC (default)"; + return value; +} diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index 804b4c39b..e179713de 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -47,7 +47,13 @@ const formSchema = z .object({ name: z.string().min(1, "Name is required"), cronExpression: z.string().min(1, "Cron expression is required"), - volumeName: z.string().min(1, "Volume name is required"), + volumeName: z + .string() + .min(1, "Volume name is required") + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, + "Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.", + ), prefix: z.string(), keepLatestCount: z.coerce .number() diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index 092538150..2e4dac472 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({ Schedule volume backups to run automatically at specified - intervals. + intervals
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 06c88fff4..c89b9893e 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.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 SaveBitbucketProviderCompose = ({ composeId }: Props) => { 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 && ( { + const debouncedSetSearch = _.debounce((value: string) => { setDebouncedSearchTerm(value); }, 350); diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 5b929f3b6..a75f50386 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -1,5 +1,5 @@ import { FancyAnsi } from "fancy-ansi"; -import { escapeRegExp } from "lodash"; +import _ from "lodash"; import { Badge } from "@/components/ui/badge"; import { Tooltip, @@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { } const htmlContent = fancyAnsi.toHtml(text); - const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi"); + const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi"); const modifiedContent = htmlContent.replace( searchRegex, diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 5e97edfe2..80a79eb2b 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] { // { timestamp: new Date("2024-12-10T10:00:00.000Z"), // message: "The server is running on port 8080" } const logRegex = - /^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/; + /^(?:(?\d+)\s+)?(?(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?[\s\S]*)$/; return logString .split("\n") @@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] { const match = line.match(logRegex); if (!match) return null; - const [, , timestamp, message] = match; + const { timestamp, message } = match.groups ?? {}; if (!message?.trim()) return null; @@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => { /(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) || /(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) || /\b(?:deprecated|obsolete)\b/i.test(lowerMessage) || - /\b(?:unstable|experimental)\b/i.test(lowerMessage) + /\b(?:unstable|experimental)\b/i.test(lowerMessage) || + /⚠|⚠️/i.test(lowerMessage) ) { return LOG_STYLES.warning; } diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index b96b7c866..f77983996 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -103,7 +103,7 @@ export const ImpersonationBar = () => { setOpen(false); toast.success("Successfully impersonating user", { - description: `You are now viewing as ${selectedUser.name || selectedUser.email}`, + description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`, }); window.location.reload(); } catch (error) { @@ -195,7 +195,8 @@ export const ImpersonationBar = () => { - {selectedUser.name || ""} + {`${selectedUser.name} ${selectedUser.lastName}`.trim() || + ""} {selectedUser.email} @@ -242,7 +243,8 @@ export const ImpersonationBar = () => { - {user.name || ""} + {`${user.name} ${user.lastName}`.trim() || + ""} {user.email} • {user.role} @@ -283,10 +285,14 @@ export const ImpersonationBar = () => { - {data?.user?.name?.slice(0, 2).toUpperCase() || "U"} + {`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() || + "U"}
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => { Impersonating - {data?.user?.name || ""} + {`${data?.user?.firstName} ${data?.user?.lastName}`.trim() || + ""}
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index b28c4d9b6..1dd41e722 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart"; const defaultData = { cpu: { - value: 0, + value: "0%", time: "", }, memory: { @@ -46,7 +46,7 @@ interface Props { } export interface DockerStats { cpu: { - value: number; + value: string; time: string; }; memory: { @@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({ Used: {currentData.cpu.value} - +
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 febaa8644..d9841716e 100644 --- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx +++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; @@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources"; const addDockerImage = z.object({ dockerImage: z.string().min(1, "Docker image is required"), command: z.string(), + args: z + .array( + z.object({ + value: z.string().min(1, "Argument cannot be empty"), + }), + ) + .optional(), }); interface Props { @@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => { defaultValues: { dockerImage: "", command: "", + args: [], }, resolver: zodResolver(addDockerImage), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "args", + }); + useEffect(() => { if (data) { form.reset({ dockerImage: data.dockerImage, command: data.command || "", + args: data.args?.map((arg) => ({ value: arg })) || [], }); } - }, [data, form, form.reset]); + }, [data, form]); const onSubmit = async (formData: AddDockerImage) => { await mutateAsync({ @@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => { mariadbId: id || "", dockerImage: formData?.dockerImage, command: formData?.command, + args: formData?.args?.map((arg) => arg.value).filter(Boolean), }) .then(async () => { toast.success("Custom Command Updated"); @@ -113,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => { Docker Image - + @@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => { Command - + )} /> + +
+
+ Arguments (Args) + +
+ + {fields.length === 0 && ( +

+ No arguments added yet. Click "Add Argument" to add one. +

+ )} + + {fields.map((field, index) => ( + ( + +
+ + + + +
+ +
+ )} + /> + ))} +
+
- - {/* Action buttons for non-production environments */} - {/* - - */} - {environment.name !== "production" && ( -
+
+ {!environment.isDefault && ( - - {canDeleteEnvironments && ( - - )} -
- )} + )} + {canDeleteEnvironments && !environment.isDefault && ( + + )} +
); })} diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 783c5bb32..8234593e1 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -10,10 +10,12 @@ import { TrashIcon, } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/router"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; +import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { StatusTooltip } from "@/components/shared/status-tooltip"; import { AlertDialog, @@ -44,7 +46,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { Select, SelectContent, @@ -52,16 +53,25 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { TimeBadge } from "@/components/ui/time-badge"; import { api } from "@/utils/api"; +import { useDebounce } from "@/utils/hooks/use-debounce"; import { HandleProject } from "./handle-project"; import { ProjectEnvironment } from "./project-environment"; export const ShowProjects = () => { const utils = api.useUtils(); + const router = useRouter(); + const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); - const [searchQuery, setSearchQuery] = useState(""); + + const [searchQuery, setSearchQuery] = useState( + router.isReady && typeof router.query.q === "string" ? router.query.q : "", + ); + const debouncedSearchQuery = useDebounce(searchQuery, 500); + const [sortBy, setSortBy] = useState(() => { if (typeof window !== "undefined") { return localStorage.getItem("projectsSort") || "createdAt-desc"; @@ -73,14 +83,41 @@ export const ShowProjects = () => { localStorage.setItem("projectsSort", sortBy); }, [sortBy]); + useEffect(() => { + if (!router.isReady) return; + const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; + if (urlQuery !== searchQuery) { + setSearchQuery(urlQuery); + } + }, [router.isReady, router.query.q]); + + useEffect(() => { + if (!router.isReady) return; + const urlQuery = typeof router.query.q === "string" ? router.query.q : ""; + if (debouncedSearchQuery === urlQuery) return; + + const newQuery = { ...router.query }; + if (debouncedSearchQuery) { + newQuery.q = debouncedSearchQuery; + } else { + delete newQuery.q; + } + router.replace({ pathname: router.pathname, query: newQuery }, undefined, { + shallow: true, + }); + }, [debouncedSearchQuery]); + const filteredProjects = useMemo(() => { if (!data) return []; - // First filter by search query const filtered = data.filter( (project) => - project.name.toLowerCase().includes(searchQuery.toLowerCase()) || - project.description?.toLowerCase().includes(searchQuery.toLowerCase()), + project.name + .toLowerCase() + .includes(debouncedSearchQuery.toLowerCase()) || + project.description + ?.toLowerCase() + .includes(debouncedSearchQuery.toLowerCase()), ); // Then sort the filtered results @@ -128,13 +165,18 @@ export const ShowProjects = () => { } return direction === "asc" ? comparison : -comparison; }); - }, [data, searchQuery, sortBy]); + }, [data, debouncedSearchQuery, sortBy]); return ( <> + {!isCloud && ( +
+ +
+ )}
@@ -148,8 +190,9 @@ export const ShowProjects = () => { Create and manage your projects - - {(auth?.role === "owner" || auth?.canCreateProjects) && ( + {(auth?.role === "owner" || + auth?.role === "admin" || + auth?.canCreateProjects) && (
@@ -245,13 +288,29 @@ export const ShowProjects = () => { ) .some(Boolean); + // 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 (
{ + if (hasNoEnvironments) { + e.preventDefault(); + } + }} > {haveServicesWithDomains ? ( @@ -298,7 +357,13 @@ export const ShowProjects = () => { {domain.host} @@ -340,7 +405,13 @@ export const ShowProjects = () => { {domain.host} @@ -360,7 +431,7 @@ export const ShowProjects = () => { ) : null} - +
@@ -368,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/requests/request-distribution-chart.tsx b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx index 2a5db2a94..c760c8175 100644 --- a/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx +++ b/apps/dokploy/components/dashboard/requests/request-distribution-chart.tsx @@ -49,51 +49,65 @@ export const RequestDistributionChart = ({ ); return ( - - - - - - new Date(value).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - } - /> - - } - labelFormatter={(value) => - new Date(value).toLocaleString([], { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) - } - /> - - - - +
+ + + + + + new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + } + /> + + } + labelFormatter={(value) => + new Date(value).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + } + /> + + + + +
); }; diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index ab602f463..cc4f1764a 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -51,13 +51,38 @@ export const ShowRequests = () => { const { mutateAsync: updateLogCleanup } = api.settings.updateLogCleanup.useMutation(); const [cronExpression, setCronExpression] = useState(null); + + // Set default date range to last 3 days + const getDefaultDateRange = () => { + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - 3); + return { from, to }; + }; + const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined; - }>({ - from: undefined, - to: undefined, - }); + }>(getDefaultDateRange()); + + // Check if logs exist to determine if traefik has been reloaded + // Only fetch when active to minimize network calls + const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery( + { + page: { + pageIndex: 0, + pageSize: 1, + }, + }, + { + enabled: !!isActive, + refetchInterval: 5000, // Check every 5 seconds when active + }, + ); + + // Determine if warning should be shown + // Show warning only if active but no logs exist yet + const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0; useEffect(() => { if (logCleanupStatus) { @@ -79,16 +104,18 @@ export const ShowRequests = () => { See all the incoming requests that pass trough Traefik - - When you activate, you need to reload traefik to apply the - changes, you can reload traefik in{" "} - - Settings - - + {shouldShowWarning && ( + + When you activate, you need to reload traefik to apply the + changes, you can reload traefik in{" "} + + Settings + + + )}
@@ -169,17 +196,13 @@ export const ShowRequests = () => { {isActive ? ( <>
- {(dateRange.from || dateRange.to) && ( - - )} + + + + + + + + No models found. + {displayModels.map((model) => { + const isSelected = field.value === model.id; + return ( + { + field.onChange(model.id); + setModelPopoverOpen(false); + setModelSearch(""); + }} + > + + {model.id} + + ); + })} + + + + + + Select an AI model to use + + + + ); + }} /> )} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 97adb0f91..1d80019ba 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1,5 +1,11 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react"; +import { + AlertTriangle, + Mail, + PenBoxIcon, + PlusIcon, + Trash2, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -10,6 +16,7 @@ import { GotifyIcon, LarkIcon, NtfyIcon, + PushoverIcon, SlackIcon, TelegramIcon, } from "@/components/icons/notification-icons"; @@ -45,6 +52,7 @@ const notificationBaseSchema = z.object({ appDeploy: z.boolean().default(false), appBuildError: z.boolean().default(false), databaseBackup: z.boolean().default(false), + volumeBackup: z.boolean().default(false), dokployRestart: z.boolean().default(false), dockerCleanup: z.boolean().default(false), serverThreshold: z.boolean().default(false), @@ -104,7 +112,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ type: z.literal("ntfy"), serverUrl: z.string().min(1, { message: "Server URL is required" }), topic: z.string().min(1, { message: "Topic is required" }), - accessToken: z.string().min(1, { message: "Access Token is required" }), + accessToken: z.string().optional(), priority: z.number().min(1).max(5).default(3), }) .merge(notificationBaseSchema), @@ -116,7 +124,32 @@ export const notificationSchema = z.discriminatedUnion("type", [ username: z.string().optional(), }) .merge(notificationBaseSchema), - z + z + .object({ + type: z.literal("pushover"), + userKey: z.string().min(1, { message: "User Key is required" }), + apiToken: z.string().min(1, { message: "API Token is required" }), + priority: z.number().min(-2).max(2).default(0), + retry: z.number().min(30).nullish(), + expire: z.number().min(1).max(10800).nullish(), + }) + .merge(notificationBaseSchema), + z + .object({ + type: z.literal("custom"), + endpoint: z.string().min(1, { message: "Endpoint URL is required" }), + headers: z + .array( + z.object({ + key: z.string(), + value: z.string(), + }), + ) + .optional() + .default([]), + }) + .merge(notificationBaseSchema), + z .object({ type: z.literal("lark"), webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), @@ -157,6 +190,14 @@ export const notificationsMap = { icon: , label: "Mattermost", }, + pushover: { + icon: , + label: "Pushover", + }, + custom: { + icon: , + label: "Custom", + }, }; export type NotificationSchema = z.infer; @@ -194,6 +235,14 @@ export const HandleNotifications = ({ notificationId }: Props) => { api.notification.testMattermostConnection.useMutation(); const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } = api.notification.testLarkConnection.useMutation(); + 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(); const slackMutation = notificationId ? api.notification.updateSlack.useMutation() : api.notification.createSlack.useMutation(); @@ -218,6 +267,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: { @@ -235,6 +287,15 @@ export const HandleNotifications = ({ notificationId }: Props) => { name: "toAddresses" as never, }); + const { + fields: headerFields, + append: appendHeader, + remove: removeHeader, + } = useFieldArray({ + control: form.control, + name: "headers" as never, + }); + useEffect(() => { if (type === "email" && fields.length === 0) { append(""); @@ -249,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, dockerCleanup: notification.dockerCleanup, webhookUrl: notification.slack?.webhookUrl, channel: notification.slack?.channel || "", @@ -262,6 +324,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, botToken: notification.telegram?.botToken, messageThreadId: notification.telegram?.messageThreadId || "", chatId: notification.telegram?.chatId, @@ -276,6 +339,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, type: notification.notificationType, webhookUrl: notification.discord?.webhookUrl, decoration: notification.discord?.decoration || undefined, @@ -289,6 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, type: notification.notificationType, smtpServer: notification.email?.smtpServer, smtpPort: notification.email?.smtpPort, @@ -306,6 +371,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, type: notification.notificationType, appToken: notification.gotify?.appToken, decoration: notification.gotify?.decoration || undefined, @@ -320,8 +386,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, type: notification.notificationType, - accessToken: notification.ntfy?.accessToken, + accessToken: notification.ntfy?.accessToken || "", topic: notification.ntfy?.topic, priority: notification.ntfy?.priority, serverUrl: notification.ntfy?.serverUrl, @@ -329,6 +396,21 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "mattermost") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + webhookUrl: notification.mattermost?.webhookUrl, + channel: notification.mattermost?.channel || "", + username: notification.mattermost?.username || "", + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } else if (notification.notificationType === "lark") { form.reset({ appBuildError: notification.appBuildError, @@ -339,18 +421,43 @@ export const HandleNotifications = ({ notificationId }: Props) => { webhookUrl: notification.lark?.webhookUrl, name: notification.name, dockerCleanup: notification.dockerCleanup, + volumeBackup: notification.volumeBackup, serverThreshold: notification.serverThreshold, }); - } else if (notification.notificationType === "mattermost") { + } else if (notification.notificationType === "custom") { form.reset({ appBuildError: notification.appBuildError, appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, type: notification.notificationType, - webhookUrl: notification.mattermost?.webhookUrl, - channel: notification.mattermost?.channel || "", - username: notification.mattermost?.username || "", + endpoint: notification.custom?.endpoint || "", + headers: notification.custom?.headers + ? Object.entries(notification.custom.headers).map( + ([key, value]) => ({ + key, + value, + }), + ) + : [], + name: notification.name, + volumeBackup: notification.volumeBackup, + 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, @@ -370,6 +477,8 @@ export const HandleNotifications = ({ notificationId }: Props) => { ntfy: ntfyMutation, mattermost: mattermostMutation, lark: larkMutation, + custom: customMutation, + pushover: pushoverMutation, }; const onSubmit = async (data: NotificationSchema) => { @@ -378,6 +487,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy, dokployRestart, databaseBackup, + volumeBackup, dockerCleanup, serverThreshold, } = data; @@ -388,6 +498,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, channel: data.channel, name: data.name, @@ -402,6 +513,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, botToken: data.botToken, messageThreadId: data.messageThreadId || "", chatId: data.chatId, @@ -417,6 +529,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, decoration: data.decoration, name: data.name, @@ -431,6 +544,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, smtpServer: data.smtpServer, smtpPort: data.smtpPort, username: data.username, @@ -449,6 +563,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, serverUrl: data.serverUrl, appToken: data.appToken, priority: data.priority, @@ -464,8 +579,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, serverUrl: data.serverUrl, - accessToken: data.accessToken, + accessToken: data.accessToken || "", topic: data.topic, priority: data.priority, name: data.name, @@ -479,6 +595,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, channel: data.channel || undefined, username: data.username || undefined, @@ -494,15 +611,63 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, name: data.name, dockerCleanup: dockerCleanup, notificationId: notificationId || "", larkId: notification?.larkId || "", - - serverThreshold: serverThreshold, }); + } else if (data.type === "custom") { + // Convert headers array to object + const headersRecord = + data.headers && data.headers.length > 0 + ? data.headers.reduce( + (acc, { key, value }) => { + if (key.trim()) acc[key] = value; + return acc; + }, + {} as Record, + ) + : undefined; + + promise = customMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + endpoint: data.endpoint, + headers: headersRecord, + name: data.name, + dockerCleanup: dockerCleanup, + serverThreshold: serverThreshold, + 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) { @@ -1051,8 +1216,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { + + Optional. Leave blank for public topics. + )} @@ -1144,6 +1313,94 @@ export const HandleNotifications = ({ notificationId }: Props) => { /> )} + + {type === "custom" && ( +
+ ( + + Webhook URL + + + + + The URL where POST requests will be sent with + notification data. + + + + )} + /> + +
+
+ Headers + + Optional. Custom headers for your POST request (e.g., + Authorization, Content-Type). + +
+ +
+ {headerFields.map((field, index) => ( +
+ ( + + + + + + )} + /> + ( + + + + + + )} + /> + +
+ ))} +
+ + +
+
+ )} + {type === "lark" && ( <> { /> )} + + {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). + + + + )} + /> + + )} + + )}
@@ -1234,6 +1633,27 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} /> + ( + +
+ Volume Backup + + Trigger the action when a volume backup is created. + +
+ + + +
+ )} + /> + { isLoadingGotify || isLoadingNtfy || isLoadingMattermost || - isLoadingLark + isLoadingLark || + isLoadingCustom || + isLoadingPushover } variant="secondary" + type="button" onClick={async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + try { - if (type === "slack") { + if (data.type === "slack") { await testSlackConnection({ - webhookUrl: form.getValues("webhookUrl"), - channel: form.getValues("channel"), + webhookUrl: data.webhookUrl, + channel: data.channel, }); - } else if (type === "telegram") { + } else if (data.type === "telegram") { await testTelegramConnection({ - botToken: form.getValues("botToken"), - chatId: form.getValues("chatId"), - messageThreadId: form.getValues("messageThreadId") || "", + botToken: data.botToken, + chatId: data.chatId, + messageThreadId: data.messageThreadId || "", }); - } else if (type === "discord") { + } else if (data.type === "discord") { await testDiscordConnection({ - webhookUrl: form.getValues("webhookUrl"), - decoration: form.getValues("decoration"), + webhookUrl: data.webhookUrl, + decoration: data.decoration, }); - } else if (type === "email") { + } else if (data.type === "email") { await testEmailConnection({ - smtpServer: form.getValues("smtpServer"), - smtpPort: form.getValues("smtpPort"), - username: form.getValues("username"), - password: form.getValues("password"), - toAddresses: form.getValues("toAddresses"), - fromAddress: form.getValues("fromAddress"), + smtpServer: data.smtpServer, + smtpPort: data.smtpPort, + username: data.username, + password: data.password, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, }); - } else if (type === "gotify") { + } else if (data.type === "gotify") { await testGotifyConnection({ - serverUrl: form.getValues("serverUrl"), - appToken: form.getValues("appToken"), - priority: form.getValues("priority"), - decoration: form.getValues("decoration"), + serverUrl: data.serverUrl, + appToken: data.appToken, + priority: data.priority, + decoration: data.decoration, }); - } else if (type === "ntfy") { + } else if (data.type === "ntfy") { await testNtfyConnection({ - serverUrl: form.getValues("serverUrl"), - topic: form.getValues("topic"), - accessToken: form.getValues("accessToken"), - priority: form.getValues("priority"), + serverUrl: data.serverUrl, + topic: data.topic, + accessToken: data.accessToken || "", + priority: data.priority, }); - } else if (type === "mattermost") { + } else if (data.type === "mattermost") { await testMattermostConnection({ - webhookUrl: form.getValues("webhookUrl"), - channel: form.getValues("channel") || undefined, - username: form.getValues("username") || undefined, + webhookUrl: data.webhookUrl, + channel: data.channel || undefined, + username: data.username || undefined, }); - } else if (type === "lark") { + } else if (data.type === "lark") { await testLarkConnection({ - webhookUrl: form.getValues("webhookUrl"), + webhookUrl: data.webhookUrl, + }); + } else if (data.type === "custom") { + const headersRecord = + data.headers && data.headers.length > 0 + ? data.headers.reduce( + (acc, { key, value }) => { + if (key.trim()) acc[key] = value; + return acc; + }, + {} as Record, + ) + : undefined; + await testCustomConnection({ + 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 { - toast.error("Error testing the provider"); + } catch (error) { + toast.error( + `Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`, + ); } }} > diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 5b7a2220e..06ffd91e4 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -1,4 +1,4 @@ -import { Bell, Loader2, Mail, Trash2 } from "lucide-react"; +import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DiscordIcon, @@ -96,6 +96,11 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "custom" && ( +
+ +
+ )} {notification.notificationType === "lark" && (
diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 583f3fefe..461a4c17c 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -1,7 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Loader2, User } from "lucide-react"; +import { Loader2, Palette, User } from "lucide-react"; import { useTranslation } from "next-i18next"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -27,6 +27,7 @@ import { import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; +import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils"; import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils"; import { api } from "@/utils/api"; import { Configure2FA } from "./configure-2fa"; @@ -40,7 +41,8 @@ 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), }); @@ -73,6 +75,7 @@ export const ProfileForm = () => { } = api.user.update.useMutation(); const { t } = useTranslation("settings"); const [gravatarHash, setGravatarHash] = useState(null); + const colorInputRef = useRef(null); const availableAvatars = useMemo(() => { if (gravatarHash === null) return randomImages; @@ -88,7 +91,8 @@ export const ProfileForm = () => { image: data?.user?.image || "", currentPassword: "", allowImpersonation: data?.user?.allowImpersonation || false, - name: data?.user?.name || "", + firstName: data?.user?.firstName || "", + lastName: data?.user?.lastName || "", }, resolver: zodResolver(profileSchema), }); @@ -102,7 +106,8 @@ export const ProfileForm = () => { image: data?.user?.image || "", currentPassword: form.getValues("currentPassword") || "", allowImpersonation: data?.user?.allowImpersonation, - name: data?.user?.name || "", + firstName: data?.user?.firstName || "", + lastName: data?.user?.lastName || "", }, { keepValues: true, @@ -126,7 +131,8 @@ 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(); toast.success("Profile Updated"); @@ -135,7 +141,8 @@ export const ProfileForm = () => { password: "", image: values.image, currentPassword: "", - name: values.name || "", + firstName: values.firstName || "", + lastName: values.lastName || "", }); } catch (error) { toast.error("Error updating the profile"); @@ -177,12 +184,25 @@ export const ProfileForm = () => {
( - Name + First Name - + + + + + )} + /> + ( + + Last Name + + @@ -256,16 +276,8 @@ export const ProfileForm = () => { onValueChange={(e) => { field.onChange(e); }} - defaultValue={ - field.value?.startsWith("data:") - ? "upload" - : field.value - } - value={ - field.value?.startsWith("data:") - ? "upload" - : field.value - } + defaultValue={getAvatarType(field.value)} + value={getAvatarType(field.value)} className="flex flex-row flex-wrap gap-2 max-xl:justify-center" > @@ -280,7 +292,7 @@ export const ProfileForm = () => { {getFallbackAvatarInitials( - data?.user?.name, + `${data?.user?.firstName} ${data?.user?.lastName}`.trim(), )} @@ -352,6 +364,40 @@ export const ProfileForm = () => { /> + + + + + +
+ colorInputRef.current?.click() + } + > + {!isSolidColorAvatar(field.value) && ( + + )} +
+ +
+
{availableAvatars.map((image) => ( diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx index 41156d35b..334d25b20 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx @@ -1,4 +1,6 @@ +import { Activity } from "lucide-react"; import { useState } from "react"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup"; interface Props { serverId: string; + asButton?: boolean; } -export const ShowServerActions = ({ serverId }: Props) => { +export const ShowServerActions = ({ serverId, asButton = false }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - + {asButton ? ( + + + + ) : ( e.preventDefault()} + onSelect={(e) => { + e.preventDefault(); + setIsOpen(true); + }} > View Actions - + )}
Web server settings diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx index 41c8ae5c5..c80648142 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx @@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => { serverId: serverId, }) .then(async () => { - toast.success("Cleaned all"); + toast.success("Cleaning in progress... Please wait"); }) .catch(() => { toast.error("Error cleaning all"); 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 d9573ca74..aebba8877 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 @@ -1,5 +1,7 @@ import { useTranslation } from "next-i18next"; import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => { - + + The Traefik container will be recreated from scratch. This + means the container will be deleted and created again, which + may cause downtime in your applications. + +

+ Are you sure you want to{" "} + {haveTraefikDashboardPortEnabled ? "disable" : "enable"} the + Traefik dashboard? +

+
+ } onClick={async () => { await toggleDashboard({ enableDashboard: !haveTraefikDashboardPortEnabled, @@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => { ); refetchDashboard(); }) - .catch(() => {}); + .catch((error) => { + const errorMessage = + error?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + }); }} - className="w-full cursor-pointer space-x-3" + disabled={toggleDashboardIsLoading} + type="default" > - - {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard - - + e.preventDefault()} + className="w-full cursor-pointer space-x-3" + > + + {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} + Dashboard + + + e.preventDefault()} diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx index 4021ddaf5..97cf3f6be 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx @@ -7,9 +7,12 @@ interface Props { serverId?: string; } export const ToggleDockerCleanup = ({ serverId }: Props) => { - const { data, refetch } = api.user.get.useQuery(undefined, { - enabled: !serverId, - }); + const { data, refetch } = api.settings.getWebServerSettings.useQuery( + undefined, + { + enabled: !serverId, + }, + ); const { data: server, refetch: refetchServer } = api.server.one.useQuery( { @@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => { const enabled = serverId ? server?.enableDockerCleanup - : data?.user.enableDockerCleanup; + : data?.enableDockerCleanup; const { mutateAsync } = api.settings.updateDockerCleanup.useMutation(); @@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => { try { await mutateAsync({ enableDockerCleanup: checked, - serverId: serverId, + ...(serverId && { serverId }), + } as { + enableDockerCleanup: boolean; + serverId?: string; }); if (serverId) { await refetchServer(); diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx index cdbe8a95b..99804ba6b 100644 --- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon } from "lucide-react"; +import { Pencil, PlusIcon } from "lucide-react"; import Link from "next/link"; import { useTranslation } from "next-i18next"; import { useEffect, useState } from "react"; @@ -52,15 +52,17 @@ const Schema = z.object({ sshKeyId: z.string().min(1, { message: "SSH Key is required", }), + serverType: z.enum(["deploy", "build"]).default("deploy"), }); type Schema = z.infer; interface Props { serverId?: string; + asButton?: boolean; } -export const HandleServers = ({ serverId }: Props) => { +export const HandleServers = ({ serverId, asButton = false }: Props) => { const { t } = useTranslation("settings"); const utils = api.useUtils(); @@ -89,6 +91,7 @@ export const HandleServers = ({ serverId }: Props) => { port: 22, username: "root", sshKeyId: "", + serverType: "deploy", }, resolver: zodResolver(Schema), }); @@ -101,6 +104,7 @@ export const HandleServers = ({ serverId }: Props) => { port: data?.port || 22, username: data?.username || "root", sshKeyId: data?.sshKeyId || "", + serverType: data?.serverType || "deploy", }); }, [form, form.reset, form.formState.isSubmitSuccessful, data]); @@ -116,6 +120,7 @@ export const HandleServers = ({ serverId }: Props) => { port: data.port || 22, username: data.username || "root", sshKeyId: data.sshKeyId || "", + serverType: data.serverType || "deploy", serverId: serverId || "", }) .then(async (_data) => { @@ -133,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => { return ( - - {serverId ? ( + {serverId ? ( + asButton ? ( + + + + ) : ( e.preventDefault()} + onSelect={(e) => { + e.preventDefault(); + setIsOpen(true); + }} > Edit Server - ) : ( + ) + ) : ( + - )} - + + )} {serverId ? "Edit" : "Create"} Server @@ -266,6 +282,50 @@ export const HandleServers = ({ serverId }: Props) => {
)} /> + { + const serverTypeValue = form.watch("serverType"); + return ( + + Server Type + + + {serverTypeValue === "deploy" && ( + + Deploy servers are used to run your applications, + databases, and services. They handle the deployment and + execution of your projects. + + )} + {serverTypeValue === "build" && ( + + Build servers are dedicated to building your + applications. They handle the compilation and build + process, offloading this work from your deployment + servers. Build servers won't appear in deployment + options. + + )} + + ); + }} + /> ; export const SetupMonitoring = ({ serverId }: Props) => { - const { data } = serverId + const { data: serverData } = serverId ? api.server.one.useQuery( { serverId: serverId || "", @@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => { enabled: !!serverId, }, ) - : api.user.getServerMetrics.useQuery(); + : { data: null }; + + const { data: webServerSettings } = + api.settings.getWebServerSettings.useQuery(undefined, { + enabled: !serverId, + }); + + const data = serverId ? serverData : webServerSettings; const url = useUrl(); diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 2049b6c6b..13ff2d6e4 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -1,5 +1,5 @@ import copy from "copy-to-clipboard"; -import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react"; +import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; @@ -22,7 +22,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; @@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server"; interface Props { serverId: string; + asButton?: boolean; } -export const SetupServer = ({ serverId }: Props) => { +export const SetupServer = ({ serverId, asButton = false }: Props) => { const [isOpen, setIsOpen] = useState(false); const { data: server } = api.server.one.useQuery( { @@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => { const [activeLog, setActiveLog] = useState(null); const { data: isCloud } = api.settings.isCloud.useQuery(); + const isBuildServer = server?.serverType === "build"; const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [filteredLogs, setFilteredLogs] = useState([]); const [isDeploying, setIsDeploying] = useState(false); @@ -80,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => { return ( - - + + + ) : ( + + )}
@@ -117,17 +127,26 @@ export const SetupServer = ({ serverId }: Props) => { SSH Keys Deployments Validate - Security - {isCloud && ( - Monitoring + + {!isBuildServer && ( + <> + Security + {isCloud && ( + Monitoring + )} + GPU Setup + )} - GPU Setup {
- -
- -
-
- -
-
- -
-
-
- -
- -
-
+ {!isBuildServer && ( + <> + +
+ +
+
+ +
+
+ +
+
+
+ +
+ +
+
+ + )}
)} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 191aab9ce..92d6fc5c3 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -1,5 +1,16 @@ import { format } from "date-fns"; -import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react"; +import { + Clock, + Key, + KeyIcon, + Loader2, + MoreHorizontal, + Network, + ServerIcon, + Terminal, + Trash2, + User, +} from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useTranslation } from "next-i18next"; @@ -18,20 +29,15 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal"; import { TerminalModal } from "../web-server/terminal-modal"; @@ -59,7 +65,7 @@ export const ShowServers = () => { return (
{query?.success && isCloud && } - +
@@ -114,185 +120,44 @@ export const ShowServers = () => {
) : ( -
- - -
- See all servers -
-
- - - Name - {isCloud && ( - - Status - - )} - - IP Address - - - Port - - - Username - - - SSH Key - - - Created - - - Actions - - - - - {data?.map((server) => { - const canDelete = server.totalSum === 0; - const isActive = server.serverStatus === "active"; - return ( - - - {server.name} - - {isCloud && ( - - - {server.serverStatus} - - - )} - - {server.ipAddress} - - - {server.port} - - - {server.username} - - - - {server.sshKeyId ? "Yes" : "No"} - - - - - {format( - new Date(server.createdAt), - "PPpp", - )} - - - - - - - - - - - Actions - - - {isActive && ( - <> - {server.sshKeyId && ( - - - {t( - "settings.common.enterTerminal", - )} - - - )} - - - - - {server.sshKeyId && ( - - )} - - )} - - - You can not delete this server - because it has active services. - - You have active services - associated with this server, - please delete them first. - - - ) - } - onClick={async () => { - await mutateAsync({ - serverId: server.serverId, - }) - .then(() => { - refetch(); - toast.success( - `Server ${server.name} deleted successfully`, - ); - }) - .catch((err) => { - toast.error(err.message); - }); - }} - > - e.preventDefault()} - > - Delete Server - - - - {isActive && server.sshKeyId && ( - <> - +
+
+ {data?.map((server) => { + const canDelete = server.totalSum === 0; + const isActive = server.serverStatus === "active"; + const isBuildServer = server.serverType === "build"; + return ( + + +
+
+ + + {server.name} + +
+ {isActive && + server.sshKeyId && + !isBuildServer && ( + + + + + - Extra + Advanced - @@ -308,29 +173,267 @@ export const ShowServers = () => { } /> )} - - - - )} - - - - - ); - })} - -
+ + + )} +
+ +
+ {isCloud && ( + <> + {server.serverStatus === "active" ? ( + + {server.serverStatus} + + ) : ( + + + + + {server.serverStatus} + + + + +

+ This server is deactivated due + to lack of payment. Please pay + your invoice to reactivate it. + If you think this is an error, + please contact support. +

+
+
+ )} + + )} + + {server.serverType} + +
+
+ + +
+ + + IP: + + + {server.ipAddress} + + + Port: + + + {server.port} + +
+
+ + + User: + + + {server.username} + +
+
+ + + SSH Key: + + + {server.sshKeyId ? "Yes" : "No"} + +
+
+ + + Created{" "} + {format( + new Date(server.createdAt), + "PPp", + )} + +
-
+ {/* Compact Actions */} + {isActive && ( +
+
+ + + + + +
+

+ Setup Server +

+

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

+
+
+
+
+ + + {server.sshKeyId && ( + + +
+ + + +
+
+ +

Terminal

+
+
+ )} + + + +
+ +
+
+ +

Edit Server

+
+
+ + {server.sshKeyId && !isBuildServer && ( + + +
+ +
+
+ +

Web Server Actions

+
+
+ )} + +
+ + + +
+ + You can not delete this + server because it has + active services. + + You have active services + associated with this + server, please delete + them first. + +
+ ) + } + onClick={async () => { + await mutateAsync({ + serverId: server.serverId, + }) + .then(() => { + refetch(); + toast.success( + `Server ${server.name} deleted successfully`, + ); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + + +
+ + +

+ {canDelete + ? "Delete Server" + : "Cannot delete - has active services"} +

+
+ +
+
+ )} + + + ); + })} +
+ +
{data && data?.length > 0 && (
diff --git a/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx b/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx index c09753f3e..5aaf154fd 100644 --- a/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx @@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => { enabled: !!serverId, }, ); + const { data: server } = api.server.one.useQuery( + { serverId }, + { + enabled: !!serverId, + }, + ); + const isBuildServer = server?.serverType === "build"; const _utils = api.useUtils(); return ( @@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {

Status

- Shows the server configuration status + {isBuildServer + ? "Shows the build server configuration status" + : "Shows the server configuration status"}

{ : undefined } /> - + {!isBuildServer && ( + + )} { } /> - + {!isBuildServer && ( + <> + + + + )} { : "Not Created" } /> -
diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx index 0141aca08..1f463a18f 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-server.tsx @@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => { port: data.port || 22, username: data.username || "root", sshKeyId: data.sshKeyId || "", + serverType: "deploy", }) .then(async (_data) => { toast.success("Server Created"); diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 6e0384554..e778f2e96 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -158,6 +158,7 @@ export const AddInvitation = () => { Member + Admin diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index fb4d01547..7c6ef8b84 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -1,4 +1,3 @@ -import type { findEnvironmentById } from "@dokploy/server/index"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -27,12 +26,10 @@ import { FormMessage, } from "@/components/ui/form"; import { Switch } from "@/components/ui/switch"; -import { api } from "@/utils/api"; +import { api, type RouterOutputs } from "@/utils/api"; -type Environment = Omit< - Awaited>, - "project" ->; +type Project = RouterOutputs["project"]["all"][number]; +type Environment = Project["environments"][number]; export type Services = { appName: string; @@ -53,17 +50,16 @@ export type Services = { }; export const extractServices = (data: Environment | undefined) => { - const applications: Services[] = - data?.applications.map((item) => ({ - appName: item.appName, - name: item.name, - type: "application", - id: item.applicationId, - createdAt: item.createdAt, - status: item.applicationStatus, - description: item.description, - serverId: item.serverId, - })) || []; + const applications: Services[] = (data?.applications?.map((item) => ({ + appName: item.appName, + name: item.name, + type: "application", + id: item.applicationId, + createdAt: item.createdAt, + status: item.applicationStatus, + description: item.description, + serverId: item.serverId, + })) ?? []) as Services[]; const mariadb: Services[] = data?.mariadb.map((item) => ({ @@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => { serverId: item.serverId, })) || []; - const compose: Services[] = - data?.compose.map((item) => ({ - appName: item.appName, - name: item.name, - type: "compose", - id: item.composeId, - createdAt: item.createdAt, - status: item.composeStatus, - description: item.description, - serverId: item.serverId, - })) || []; + const compose: Services[] = (data?.compose?.map((item) => ({ + appName: item.appName, + name: item.name, + type: "compose", + id: item.composeId, + createdAt: item.createdAt, + status: item.composeStatus, + description: item.description, + serverId: item.serverId, + })) ?? []) as Services[]; applications.push( ...mysql, diff --git a/apps/dokploy/components/dashboard/settings/users/change-role.tsx b/apps/dokploy/components/dashboard/settings/users/change-role.tsx new file mode 100644 index 000000000..93dc4dfc0 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/users/change-role.tsx @@ -0,0 +1,159 @@ +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 { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +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"; + +const changeRoleSchema = z.object({ + role: z.enum(["admin", "member"]), +}); + +type ChangeRoleSchema = z.infer; + +interface Props { + memberId: string; + currentRole: "admin" | "member"; + userEmail: string; +} + +export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + + const { mutateAsync, isError, error, isLoading } = + api.organization.updateMemberRole.useMutation(); + + const form = useForm({ + defaultValues: { + role: currentRole, + }, + resolver: zodResolver(changeRoleSchema), + }); + + useEffect(() => { + if (isOpen) { + form.reset({ + role: currentRole, + }); + } + }, [form, currentRole, isOpen]); + + const onSubmit = async (data: ChangeRoleSchema) => { + await mutateAsync({ + memberId, + role: data.role, + }) + .then(async () => { + toast.success("Role updated successfully"); + await utils.user.all.invalidate(); + setIsOpen(false); + }) + .catch((error) => { + toast.error(error?.message || "Error updating role"); + }); + }; + + return ( + + + e.preventDefault()} + > + Change Role + + + + + Change User Role + + Change the role for {userEmail} + + + {isError && {error?.message}} + + + + ( + + Role + + + Admin: Can manage users and settings. +
+ Member: Limited permissions, can be + customized. +
+ + Note: Owner role is intransferible. + +
+ +
+ )} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 51d8704a3..a52cfda6d 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -21,7 +21,6 @@ import { import { Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, @@ -30,12 +29,15 @@ import { import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { AddUserPermissions } from "./add-permissions"; +import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); + const utils = api.useUtils(); + const { data: session } = authClient.useSession(); return (
@@ -68,7 +70,6 @@ export const ShowUsers = () => { ) : (
- See all users Email @@ -83,6 +84,52 @@ export const ShowUsers = () => { {data?.map((member) => { + const currentUserRole = data?.find( + (m) => m.user.id === session?.user?.id, + )?.role; + + // Owner never has "Edit Permissions" (they're absolute owner) + // Other users can edit permissions if target is not themselves and target is a member + const canEditPermissions = + member.role !== "owner" && + member.role === "member" && + member.user.id !== session?.user?.id; + + // Can change role based on hierarchy: + // - Owner: Can change anyone's role (except themselves and other owners) + // - Admin: Can only change member roles (not other admins or owners) + // - Owner role is intransferible + const canChangeRole = + member.role !== "owner" && + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + + // Delete/Unlink follow same hierarchy as role changes + // - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted) + // - Admin: Can only delete/unlink members (not other admins or owner) + const canDelete = + member.role !== "owner" && + !isCloud && + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + + const canUnlink = + member.role !== "owner" && + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + + const hasAnyAction = + canEditPermissions || + canChangeRole || + canDelete || + canUnlink; + return ( @@ -111,62 +158,73 @@ export const ShowUsers = () => { - - - - - - - Actions - + {hasAnyAction ? ( + + + + + + + Actions + - {member.role !== "owner" && ( - - )} + {canChangeRole && ( + + )} - {member.role !== "owner" && ( - <> - {!isCloud && ( - { - await mutateAsync({ - userId: member.user.id, + {canEditPermissions && ( + + )} + + {canDelete && ( + { + await mutateAsync({ + userId: member.user.id, + }) + .then(() => { + toast.success( + "User deleted successfully", + ); + refetch(); }) - .then(() => { - toast.success( - "User deleted successfully", - ); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting destination", - ); - }); - }} + .catch((err) => { + toast.error( + err?.message || + "Error deleting user", + ); + }); + }} + > + e.preventDefault()} > - - e.preventDefault() - } - > - Delete User - - - )} + Delete User + + + )} + {canUnlink && ( { }, ); - console.log(orgCount); - if (orgCount === 1) { await mutateAsync({ userId: member.user.id, @@ -227,10 +283,21 @@ export const ShowUsers = () => { Unlink User - - )} - - + )} + + + ) : ( + + )} ); diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index 51cb7af3e..e0be5c7f3 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -36,7 +36,7 @@ import { api } from "@/utils/api"; const addServerDomain = z .object({ - domain: z.string(), + domain: z.string().trim().toLowerCase(), letsEncryptEmail: z.string(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]), @@ -49,7 +49,11 @@ const addServerDomain = z message: "Required", }); } - if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { + if ( + data.https && + data.certificateType === "letsencrypt" && + !data.letsEncryptEmail + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: @@ -63,7 +67,7 @@ type AddServerDomain = z.infer; export const WebDomain = () => { const { t } = useTranslation("settings"); - const { data, refetch } = api.user.get.useQuery(); + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); const { mutateAsync, isLoading } = api.settings.assignDomainServer.useMutation(); @@ -78,15 +82,15 @@ export const WebDomain = () => { }); const https = form.watch("https"); const domain = form.watch("domain") || ""; - const host = data?.user?.host || ""; + const host = data?.host || ""; const hasChanged = domain !== host; useEffect(() => { if (data) { form.reset({ - domain: data?.user?.host || "", - certificateType: data?.user?.certificateType, - letsEncryptEmail: data?.user?.letsEncryptEmail || "", - https: data?.user?.https || false, + domain: data?.host || "", + certificateType: data?.certificateType || "none", + letsEncryptEmail: data?.letsEncryptEmail || "", + https: data?.https || false, }); } }, [form, form.reset, data]); diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index 2a2ce4ab1..c9cb7985b 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server"; export const WebServer = () => { const { t } = useTranslation("settings"); - const { data } = api.user.get.useQuery(); + const { data: webServerSettings } = + api.settings.getWebServerSettings.useQuery(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); @@ -53,7 +54,7 @@ export const WebServer = () => {
- Server IP: {data?.user.serverIp} + Server IP: {webServerSettings?.serverIp} Version: {dokployVersion} diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx index 282f1fddd..3ce95aa1f 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { }); toast.success(t("settings.server.webServer.traefik.portsUpdated")); setOpen(false); - } catch {} + } catch (error) { + toast.error((error as Error).message || "Error updating Traefik ports"); + } }; return ( @@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {

) : ( - +
{fields.map((field, index) => ( - + {
)} + + + The Traefik container will be recreated from scratch. This + means the container will be deleted and created again, which + may cause downtime in your applications. + - + {isDefault ? ( + + ) : ( + + )} + + {org.ownerId === session?.user?.id && ( + <> + + { + await deleteOrganization({ + organizationId: org.id, + }) + .then(() => { + refetch(); + toast.success( + "Organization deleted successfully", + ); + }) + .catch((error) => { + toast.error( + error?.message || + "Error deleting organization", + ); + }); + }} + > + + + + )} - )} - - ))} - {(user?.role === "owner" || isCloud) && ( + + ); + })} + {(user?.role === "owner" || + user?.role === "admin" || + isCloud) && ( <> @@ -1018,7 +1106,7 @@ export default function Page({ children }: Props) { - {!isCloud && auth?.role === "owner" && ( + {!isCloud && (auth?.role === "owner" || auth?.role === "admin") && ( @@ -1062,6 +1150,7 @@ export default function Page({ children }: Props) { + {!isCloud && } )} diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index 337e8f483..cc952150b 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -49,7 +49,9 @@ export const UserNav = () => { alt={data?.user?.image || ""} /> - {getFallbackAvatarInitials(data?.user?.name)} + {getFallbackAvatarInitials( + `${data?.user?.firstName} ${data?.user?.lastName}`.trim(), + )}
@@ -102,7 +104,9 @@ export const UserNav = () => { > Monitoring - {(data?.role === "owner" || data?.canAccessToTraefikFiles) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToTraefikFiles) && ( { @@ -112,7 +116,9 @@ export const UserNav = () => { Traefik )} - {(data?.role === "owner" || data?.canAccessToDocker) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToDocker) && ( { @@ -126,7 +132,7 @@ export const UserNav = () => { )} ) : ( - data?.role === "owner" && ( + (data?.role === "owner" || data?.role === "admin") && ( { diff --git a/apps/dokploy/components/shared/HubSpotWidget.tsx b/apps/dokploy/components/shared/HubSpotWidget.tsx new file mode 100644 index 000000000..ff3013459 --- /dev/null +++ b/apps/dokploy/components/shared/HubSpotWidget.tsx @@ -0,0 +1,14 @@ +import Script from "next/script"; + +export const HubSpotWidget = () => { + return ( +