diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..4ffcfaed7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,21 @@ +# Dockerfile for DevContainer +FROM node:24.4.0-bullseye-slim + +# Install essential packages +RUN apt-get update && apt-get install -y \ + curl \ + bash \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set up PNPM +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.22.0 --activate + +# Create workspace directory +WORKDIR /workspaces/dokploy + +# Set up user permissions +USER node \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..eafddd06d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,53 @@ +{ + "name": "Dokploy development container", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "moby": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/git:1": { + "ppa": true, + "version": "latest" + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.20" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.vscode-typescript-next", + "bradlc.vscode-tailwindcss", + "ms-vscode.vscode-json", + "biomejs.biome", + "golang.go", + "redhat.vscode-xml", + "github.vscode-github-actions", + "github.copilot", + "github.copilot-chat" + ] + } + }, + "forwardPorts": [3000, 5432, 6379], + "portsAttributes": { + "3000": { + "label": "Dokploy App", + "onAutoForward": "notify" + }, + "5432": { + "label": "PostgreSQL", + "onAutoForward": "silent" + }, + "6379": { + "label": "Redis", + "onAutoForward": "silent" + } + }, + "remoteUser": "node", + "workspaceFolder": "/workspaces/dokploy", + "runArgs": ["--name", "dokploy-devcontainer"] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b849afc0..e210811b0 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. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you. ## Issues related (if applicable) diff --git a/.github/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/deploy.yml b/.github/workflows/deploy.yml index 3ed957b72..321fb2029 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-cloud + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -25,8 +36,7 @@ jobs: context: . file: ./Dockerfile.cloud push: true - tags: | - siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-cloud.outputs.tags }} platforms: linux/amd64 build-args: | NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} @@ -40,6 +50,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-schedule + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -52,8 +72,7 @@ jobs: context: . file: ./Dockerfile.schedule push: true - tags: | - siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-schedule.outputs.tags }} platforms: linux/amd64 build-and-push-server-image: @@ -63,6 +82,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-server + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -75,6 +104,5 @@ jobs: context: . file: ./Dockerfile.server push: true - tags: | - siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-server.outputs.tags }} platforms: linux/amd64 diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 000000000..3554babb2 --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,22 @@ + +name: PR Quality + +permissions: + contents: read + issues: read + pull-requests: write + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + anti-slop: + runs-on: ubuntu-latest + steps: + - uses: peakoss/anti-slop@v0 + with: + max-failures: 4 + blocked-commit-authors: "claude,copilot" + require-description: true + min-account-age: 5 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6c74dbc02..2ad24fc0c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -18,8 +18,34 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.16.0 + node-version: 24.4.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..549af945b --- /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: 24.4.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..d531bab01 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ node_modules .env.test.local .env.production.local +openapi.json + # Testing coverage diff --git a/.nvmrc b/.nvmrc index 593cb75bc..84e5de6ef 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16.0 \ No newline at end of file +24.4.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38a36345e..ad37899e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute. -Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues. +Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues. We have a few guidelines to follow when contributing to this project: @@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project: - [Development](#development) - [Build](#build) - [Pull Request](#pull-request) +- [Important Considerations](#important-considerations-for-pull-requests) ## Commit Convention @@ -52,7 +53,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory. +We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git @@ -148,7 +149,7 @@ curl -sSL https://railpack.com/install.sh | sh ```bash # Install Buildpacks -curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack +curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack ``` ## Pull Request @@ -162,11 +163,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0. - If your pull request fixes an open issue, please reference the issue in the pull request description. - Once your pull request is merged, you will be automatically added as a contributor to the project. -**Important Considerations for Pull Requests:** +### Important Considerations for Pull Requests +- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first. - **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects. - **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task. - **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`). +- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing. Thank you for your contribution! diff --git a/Dockerfile b/Dockerfile index 11310b18e..ed936508f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # syntax=docker/dockerfile:1 -FROM node:20.16.0-slim AS base +FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build -RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy +RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist @@ -46,23 +46,27 @@ 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" ] + +HEALTHCHECK --interval=10s --timeout=3s --retries=10 \ + CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 + + CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index 8e4bac215..05e7cde49 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,9 +1,9 @@ # syntax=docker/dockerfile:1 -FROM node:20.16.0-slim AS base +FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -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 @@ -29,7 +29,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/dokploy run build -RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy +RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist @@ -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..81b13fd64 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,9 +1,9 @@ # syntax=docker/dockerfile:1 -FROM node:20.16.0-slim AS base +FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/schedules run build -RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules +RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist @@ -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..8990ece4d 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,9 +1,9 @@ # syntax=docker/dockerfile:1 -FROM node:20.16.0-slim AS base +FROM node:24.4.0-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=./apps/api run build -RUN pnpm --filter=./apps/api --prod deploy /prod/api +RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist @@ -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 3c9bfd68b..2ddc1f498 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,6 @@
-
- Special thanks to: -
-
- - Tuple's sponsorship image - - -### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy) - -[Available for MacOS & Windows](https://tuple.app/dokploy)
- -
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. @@ -57,67 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). -## ♥️ Sponsors - -🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features. - -[Dokploy Open Collective](https://opencollective.com/dokploy) [Github Sponsors](https://github.com/sponsors/Siumauricio) - - - - -### Hero Sponsors 🎖 - -
- Hostinger - LX Aer -
- - - - - -### Premium Supporters 🥇 - -
- Supafort.com - agentdock.ai -
- - - - - -### Elite Contributors 🥈 - -
- AmericanCloud - Tolgee -
- -### Supporting Members 🥉 - -
- -Cloudblast.io - -Synexa - -
- -### Community Backers 🤝 - -#### Organizations: - -[Sponsors on Open Collective](https://opencollective.com/dokploy) - -#### Individuals: - -[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy) - ### Contributors 🤝 diff --git a/apps/api/.env.example b/apps/api/.env.example index 647e2a077..01edbec0c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,2 +1,11 @@ LEMON_SQUEEZY_API_KEY="" -LEMON_SQUEEZY_STORE_ID="" \ No newline at end of file +LEMON_SQUEEZY_STORE_ID="" + +# Inngest (for GET /jobs - list deployment queue). Self-hosted example: +# INNGEST_BASE_URL="http://localhost:8288" +# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com" +# INNGEST_SIGNING_KEY="your-signing-key" +# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied. +# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z" +# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000. +# INNGEST_JOBS_MAX_EVENTS=100 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index dfc2a355d..c7e76afc7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "PORT=4000 tsx watch src/index.ts", - "build": "tsc --project tsconfig.json", + "build": "rimraf dist && tsc --project tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit" }, @@ -12,27 +12,27 @@ "inngest": "3.40.1", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.14.3", - "@hono/zod-validator": "0.3.0", - "@nerimity/mimiqueue": "1.2.3", + "@hono/zod-validator": "0.7.6", "dotenv": "^16.4.5", - "hono": "^4.7.10", + "hono": "^4.11.7", "pino": "9.4.0", "pino-pretty": "11.2.2", "react": "18.2.0", "react-dom": "18.2.0", "redis": "4.7.0", - "zod": "^3.25.32" + "zod": "^4.3.6" }, "devDependencies": { - "@types/node": "^20.17.51", + "@types/node": "^24.4.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", + "rimraf": "6.1.3", "tsx": "^4.16.2", "typescript": "^5.8.3" }, - "packageManager": "pnpm@9.12.0", + "packageManager": "pnpm@10.22.0", "engines": { - "node": "^20.16.0", - "pnpm": ">=9.12.0" + "node": "^24.4.0", + "pnpm": ">=10.22.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8ddb56dec..0bb6e1401 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { type DeployJob, deployJobSchema, } from "./schema.js"; +import { fetchDeploymentJobs } from "./service.js"; import { deploy } from "./utils.js"; const app = new Hono(); @@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { 200, ); } catch (error) { - console.log("error", error); logger.error("Failed to send deployment event", error); return c.json( { @@ -176,6 +176,29 @@ app.get("/health", async (c) => { return c.json({ status: "ok" }); }); +// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI +app.get("/jobs", async (c) => { + const serverId = c.req.query("serverId"); + if (!serverId) { + return c.json({ message: "serverId is required" }, 400); + } + + try { + const rows = await fetchDeploymentJobs(serverId); + return c.json(rows); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("INNGEST_BASE_URL")) { + return c.json( + { message: "INNGEST_BASE_URL is required to list deployment jobs" }, + 503, + ); + } + logger.error("Failed to fetch jobs from Inngest", { serverId, error }); + return c.json([], 200); + } +}); + // Serve Inngest functions endpoint app.on( ["GET", "POST", "PUT"], 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/service.ts b/apps/api/src/service.ts new file mode 100644 index 000000000..414ee7d9d --- /dev/null +++ b/apps/api/src/service.ts @@ -0,0 +1,239 @@ +import { logger } from "./logger.js"; + +const baseUrl = process.env.INNGEST_BASE_URL ?? ""; +const signingKey = process.env.INNGEST_SIGNING_KEY ?? ""; + +const DEFAULT_MAX_EVENTS = 500; +const MAX_EVENTS = DEFAULT_MAX_EVENTS; + +/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */ +type InngestEventRow = { + internal_id?: string; + accountID?: string; + environmentID?: string; + source?: string; + sourceID?: string | null; + /** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */ + receivedAt?: string; + received_at?: string; + id: string; + name: string; + data: Record; + user?: unknown; + ts: number; + v?: string | null; + metadata?: { + fetchedAt: string; + cachedUntil: string | null; + }; +}; + +/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */ +type InngestRun = { + run_id: string; + event_id: string; + status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"? + run_started_at?: string; + ended_at?: string | null; + output?: unknown; + // dev server / API may use different casing + run_started_at_ms?: number; +}; + +function getEventReceivedAt(ev: InngestEventRow): string | undefined { + return ev.receivedAt ?? ev.received_at; +} + +/** Map Inngest run status to BullMQ-style state for the UI */ +function runStatusToState( + status: string, +): "pending" | "active" | "completed" | "failed" | "cancelled" { + const s = status.toLowerCase(); + if (s === "running") return "active"; + if (s === "completed") return "completed"; + if (s === "failed") return "failed"; + if (s === "cancelled") return "cancelled"; + if (s === "queued") return "pending"; + return "pending"; +} + +export const fetchInngestEvents = async () => { + const maxEvents = MAX_EVENTS; + const all: InngestEventRow[] = []; + let cursor: string | undefined; + + do { + const params = new URLSearchParams({ limit: "100" }); + if (cursor) { + params.set("cursor", cursor); + } + + const res = await fetch(`${baseUrl}/v1/events?${params}`, { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + logger.warn("Inngest API error", { + status: res.status, + body: await res.text(), + }); + break; + } + + const body = (await res.json()) as { + data?: InngestEventRow[]; + cursor?: string; + nextCursor?: string; + }; + const data = Array.isArray(body.data) ? body.data : []; + all.push(...data); + + // Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs) + const nextCursor = + body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id; + const hasMore = data.length === 100 && nextCursor && all.length < maxEvents; + cursor = hasMore ? nextCursor : undefined; + } while (cursor); + + return all.slice(0, maxEvents); +}; + +/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */ +export const fetchInngestRunsForEvent = async ( + eventId: string, +): Promise => { + const res = await fetch( + `${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`, + { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }, + ); + if (!res.ok) { + logger.warn("Inngest runs API error", { + eventId, + status: res.status, + body: await res.text(), + }); + return []; + } + const body = (await res.json()) as { data?: InngestRun[] }; + return Array.isArray(body.data) ? body.data : []; +}; + +/** One row for the queue UI (BullMQ-compatible shape) */ +export type DeploymentJobRow = { + id: string; + name: string; + data: Record; + timestamp: number; + processedOn?: number; + finishedOn?: number; + failedReason?: string; + state: string; +}; + +/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */ +function buildDeploymentRowsFromRuns( + events: InngestEventRow[], + runsByEventId: Map, + serverId: string, +): DeploymentJobRow[] { + const requested = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + const rows: DeploymentJobRow[] = []; + + for (const ev of requested) { + const data = (ev.data ?? {}) as Record; + const runs = runsByEventId.get(ev.id) ?? []; + + if (runs.length === 0) { + // Queued: event received but no run yet + rows.push({ + id: ev.id, + name: ev.name, + data, + timestamp: ev.ts, + processedOn: ev.ts, + finishedOn: undefined, + failedReason: undefined, + state: "pending", + }); + continue; + } + + for (const run of runs) { + const state = runStatusToState(run.status); + const runStartedMs = + run.run_started_at_ms ?? + (run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts); + const endedMs = run.ended_at + ? new Date(run.ended_at).getTime() + : undefined; + const failedReason = + state === "failed" && + run.output && + typeof run.output === "object" && + "error" in run.output + ? String((run.output as { error?: unknown }).error) + : undefined; + + rows.push({ + id: run.run_id, + name: ev.name, + data, + timestamp: runStartedMs, + processedOn: runStartedMs, + finishedOn: + state === "completed" || state === "failed" || state === "cancelled" + ? endedMs + : undefined, + failedReason, + state, + }); + } + } + + return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); +} + +/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */ +export const fetchDeploymentJobs = async ( + serverId: string, +): Promise => { + if (!signingKey) { + logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list"); + return []; + } + if (!baseUrl) { + throw new Error("INNGEST_BASE_URL is required to list deployment jobs"); + } + + const events = await fetchInngestEvents(); + + const requestedForServer = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + // Limit to avoid too many run fetches + const toFetch = requestedForServer.slice(0, 50); + const runsByEventId = new Map(); + + await Promise.all( + toFetch.map(async (ev) => { + const runs = await fetchInngestRunsForEvent(ev.id); + runsByEventId.set(ev.id, runs); + }), + ); + + return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId); +}; diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts index ee2ac3e50..b99ec492a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -1,14 +1,15 @@ import { - deployRemoteApplication, - deployRemoteCompose, - deployRemotePreviewApplication, - rebuildRemoteApplication, - rebuildRemoteCompose, + deployApplication, + deployCompose, + deployPreviewApplication, + rebuildApplication, + rebuildCompose, + rebuildPreviewApplication, updateApplicationStatus, updateCompose, updatePreviewDeployment, } from "@dokploy/server"; -import type { DeployJob } from "./schema"; +import type { DeployJob } from "./schema.js"; export const deploy = async (job: DeployJob) => { try { @@ -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/.nvmrc b/apps/dokploy/.nvmrc deleted file mode 100644 index 593cb75bc..000000000 --- a/apps/dokploy/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -20.16.0 \ 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__/compose/domain/network-service.test.ts b/apps/dokploy/__test__/compose/domain/network-service.test.ts index b8d03c751..83fe8a166 100644 --- a/apps/dokploy/__test__/compose/domain/network-service.test.ts +++ b/apps/dokploy/__test__/compose/domain/network-service.test.ts @@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest"; describe("addDokployNetworkToService", () => { it("should add network to an empty array", () => { const result = addDokployNetworkToService([]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should not add duplicate network to an array", () => { const result = addDokployNetworkToService(["dokploy-network"]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should add network to an existing array with other networks", () => { const result = addDokployNetworkToService(["other-network"]); - expect(result).toEqual(["other-network", "dokploy-network"]); + expect(result).toEqual(["other-network", "dokploy-network", "default"]); }); it("should add network to an object if networks is an object", () => { const result = addDokployNetworkToService({ "other-network": {} }); - expect(result).toEqual({ "other-network": {}, "dokploy-network": {} }); + expect(result).toEqual({ + "other-network": {}, + "dokploy-network": {}, + default: {}, + }); + }); + + it("should not duplicate default network when already present", () => { + const result = addDokployNetworkToService(["default", "dokploy-network"]); + expect(result).toEqual(["default", "dokploy-network"]); }); }); diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts new file mode 100644 index 000000000..1a33489b5 --- /dev/null +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -0,0 +1,287 @@ +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), + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + then: (resolve: (v: any) => void) => { + resolve([]); + }, + } as any; + return chain; + }; + + return { + db: { + select: vi.fn(() => createChainableMock()), + insert: vi.fn(), + update: vi.fn(() => createChainableMock()), + delete: vi.fn(), + query: { + applications: { + findFirst: vi.fn(), + }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + }, + }; +}); + +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..4adff6f07 --- /dev/null +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -0,0 +1,490 @@ +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([{}]), + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + then: (resolve: (v: any) => void) => { + resolve([]); + }, + }; + return chain; + }; + + return { + db: { + select: vi.fn(() => createChainableMock()), + insert: vi.fn(), + update: vi.fn(() => createChainableMock()), + delete: vi.fn(), + query: { + applications: { + findFirst: vi.fn(), + }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + }, + }; +}); + +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..d2e773dfc 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 = { @@ -78,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => { { commits: [{ message: "[skip ci] test" }] }, ), ).toBe("[skip ci] test"); + + // Soft Serve + expect( + extractCommitMessage( + { "x-softserve-event": "push" }, + { commits: [{ message: "[skip ci] test" }] }, + ), + ).toBe("[skip ci] test"); }); it("should handle missing commit message", () => { @@ -94,5 +107,313 @@ describe("GitHub Webhook Skip CI", () => { expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( "NEW COMMIT", ); + expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe( + "NEW COMMIT", + ); + }); +}); + +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__/deploy/soft-serve.test.ts b/apps/dokploy/__test__/deploy/soft-serve.test.ts new file mode 100644 index 000000000..609f15dee --- /dev/null +++ b/apps/dokploy/__test__/deploy/soft-serve.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + extractBranchName, + extractCommitMessage, + extractHash, + getProviderByHeader, +} from "@/pages/api/deploy/[refreshToken]"; + +describe("Soft Serve Webhook", () => { + const mockSoftServeHeaders = { + "x-softserve-event": "push", + }; + + const createMockBody = (message: string, hash: string, branch: string) => ({ + event: "push", + ref: `refs/heads/${branch}`, + after: hash, + commits: [{ message: message }], + }); + const message: string = "feat: add new feature"; + const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5"; + const branch: string = "feat/add-new"; + const goodWebhook = createMockBody(message, hash, branch); + + it("should properly extract the provider name", () => { + expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve"); + }); + + it("should properly extract the commit message", () => { + expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe( + message, + ); + }); + + it("should properly extract hash", () => { + expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash); + }); + + it("should properly extract branch name", () => { + expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch); + }); + + it("should gracefully handle invalid webhook", () => { + expect(getProviderByHeader({})).toBeNull(); + expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT"); + expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT"); + expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull(); + }); +}); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index b597b3aa4..6e9940d6d 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants"; import AdmZip from "adm-zip"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const OUTPUT_BASE = "./__test__/drop/zips/output"; const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); @@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => { // @ts-ignore ...actual, paths: () => ({ - APPLICATIONS_PATH: "./__test__/drop/zips/output", + // @ts-ignore + ...actual.paths(), + BASE_PATH: OUTPUT_BASE, + APPLICATIONS_PATH: OUTPUT_BASE, }), }; }); @@ -25,11 +29,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,17 +47,22 @@ 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, dockerBuildStage: "", isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, previewCertificateType: "none", previewCustomCertResolver: null, previewEnv: null, @@ -58,6 +73,7 @@ const baseApp: ApplicationNested = { previewWildcard: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", @@ -73,6 +89,7 @@ const baseApp: ApplicationNested = { }, }, buildArgs: null, + buildSecrets: null, buildPath: "/", gitlabPathNamespace: "", buildType: "nixpacks", @@ -133,8 +150,180 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, rollbackActive: false, + stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; +/** + * GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal. + * Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron + * plus cover files (package.json, index.js). unzipDrop must reject and never write outside output. + */ +describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => { + baseApp.appName = "ghsa-rce"; + // PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace) + const traversalEntry = "../../../../../etc/cron.d/malicious-cron"; + const cronPayload = "* * * * * root id\n"; + const placeholder = "x".repeat(traversalEntry.length); + const zip = new AdmZip(); + zip.addFile( + "package.json", + Buffer.from('{"name": "app", "version": "1.0.0"}'), + ); + zip.addFile("index.js", Buffer.from('console.log("Application");')); + zip.addFile(placeholder, Buffer.from(cronPayload)); + let buf = Buffer.from(zip.toBuffer()); + buf = Buffer.from( + buf.toString("binary").split(placeholder).join(traversalEntry), + "binary", + ); + const file = new File([buf as unknown as ArrayBuffer], "exploit.zip"); + await expect(unzipDrop(file, baseApp)).rejects.toThrow( + /Path traversal detected.*resolved path escapes output directory/, + ); + }); +}); + +describe("security: existing symlink escape", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("should NOT write outside base when directory is a symlink", async () => { + const appName = "symlink-existing"; + const output = path.join(APPLICATIONS_PATH, appName, "code"); + await fs.mkdir(output, { recursive: true }); + + // outside target (attacker wants to write here) + const outside = path.join(APPLICATIONS_PATH, "..", "outside"); + await fs.mkdir(outside, { recursive: true }); + + // attacker-controlled symlink inside project + await fs.symlink(outside, path.join(output, "logs")); + + // zip looks totally harmless + const zip = new AdmZip(); + zip.addFile("logs/pwned.txt", Buffer.from("owned")); + + const file = new File([zip.toBuffer() as any], "exploit.zip"); + + await unzipDrop(file, { ...baseApp, appName }); + + // if vulnerable -> file exists outside sandbox + const escaped = await fs + .readFile(path.join(outside, "pwned.txt"), "utf8") + .then(() => true) + .catch(() => false); + + expect(escaped).toBe(false); + }); +}); + +describe("security: zip symlink entry blocked", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("rejects zip containing real symlink entry", async () => { + const appName = "zip-symlink"; + + const zipBuffer = await fs.readFile( + path.join(__dirname, "./zips/payload/symlink-entry.zip"), + ); + + const file = new File([zipBuffer as any], "exploit.zip"); + + await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow( + /Dangerous node entries are not allowed/, + ); + }); +}); + +describe("unzipDrop path under output (no traversal)", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => { + baseApp.appName = "cron-under-output"; + const zip = new AdmZip(); + zip.addFile( + "etc/cron.d/malicious-cron", + Buffer.from("* * * * * root id\n"), + ); + zip.addFile("package.json", Buffer.from('{"name":"app"}')); + const file = new File( + [zip.toBuffer() as unknown as ArrayBuffer], + "app.zip", + ); + const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); + await unzipDrop(file, baseApp); + const content = await fs.readFile( + path.join(outputPath, "etc/cron.d/malicious-cron"), + "utf8", + ); + expect(content).toBe("* * * * * root id\n"); + }); +}); + +describe("security: traversal inside BASE_PATH (sandbox escape)", () => { + beforeAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + afterAll(async () => { + await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true }); + }); + + it("should NOT allow writing outside application directory but inside BASE_PATH", async () => { + const appName = "sandbox-escape"; + + const base = APPLICATIONS_PATH.replace("/applications", ""); + const output = path.join(APPLICATIONS_PATH, appName, "code"); + + await fs.mkdir(output, { recursive: true }); + + // attacker writes into traefik config inside base + const zip = new AdmZip(); + zip.addFile( + "../../../traefik/dynamic/evil.yml", + Buffer.from("pwned: true"), + ); + + const file = new File([zip.toBuffer() as any], "exploit.zip"); + + await unzipDrop(file, { ...baseApp, appName }); + + const escapedPath = path.join(base, "traefik/dynamic/evil.yml"); + + const exists = await fs + .readFile(escapedPath) + .then(() => true) + .catch(() => false); + + expect(exists).toBe(false); + }); +}); + describe("unzipDrop using real zip files", () => { // const { APPLICATIONS_PATH } = paths(); beforeAll(async () => { @@ -151,14 +340,12 @@ describe("unzipDrop using real zip files", () => { try { const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); const zip = new AdmZip("./__test__/drop/zips/single-file.zip"); - console.log(`Output Path: ${outputPath}`); const zipBuffer = zip.toBuffer() as Buffer; const file = new File([zipBuffer], "single.zip"); await unzipDrop(file, baseApp); const files = await fs.readdir(outputPath, { withFileTypes: true }); expect(files.some((f) => f.name === "test.txt")).toBe(true); } catch (err) { - console.log(err); } finally { } }); diff --git a/apps/dokploy/__test__/drop/zips/payload/link b/apps/dokploy/__test__/drop/zips/payload/link new file mode 120000 index 000000000..3594e94c0 --- /dev/null +++ b/apps/dokploy/__test__/drop/zips/payload/link @@ -0,0 +1 @@ +/etc/passwd \ No newline at end of file diff --git a/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip new file mode 100644 index 000000000..b30279c6b Binary files /dev/null and b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip differ 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__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts new file mode 100644 index 000000000..7f14e2d0e --- /dev/null +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkPermission } = await import("@dokploy/server/services/permission"); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("static roles bypass enterprise resources", () => { + it("owner bypasses deployment.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { deployment: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses backup.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { backup: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses schedule.delete", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { schedule: ["delete"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses multiple enterprise permissions at once", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { + deployment: ["read"], + backup: ["create"], + domain: ["delete"], + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("static roles validate free-tier resources", () => { + it("owner passes project.create", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails project.create (no legacy override)", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).rejects.toThrow(); + }); + + it("member passes service.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails service.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["create"] }), + ).rejects.toThrow(); + }); +}); + +describe("legacy boolean overrides for member", () => { + it("member passes project.create with canCreateProjects=true", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member passes docker.read with canAccessToDocker=true", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + await expect( + checkPermission(ctx, { docker: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails docker.read with canAccessToDocker=false", async () => { + memberToReturn = mockMemberData("member"); + await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts new file mode 100644 index 000000000..9568b12af --- /dev/null +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { + enterpriseOnlyResources, + statements, +} from "@dokploy/server/lib/access-control"; + +const FREE_TIER_RESOURCES = [ + "organization", + "member", + "invitation", + "team", + "ac", + "project", + "service", + "environment", + "docker", + "sshKeys", + "gitProviders", + "traefikFiles", + "api", +]; + +const ENTERPRISE_RESOURCES = [ + "volume", + "deployment", + "envVars", + "projectEnvVars", + "environmentEnvVars", + "server", + "registry", + "certificate", + "backup", + "volumeBackup", + "schedule", + "domain", + "destination", + "notification", + "logs", + "monitoring", + "auditLog", +]; + +describe("enterpriseOnlyResources set", () => { + it("contains all enterprise resources", () => { + for (const resource of ENTERPRISE_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(true); + } + }); + + it("does NOT contain free-tier resources", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("every resource in statements is either free or enterprise", () => { + const allResources = Object.keys(statements); + for (const resource of allResources) { + const isFree = FREE_TIER_RESOURCES.includes(resource); + const isEnterprise = enterpriseOnlyResources.has(resource); + expect(isFree || isEnterprise).toBe(true); + } + }); + + it("free and enterprise sets don't overlap", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("all statement resources are accounted for", () => { + const allResources = Object.keys(statements); + const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES]; + for (const resource of allResources) { + expect(categorized).toContain(resource); + } + }); +}); diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts new file mode 100644 index 000000000..759c8dad8 --- /dev/null +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { resolvePermissions } = await import( + "@dokploy/server/services/permission" +); +const { enterpriseOnlyResources, statements } = await import( + "@dokploy/server/lib/access-control" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("enterprise resources for static roles", () => { + it("owner gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("admin gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("admin"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("member gets true for service-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.deployment.read).toBe(true); + expect(perms.deployment.create).toBe(true); + expect(perms.domain.read).toBe(true); + expect(perms.backup.read).toBe(true); + expect(perms.logs.read).toBe(true); + expect(perms.monitoring.read).toBe(true); + }); + + it("member gets false for org-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.server.read).toBe(false); + expect(perms.registry.read).toBe(false); + expect(perms.certificate.read).toBe(false); + expect(perms.destination.read).toBe(false); + expect(perms.notification.read).toBe(false); + expect(perms.auditLog.read).toBe(false); + }); +}); + +describe("free-tier resources for member", () => { + it("member gets service.read=true", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.service.read).toBe(true); + }); + + it("member gets project.create=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(false); + }); + + it("member gets project.create=true with canCreateProjects", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + }); + + it("member gets docker.read=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(false); + }); + + it("member gets docker.read=true with canAccessToDocker", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(true); + }); +}); + +describe("free-tier resources for owner", () => { + it("owner gets all free-tier permissions as true", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + expect(perms.project.delete).toBe(true); + expect(perms.service.create).toBe(true); + expect(perms.service.read).toBe(true); + expect(perms.service.delete).toBe(true); + expect(perms.docker.read).toBe(true); + expect(perms.traefikFiles.read).toBe(true); + expect(perms.traefikFiles.write).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts new file mode 100644 index 000000000..b3786807d --- /dev/null +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + accessedServices: string[] = [], + accessedProjects: string[] = [], +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects, + accessedServices, + accessedEnvironments: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkServicePermissionAndAccess, checkServiceAccess } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("checkServicePermissionAndAccess", () => { + it("owner bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("owner", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("admin", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + backup: ["create"], + }), + ).resolves.toBeUndefined(); + }); + + it("member with access to service passes", async () => { + memberToReturn = mockMemberData("member", ["service-123"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("member WITHOUT access to service fails", async () => { + memberToReturn = mockMemberData("member", ["other-service"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); + + it("member with empty accessedServices fails", async () => { + memberToReturn = mockMemberData("member", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + domain: ["delete"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); +}); + +describe("checkServiceAccess", () => { + it("member with service access passes read check", async () => { + memberToReturn = mockMemberData("member", ["app-1"]); + await expect( + checkServiceAccess(ctx, "app-1", "read"), + ).resolves.toBeUndefined(); + }); + + it("member without service access fails read check", async () => { + memberToReturn = mockMemberData("member", []); + await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow( + "You don't have access to this service", + ); + }); + + it("owner bypasses all access checks", async () => { + memberToReturn = mockMemberData("owner", [], []); + await expect( + checkServiceAccess(ctx, "project-1", "create"), + ).resolves.toBeUndefined(); + }); +}); 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 new file mode 100644 index 000000000..fb448e3af --- /dev/null +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -0,0 +1,161 @@ +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 = { + TaskTemplate?: { + ContainerSpec?: { + StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; + }; + }; + [key: string]: unknown; +}; + +const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = + vi.hoisted(() => { + const inspect = vi.fn<() => Promise>(); + const getService = vi.fn(() => ({ inspect })); + const createService = vi.fn< + (opts: MockCreateServiceOptions) => Promise + >(async () => undefined); + const getRemoteDocker = vi.fn(async () => ({ + getService, + createService, + })); + return { + inspectMock: inspect, + getServiceMock: getService, + createServiceMock: createService, + getRemoteDockerMock: getRemoteDocker, + }; + }); + +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: getRemoteDockerMock, +})); + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "dockerfile", + env: null, + mounts: [], + cpuLimit: null, + memoryLimit: null, + memoryReservation: null, + cpuReservation: null, + command: null, + ports: [], + sourceType: "docker", + dockerImage: "example:latest", + registry: null, + environment: { + project: { env: null }, + env: null, + }, + replicas: 1, + stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, + serverId: "server-id", + ...overrides, + }) as unknown as ApplicationNested; + +describe("mechanizeDockerContainer", () => { + beforeEach(() => { + inspectMock.mockReset(); + inspectMock.mockRejectedValue(new Error("service not found")); + getServiceMock.mockClear(); + createServiceMock.mockClear(); + getRemoteDockerMock.mockClear(); + getRemoteDockerMock.mockResolvedValue({ + getService: getServiceMock, + createService: createServiceMock, + }); + }); + + it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { + const application = createApplication({ stopGracePeriodSwarm: 0n }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0); + expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe( + "number", + ); + }); + + it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => { + const application = createApplication({ stopGracePeriodSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty( + "StopGracePeriod", + ); + }); + + it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { + const ulimits = [ + { Name: "nofile", Soft: 10000, Hard: 20000 }, + { Name: "nproc", Soft: 4096, Hard: 8192 }, + ]; + const application = createApplication({ ulimitsSwarm: ulimits }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); + }); + + it("omits Ulimits when ulimitsSwarm is null", async () => { + const application = createApplication({ ulimitsSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); + + it("omits Ulimits when ulimitsSwarm is an empty array", async () => { + const application = createApplication({ ulimitsSwarm: [] }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); +}); diff --git a/apps/dokploy/__test__/setup.ts b/apps/dokploy/__test__/setup.ts new file mode 100644 index 000000000..04fd08b0c --- /dev/null +++ b/apps/dokploy/__test__/setup.ts @@ -0,0 +1,43 @@ +import { vi } from "vitest"; + +/** + * Mock the DB module so tests that import from @dokploy/server (barrel) + * never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs). + * Without this, loading the server barrel pulls in lib/auth and db, which + * connect to localhost:5432 and cause ECONNREFUSED. + */ +vi.mock("@dokploy/server/db", () => { + const chain = () => chain; + chain.set = () => chain; + chain.where = () => chain; + chain.values = () => chain; + chain.returning = () => Promise.resolve([{}]); + chain.from = () => chain; + chain.innerJoin = () => chain; + chain.then = (resolve: (value: unknown) => void) => { + resolve([]); + }; + + const tableMock = { + findFirst: vi.fn(() => Promise.resolve(undefined)), + findMany: vi.fn(() => Promise.resolve([])), + insert: vi.fn(() => Promise.resolve([{}])), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + }; + + return { + db: { + select: vi.fn(() => chain), + insert: vi.fn(() => ({ + values: () => ({ returning: () => Promise.resolve([{}]) }), + })), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + query: new Proxy({} as Record, { + get: () => tableMock, + }), + }, + dbUrl: "postgres://mock:mock@localhost:5432/mock", + }; +}); diff --git a/apps/dokploy/__test__/templates/helpers.template.test.ts b/apps/dokploy/__test__/templates/helpers.template.test.ts index 1144b65fe..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); @@ -228,5 +272,58 @@ describe("helpers functions", () => { "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", ); }); + + it("should handle JWT payload with newlines and whitespace by trimming them", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithNewlines = `{ + "role": "anon", + "iss": "supabase", + "exp": ${expiry} +} +`; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithNewlines, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("anon"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); + + it("should handle JWT payload with leading and trailing whitespace", () => { + const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000); + const expiry = iat + 3600; + const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `; + const jwt = processValue( + "${jwt:secret:payload}", + { + secret: "mysecret", + payload: payloadWithWhitespace, + }, + mockSchema, + ); + expect(jwt).toMatch(jwtMatchExp); + const parts = jwt.split(".") as JWTParts; + jwtCheckHeader(parts[0]); + const decodedPayload = jwtBase64Decode(parts[1]); + expect(decodedPayload).toHaveProperty("role"); + expect(decodedPayload.role).toEqual("service_role"); + expect(decodedPayload).toHaveProperty("iss"); + expect(decodedPayload.iss).toEqual("supabase"); + expect(decodedPayload).toHaveProperty("exp"); + expect(decodedPayload.exp).toEqual(expiry); + }); }); }); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 6858f0f00..e07f34ade 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, @@ -40,33 +48,25 @@ const baseAdmin: User = { urlCallback: "", }, }, + whitelabelingConfig: { + appName: null, + appDescription: null, + logoUrl: null, + faviconUrl: null, + customCss: null, + loginLogoUrl: null, + supportUrl: null, + docsUrl: null, + errorPageTitle: null, + errorPageDescription: null, + metaTitle: null, + footerText: null, + }, 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 +84,7 @@ test("Should read the configuration file", () => { test("Should apply redirect-to-https", () => { updateServerTraefik( { - ...baseAdmin, + ...baseSettings, https: true, certificateType: "letsencrypt", }, @@ -99,7 +99,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 +109,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 +118,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 5be96e473..9121dc8a1 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, @@ -25,8 +35,10 @@ const baseApp: ApplicationNested = { registryUrl: "", watchPaths: [], buildArgs: null, + buildSecrets: null, isPreviewDeploymentsActive: false, previewBuildArgs: null, + previewBuildSecrets: null, triggerType: "push", previewCertificateType: "none", previewEnv: null, @@ -39,6 +51,7 @@ const baseApp: ApplicationNested = { environmentId: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", @@ -111,6 +124,8 @@ const baseApp: ApplicationNested = { updateConfigSwarm: null, username: null, dockerContextPath: null, + stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { @@ -260,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); + +/** IDN/Punycode */ + +test("Internationalized domain name is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "тест.рф" }, + "web", + ); + + // тест.рф in punycode is xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); + +test("ASCII domain remains unchanged", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "example.com" }, + "web", + ); + + expect(router.rule).toContain("Host(`example.com`)"); +}); + +test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "сайт.ru" }, + "web", + ); + + // сайт in punycode is xn--80aswg + expect(router.rule).toContain("Host(`xn--80aswg.ru`)"); + expect(router.rule).not.toContain("сайт"); +}); + +test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "app.тест.рф" }, + "web", + ); + + // app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts index ddc84d6ac..65eb374ea 100644 --- a/apps/dokploy/__test__/vitest.config.ts +++ b/apps/dokploy/__test__/vitest.config.ts @@ -7,13 +7,22 @@ export default defineConfig({ include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__ exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"], pool: "forks", + setupFiles: [path.resolve(__dirname, "setup.ts")], }, define: { "process.env": { NODE: "test", + GITHUB_CLIENT_ID: "test", + GITHUB_CLIENT_SECRET: "test", + GOOGLE_CLIENT_ID: "test", + GOOGLE_CLIENT_SECRET: "test", }, }, - plugins: [tsconfigPaths()], + plugins: [ + tsconfigPaths({ + projects: [path.resolve(__dirname, "../tsconfig.json")], + }), + ], resolve: { alias: { "@dokploy/server": path.resolve( diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts new file mode 100644 index 000000000..8107bb591 --- /dev/null +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const BASE = "/base"; + +vi.mock("@dokploy/server/constants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + paths: () => ({ + ...actual.paths(), + BASE_PATH: BASE, + LOGS_PATH: `${BASE}/logs`, + APPLICATIONS_PATH: `${BASE}/applications`, + }), + }; +}); + +// Import after mock so paths() uses our BASE +const { readValidDirectory } = await import("@dokploy/server"); + +describe("readValidDirectory (path traversal)", () => { + it("returns true when directory is exactly BASE_PATH", () => { + expect(readValidDirectory(BASE)).toBe(true); + expect(readValidDirectory(path.resolve(BASE))).toBe(true); + }); + + it("returns true when directory is under BASE_PATH", () => { + expect(readValidDirectory(`${BASE}/logs`)).toBe(true); + expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true); + expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true); + }); + + it("returns false for path traversal escaping base (absolute)", () => { + expect(readValidDirectory("/etc/passwd")).toBe(false); + expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false); + expect(readValidDirectory("/tmp/outside")).toBe(false); + }); + + it("returns false when resolved path escapes base via ..", () => { + // Resolved: /etc/passwd (outside /base) + expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false); + expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false); + expect(readValidDirectory(`${BASE}/..`)).toBe(false); + }); + + it("returns true when .. stays within base", () => { + // e.g. /base/logs/../applications -> /base/applications (still under /base) + expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true); + expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true); + }); + + it("accepts serverId for remote base path", () => { + // With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw + expect(readValidDirectory(BASE, "server-1")).toBe(true); + expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false); + }); + + it("returns false for null/undefined-like paths that resolve outside", () => { + // Paths that might resolve to cwd or root + expect(readValidDirectory(".")).toBe(false); + expect(readValidDirectory("..")).toBe(false); + }); + + it("returns true for BASE_PATH with trailing slash or double slashes under base", () => { + expect(readValidDirectory(`${BASE}/`)).toBe(true); + expect(readValidDirectory(`${BASE}//logs`)).toBe(true); + expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true); + }); + + it("returns false when path looks like base but is a sibling or prefix", () => { + expect(readValidDirectory("/base-evil")).toBe(false); + expect(readValidDirectory("/bas")).toBe(false); + expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false); + }); + + it("returns false for empty string (resolves to cwd)", () => { + expect(readValidDirectory("")).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/wss/utils.test.ts b/apps/dokploy/__test__/wss/utils.test.ts new file mode 100644 index 000000000..209bd5f86 --- /dev/null +++ b/apps/dokploy/__test__/wss/utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + isValidContainerId, + isValidSearch, + isValidSince, + isValidTail, +} from "../../server/wss/utils"; + +describe("isValidTail (docker-container-logs)", () => { + it("accepts valid numeric tail values", () => { + expect(isValidTail("0")).toBe(true); + expect(isValidTail("1")).toBe(true); + expect(isValidTail("100")).toBe(true); + expect(isValidTail("10000")).toBe(true); + }); + + it("rejects tail above 10000", () => { + expect(isValidTail("10001")).toBe(false); + expect(isValidTail("99999")).toBe(false); + }); + + it("rejects non-numeric tail", () => { + expect(isValidTail("")).toBe(false); + expect(isValidTail("abc")).toBe(false); + expect(isValidTail("10a")).toBe(false); + expect(isValidTail("-1")).toBe(false); + }); + + it("rejects command injection payloads in tail", () => { + expect(isValidTail("10; whoami; #")).toBe(false); + expect(isValidTail("100 | cat /etc/passwd")).toBe(false); + expect(isValidTail("$(id)")).toBe(false); + expect(isValidTail("`id`")).toBe(false); + expect(isValidTail("100\nid")).toBe(false); + expect(isValidTail("100 && id")).toBe(false); + expect(isValidTail("100; env | grep DATABASE")).toBe(false); + }); +}); + +describe("isValidSince (docker-container-logs)", () => { + it("accepts 'all'", () => { + expect(isValidSince("all")).toBe(true); + }); + + it("accepts valid duration format (number + s|m|h|d)", () => { + expect(isValidSince("5s")).toBe(true); + expect(isValidSince("10m")).toBe(true); + expect(isValidSince("1h")).toBe(true); + expect(isValidSince("2d")).toBe(true); + expect(isValidSince("0s")).toBe(true); + expect(isValidSince("999d")).toBe(true); + }); + + it("rejects invalid duration format", () => { + expect(isValidSince("")).toBe(false); + expect(isValidSince("5")).toBe(false); + expect(isValidSince("s")).toBe(false); + expect(isValidSince("5x")).toBe(false); + expect(isValidSince("5sec")).toBe(false); + expect(isValidSince("5 m")).toBe(false); + }); + + it("rejects command injection payloads in since", () => { + expect(isValidSince("5s; whoami")).toBe(false); + expect(isValidSince("all; id")).toBe(false); + expect(isValidSince("1m$(id)")).toBe(false); + expect(isValidSince("1m | cat /etc/passwd")).toBe(false); + }); +}); + +describe("isValidSearch (docker-container-logs)", () => { + it("accepts empty string", () => { + expect(isValidSearch("")).toBe(true); + }); + + it("accepts only alphanumeric, space, dot, underscore, hyphen", () => { + expect(isValidSearch("error")).toBe(true); + expect(isValidSearch("foo bar")).toBe(true); + expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true); + expect(isValidSearch("")).toBe(true); + }); + + it("rejects strings longer than 500 chars", () => { + expect(isValidSearch("a".repeat(501))).toBe(false); + expect(isValidSearch("a".repeat(500))).toBe(true); + }); + + it("rejects control characters and non-printable", () => { + expect(isValidSearch("foo\nbar")).toBe(false); + expect(isValidSearch("foo\rbar")).toBe(false); + expect(isValidSearch("\x00")).toBe(false); + expect(isValidSearch("a\x19b")).toBe(false); + }); + + it("rejects command injection vectors in search (search is concatenated into shell)", () => { + // Double-quoted context (SSH line 99): $ and ` execute + expect(isValidSearch("$(whoami)")).toBe(false); + expect(isValidSearch("`id`")).toBe(false); + expect(isValidSearch("$(id)")).toBe(false); + // Single-quoted context (local line 153): ' breaks out + expect(isValidSearch("'$(whoami)'")).toBe(false); + expect(isValidSearch("error'")).toBe(false); + expect(isValidSearch("'; whoami; #")).toBe(false); + // Other shell-metacharacters + expect(isValidSearch("error; id")).toBe(false); + expect(isValidSearch("a|b")).toBe(false); + expect(isValidSearch('error"')).toBe(false); + expect(isValidSearch("a&b")).toBe(false); + }); +}); + +describe("isValidContainerId (docker-container-logs)", () => { + it("accepts valid hex container IDs", () => { + expect(isValidContainerId("a".repeat(12))).toBe(true); + expect(isValidContainerId("abc123def456")).toBe(true); + expect(isValidContainerId("a".repeat(64))).toBe(true); + }); + + it("accepts valid container names", () => { + expect(isValidContainerId("my-container")).toBe(true); + expect(isValidContainerId("app_1")).toBe(true); + expect(isValidContainerId("service.name")).toBe(true); + }); + + it("rejects command injection in container ID", () => { + expect(isValidContainerId("dummy; whoami")).toBe(false); + expect(isValidContainerId("$(id)")).toBe(false); + expect(isValidContainerId("`id`")).toBe(false); + expect(isValidContainerId("container|cat /etc/passwd")).toBe(false); + expect(isValidContainerId("x; env | grep DATABASE")).toBe(false); + }); +}); 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 c8a4616ca..21e3cf3de 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,184 +1,122 @@ -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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { + EndpointSpecForm, + HealthCheckForm, + LabelsForm, + ModeForm, + NetworkForm, + PlacementForm, + RestartPolicyForm, + RollbackConfigForm, + StopGracePeriodForm, + UpdateConfigForm, +} from "./swarm-forms"; -const HealthCheckSwarmSchema = z - .object({ - Test: z.array(z.string()).optional(), - Interval: z.number().optional(), - Timeout: z.number().optional(), - StartPeriod: z.number().optional(), - Retries: z.number().optional(), - }) - .strict(); - -const RestartPolicySwarmSchema = z - .object({ - Condition: z.string().optional(), - Delay: z.number().optional(), - MaxAttempts: z.number().optional(), - Window: z.number().optional(), - }) - .strict(); - -const PreferenceSchema = z - .object({ - Spread: z.object({ - SpreadDescriptor: z.string(), - }), - }) - .strict(); - -const PlatformSchema = z - .object({ - Architecture: z.string(), - OS: z.string(), - }) - .strict(); - -const PlacementSwarmSchema = z - .object({ - Constraints: z.array(z.string()).optional(), - Preferences: z.array(PreferenceSchema).optional(), - MaxReplicas: z.number().optional(), - Platforms: z.array(PlatformSchema).optional(), - }) - .strict(); - -const UpdateConfigSwarmSchema = z - .object({ - Parallelism: z.number(), - Delay: z.number().optional(), - FailureAction: z.string().optional(), - Monitor: z.number().optional(), - MaxFailureRatio: z.number().optional(), - Order: z.string(), - }) - .strict(); - -const ReplicatedSchema = z - .object({ - Replicas: z.number().optional(), - }) - .strict(); - -const ReplicatedJobSchema = z - .object({ - MaxConcurrent: z.number().optional(), - TotalCompletions: z.number().optional(), - }) - .strict(); - -const ServiceModeSwarmSchema = z - .object({ - Replicated: ReplicatedSchema.optional(), - Global: z.object({}).optional(), - ReplicatedJob: ReplicatedJobSchema.optional(), - GlobalJob: z.object({}).optional(), - }) - .strict(); - -const NetworkSwarmSchema = z.array( - z - .object({ - Target: z.string().optional(), - Aliases: z.array(z.string()).optional(), - DriverOpts: z.object({}).optional(), - }) - .strict(), -); - -const LabelsSwarmSchema = z.record(z.string()); - -const 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(), -}); +const menuItems: MenuItem[] = [ + { + id: "health-check", + label: "Health Check", + description: "Configure health check settings", + docDescription: + "Configure HEALTHCHECK to test a container's health. Determines if a container is healthy by running a command inside the container. Test, Interval, Timeout, StartPeriod, and Retries control health monitoring.", + }, + { + id: "restart-policy", + label: "Restart Policy", + description: "Configure restart policy", + docDescription: + "Configure the restart policy for containers in the service. Condition (none, on-failure, any), Delay (nanoseconds between restarts), MaxAttempts, and Window control restart behavior.", + }, + { + id: "placement", + label: "Placement", + description: "Configure placement constraints", + docDescription: + "Control which nodes service tasks can be scheduled on. Constraints (node.id==xyz), Preferences (spread.node.labels.zone), MaxReplicas, and Platforms specify task placement rules.", + }, + { + id: "update-config", + label: "Update Config", + description: "Configure update strategy", + docDescription: + "Configure how the service should be updated. Parallelism (tasks updated simultaneously), Delay, FailureAction (pause, continue, rollback), Monitor, MaxFailureRatio, and Order (stop-first, start-first) control updates.", + }, + { + id: "rollback-config", + label: "Rollback Config", + description: "Configure rollback strategy", + docDescription: + "Configure automated rollback on update failure. Uses same parameters as UpdateConfig: Parallelism, Delay, FailureAction, Monitor, MaxFailureRatio, and Order.", + }, + { + id: "mode", + label: "Mode", + description: "Configure service mode", + docDescription: + "Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).", + }, + { + id: "network", + label: "Network", + description: "Configure network attachments", + docDescription: + "Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.", + }, + { + id: "labels", + label: "Labels", + description: "Configure service labels", + docDescription: + "Add metadata to services using labels. Labels are key-value pairs (e.g., com.example.foo=bar) for organizing and filtering services.", + }, + { + id: "stop-grace-period", + label: "Stop Grace Period", + description: "Configure stop grace period", + docDescription: + "Time to wait before forcefully killing a container. Specified in nanoseconds (e.g., 10000000000 = 10 seconds). Allows containers to shutdown gracefully.", + }, + { + id: "endpoint-spec", + label: "Endpoint Spec", + description: "Configure endpoint specification", + docDescription: + "Configure endpoint mode for service discovery. Mode 'vip' (virtual IP - default) uses a single virtual IP. Mode 'dnsrr' (DNS round-robin) returns DNS entries for all tasks.", + }, +]; -type AddSwarmSettings = z.infer; +const hasStopGracePeriodSwarm = ( + value: unknown, +): value is { stopGracePeriodSwarm: bigint | number | string | null } => + typeof value === "object" && + value !== null && + "stopGracePeriodSwarm" in value; interface Props { id: string; @@ -294,22 +232,23 @@ export const AddSwarmSettings = ({ id, type }: Props) => { 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, @@ -317,485 +256,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
-
- - ( - - Health Check - - - - - Check the interface - - - - + {/* Left Column - Menu */} +
+ +
- - - -
-										
-									
-
- )} - /> - - ( - - Restart Policy - - - - - Check the interface - - - - - -
-														{`{
-	Condition?: string | undefined;
-	Delay?: number | undefined;
-	MaxAttempts?: number | undefined;
-	Window?: number | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Placement - - - - - Check the interface - - - - - -
-														{`{
-	Constraints?: string[] | undefined;
-	Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
-	MaxReplicas?: number | undefined;
-	Platforms?:
-		| Array<{
-				Architecture: string;
-				OS: string;
-		  }>
-		| undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Update Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Rollback Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Mode - - - - - Check the interface - - - - - -
-														{`{
-	Replicated?: { Replicas?: number | undefined } | undefined;
-	Global?: {} | undefined;
-	ReplicatedJob?:
-		| {
-				MaxConcurrent?: number | undefined;
-				TotalCompletions?: number | undefined;
-		  }
-		| undefined;
-	GlobalJob?: {} | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - ( - - Network - - - - - Check the interface - - - - - -
-														{`[
-  {
-	"Target" : string | undefined;
-	"Aliases" : string[] | undefined;
-	"DriverOpts" : { [key: string]: string } | undefined;
-  }
-]`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - ( - - Labels - - - - - Check the interface - - - - - -
-														{`{
-	[name: string]: string;
-}`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - - - - - - + {/* Right Column - Form */} +
+ {activeMenu === "health-check" && ( + + )} + {activeMenu === "restart-policy" && ( + + )} + {activeMenu === "placement" && ( + + )} + {activeMenu === "update-config" && ( + + )} + {activeMenu === "rollback-config" && ( + + )} + {activeMenu === "mode" && } + {activeMenu === "network" && } + {activeMenu === "labels" && } + {activeMenu === "stop-grace-period" && ( + + )} + {activeMenu === "endpoint-spec" && ( + + )} +
+
); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index 4b0de9586..02db06345 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Server } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -74,7 +74,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { redis: () => api.redis.update.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); @@ -237,7 +237,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { )}
-
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..6d95634be --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx @@ -0,0 +1,154 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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..f62037fca --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -0,0 +1,270 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +export const healthCheckFormSchema = z.object({ + Test: z.array(z.string()).optional(), + Interval: z.coerce.number().optional(), + Timeout: z.coerce.number().optional(), + StartPeriod: z.coerce.number().optional(), + Retries: z.coerce.number().optional(), +}); + +interface HealthCheckFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { + const [isLoading, setIsLoading] = useState(false); + + const queryMap = { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + }; + const { data, refetch } = queryMap[type] + ? queryMap[type]() + : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + + const mutationMap = { + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + application: () => api.application.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + }; + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.mongo.update.useMutation(); + + const form = useForm({ + resolver: zodResolver(healthCheckFormSchema), + defaultValues: { + Test: [], + Interval: undefined, + Timeout: undefined, + StartPeriod: undefined, + Retries: undefined, + }, + }); + + const testCommands = form.watch("Test") || []; + + useEffect(() => { + if (data?.healthCheckSwarm) { + const hc = data.healthCheckSwarm; + form.reset({ + Test: hc.Test || [], + Interval: hc.Interval, + Timeout: hc.Timeout, + StartPeriod: hc.StartPeriod, + Retries: hc.Retries, + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = + (formData.Test && formData.Test.length > 0) || + formData.Interval !== undefined || + formData.Timeout !== undefined || + formData.StartPeriod !== undefined || + formData.Retries !== undefined; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + healthCheckSwarm: hasAnyValue ? formData : null, + }); + + toast.success("Health check updated successfully"); + refetch(); + } catch { + toast.error("Error updating health check"); + } finally { + setIsLoading(false); + } + }; + + const addTestCommand = () => { + form.setValue("Test", [...testCommands, ""]); + }; + + const updateTestCommand = (index: number, value: string) => { + const newCommands = [...testCommands]; + newCommands[index] = value; + form.setValue("Test", newCommands); + }; + + const removeTestCommand = (index: number) => { + form.setValue( + "Test", + testCommands.filter((_: string, i: number) => i !== index), + ); + }; + + return ( +
+ +
+ Test Commands + + Command to run for health check (e.g., ["CMD-SHELL", "curl -f + http://localhost:3000/health"]) + +
+ {testCommands.map((cmd: string, index: number) => ( +
+ updateTestCommand(index, e.target.value)} + placeholder={ + index === 0 + ? "CMD-SHELL" + : "curl -f http://localhost:3000/health" + } + /> + +
+ ))} + +
+
+ + ( + + Interval (nanoseconds) + + Time between health checks (e.g., 10000000000 for 10 seconds) + + + + + + + )} + /> + + ( + + Timeout (nanoseconds) + + Maximum time to wait for health check response + + + + + + + )} + /> + + ( + + Start Period (nanoseconds) + + Initial grace period before health checks begin + + + + + + + )} + /> + + ( + + Retries + + Number of consecutive failures needed to consider container + unhealthy + + + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts new file mode 100644 index 000000000..df972102d --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -0,0 +1,11 @@ +export { EndpointSpecForm } from "./endpoint-spec-form"; +export { HealthCheckForm } from "./health-check-form"; +export { LabelsForm } from "./labels-form"; +export { ModeForm } from "./mode-form"; +export { NetworkForm } from "./network-form"; +export { PlacementForm } from "./placement-form"; +export { RestartPolicyForm } from "./restart-policy-form"; +export { RollbackConfigForm } from "./rollback-config-form"; +export { StopGracePeriodForm } from "./stop-grace-period-form"; +export { UpdateConfigForm } from "./update-config-form"; +export { filterEmptyValues, hasValues } from "./utils"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx new file mode 100644 index 000000000..41ce741ae --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx @@ -0,0 +1,200 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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..a6885a7e4 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx @@ -0,0 +1,202 @@ +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 !== undefined && formData.Replicas !== "" + ? Number(formData.Replicas) + : undefined, + }, + } + : { Global: {} }; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + modeSwarm: modeData, + }); + + toast.success("Mode updated successfully"); + refetch(); + } catch { + toast.error("Error updating mode"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + ( + + Mode Type + + Choose between replicated or global service mode + + + + + )} + /> + + {modeType === "Replicated" && ( + ( + + Replicas + Number of replicas to run + + + + + + )} + /> + )} + +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx new file mode 100644 index 000000000..7d6ebbaf3 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -0,0 +1,313 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const driverOptEntrySchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const networkFormSchema = z.object({ + networks: z + .array( + z.object({ + Target: z.string().optional(), + Aliases: z.string().optional(), + DriverOptsEntries: z.array(driverOptEntrySchema).optional(), + }), + ) + .optional(), +}); + +interface NetworkFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const NetworkForm = ({ id, type }: NetworkFormProps) => { + const [isLoading, setIsLoading] = useState(false); + + const queryMap = { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + }; + const { data, refetch } = queryMap[type] + ? queryMap[type]() + : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + + const mutationMap = { + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + application: () => api.application.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + }; + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.mongo.update.useMutation(); + + const form = useForm>({ + resolver: zodResolver(networkFormSchema), + defaultValues: { + networks: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "networks", + }); + + useEffect(() => { + if (data?.networkSwarm && Array.isArray(data.networkSwarm)) { + const networkEntries = data.networkSwarm.map((network) => ({ + Target: network.Target || "", + Aliases: network.Aliases?.join(", ") || "", + DriverOptsEntries: network.DriverOpts + ? Object.entries(network.DriverOpts).map(([key, value]) => ({ + key, + value: value ?? "", + })) + : [], + })); + form.reset({ networks: networkEntries }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + const networksArray = + formData.networks + ?.filter((network) => network.Target) + .map((network) => { + const entries = (network.DriverOptsEntries ?? []).filter( + (e) => e.key.trim() !== "", + ); + const driverOpts = + entries.length > 0 + ? Object.fromEntries( + entries.map((e) => [e.key.trim(), e.value]), + ) + : undefined; + return { + Target: network.Target, + Aliases: network.Aliases + ? network.Aliases.split(",").map((alias) => alias.trim()) + : undefined, + DriverOpts: driverOpts, + }; + }) || []; + + // If no networks, send null to clear the database + const networksToSend = networksArray.length > 0 ? networksArray : null; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + networkSwarm: networksToSend, + }); + + toast.success("Network configuration updated successfully"); + refetch(); + } catch { + toast.error("Error updating network configuration"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ Networks + + Configure network attachments for your service + +
+ {fields.map((field, index) => ( +
+ ( + + Network Name + + + + + The name of the network to attach to + + + + )} + /> + ( + + Aliases (optional) + + + + + Comma-separated list of network aliases + + + + )} + /> +
+ Driver options (optional) + + e.g. com.docker.network.driver.mtu, + com.docker.network.driver.host_binding + + {( + form.watch(`networks.${index}.DriverOptsEntries`) ?? [] + ).map((_, optIndex) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+ +
+ ))} + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx new file mode 100644 index 000000000..b4091aac0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -0,0 +1,347 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const PreferenceSchema = z.object({ + SpreadDescriptor: z.string(), +}); + +const PlatformSchema = z.object({ + Architecture: z.string(), + OS: z.string(), +}); + +export const placementFormSchema = z.object({ + Constraints: z.array(z.string()).optional(), + Preferences: z.array(PreferenceSchema).optional(), + MaxReplicas: z.coerce.number().optional(), + Platforms: z.array(PlatformSchema).optional(), +}); + +interface PlacementFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const PlacementForm = ({ id, type }: PlacementFormProps) => { + const [isLoading, setIsLoading] = useState(false); + + const queryMap = { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + }; + const { data, refetch } = queryMap[type] + ? queryMap[type]() + : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + + const mutationMap = { + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + application: () => api.application.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + }; + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.mongo.update.useMutation(); + + const form = useForm({ + resolver: zodResolver(placementFormSchema), + defaultValues: { + Constraints: [], + Preferences: [], + MaxReplicas: undefined, + Platforms: [], + }, + }); + + const constraints = form.watch("Constraints") || []; + const preferences = form.watch("Preferences") || []; + const platforms = form.watch("Platforms") || []; + + useEffect(() => { + if (data?.placementSwarm) { + const placement = data.placementSwarm; + form.reset({ + Constraints: placement.Constraints || [], + Preferences: + placement.Preferences?.map((p: any) => ({ + SpreadDescriptor: p.Spread?.SpreadDescriptor || "", + })) || [], + MaxReplicas: placement.MaxReplicas, + Platforms: placement.Platforms || [], + }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + // Check if all values are empty, if so, send null to clear the database + const hasAnyValue = + (formData.Constraints && formData.Constraints.length > 0) || + (formData.Preferences && formData.Preferences.length > 0) || + (formData.Platforms && formData.Platforms.length > 0) || + formData.MaxReplicas !== undefined; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + placementSwarm: hasAnyValue + ? { + ...formData, + Preferences: formData.Preferences?.map((p) => ({ + Spread: { SpreadDescriptor: p.SpreadDescriptor }, + })), + } + : null, + }); + + toast.success("Placement updated successfully"); + refetch(); + } catch { + toast.error("Error updating placement"); + } finally { + setIsLoading(false); + } + }; + + const addConstraint = () => { + form.setValue("Constraints", [...constraints, ""]); + }; + + const updateConstraint = (index: number, value: string) => { + const newConstraints = [...constraints]; + newConstraints[index] = value; + form.setValue("Constraints", newConstraints); + }; + + const removeConstraint = (index: number) => { + form.setValue( + "Constraints", + constraints.filter((_: string, i: number) => i !== index), + ); + }; + + const addPreference = () => { + form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]); + }; + + const updatePreference = (index: number, value: string) => { + const newPreferences = [...preferences]; + if (newPreferences[index]) { + newPreferences[index].SpreadDescriptor = value; + form.setValue("Preferences", newPreferences); + } + }; + + const removePreference = (index: number) => { + form.setValue( + "Preferences", + preferences.filter((_: any, i: number) => i !== index), + ); + }; + + const addPlatform = () => { + form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]); + }; + + const updatePlatform = ( + index: number, + field: "Architecture" | "OS", + value: string, + ) => { + const newPlatforms = [...platforms]; + if (newPlatforms[index]) { + newPlatforms[index][field] = value; + form.setValue("Platforms", newPlatforms); + } + }; + + const removePlatform = (index: number) => { + form.setValue( + "Platforms", + platforms.filter((_: any, i: number) => i !== index), + ); + }; + + return ( +
+ +
+ Constraints + + Placement constraints (e.g., "node.role==manager") + +
+ {constraints.map((constraint: string, index: number) => ( +
+ updateConstraint(index, e.target.value)} + placeholder="node.role==manager" + /> + +
+ ))} + +
+
+ +
+ Preferences + + Spread preferences for task distribution (e.g., + "node.labels.region") + +
+ {preferences.map((pref: any, index: number) => ( +
+ updatePreference(index, e.target.value)} + placeholder="node.labels.region" + /> + +
+ ))} + +
+
+ + ( + + Max Replicas + + Maximum number of replicas per node + + + + + + + )} + /> + +
+ Platforms + + Target platforms for task scheduling + +
+ {platforms.map((platform: any, index: number) => ( +
+ + updatePlatform(index, "Architecture", e.target.value) + } + placeholder="amd64" + /> + updatePlatform(index, "OS", e.target.value)} + placeholder="linux" + /> + +
+ ))} + +
+
+ +
+ + +
+ + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx new file mode 100644 index 000000000..db7be5629 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx @@ -0,0 +1,219 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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..528b9d1cc --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx @@ -0,0 +1,257 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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..af2d826db --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx @@ -0,0 +1,264 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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..602e6877d 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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; @@ -42,27 +50,35 @@ export const AddCommand = ({ applicationId }: Props) => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = api.application.update.useMutation(); + const { mutateAsync, isPending } = api.application.update.useMutation(); 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,16 +116,68 @@ 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/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx index 17d033cf2..7b1614fda 100644 --- a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Code2, Globe2, HardDrive } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => { } | null>(null); const utils = api.useUtils(); - const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } = + const { mutateAsync: processTemplate, isPending: isLoadingTemplate } = api.compose.processTemplate.useMutation(); const { mutateAsync: importTemplate, - isLoading: isImporting, + isPending: isImporting, isSuccess: isImportSuccess, } = api.compose.import.useMutation(); diff --git a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx index 568792461..91570d2db 100644 --- a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; @@ -35,13 +35,9 @@ import { api } from "@/utils/api"; const AddPortSchema = z.object({ publishedPort: z.number().int().min(1).max(65535), - publishMode: z.enum(["ingress", "host"], { - required_error: "Publish mode is required", - }), + publishMode: z.enum(["ingress", "host"]), targetPort: z.number().int().min(1).max(65535), - protocol: z.enum(["tcp", "udp"], { - required_error: "Protocol is required", - }), + protocol: z.enum(["tcp", "udp"]), }); type AddPort = z.infer; @@ -68,7 +64,7 @@ export const HandlePorts = ({ enabled: !!portId, }, ); - const { mutateAsync, isLoading, error, isError } = portId + const { mutateAsync, isPending, error, isError } = portId ? api.port.update.useMutation() : api.port.create.useMutation(); @@ -270,7 +266,7 @@ export const HandlePorts = ({ + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index 7f69760c5..5d86e78c3 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -1,7 +1,7 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { InfoIcon } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -22,6 +22,17 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + createConverter, + NumberInputWithSteps, +} from "@/components/ui/number-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tooltip, TooltipContent, @@ -30,13 +41,53 @@ 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 ulimitSchema = z.object({ + Name: z.string().min(1, "Name is required"), + Soft: z.coerce.number().int().min(-1, "Must be >= -1"), + Hard: z.coerce.number().int().min(-1, "Must be >= -1"), +}); + const addResourcesSchema = z.object({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: z.string().optional(), cpuReservation: z.string().optional(), + ulimitsSwarm: z.array(ulimitSchema).optional(), }); +const ULIMIT_PRESETS = [ + { value: "nofile", label: "nofile (Open Files)" }, + { value: "nproc", label: "nproc (Processes)" }, + { value: "memlock", label: "memlock (Locked Memory)" }, + { value: "stack", label: "stack (Stack Size)" }, + { value: "core", label: "core (Core File Size)" }, + { value: "cpu", label: "cpu (CPU Time)" }, + { value: "data", label: "data (Data Segment)" }, + { value: "fsize", label: "fsize (File Size)" }, + { value: "locks", label: "locks (File Locks)" }, + { value: "msgqueue", label: "msgqueue (Message Queues)" }, + { value: "nice", label: "nice (Nice Priority)" }, + { value: "rtprio", label: "rtprio (Real-time Priority)" }, + { value: "sigpending", label: "sigpending (Pending Signals)" }, +]; + export type ServiceType = | "application" | "libsql" @@ -52,6 +103,7 @@ interface Props { } type AddResources = z.infer; + export const ShowResources = ({ id, type }: Props) => { const queryMap = { application: () => @@ -79,20 +131,26 @@ export const ShowResources = ({ id, type }: Props) => { redis: () => api.redis.update.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { cpuLimit: "", cpuReservation: "", memoryLimit: "", memoryReservation: "", + ulimitsSwarm: [], }, resolver: zodResolver(addResourcesSchema), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "ulimitsSwarm", + }); + useEffect(() => { if (data) { form.reset({ @@ -100,6 +158,7 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: data?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); @@ -117,6 +176,10 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, memoryReservation: formData.memoryReservation || null, + ulimitsSwarm: + formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0 + ? formData.ulimitsSwarm + : null, }) .then(async () => { toast.success("Resources Updated"); @@ -154,7 +217,10 @@ export const ShowResources = ({ id, type }: Props) => { render={({ field }) => { return ( -
+
e.preventDefault()} + > Memory Limit @@ -164,16 +230,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.

- @@ -186,7 +256,10 @@ export const ShowResources = ({ id, type }: Props) => { name="memoryReservation" render={({ field }) => ( -
+
e.preventDefault()} + > Memory Reservation @@ -196,16 +269,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.

- @@ -219,7 +296,10 @@ export const ShowResources = ({ id, type }: Props) => { render={({ field }) => { return ( -
+
e.preventDefault()} + > CPU Limit @@ -229,17 +309,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.

- @@ -253,7 +336,10 @@ export const ShowResources = ({ id, type }: Props) => { render={({ field }) => { return ( -
+
e.preventDefault()} + > CPU Reservation @@ -263,14 +349,21 @@ export const ShowResources = ({ id, type }: Props) => {

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

- + @@ -278,8 +371,157 @@ export const ShowResources = ({ id, type }: Props) => { }} />
+ + {/* Ulimits Section */} +
+
+
+ Ulimits + + + + + + +

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

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

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

+ )} +
+
-
diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index ae23f1866..94efbc285 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -15,13 +15,17 @@ interface Props { } export const ShowTraefikConfig = ({ applicationId }: Props) => { - const { data, isLoading } = api.application.readTraefikConfig.useQuery( + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.traefikFiles.read ?? false; + const { data, isPending } = api.application.readTraefikConfig.useQuery( { applicationId, }, - { enabled: !!applicationId }, + { enabled: !!applicationId && canRead }, ); + if (!canRead) return null; + return ( @@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
- {isLoading ? ( + {isPending ? ( Loading... diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx index bf3d5d9bc..b3646803c 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -7,6 +7,7 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -24,6 +25,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; const UpdateTraefikConfigSchema = z.object({ @@ -58,7 +60,10 @@ export const validateAndFormatYAML = (yamlText: string) => { }; export const UpdateTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.traefikFiles.write ?? false; const [open, setOpen] = useState(false); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( { applicationId, @@ -66,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { { enabled: !!applicationId }, ); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.application.updateTraefikConfig.useMutation(); const form = useForm({ @@ -85,13 +90,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { }, [data]); const onSubmit = async (data: UpdateTraefikConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: (error as string) || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: (error as string) || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -116,12 +123,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { setOpen(open); if (!open) { form.reset(); + setSkipYamlValidation(false); } }} > - - - + {canWrite && ( + + + + )} Update traefik config @@ -169,9 +179,30 @@ routers:
- + +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

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

+
- {data && data?.mounts.length > 0 && ( + {canCreate && data && data?.mounts.length > 0 && ( Add Volume @@ -65,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => { No volumes/mounts configured - - Add Volume - + {canCreate && ( + + Add Volume + + )} ) : (
@@ -132,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
- - { - await deleteVolume({ - mountId: mount.mountId, - }) - .then(() => { - refetch(); - toast.success("Volume deleted successfully"); + {canCreate && ( + + )} + {canDelete && ( + { + await deleteVolume({ + mountId: mount.mountId, }) - .catch(() => { - toast.error("Error deleting volume"); - }); - }} - > - - + + + )}
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 a75292b07..882123efb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -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 @@ -87,7 +93,7 @@ export const UpdateVolume = ({ }, ); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.mounts.update.useMutation(); const form = useForm({ @@ -181,7 +187,7 @@ export const UpdateVolume = ({ variant="ghost" size="icon" className="group hover:bg-blue-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -304,7 +310,7 @@ PORT=3000 + + ) : ( + + )} + + + Select a Railpack version or choose manual to enter a + custom version.{" "} +
+ View releases + + + + + )} + /> + )}
-
diff --git a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx index e957a496c..ed1373aa0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx @@ -1,4 +1,4 @@ -import { Paintbrush } from "lucide-react"; +import { Ban } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, @@ -20,7 +20,7 @@ interface Props { } export const CancelQueues = ({ id, type }: Props) => { - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = type === "application" ? api.application.cleanQueues.useMutation() : api.compose.cleanQueues.useMutation(); @@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx new file mode 100644 index 000000000..81f998a9d --- /dev/null +++ b/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx @@ -0,0 +1,73 @@ +import { Paintbrush } from "lucide-react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; + +interface Props { + id: string; + type: "application" | "compose"; +} + +export const ClearDeployments = ({ id, type }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, isPending } = + type === "application" + ? api.application.clearDeployments.useMutation() + : api.compose.clearDeployments.useMutation(); + + return ( + + + + + + + + Are you sure you want to clear old deployments? + + + This will delete all old deployment records and logs, keeping only + the active deployment (the most recent successful one). + + + + Cancel + { + await mutateAsync({ + applicationId: id || "", + composeId: id || "", + }) + .then(async () => { + toast.success("Old deployments cleared successfully"); + await utils.deployment.allByType.invalidate({ + id, + type: type as "application" | "compose", + }); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + Confirm + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx new file mode 100644 index 000000000..ad5e9b058 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx @@ -0,0 +1,65 @@ +import { Scissors } from "lucide-react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; + +interface Props { + id: string; + type: "application" | "compose"; +} + +export const KillBuild = ({ id, type }: Props) => { + const { mutateAsync, isPending } = + type === "application" + ? api.application.killBuild.useMutation() + : api.compose.killBuild.useMutation(); + + return ( + + + + + + + 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-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 69c697721..4285f04c4 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -1,6 +1,8 @@ -import { Loader2 } from "lucide-react"; +import copy from "copy-to-clipboard"; +import { Check, Copy, Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -29,9 +31,10 @@ export const ShowDeployment = ({ const [data, setData] = useState(""); const [showExtraLogs, setShowExtraLogs] = useState(false); const [filteredLogs, setFilteredLogs] = useState([]); - const wsRef = useRef(null); // Ref to hold WebSocket instance + const wsRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const scrollRef = useRef(null); + const [copied, setCopied] = useState(false); const scrollToBottom = () => { if (autoScroll && scrollRef.current) { @@ -106,6 +109,20 @@ export const ShowDeployment = ({ } }, [filteredLogs, autoScroll]); + const handleCopy = () => { + const logContent = filteredLogs + .map(({ timestamp, message }: LogLine) => + `${timestamp?.toISOString() || ""} ${message}`.trim(), + ) + .join("\n"); + + const success = copy(logContent); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + const optionalErrors = parseLogs(errorMessage || ""); return ( @@ -128,13 +145,27 @@ export const ShowDeployment = ({ Deployment - + See all the details of this deployment |{" "} {filteredLogs.length} lines + + {serverId && (
0 ? ( filteredLogs.map((log: LogLine, index: number) => ( - + )) ) : ( <> {optionalErrors.length > 0 ? ( optionalErrors.map((log: LogLine, index: number) => ( - + )) ) : (
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1045856c2..3cecef1ec 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,6 +1,17 @@ -import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + Clock, + Copy, + Loader2, + RefreshCcw, + RocketIcon, + Settings, + Trash2, +} from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import copy from "copy-to-clipboard"; import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -17,6 +28,8 @@ import { import { api, type RouterOutputs } from "@/utils/api"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; import { CancelQueues } from "./cancel-queues"; +import { ClearDeployments } from "./clear-deployments"; +import { KillBuild } from "./kill-build"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; @@ -50,7 +63,7 @@ export const ShowDeployments = ({ const [activeLog, setActiveLog] = useState< RouterOutputs["deployment"]["all"][number] | null >(null); - const { data: deployments, isLoading: isLoadingDeployments } = + const { data: deployments, isPending: isLoadingDeployments } = api.deployment.allByType.useQuery( { id, @@ -64,22 +77,47 @@ export const ShowDeployments = ({ const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync: rollback, isLoading: isRollingBack } = + const { mutateAsync: rollback, isPending: isRollingBack } = api.rollback.rollback.useMutation(); - const { mutateAsync: killProcess, isLoading: isKillingProcess } = + const { mutateAsync: killProcess, isPending: isKillingProcess } = api.deployment.killProcess.useMutation(); + const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + api.deployment.removeDeployment.useMutation(); // Cancel deployment mutations const { mutateAsync: cancelApplicationDeployment, - isLoading: isCancellingApp, + isPending: isCancellingApp, } = api.application.cancelDeployment.useMutation(); const { mutateAsync: cancelComposeDeployment, - isLoading: isCancellingCompose, + isPending: isCancellingCompose, } = api.compose.cancelDeployment.useMutation(); const [url, setUrl] = React.useState(""); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set(), + ); + + const webhookUrl = useMemo( + () => + `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + [url, refreshToken, type], + ); + + 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 +155,13 @@ export const ShowDeployments = ({ See the last 10 deployments for this {type}
-
+
+ {(type === "application" || type === "compose") && ( + + )} + {(type === "application" || type === "compose") && ( + + )} {(type === "application" || type === "compose") && ( )} @@ -188,11 +232,27 @@ export const ShowDeployments = ({
Webhook URL:
- - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(webhookUrl); + toast.success("Copied to clipboard."); + } + }} + onClick={() => { + copy(webhookUrl); + toast.success("Copied to clipboard."); + }} + > + {webhookUrl} + + {(type === "application" || type === "compose") && ( )} @@ -217,122 +277,212 @@ 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, + ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; + + 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"); }); }} > )} + + + {canDelete && ( + { + try { + await removeDeployment({ + deploymentId: deployment.deploymentId, + }); + toast.success("Deployment deleted successfully"); + } catch (error) { + toast.error("Error deleting deployment"); + } + }} + > + + + )} + + {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..00eb62272 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -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(), @@ -153,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }, ); - const { mutateAsync, isError, error, isLoading } = domainId + const { mutateAsync, isError, error, isPending } = domainId ? api.domain.update.useMutation() : api.domain.create.useMutation(); - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = @@ -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) { @@ -232,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { domainType: type, }); } - }, [form, data, isLoading, domainId]); + }, [form, data, isPending, domainId]); // Separate effect for handling custom cert resolver validation useEffect(() => { @@ -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
@@ -708,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { - diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 1fd3d82e9..06428ae21 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -50,6 +50,9 @@ interface Props { } export const ShowDomains = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canCreateDomain = permissions?.domain.create ?? false; + const canDeleteDomain = permissions?.domain.delete ?? false; const { data: application } = type === "application" ? api.application.one.useQuery( @@ -97,7 +100,7 @@ export const ShowDomains = ({ id, type }: Props) => { const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); - const { mutateAsync: deleteDomain, isLoading: isRemoving } = + const { mutateAsync: deleteDomain, isPending: isRemoving } = api.domain.delete.useMutation(); const handleValidateDomain = async (host: string) => { @@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
- {data && data?.length > 0 && ( + {canCreateDomain && data && data?.length > 0 && ( - -
+ {canCreateDomain && ( +
+ + + +
+ )}
) : (
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => { } /> )} - - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); + + + )} + {canDeleteDomain && ( + { + await deleteDomain({ + domainId: item.domainId, }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx index 1ae24fd4f..86f7a0dff 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { EyeIcon, EyeOffIcon } from "lucide-react"; import { type CSSProperties, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -36,6 +36,8 @@ interface Props { } export const ShowEnvironment = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; const queryMap = { compose: () => api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), @@ -62,7 +64,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); @@ -111,6 +113,21 @@ export const ShowEnvironment = ({ id, type }: Props) => { }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isPending]); + return (
@@ -173,25 +190,27 @@ PORT=3000 )} /> -
- {hasChanges && ( + {canWrite && ( +
+ {hasChanges && ( + + )} - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 78edb1aaa..fcfd81778 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -1,17 +1,27 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; 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; @@ -21,7 +31,9 @@ interface Props { } export const ShowEnvironment = ({ applicationId }: Props) => { - const { mutateAsync, isLoading } = + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; + const { mutateAsync, isPending } = api.application.saveEnvironment.useMutation(); const { data, refetch } = api.application.one.useQuery( @@ -37,6 +49,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => { defaultValues: { env: "", buildArgs: "", + buildSecrets: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); @@ -44,15 +58,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => { // Watch form values 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 || ""); + currentBuildArgs !== (data?.buildArgs || "") || + currentBuildSecrets !== (data?.buildSecrets || "") || + currentCreateEnvFile !== (data?.createEnvFile ?? true); useEffect(() => { if (data) { form.reset({ env: data.env || "", buildArgs: data.buildArgs || "", + buildSecrets: data.buildSecrets || "", + createEnvFile: data.createEnvFile ?? true, }); } }, [data, form]); @@ -61,6 +81,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => { mutateAsync({ env: formData.env, buildArgs: formData.buildArgs, + buildSecrets: formData.buildSecrets, + createEnvFile: formData.createEnvFile, applicationId, }) .then(async () => { @@ -76,9 +98,26 @@ export const ShowEnvironment = ({ applicationId }: Props) => { form.reset({ env: data?.env || "", buildArgs: data?.buildArgs || "", + buildSecrets: data?.buildSecrets || "", + createEnvFile: data?.createEnvFile ?? true, }); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isPending]); + return (
@@ -104,13 +143,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => { {data?.buildType === "dockerfile" && ( - Available only at build-time. See documentation  + Arguments are available only at build-time. See + documentation  @@ -122,21 +162,71 @@ export const ShowEnvironment = ({ applicationId }: Props) => { placeholder="NPM_TOKEN=xyz" /> )} -
- {hasChanges && ( - + )} + - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 6f6db5dd1..a4fab46d9 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -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"), @@ -73,15 +74,16 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { api.bitbucket.bitbucketProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingBitbucketProvider } = + const { mutateAsync, isPending: isSavingBitbucketProvider } = api.application.saveBitbucketProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { owner: "", repo: "", + slug: "", }, bitbucketId: "", branch: "", @@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { } = api.bitbucket.getBitbucketBranches.useQuery( { owner: repository?.owner, - repo: repository?.repo, + repo: repository?.slug || repository?.repo || "", bitbucketId, }, { - enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId, + enabled: + !!repository?.owner && + !!(repository?.slug || repository?.repo) && + !!bitbucketId, }, ); @@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { repository: { repo: data.bitbucketRepository || "", owner: data.bitbucketOwner || "", + slug: data.bitbucketRepositorySlug || "", }, buildPath: data.bitbucketBuildPath || "/", bitbucketId: data.bitbucketId || "", @@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { await mutateAsync({ bitbucketBranch: data.branch, bitbucketRepository: data.repository.repo, + bitbucketRepositorySlug: data.repository.slug || data.repository.repo, bitbucketOwner: data.repository.owner, bitbucketBuildPath: data.buildPath, bitbucketId: data.bitbucketId, @@ -150,7 +157,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules || false, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { form.setValue("repository", { owner: "", repo: "", + slug: "", }); form.setValue("branch", ""); }} @@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Repository {field.value.owner && field.value.repo && ( { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -255,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -271,6 +283,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { form.setValue("repository", { owner: repo.owner.username as string, repo: repo.name, + slug: repo.slug, }); form.setValue("branch", ""); }} @@ -320,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -337,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -403,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Watch Paths - -
- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx index fcdcf0a93..078271bca 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx index 00e18c2ab..583b865c5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { TrashIcon } from "lucide-react"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -24,10 +24,10 @@ interface Props { export const SaveDragNDrop = ({ applicationId }: Props) => { const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = api.application.dropDeployment.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(uploadFileSchema), }); @@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 61690e740..37a387bb5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => { const { data: sshKeys } = api.sshKey.all.useQuery(); const router = useRouter(); - const { mutateAsync, isLoading } = - api.application.saveGitProdiver.useMutation(); + const { mutateAsync, isPending } = + api.application.saveGitProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { branch: "", buildPath: "/", @@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => { Watch Paths - -

- ? -
+ +

@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {

-
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index 2198f4a97..02cae2c4a 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGiteaProvider } = + const { mutateAsync, isPending: isSavingGiteaProvider } = api.application.saveGiteaProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo: GiteaRepository) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -459,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { { - const newPaths = [...field.value]; + const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); field.onChange(newPaths); }} @@ -477,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { const input = e.currentTarget; const path = input.value.trim(); if (path) { - field.onChange([...field.value, path]); + field.onChange([...(field.value || []), path]); input.value = ""; } } @@ -494,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { ) as HTMLInputElement; const path = input.value.trim(); if (path) { - field.onChange([...field.value, path]); + field.onChange([...(field.value || []), path]); input.value = ""; } }} diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 9a4b92ce1..6bce2d243 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGithubProvider } = + const { mutateAsync, isPending: isSavingGithubProvider } = api.application.saveGithubProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); - const { data: repositories, isLoading: isLoadingRepositories } = + const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { githubId, @@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -455,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index cb7209f8a..b49a1658f 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo } from "react"; @@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGitlabProvider } = + const { mutateAsync, isPending: isSavingGitlabProvider } = api.application.saveGitlabProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
Repository - {field.value.owner && field.value.repo && ( + {field.value.gitlabPathNamespace && ( { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -444,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx index a60db800c..9a49b204e 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx @@ -36,13 +36,13 @@ interface Props { } export const ShowProviderForm = ({ applicationId }: Props) => { - const { data: githubProviders, isLoading: isLoadingGithub } = + const { data: githubProviders, isPending: isLoadingGithub } = api.github.githubProviders.useQuery(); - const { data: gitlabProviders, isLoading: isLoadingGitlab } = + const { data: gitlabProviders, isPending: isLoadingGitlab } = api.gitlab.gitlabProviders.useQuery(); - const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = + const { data: bitbucketProviders, isPending: isLoadingBitbucket } = api.bitbucket.bitbucketProviders.useQuery(); - const { data: giteaProviders, isLoading: isLoadingGitea } = + const { data: giteaProviders, isPending: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); const { data: application, refetch } = api.application.one.useQuery({ diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index 5387659ad..01fc9e84a 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -30,6 +30,9 @@ interface Props { export const ShowGeneralApplication = ({ applicationId }: Props) => { const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const canUpdateService = permissions?.service.create ?? false; const { data, refetch } = api.application.one.useQuery( { applicationId, @@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { { enabled: !!applicationId }, ); const { mutateAsync: update } = api.application.update.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.application.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.application.stop.useMutation(); const { mutateAsync: deploy } = api.application.deploy.useMutation(); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.application.reload.useMutation(); const { mutateAsync: redeploy } = api.application.redeploy.useMutation(); @@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { - { - await deploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application deployed successfully"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`, - ); + {canDeploy && ( + { + await deploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error deploying application"); - }); - }} - > - - - { - await reload({ - applicationId: applicationId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Application reloaded successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await reload({ + applicationId: applicationId, + appName: data?.appName || "", }) - .catch(() => { - toast.error("Error reloading application"); - }); - }} - > - - - { - await redeploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application rebuilt successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await redeploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error rebuilding application"); - }); - }} - > - - + + + )} - {data?.applicationStatus === "idle" ? ( + {canDeploy && data?.applicationStatus === "idle" ? ( { - ) : ( + ) : canDeploy ? ( { - )} + ) : null} { Open Terminal -
- Autodeploy - { - await update({ - applicationId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + applicationId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )} -
- Clean Cache - { - await update({ - applicationId, - cleanCache: enabled, - }) - .then(async () => { - toast.success("Clean Cache Updated"); - await refetch(); + {canUpdateService && ( +
+ Clean Cache + { + await update({ + applicationId, + cleanCache: enabled, }) - .catch(() => { - toast.error("Error updating Clean Cache"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Clean Cache Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Clean Cache"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index e5dff075e..cbb6bce09 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -34,6 +34,7 @@ export const DockerLogs = dynamic( export const badgeStateColor = (state: string) => { switch (state) { case "running": + case "ready": return "green"; case "exited": case "shutdown": @@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { const [containerId, setContainerId] = useState(); const [option, setOption] = useState<"swarm" | "native">("native"); - const { data: services, isLoading: servicesLoading } = + const { data: services, isPending: servicesLoading } = api.docker.getServiceContainersByAppName.useQuery( { appName, @@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, ); - const { data: containers, isLoading: containersLoading } = + const { data: containers, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))}
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services?.find((c) => c.containerId === containerId)?.error} +
+ )} void; + onOpenChange: (open: boolean) => void; + alwaysVisible?: boolean; +} + +export const CreateFileDialog = ({ + folderPath, + onCreate, + onOpenChange, + alwaysVisible = false, +}: Props) => { + const [filename, setFilename] = useState(""); + const [content, setContent] = useState(""); + + const handleCreate = () => { + if (!filename.trim()) return; + onCreate(filename.trim(), content); + setFilename(""); + setContent(""); + onOpenChange(false); + }; + + return ( + + + + + +
{ + e.preventDefault(); + handleCreate(); + }} + > + + Create file + + {folderPath ? `New file in ${folderPath}/` : "New file in root"} + + +
+
+ + setFilename(e.target.value)} + /> +
+
+ +
+ setContent(v ?? "")} + className="h-full" + wrapperClassName="h-[200px]" + lineWrapping + /> +
+
+
+ + + + + + + + +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx new file mode 100644 index 000000000..8c5a42836 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx @@ -0,0 +1,102 @@ +import { Loader2, Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + patchId: string; + entityId: string; + type: "application" | "compose"; + onSuccess?: () => void; +} + +export const EditPatchDialog = ({ + patchId, + entityId, + type, + onSuccess, +}: Props) => { + const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery( + { patchId }, + { enabled: !!patchId }, + ); + const [content, setContent] = useState(""); + + useEffect(() => { + if (patch) { + setContent(patch.content); + } + }, [patch]); + + const utils = api.useUtils(); + const updatePatch = api.patch.update.useMutation(); + + const handleSave = () => { + updatePatch + .mutateAsync({ patchId, content }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id: entityId, type }); + onSuccess?.(); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + + return ( + + + + + + + Edit Patch + + {patch ? `Editing: ${patch.filePath}` : "Loading patch..."} + + + {isPatchLoading ? ( +
+ +
+ ) : ( +
+ setContent(value ?? "")} + className="h-[400px] w-full" + wrapperClassName="h-[400px]" + lineWrapping + /> +
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts new file mode 100644 index 000000000..1854bd3e5 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -0,0 +1,2 @@ +export * from "./show-patches"; +export * from "./patch-editor"; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx new file mode 100644 index 000000000..4b212b004 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -0,0 +1,368 @@ +import { + ArrowLeft, + ChevronRight, + File, + Folder, + Loader2, + Save, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { api } from "@/utils/api"; +import { CreateFileDialog } from "./create-file-dialog"; + +interface Props { + id: string; + type: "application" | "compose"; + repoPath: string; + onClose: () => void; +} + +type DirectoryEntry = { + name: string; + path: string; + type: "file" | "directory"; + children?: DirectoryEntry[]; +}; + +export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [createFolderPath, setCreateFolderPath] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>( + new Set(), + ); + + const utils = api.useUtils(); + const { data: directories, isPending: isDirLoading } = + api.patch.readRepoDirectories.useQuery( + { id: id, type, repoPath }, + { enabled: !!repoPath }, + ); + + const { data: patches } = api.patch.byEntityId.useQuery( + { id, type }, + { enabled: !!id }, + ); + + const { mutateAsync: saveAsPatch, isPending: isSavingPatch } = + api.patch.saveFileAsPatch.useMutation(); + + const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } = + api.patch.markFileForDeletion.useMutation(); + + const updatePatch = api.patch.update.useMutation(); + + const { data: fileData, isFetching: isFileLoading } = + api.patch.readRepoFile.useQuery( + { + id, + type, + filePath: selectedFile || "", + }, + { + enabled: !!selectedFile, + }, + ); + + useEffect(() => { + if (fileData !== undefined) { + setFileContent(fileData); + } + }, [fileData]); + + const handleFileSelect = (filePath: string) => { + setSelectedFile(filePath); + }; + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleSave = () => { + if (!selectedFile) return; + saveAsPatch({ + id, + type, + filePath: selectedFile, + content: fileContent, + patchType: "update", + }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to save patch"); + }); + }; + + const handleMarkForDeletion = () => { + if (!selectedFile) return; + markForDeletion({ id, type, filePath: selectedFile }) + .then(() => { + toast.success("File marked for deletion"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to mark file for deletion"); + }); + }; + + const handleCreateFile = useCallback( + (folderPath: string, filename: string, content: string) => { + const filePath = folderPath ? `${folderPath}/${filename}` : filename; + saveAsPatch({ + id, + type, + filePath, + content, + patchType: "create", + }) + .then(() => { + toast.success("File created"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to create file"); + }); + }, + [id, type, saveAsPatch, utils], + ); + + const selectedFilePatch = patches?.find( + (p) => p.filePath === selectedFile && p.type === "delete", + ); + + const handleUnmarkDeletion = () => { + if (!selectedFilePatch) return; + updatePatch + .mutateAsync({ + patchId: selectedFilePatch.patchId, + type: "update", + content: fileData || "", + }) + .then(() => { + toast.success("Deletion unmarked"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to unmark deletion"); + }); + }; + + const hasChanges = fileData !== undefined && fileContent !== fileData; + + const renderTree = useCallback( + (entries: DirectoryEntry[], depth = 0) => { + return entries + .sort((a, b) => { + // Directories first, then alphabetically + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .map((entry) => { + const isExpanded = expandedFolders.has(entry.path); + const isSelected = selectedFile === entry.path; + + if (entry.type === "directory") { + return ( +
+
+ + + handleCreateFile(entry.path, filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? entry.path : null) + } + /> +
+ {isExpanded && entry.children && ( +
{renderTree(entry.children, depth + 1)}
+ )} +
+ ); + } + + const isMarkedForDeletion = patches?.some( + (p) => p.filePath === entry.path && p.type === "delete", + ); + + return ( + + ); + }); + }, + [expandedFolders, selectedFile, patches, handleCreateFile], + ); + + return ( + + +
+ +
+ Edit File + + {selectedFile + ? `Editing: ${selectedFile}` + : "Select a file from the tree to edit"} + +
+
+ {selectedFile && ( +
+ {selectedFilePatch ? ( + + ) : ( + <> + + + + )} +
+ )} +
+ +
+
+ +
+
+ + handleCreateFile("", filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? "" : null) + } + /> + + New file in root + +
+ {isDirLoading ? ( +
+ +
+ ) : directories ? ( + renderTree(directories) + ) : ( +
+ No files found +
+ )} +
+
+
+
+ {isFileLoading ? ( +
+ +
+ ) : selectedFile ? ( + setFileContent(value || "")} + className="h-full w-full" + wrapperClassName="h-full" + lineWrapping + /> + ) : ( +
+ Select a file to edit +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx new file mode 100644 index 000000000..e471b3fc1 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -0,0 +1,225 @@ +import { File, FilePlus2, Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { EditPatchDialog } from "./edit-patch-dialog"; +import { PatchEditor } from "./patch-editor"; + +interface Props { + id: string; + type: "application" | "compose"; +} + +export const ShowPatches = ({ id, type }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [repoPath, setRepoPath] = useState(null); + const [isLoadingRepo, setIsLoadingRepo] = useState(false); + + const utils = api.useUtils(); + + const { data: patches, isPending: isPatchesLoading } = + api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id }); + + const mutationMap = { + application: () => api.patch.delete.useMutation(), + compose: () => api.patch.delete.useMutation(), + }; + + const ensureRepo = api.patch.ensureRepo.useMutation(); + + const togglePatch = api.patch.toggleEnabled.useMutation(); + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.patch.delete.useMutation(); + + const handleCloseEditor = () => { + setSelectedFile(null); + setRepoPath(null); + }; + + if (repoPath) { + return ( + + ); + } + + const handleOpenEditor = async () => { + setIsLoadingRepo(true); + await ensureRepo + .mutateAsync({ id, type }) + .then((result) => { + setRepoPath(result); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }; + + return ( + + +
+ Patches + + Apply code patches to your repository during build. Patches are + applied after cloning the repository and before building. + +
+ {patches && patches?.length > 0 && ( + + )} +
+ + {isPatchesLoading ? ( +
+ +
+ ) : patches?.length === 0 ? ( +
+
+ +
+
+

No patches yet

+

+ Add file patches to modify your repo before each build—configs, + env, or code. Create your first patch to get started. +

+
+ +
+ ) : ( + + + + File Path + Type + Enabled + Actions + + + + {patches?.map((patch) => ( + + +
+ + {patch.filePath} +
+
+ + + {patch.type} + + + + { + togglePatch + .mutateAsync({ + patchId: patch.patchId, + enabled: checked, + }) + .then(() => { + toast.success("Patch updated"); + utils.patch.byEntityId.invalidate({ + id, + type, + }); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }} + /> + + +
+ {(patch.type === "update" || patch.type === "create") && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx index eac4559f1..72815fd8f 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Dices } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -75,17 +75,20 @@ export const AddPreviewDomain = ({ }, ); - const { mutateAsync, isError, error, isLoading } = domainId + const { mutateAsync, isError, error, isPending } = domainId ? api.domain.update.useMutation() : api.domain.create.useMutation(); - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); const form = useForm({ resolver: zodResolver(domain), }); + const host = form.watch("host"); + const isTraefikMeDomain = host?.includes("traefik.me") || false; + useEffect(() => { if (data) { form.reset({ @@ -100,7 +103,7 @@ export const AddPreviewDomain = ({ if (!domainId) { form.reset({}); } - }, [form, form.reset, data, isLoading]); + }, [form, form.reset, data, isPending]); const dictionary = { success: domainId ? "Domain Updated" : "Domain Created", @@ -157,6 +160,13 @@ export const AddPreviewDomain = ({ name="host" render={({ field }) => ( + {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
@@ -291,7 +301,7 @@ export const AddPreviewDomain = ({ - diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index d93bbd1c8..e12400a7c 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -1,7 +1,9 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { ExternalLink, FileText, GitPullRequest, + Hammer, Loader2, PenSquare, RocketIcon, @@ -22,6 +24,12 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; @@ -35,9 +43,12 @@ interface Props { export const ShowPreviewDeployments = ({ applicationId }: Props) => { const { data } = api.application.one.useQuery({ applicationId }); - const { mutateAsync: deletePreviewDeployment, isLoading } = + const { mutateAsync: deletePreviewDeployment, isPending } = api.previewDeployment.delete.useMutation(); + const { mutateAsync: redeployPreviewDeployment } = + api.previewDeployment.redeploy.useMutation(); + const { data: previewDeployments, refetch: refetchPreviewDeployments, @@ -46,6 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { { applicationId }, { enabled: !!applicationId, + refetchInterval: 2000, }, ); @@ -182,7 +194,68 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { id={deployment.previewDeploymentId} type="previewDeployment" serverId={data?.serverId || ""} - /> + > + + + + { + await redeployPreviewDeployment({ + previewDeploymentId: + deployment.previewDeploymentId, + }) + .then(() => { + toast.success( + "Preview deployment rebuild started", + ); + refetchPreviewDeployments(); + }) + .catch(() => { + toast.error( + "Error rebuilding preview deployment", + ); + }); + }} + > + + { diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index e403c8be5..36ddb53f1 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 { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { + CheckIcon, + ChevronsUpDown, DatabaseZap, Info, PenBoxIcon, @@ -7,12 +9,20 @@ import { RefreshCw, } from "lucide-react"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import { type Control, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; 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: "* * * * *" }, @@ -57,6 +74,7 @@ export const commonCronExpressions = [ { label: "Every month on the 1st at midnight", value: "0 0 1 * *" }, { label: "Every 15 minutes", value: "*/15 * * * *" }, { label: "Every weekday at midnight", value: "0 0 * * 1-5" }, + { label: "Custom", value: "custom" }, ]; const formSchema = z @@ -74,6 +92,7 @@ const formSchema = z "dokploy-server", ]), script: z.string(), + timezone: z.string().optional(), }) .superRefine((data, ctx) => { if (data.scheduleType === "compose" && !data.serviceName) { @@ -115,13 +134,94 @@ interface Props { scheduleType?: "application" | "compose" | "server" | "dokploy-server"; } +export const ScheduleFormField = ({ + name, + formControl, +}: { + name: string; + formControl: Control; +}) => { + const [selectedOption, setSelectedOption] = useState(""); + + return ( + ( + + + Schedule + + + + + + +

Cron expression format: minute hour day month weekday

+

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

+
+
+
+
+
+ +
+ + { + const value = e.target.value; + const commonExpression = commonCronExpressions.find( + (expression) => expression.value === value, + ); + if (commonExpression) { + setSelectedOption(commonExpression.value); + } else { + setSelectedOption("custom"); + } + field.onChange(e); + }} + /> + +
+
+ + Choose a predefined schedule or enter a custom cron expression + + +
+ )} + /> + ); +}; + export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); - const utils = api.useUtils(); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: standardSchemaResolver(formSchema), defaultValues: { name: "", cronExpression: "", @@ -131,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: "", scheduleType: scheduleType || "application", script: "", + timezone: undefined, }, }); @@ -169,15 +270,16 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: schedule.serviceName || "", scheduleType: schedule.scheduleType, script: schedule.script || "", + timezone: schedule.timezone || undefined, }); } }, [form, schedule, scheduleId]); - const { mutateAsync, isLoading } = scheduleId + const { mutateAsync, isPending } = scheduleId ? api.schedule.update.useMutation() : api.schedule.create.useMutation(); - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: z.output) => { if (!id && !scheduleId) return; await mutateAsync({ @@ -377,13 +479,18 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { )} /> + + ( - Schedule + Timezone @@ -391,45 +498,69 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {

- Cron expression format: minute hour day month - weekday + Select a timezone for the schedule. If not + specified, UTC will be used.

-

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

-
- -
- - + + + - -
-
+ + No timezone found. + + {Object.entries(TIMEZONES).map( + ([region, zones]) => ( + + {zones.map((tz) => ( + { + field.onChange(tz.value); + }} + > + {tz.value} + + + ))} + + ), + )} + + + + + - Choose a predefined schedule or enter a custom cron - expression + Optional: Choose a timezone for the schedule execution time
@@ -531,7 +662,7 @@ echo "Hello, world!" )} /> - diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3209b6e03..a9550fda2 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -6,6 +6,7 @@ import { Terminal, Trash2, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -33,6 +34,9 @@ interface Props { } export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { + const [runningSchedules, setRunningSchedules] = useState>( + new Set(), + ); const { data: schedules, isLoading: isLoadingSchedules, @@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { enabled: !!id, }, ); - const utils = api.useUtils(); - - const { mutateAsync: deleteSchedule, isLoading: isDeleting } = + const { mutateAsync: deleteSchedule, isPending: isDeleting } = api.schedule.delete.useMutation(); + const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); - const { mutateAsync: runManually, isLoading } = - api.schedule.runManually.useMutation(); + const handleRunManually = async (scheduleId: string) => { + setRunningSchedules((prev) => new Set(prev).add(scheduleId)); + try { + await runManually({ scheduleId }); + toast.success("Schedule run successfully"); + await refetchSchedules(); + } catch { + toast.error("Error running schedule"); + } finally { + setRunningSchedules((prev) => { + const newSet = new Set(prev); + newSet.delete(scheduleId); + return newSet; + }); + } + }; return ( @@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { Schedule tasks to run automatically at specified intervals.
- {schedules && schedules.length > 0 && ( )} @@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { {isLoadingSchedules ? ( -
+
Loading scheduled tasks... @@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { return (
-
+
-
+

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

{schedule.command && ( -
- - +
+ + {schedule.command}
)}
-
{ serverId={serverId || undefined} > - @@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { type="button" variant="ghost" size="icon" - isLoading={isLoading} - onClick={async () => { - toast.success("Schedule run successfully"); - - await runManually({ - scheduleId: schedule.scheduleId, - }) - .then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchSchedules(); - }) - .catch(() => { - toast.error("Error running schedule"); - }); - }} + disabled={runningSchedules.has(schedule.scheduleId)} + onClick={() => + handleRunManually(schedule.scheduleId) + } > - + {runningSchedules.has(schedule.scheduleId) ? ( + + ) : ( + + )} Run Manual Schedule - - { diff --git a/apps/dokploy/components/dashboard/application/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/update-application.tsx b/apps/dokploy/components/dashboard/application/update-application.tsx index 754074d75..98c49a999 100644 --- a/apps/dokploy/components/dashboard/application/update-application.tsx +++ b/apps/dokploy/components/dashboard/application/update-application.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -43,7 +43,7 @@ interface Props { export const UpdateApplication = ({ applicationId }: Props) => { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.application.update.useMutation(); const { data } = api.application.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => { /> diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx index 6eda33648..684620947 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { debounce } from "lodash"; +import debounce from "lodash/debounce"; import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -53,27 +53,15 @@ interface Props { } const RestoreBackupSchema = z.object({ - destinationId: z - .string({ - required_error: "Please select a destination", - }) - .min(1, { - message: "Destination is required", - }), - backupFile: z - .string({ - required_error: "Please select a backup file", - }) - .min(1, { - message: "Backup file is required", - }), - volumeName: z - .string({ - required_error: "Please enter a volume name", - }) - .min(1, { - message: "Volume name is required", - }), + destinationId: z.string().min(1, { + message: "Destination is required", + }), + backupFile: z.string().min(1, { + message: "Backup file is required", + }), + volumeName: z.string().min(1, { + message: "Volume name is required", + }), }); export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { @@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm>({ + const form = useForm({ defaultValues: { destinationId: "", backupFile: "", @@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { debouncedSetSearch(value); }; - const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { destinationId: destinationId, search: debouncedSearchTerm, @@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { onValueChange={handleSearchChange} className="h-9" /> - {isLoading ? ( + {isPending ? (
Loading backup files...
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 c88dd92f5..526bcfa77 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 @@ -5,6 +5,7 @@ import { Play, Trash2, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; @@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({ type = "application", serverId, }: Props) => { + const [runningBackups, setRunningBackups] = useState>(new Set()); const { data: volumeBackups, isLoading: isLoadingVolumeBackups, @@ -51,34 +53,46 @@ export const ShowVolumeBackups = ({ enabled: !!id, }, ); - const utils = api.useUtils(); - - const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = + const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } = api.volumeBackups.delete.useMutation(); - - const { mutateAsync: runManually, isLoading } = + const { mutateAsync: runManually } = api.volumeBackups.runManually.useMutation(); + const handleRunManually = async (volumeBackupId: string) => { + setRunningBackups((prev) => new Set(prev).add(volumeBackupId)); + try { + await runManually({ volumeBackupId }); + toast.success("Volume backup run successfully"); + await refetchVolumeBackups(); + } catch { + toast.error("Error running volume backup"); + } finally { + setRunningBackups((prev) => { + const newSet = new Set(prev); + newSet.delete(volumeBackupId); + return newSet; + }); + } + }; + return ( -
+
Volume Backups Schedule volume backups to run automatically at specified - intervals. + intervals
- -
+
{volumeBackups && volumeBackups.length > 0 && ( <> -
{isLoadingVolumeBackups ? ( -
+
Loading volume backups... @@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({ return (
-
+
-
+

{volumeBackup.name} @@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({

- -
+
- @@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({ type="button" variant="ghost" size="icon" - isLoading={isLoading} - onClick={async () => { - toast.success("Volume backup run successfully"); - - await runManually({ - volumeBackupId: volumeBackup.volumeBackupId, - }) - .then(async () => { - await new Promise((resolve) => - setTimeout(resolve, 1500), - ); - refetchVolumeBackups(); - }) - .catch(() => { - toast.error("Error running volume backup"); - }); - }} + disabled={runningBackups.has( + volumeBackup.volumeBackupId, + )} + onClick={() => + handleRunManually(volumeBackup.volumeBackupId) + } > - + {runningBackups.has(volumeBackup.volumeBackupId) ? ( + + ) : ( + + )} @@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({ - - @@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({ })}
) : ( -
+

No volume backups diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx index 52eb18907..c5f9334ec 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); const form = useForm({ defaultValues: { @@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => { />

-
diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx index 5b6e04154..0fad7d20e 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index c1d03dede..35fe01ff9 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -1,5 +1,5 @@ import type { ServiceType } from "@dokploy/server/db/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; import { Copy, Trash2 } from "lucide-react"; import { useRouter } from "next/router"; @@ -46,6 +46,8 @@ interface Props { } export const DeleteService = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDelete = permissions?.service.delete ?? false; const [isOpen, setIsOpen] = useState(false); const queryMap = { @@ -76,7 +78,7 @@ export const DeleteService = ({ id, type }: Props) => { mongo: () => api.mongo.remove.useMutation(), compose: () => api.compose.delete.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.remove.useMutation(); const { push } = useRouter(); @@ -107,7 +109,7 @@ export const DeleteService = ({ id, type }: Props) => { push( `/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`, ); - toast.success("deleted successfully"); + toast.success("Service deleted successfully"); setIsOpen(false); }) .catch(() => { @@ -126,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => { data?.applicationStatus === "running") || (data && "composeStatus" in data && data?.composeStatus === "running"); + if (!canDelete) return null; + return ( @@ -133,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => { variant="ghost" size="icon" className="group hover:bg-red-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -231,7 +235,7 @@ export const DeleteService = ({ id, type }: Props) => { - - { - await redeploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading compose"); - }); - }} - > - - - {data?.composeType === "docker-compose" && - data?.composeStatus === "idle" ? ( + {canDeploy && ( { - await start({ + await deploy({ composeId: composeId, }) .then(() => { - toast.success("Compose started successfully"); + toast.success("Compose deployed successfully"); refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); }) .catch(() => { - toast.error("Error starting compose"); + toast.error("Error deploying compose"); }); }} > - ) : ( + )} + {canDeploy && ( { - await stop({ + await redeploy({ composeId: composeId, }) .then(() => { - toast.success("Compose stopped successfully"); + toast.success("Compose reloaded successfully"); refetch(); }) .catch(() => { - toast.error("Error stopping compose"); + toast.error("Error reloading compose"); }); }} > )} + {canDeploy && + (data?.composeType === "docker-compose" && + data?.composeStatus === "idle" ? ( + { + await start({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting compose"); + }); + }} + > + + + ) : ( + { + await stop({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping compose"); + }); + }} + > + + + ))} -
- Autodeploy - { - await update({ - composeId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + composeId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index afb66bd54..28f958e3e 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect } from "react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -26,6 +26,8 @@ const AddComposeFile = z.object({ type AddComposeFile = z.infer; export const ComposeFileEditor = ({ composeId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canUpdate = permissions?.service.create ?? false; const utils = api.useUtils(); const { data, refetch } = api.compose.one.useQuery( { @@ -34,7 +36,8 @@ export const ComposeFileEditor = ({ composeId }: Props) => { { enabled: !!composeId }, ); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const form = useForm({ defaultValues: { @@ -53,6 +56,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => { } }, [form, form.reset, data]); + useEffect(() => { + if (data?.composeFile !== undefined) { + setHasUnsavedChanges(composeFile !== data.composeFile); + } + }, [composeFile, data?.composeFile]); + const onSubmit = async (data: AddComposeFile) => { const { valid, error } = validateAndFormatYAML(data.composeFile); if (!valid) { @@ -67,10 +76,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => { await mutateAsync({ composeId, composeFile: data.composeFile, + composePath: "./docker-compose.yml", sourceType: "raw", }) .then(async () => { toast.success("Compose config Updated"); + setHasUnsavedChanges(false); refetch(); await utils.compose.getConvertedCompose.invalidate({ composeId, @@ -84,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -94,11 +105,24 @@ export const ComposeFileEditor = ({ composeId }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return ( <>
+
+
+

Compose File

+

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

+
+
- + {canUpdate && ( + + )}
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 d2a5d622a..3e099251e 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 @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -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"), @@ -73,15 +74,16 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { api.bitbucket.bitbucketProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingBitbucketProvider } = + const { mutateAsync, isPending: isSavingBitbucketProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", 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, @@ -152,7 +159,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -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 && ( { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -257,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -273,6 +285,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { form.setValue("repository", { owner: repo.owner.username as string, repo: repo.name, + slug: repo.slug, }); form.setValue("branch", ""); }} @@ -322,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -339,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index d8c9d4d8f..c84a55bb3 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { const { data: sshKeys } = api.sshKey.all.useQuery(); const router = useRouter(); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { branch: "", repositoryURL: "", @@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {

-
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index fce562285..39f025438 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ interface Props { export const SaveGiteaProviderCompose = ({ composeId }: Props) => { const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGiteaProvider } = + const { mutateAsync, isPending: isSavingGiteaProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -327,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index fa7f40b96..827ce1a8a 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGithubProvider } = + const { mutateAsync, isPending: isSavingGithubProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { const repository = form.watch("repository"); const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); - const { data: repositories, isLoading: isLoadingRepositories } = + const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { githubId, @@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { triggerType: data.triggerType, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -317,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -334,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... 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 9ace74ff6..63de87d8f 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; 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"; @@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGitlabProvider } = + const { mutateAsync, isPending: isSavingGitlabProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -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, @@ -160,7 +170,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { enableSubmodules: data.enableSubmodules, }) .then(async () => { - toast.success("Service Provided Saved"); + toast.success("Service Provider Saved"); await refetch(); }) .catch(() => { @@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
Repository - {field.value.owner && field.value.repo && ( + {field.value.gitlabPathNamespace && ( { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -264,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -339,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -356,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx index 798f72249..759fe728c 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx @@ -27,13 +27,13 @@ interface Props { } export const ShowProviderFormCompose = ({ composeId }: Props) => { - const { data: githubProviders, isLoading: isLoadingGithub } = + const { data: githubProviders, isPending: isLoadingGithub } = api.github.githubProviders.useQuery(); - const { data: gitlabProviders, isLoading: isLoadingGitlab } = + const { data: gitlabProviders, isPending: isLoadingGitlab } = api.gitlab.gitlabProviders.useQuery(); - const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = + const { data: bitbucketProviders, isPending: isLoadingBitbucket } = api.bitbucket.bitbucketProviders.useQuery(); - const { data: giteaProviders, isLoading: isLoadingGitea } = + const { data: giteaProviders, isPending: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); const { mutateAsync: disconnectGitProvider } = diff --git a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx index 2c488aefe..99c749c26 100644 --- a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx index fac6c2a34..211f5f5c7 100644 --- a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx @@ -32,7 +32,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { }, ); - const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation(); + const { mutateAsync, isPending } = api.compose.fetchSourceType.useMutation(); useEffect(() => { if (isOpen) { @@ -66,7 +66,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { Preview your docker-compose file with added domains. Note: At least one domain must be specified for this conversion to take effect. - {isLoading ? ( + {isPending ? (
@@ -82,7 +82,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
@@ -145,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -154,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services.find((c) => c.containerId === containerId)?.error} +
+ )} { - const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( + const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { appName, appType, @@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({ { - field.onChange(value); - }} - > - - - - - - - {commonCronExpressions.map((expr) => ( - - {expr.label} ({expr.value}) - - ))} - - -
- - - -
-
- - Choose a predefined schedule or enter a custom cron - expression - - -
- ); - }} - /> + + + diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 6a0fb030a..ba8e4caf5 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { debounce } from "lodash"; +import debounce from "lodash/debounce"; import { CheckIcon, ChevronsUpDown, @@ -78,27 +78,15 @@ interface Props { const RestoreBackupSchema = z .object({ - destinationId: z - .string({ - required_error: "Please select a destination", - }) - .min(1, { - message: "Destination is required", - }), - backupFile: z - .string({ - required_error: "Please select a backup file", - }) - .min(1, { - message: "Backup file is required", - }), - databaseName: z - .string({ - required_error: "Please enter a database name", - }) - .min(1, { - message: "Database name is required", - }), + destinationId: z.string().min(1, { + message: "Destination is required", + }), + backupFile: z.string().min(1, { + message: "Backup file is required", + }), + databaseName: z.string().min(1, { + message: "Database name is required", + }), databaseType: z .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) .optional(), @@ -219,7 +207,7 @@ export const RestoreBackup = ({ const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm>({ + const form = useForm({ defaultValues: { destinationId: "", backupFile: "", @@ -245,7 +233,7 @@ export const RestoreBackup = ({ debouncedSetSearch(value); }; - const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { destinationId: destionationId, search: debouncedSearchTerm, @@ -454,7 +442,7 @@ export const RestoreBackup = ({ onValueChange={handleSearchChange} className="h-9" /> - {isLoading ? ( + {isPending ? (
Loading backup files...
diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 0d25e7bd8..592baf57a 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -91,11 +91,11 @@ export const ShowBackups = ({ const mutation = mutationMap[key as keyof typeof mutationMap]; - const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation + const { mutateAsync: manualBackup, isPending: isManualBackup } = mutation ? mutation : api.backup.manualBackupMongo.useMutation(); - const { mutateAsync: deleteBackup, isLoading: isRemoving } = + const { mutateAsync: deleteBackup, isPending: isRemoving } = api.backup.remove.useMutation(); return ( diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx new file mode 100644 index 000000000..770d4efd0 --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + ChevronLeft, + ChevronRight, + ExternalLink, + Loader2, + Rocket, + Server, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DeploymentRow = + inferRouterOutputs["deployment"]["allCentralized"][number]; + +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", +}; + +function getServiceInfo(d: DeploymentRow) { + const app = d.application; + const comp = d.compose; + if (app?.environment?.project && app.environment) { + return { + type: "Application" as const, + name: app.name, + projectId: app.environment.project.projectId, + environmentId: app.environment.environmentId, + projectName: app.environment.project.name, + environmentName: app.environment.name, + serviceId: app.applicationId, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + }; + } + if (comp?.environment?.project && comp.environment) { + return { + type: "Compose" as const, + name: comp.name, + projectId: comp.environment.project.projectId, + environmentId: comp.environment.environmentId, + projectName: comp.environment.project.name, + environmentName: comp.environment.name, + serviceId: comp.composeId, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + }; + } + return null; +} + +export function ShowDeploymentsTable() { + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + + const { data: deploymentsList, isLoading } = + api.deployment.allCentralized.useQuery(undefined, { + refetchInterval: 5000, + }); + + const filteredData = useMemo(() => { + if (!deploymentsList) return []; + let list = deploymentsList; + if (statusFilter !== "all") { + list = list.filter((d) => d.status === statusFilter); + } + if (typeFilter === "application") { + list = list.filter((d) => d.applicationId != null); + } else if (typeFilter === "compose") { + list = list.filter((d) => d.composeId != null); + } + if (globalFilter.trim()) { + const q = globalFilter.toLowerCase(); + list = list.filter((d) => { + const info = getServiceInfo(d); + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + ""; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? ""; + if (!info) return false; + return ( + info.name.toLowerCase().includes(q) || + info.projectName.toLowerCase().includes(q) || + info.environmentName.toLowerCase().includes(q) || + (d.title?.toLowerCase().includes(q) ?? false) || + serverName.toLowerCase().includes(q) || + buildServerName.toLowerCase().includes(q) + ); + }); + } + return list; + }, [deploymentsList, statusFilter, typeFilter, globalFilter]); + + const columns = useMemo( + () => [ + { + id: "serviceName", + accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return ; + return ( +
+ {info.type === "Application" ? ( + + ) : ( + + )} +
+ {info.name} + + {info.type} + +
+
+ ); + }, + }, + { + id: "projectName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.projectName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.projectName ?? "—"} + + ); + }, + }, + { + id: "environmentName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.environmentName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.environmentName ?? "—"} + + ); + }, + }, + { + id: "serverName", + accessorFn: (row: DeploymentRow) => + row.server?.name ?? + row.application?.server?.name ?? + row.compose?.server?.name ?? + "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const d = row.original; + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + null; + const serverType = + d.server?.serverType ?? + d.application?.server?.serverType ?? + d.compose?.server?.serverType ?? + null; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? null; + const buildServerType = + d.buildServer?.serverType ?? + d.application?.buildServer?.serverType ?? + null; + const showBuild = + buildServerName != null && buildServerName !== serverName; + if (!serverName && !showBuild) { + return ; + } + return ( +
+ {serverName && ( +
+ + {serverName} + {serverType && ( + + {serverType} + + )} +
+ )} + {showBuild && buildServerName && ( +
+ Build: + {buildServerName} + {buildServerType && ( + + {buildServerType} + + )} +
+ )} +
+ ); + }, + }, + { + accessorKey: "title", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.title || "—"} + + ), + }, + { + accessorKey: "status", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const status = row.original.status ?? "running"; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.createdAt + ? new Date(row.original.createdAt).toLocaleString() + : "—"} + + ), + }, + { + header: "", + id: "actions", + enableSorting: false, + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return null; + return ( + + ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will + appear here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries + +
+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx new file mode 100644 index 000000000..e46b33a6a --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -0,0 +1,217 @@ +"use client"; + +import type { inferRouterOutputs } from "@trpc/server"; +import Link from "next/link"; +import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type QueueRow = + inferRouterOutputs["deployment"]["queueList"][number]; + +const stateVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + pending: "secondary", + waiting: "secondary", + active: "yellow", + delayed: "outline", + completed: "green", + failed: "destructive", + cancelled: "outline", + paused: "outline", +}; + +function formatTs(ts?: number): string { + if (ts == null) return "—"; + const d = new Date(ts); + return d.toLocaleString(); +} + +function getJobLabel(row: QueueRow): string { + const d = row.data as { + applicationType?: string; + applicationId?: string; + composeId?: string; + previewDeploymentId?: string; + titleLog?: string; + type?: string; + }; + if (!d) return String(row.id); + const type = d.applicationType ?? "job"; + const title = d.titleLog ?? ""; + if (title) return title; + if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`; + if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`; + if (d.previewDeploymentId) + return `Preview ${d.previewDeploymentId.slice(0, 8)}…`; + return `${type} ${String(row.id)}`; +} + +export function ShowQueueTable(props: { embedded?: boolean }) { + const { embedded: _embedded = false } = props; + const { data: queueList, isLoading } = api.deployment.queueList.useQuery( + undefined, + { refetchInterval: 3000 }, + ); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const utils = api.useUtils(); + const { + mutateAsync: cancelApplicationDeployment, + isPending: isCancellingApp, + } = api.application.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const { + mutateAsync: cancelComposeDeployment, + isPending: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const isCancelling = isCancellingApp || isCancellingCompose; + + return ( +
+ {isLoading ? ( +
+ + Loading queue... +
+ ) : ( +
+ + + + Job ID + Label + Type + State + Added + Processed + Finished + Error + Actions + + + + {queueList?.length ? ( + queueList.map((row) => { + const d = row.data as Record; + const appType = d?.applicationType as string | undefined; + const pathInfo = row.servicePath; + const hasLink = pathInfo?.href != null; + return ( + + + {String(row.id)} + + + {getJobLabel(row)} + + {appType ?? row.name ?? "—"} + + + {row.state} + + + + {formatTs(row.timestamp)} + + + {formatTs(row.processedOn)} + + + {formatTs(row.finishedOn)} + + + {row.failedReason ?? "—"} + + +
+ {hasLink ? ( + + ) : ( + + — + + )} + {isCloud && + row.state === "active" && + (d?.applicationId != null || + d?.composeId != null) && ( + + )} +
+
+
+ ); + }) + ) : ( + + +
+ +

Queue is empty

+

+ Deployment jobs will appear here when they are queued. +

+
+
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 54db7945b..59b939008 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -1,4 +1,12 @@ -import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react"; +import copy from "copy-to-clipboard"; +import { + Check, + Copy, + Download as DownloadIcon, + Loader2, + Pause, + Play, +} from "lucide-react"; import React, { useEffect, useRef } from "react"; import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; @@ -67,6 +75,7 @@ export const DockerLogsId: React.FC = ({ const isPausedRef = useRef(false); const scrollRef = useRef(null); const [isLoading, setIsLoading] = React.useState(false); + const [copied, setCopied] = React.useState(false); const scrollToBottom = () => { if (autoScroll && scrollRef.current) { @@ -237,6 +246,29 @@ export const DockerLogsId: React.FC = ({ URL.revokeObjectURL(url); }; + const handleCopy = async () => { + const logContent = filteredLogs + .map( + ({ + timestamp, + message, + }: { + timestamp: Date | null; + message: string; + }) => + showTimestamp + ? `${timestamp?.toISOString() || "No timestamp"} ${message}` + : message, + ) + .join("\n"); + + const success = copy(logContent); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + const handleFilter = (logs: LogLine[]) => { return logs.filter((log) => { const logType = getLogType(log.message).type; @@ -320,6 +352,21 @@ export const DockerLogsId: React.FC = ({ )} {isPaused ? "Resume" : "Pause"} +
- {isLoading ? ( + {isPending ? (
Loading... @@ -192,7 +192,7 @@ export const ShowContainers = ({ serverId }: Props) => { colSpan={columns.length} className="h-24 text-center" > - {isLoading ? ( + {isPending ? (
Loading... diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index ad34d69ce..ab47514f9 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; interface Props { id: string; - containerId: string; + containerId?: string; serverId?: string; } @@ -36,7 +36,6 @@ export const DockerTerminal: React.FC = ({ }, }); const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`; @@ -57,7 +56,7 @@ export const DockerTerminal: React.FC = ({ return (
-
+
Select way to connect to {containerId} diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx index 8c848a0dc..288208fb1 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -7,6 +7,7 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -16,6 +17,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config"; @@ -47,8 +49,9 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, ); const [canEdit, setCanEdit] = useState(true); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.settings.updateTraefikFile.useMutation(); const form = useForm({ @@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, [form, form.reset, data]); const onSubmit = async (data: UpdateServerMiddlewareConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: error || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: error || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -153,14 +158,37 @@ routers: /> )}
-
- +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

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

+
+ +
diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index 7804e9add..1f0c6924c 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -45,10 +45,12 @@ import { import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; type User = typeof authClient.$Infer.Session.user; export const ImpersonationBar = () => { + const { config: whitelabeling } = useWhitelabeling(); const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [isImpersonating, setIsImpersonating] = useState(false); @@ -103,7 +105,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) { @@ -180,7 +182,10 @@ export const ImpersonationBar = () => { )} >
- + {!isImpersonating ? (
@@ -195,7 +200,8 @@ export const ImpersonationBar = () => { - {selectedUser.name || ""} + {`${selectedUser.name} ${selectedUser.lastName}`.trim() || + ""} {selectedUser.email} @@ -242,7 +248,8 @@ export const ImpersonationBar = () => { - {user.name || ""} + {`${user.name} ${user.lastName}`.trim() || + ""} {user.email} • {user.role} @@ -281,11 +288,16 @@ export const ImpersonationBar = () => {
- {data?.user?.name?.slice(0, 2).toUpperCase() || "U"} + {`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() || + "U"}
@@ -298,7 +310,8 @@ export const ImpersonationBar = () => { Impersonating - {data?.user?.name || ""} + {`${data?.user?.firstName} ${data?.user?.lastName}`.trim() || + ""}
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx index 8745db286..9917bc21b 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-external-mariadb-credentials.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -48,10 +48,10 @@ interface Props { export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { const { data: ip } = api.settings.getIp.useQuery(); const { data, refetch } = api.mariadb.one.useQuery({ mariadbId }); - const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation(); + const { mutateAsync, isPending } = api.mariadb.saveExternalPort.useMutation(); const [connectionUrl, setConnectionUrl] = useState(""); const getIp = data?.server?.ipAddress || ip; - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(DockerProviderSchema), }); @@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; @@ -140,7 +140,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { @@ -161,7 +161,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx index 8e996846f..7c89d7b52 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralMariadb = ({ mariadbId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mariadb.one.useQuery( { mariadbId, @@ -28,13 +30,13 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => { { enabled: !!mariadbId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mariadb.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mariadb.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mariadb.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => { Deploy Settings - - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - - - { - await reload({ - mariadbId: mariadbId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Mariadb reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Mariadb"); - }); - }} - > - - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mariadbId: mariadbId, - }) - .then(() => { - toast.success("Mariadb started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Mariadb"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - - ) : ( - - { - await stop({ - mariadbId: mariadbId, - }) - .then(() => { - toast.success("Mariadb stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Mariadb"); - }); - }} - > - + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + + { + await start({ + mariadbId: mariadbId, + }) + .then(() => { + toast.success("Mariadb started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Mariadb"); + }); + }} + > + + + + ) : ( + + { + await stop({ + mariadbId: mariadbId, + }) + .then(() => { + toast.success("Mariadb stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Mariadb"); + }); + }} + > + + + + ))} { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mariadb.update.useMutation(); const { data } = api.mariadb.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => { utils.mariadb.one.invalidate({ mariadbId: mariadbId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating the Mariadb"); @@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => { }; return ( - +
diff --git a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx index 23fbe51d3..72a1848fc 100644 --- a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx +++ b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralMongo = ({ mongoId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mongo.one.useQuery( { mongoId, @@ -28,13 +30,13 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => { { enabled: !!mongoId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mongo.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mongo.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mongo.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - mongoId: mongoId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Mongo reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Mongo"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mongoId: mongoId, - }) - .then(() => { - toast.success("Mongo started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Mongo"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - mongoId: mongoId, - }) - .then(() => { - toast.success("Mongo stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Mongo"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + mongoId: mongoId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Mongo reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Mongo"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + mongoId: mongoId, + }) + .then(() => { + toast.success("Mongo started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Mongo"); + }); + }} + > + + + ) : ( + { + await stop({ + mongoId: mongoId, + }) + .then(() => { + toast.success("Mongo stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Mongo"); + }); + }} + > + + + ))} { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mongo.update.useMutation(); const { data } = api.mongo.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdateMongo = ({ mongoId }: Props) => { />
diff --git a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx index 045a717b7..0e6ff08d9 100644 --- a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx +++ b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralMysql = ({ mysqlId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.mysql.one.useQuery( { mysqlId, @@ -28,12 +30,12 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => { { enabled: !!mysqlId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mysql.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mysql.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mysql.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - mysqlId: mysqlId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("MySQL reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading MySQL"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - mysqlId: mysqlId, - }) - .then(() => { - toast.success("MySQL started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting MySQL"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - mysqlId: mysqlId, - }) - .then(() => { - toast.success("MySQL stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping MySQL"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + mysqlId: mysqlId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("MySQL reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading MySQL"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + mysqlId: mysqlId, + }) + .then(() => { + toast.success("MySQL started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting MySQL"); + }); + }} + > + + + ) : ( + { + await stop({ + mysqlId: mysqlId, + }) + .then(() => { + toast.success("MySQL stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping MySQL"); + }); + }} + > + + + ))} { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mysql.update.useMutation(); const { data } = api.mysql.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => { utils.mysql.one.invalidate({ mysqlId: mysqlId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating MySQL"); @@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => { }; return ( - + 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 e0bd394a2..38a08c234 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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 { @@ -63,18 +71,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({ @@ -86,6 +101,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"); @@ -116,7 +132,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => { Docker Image - + @@ -145,6 +161,61 @@ export const ShowCustomCommand = ({ id, type }: Props) => { )} /> + +
+
+ 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/postgres/general/show-general-postgres.tsx b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx index de520053d..fd8f9ff70 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralPostgres = ({ postgresId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.postgres.one.useQuery( { postgresId: postgresId, @@ -28,13 +30,13 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => { { enabled: !!postgresId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.postgres.reload.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.postgres.stop.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.postgres.start.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - postgresId: postgresId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("PostgreSQL reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading PostgreSQL"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - postgresId: postgresId, - }) - .then(() => { - toast.success("PostgreSQL started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting PostgreSQL"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - postgresId: postgresId, - }) - .then(() => { - toast.success("PostgreSQL stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping PostgreSQL"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + postgresId: postgresId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("PostgreSQL reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading PostgreSQL"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + postgresId: postgresId, + }) + .then(() => { + toast.success("PostgreSQL started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting PostgreSQL"); + }); + }} + > + + + ) : ( + { + await stop({ + postgresId: postgresId, + }) + .then(() => { + toast.success("PostgreSQL stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping PostgreSQL"); + }); + }} + > + + + ))} { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.postgres.update.useMutation(); const { data } = api.postgres.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => { /> diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx index a187104ec..0d6d7a7bc 100644 --- a/apps/dokploy/components/dashboard/project/add-compose.tsx +++ b/apps/dokploy/components/dashboard/project/add-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CircuitBoard, HelpCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -75,11 +75,11 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { const slug = slugify(projectName); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery(); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.compose.create.useMutation(); // Get environment data to extract projectId - const { data: environment } = api.environment.one.useQuery({ environmentId }); + // const { data: environment } = api.environment.one.useQuery({ environmentId }); const hasServers = servers && servers.length > 0; // Show dropdown logic based on cloud environment @@ -117,6 +117,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { await utils.environment.one.invalidate({ environmentId, }); + // Invalidate the project query to refresh the project data for the advance-breadcrumb + await utils.project.all.invalidate(); }) .catch(() => { toast.error("Error creating the compose"); @@ -161,8 +163,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { placeholder="Frontend" {...field} onChange={(e) => { - const val = e.target.value?.trim() || ""; - const serviceName = slugify(val); + const val = e.target.value || ""; + const serviceName = slugify(val.trim()); form.setValue("appName", `${slug}-${serviceName}`); field.onChange(val); }} @@ -307,7 +309,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { - diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx index 9b68ee37c..43deb35b7 100644 --- a/apps/dokploy/components/dashboard/project/add-database.tsx +++ b/apps/dokploy/components/dashboard/project/add-database.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Database, HelpCircle } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -53,14 +53,14 @@ import { import { slugify } from "@/lib/slug"; import { api } from "@/utils/api"; -type DbType = typeof mySchema._type.type; +type DbType = z.infer["type"]; const dockerImageDefaultPlaceholder: Record = { libsql: "ghcr.io/tursodatabase/libsql-server:latest", + mongo: "mongo:7", mariadb: "mariadb:11", - mongo: "mongo:6", mysql: "mysql:8", - postgres: "postgres:15", + postgres: "postgres:18", redis: "redis:7", }; @@ -236,7 +236,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers) const shouldShowServerDropdown = hasServers; - const form = useForm({ + const form = useForm({ defaultValues: { type: "postgres", dockerImage: "", @@ -448,8 +448,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { placeholder="Name" {...field} onChange={(e) => { - const val = e.target.value?.trim() || ""; - const serviceName = slugify(val); + const val = e.target.value || ""; + const serviceName = slugify(val.trim()); form.setValue("appName", `${slug}-${serviceName}`); field.onChange(val); }} @@ -703,6 +703,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { type="password" placeholder="******************" autoComplete="one-time-code" + enablePasswordGenerator={true} {...field} /> @@ -722,6 +723,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 72c42da49..fd37e6a0c 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -116,7 +116,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { ); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery(); - const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery( + const { data: tags, isPending: isLoadingTags } = api.compose.getTags.useQuery( { baseUrl: customBaseUrl }, { enabled: open, @@ -125,7 +125,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { const utils = api.useUtils(); const [serverId, setServerId] = useState(undefined); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.compose.deployTemplate.useMutation(); const templates = @@ -332,6 +332,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { viewMode === "detailed" && "border-b", )} > + {/** biome-ignore lint/performance/noImgElement: this is a valid use for img tag */} { Cancel { const promise = mutateAsync({ serverId: diff --git a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx index d6497fd0f..f5d61fc10 100644 --- a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx +++ b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx @@ -1,15 +1,8 @@ import type { findEnvironmentsByProjectId } from "@dokploy/server"; -import { - ChevronDownIcon, - PencilIcon, - PlusIcon, - Terminal, - TrashIcon, -} from "lucide-react"; +import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react"; import { useRouter } from "next/router"; import { useState } from "react"; import { toast } from "sonner"; -import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables"; import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { @@ -63,13 +56,14 @@ export const AdvancedEnvironmentSelector = ({ const [name, setName] = useState(""); const [description, setDescription] = useState(""); - // API mutations - const { data: environment } = api.environment.one.useQuery( - { environmentId: currentEnvironmentId || "" }, - { - enabled: !!currentEnvironmentId, - }, - ); + // Get current user's permissions + const { data: permissions } = api.user.getPermissions.useQuery(); + + // Check if user can create environments + const canCreateEnvironments = !!permissions?.environment.create; + + // Check if user can delete environments + const canDeleteEnvironments = !!permissions?.environment.delete; const haveServices = selectedEnvironment && @@ -93,16 +87,20 @@ export const AdvancedEnvironmentSelector = ({ await createEnvironment.mutateAsync({ projectId, name: name.trim(), - description: description.trim() || null, + description: description.trim() || undefined, }); toast.success("Environment created successfully"); utils.environment.byProjectId.invalidate({ projectId }); + // Invalidate the project query to refresh the project data for the advance-breadcrumb + utils.project.all.invalidate(); setIsCreateDialogOpen(false); setName(""); setDescription(""); } catch (error) { - toast.error("Failed to create environment"); + toast.error( + `Failed to create environment: ${error instanceof Error ? error.message : error}`, + ); } }; @@ -113,7 +111,7 @@ export const AdvancedEnvironmentSelector = ({ await updateEnvironment.mutateAsync({ environmentId: selectedEnvironment.environmentId, name: name.trim(), - description: description.trim() || null, + description: description.trim() || undefined, }); toast.success("Environment updated successfully"); @@ -123,7 +121,9 @@ export const AdvancedEnvironmentSelector = ({ setName(""); setDescription(""); } catch (error) { - toast.error("Failed to update environment"); + toast.error( + `Failed to update environment: ${error instanceof Error ? error.message : error}`, + ); } }; @@ -140,15 +140,18 @@ export const AdvancedEnvironmentSelector = ({ setIsDeleteDialogOpen(false); setSelectedEnvironment(null); - // Redirect to production if we deleted the current environment + // Redirect to first available environment if we deleted the current environment if (selectedEnvironment.environmentId === currentEnvironmentId) { - const productionEnv = environments?.find( - (env) => env.name === "production", + const firstEnv = environments?.find( + (env) => env.environmentId !== selectedEnvironment.environmentId, ); - if (productionEnv) { + if (firstEnv) { router.push( - `/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`, + `/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`, ); + } else { + // No other environments, redirect to project page + router.push(`/dashboard/project/${projectId}`); } } } catch (error) { @@ -161,7 +164,7 @@ export const AdvancedEnvironmentSelector = ({ const result = await duplicateEnvironment.mutateAsync({ environmentId: environment.environmentId, name: `${environment.name}-copy`, - description: environment.description, + description: environment.description || undefined, }); toast.success("Environment duplicated successfully"); @@ -239,22 +242,8 @@ export const AdvancedEnvironmentSelector = ({ )}
- - {/* Action buttons for non-production environments */} - - - - {environment.name !== "production" && ( -
+
+ {!environment.isDefault && ( - + )} + {canDeleteEnvironments && !environment.isDefault && ( -
- )} + )} +
); })} - setIsCreateDialogOpen(true)} - > - - Create Environment - + {canCreateEnvironments && ( + setIsCreateDialogOpen(true)} + > + + Create Environment + + )} @@ -338,9 +330,9 @@ export const AdvancedEnvironmentSelector = ({ @@ -391,9 +383,9 @@ export const AdvancedEnvironmentSelector = ({ @@ -431,12 +423,12 @@ export const AdvancedEnvironmentSelector = ({ variant="destructive" onClick={handleDeleteEnvironment} disabled={ - deleteEnvironment.isLoading || + deleteEnvironment.isPending || haveServices || !selectedEnvironment } > - {deleteEnvironment.isLoading ? "Deleting..." : "Delete"} + {deleteEnvironment.isPending ? "Deleting..." : "Delete"} diff --git a/apps/dokploy/components/dashboard/project/ai/step-two.tsx b/apps/dokploy/components/dashboard/project/ai/step-two.tsx index 09484bc57..e13ff40ad 100644 --- a/apps/dokploy/components/dashboard/project/ai/step-two.tsx +++ b/apps/dokploy/components/dashboard/project/ai/step-two.tsx @@ -28,7 +28,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => { const suggestions = templateInfo.suggestions || []; const selectedVariant = templateInfo.details; - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.ai.suggest.useMutation(); useEffect(() => { @@ -184,7 +184,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
); } - if (isLoading) { + if (isPending) { return (
diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx index 0ad0abec8..f1418117a 100644 --- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -25,7 +25,6 @@ import { import { api } from "@/utils/api"; export type Services = { - appName: string; serverId?: string | null; name: string; type: @@ -77,7 +76,7 @@ export const DuplicateProject = ({ selectedServiceIds.includes(service.id), ); - const { mutateAsync: duplicateProject, isLoading } = + const { mutateAsync: duplicateProject, isPending } = api.project.duplicate.useMutation({ onSuccess: async (newProject) => { await utils.project.all.invalidate(); @@ -322,20 +321,20 @@ export const DuplicateProject = ({ - + {canWrite && ( + + + + )}
diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index 09fd36f84..309e41dab 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -1,4 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + import { PlusIcon, SquarePen } from "lucide-react"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -6,6 +7,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; +import { TagSelector } from "@/components/shared/tag-selector"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -61,6 +63,7 @@ interface Props { export const HandleProject = ({ projectId }: Props) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); + const [selectedTagIds, setSelectedTagIds] = useState([]); const { mutateAsync, error, isError } = projectId ? api.project.update.useMutation() @@ -74,13 +77,17 @@ export const HandleProject = ({ projectId }: Props) => { enabled: !!projectId, }, ); + + const { data: availableTags = [] } = api.tag.all.useQuery(); + const bulkAssignMutation = api.tag.bulkAssign.useMutation(); + const router = useRouter(); const form = useForm({ defaultValues: { description: "", name: "", }, - resolver: zodResolver(AddProjectSchema), + resolver: standardSchemaResolver(AddProjectSchema), }); useEffect(() => { @@ -88,6 +95,13 @@ export const HandleProject = ({ projectId }: Props) => { description: data?.description ?? "", name: data?.name ?? "", }); + // Load existing tags when editing a project + if (data?.projectTags) { + const tagIds = data.projectTags.map((pt) => pt.tagId); + setSelectedTagIds(tagIds); + } else { + setSelectedTagIds([]); + } }, [form, form.reset, form.formState.isSubmitSuccessful, data]); const onSubmit = async (data: AddProject) => { @@ -97,12 +111,26 @@ export const HandleProject = ({ projectId }: Props) => { projectId: projectId || "", }) .then(async (data) => { + // Assign tags to the project (both create and update) + const projectIdToUse = + projectId || + (data && "project" in data ? data.project.projectId : undefined); + + if (projectIdToUse) { + try { + await bulkAssignMutation.mutateAsync({ + projectId: projectIdToUse, + tagIds: selectedTagIds, + }); + } catch (error) { + toast.error("Failed to assign tags to project"); + } + } + await utils.project.all.invalidate(); toast.success(projectId ? "Project Updated" : "Project Created"); setIsOpen(false); if (!projectId) { - const projectIdToUse = - data && "project" in data ? data.project.projectId : undefined; const environmentIdToUse = data && "environment" in data ? data.environment.environmentId @@ -189,6 +217,20 @@ export const HandleProject = ({ projectId }: Props) => { )} /> + +
+ Tags + ({ + id: tag.tagId, + name: tag.name, + color: tag.color ?? undefined, + }))} + selectedTags={selectedTagIds} + onTagsChange={setSelectedTagIds} + placeholder="Select tags..." + /> +
diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index 86dfd2433..46e5d1f54 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { FileIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -39,9 +39,12 @@ interface Props { } export const ProjectEnvironment = ({ projectId, children }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.projectEnvVars.read ?? false; + const canWrite = permissions?.projectEnvVars.write ?? false; const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.project.update.useMutation(); const { data } = api.project.one.useQuery( { @@ -81,6 +84,25 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { .finally(() => {}); }; + // Add keyboard shortcut for Ctrl+S/Cmd+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [form, onSubmit, isPending, isOpen]); + + if (!canRead) { + return null; + } + return ( @@ -124,6 +146,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { )} /> - - - + {canWrite && ( + + + + )}
diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 4833cb019..0bfd7d044 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -2,7 +2,6 @@ import { AlertTriangle, ArrowUpDown, BookIcon, - ExternalLinkIcon, FolderInput, Loader2, MoreHorizontalIcon, @@ -10,12 +9,15 @@ 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 { TagBadge } from "@/components/shared/tag-badge"; +import { TagFilter } from "@/components/shared/tag-filter"; import { AlertDialog, AlertDialogAction, @@ -39,10 +41,8 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -52,16 +52,27 @@ 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 { data, isLoading } = api.project.all.useQuery(); + const router = useRouter(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data, isPending } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); - const [searchQuery, setSearchQuery] = useState(""); + const { data: availableTags } = api.tag.all.useQuery(); + + 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"; @@ -69,20 +80,77 @@ export const ShowProjects = () => { return "createdAt-desc"; }); + const [selectedTagIds, setSelectedTagIds] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("projectsTagFilter"); + return saved ? JSON.parse(saved) : []; + } + return []; + }); + useEffect(() => { localStorage.setItem("projectsSort", sortBy); }, [sortBy]); + useEffect(() => { + localStorage.setItem("projectsTagFilter", JSON.stringify(selectedTagIds)); + }, [selectedTagIds]); + + useEffect(() => { + if (!availableTags) return; + const validIds = new Set(availableTags.map((t) => t.tagId)); + setSelectedTagIds((prev) => { + const filtered = prev.filter((id) => validIds.has(id)); + return filtered.length === prev.length ? prev : filtered; + }); + }, [availableTags]); + + 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( + let 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()), ); + // Filter by selected tags (OR logic: show projects with ANY selected tag) + if (selectedTagIds.length > 0) { + filtered = filtered.filter((project) => + project.projectTags?.some((pt) => + selectedTagIds.includes(pt.tag.tagId), + ), + ); + } + // Then sort the filtered results const [field, direction] = sortBy.split("-"); return [...filtered].sort((a, b) => { @@ -96,8 +164,30 @@ export const ShowProjects = () => { new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); break; case "services": { - const aTotalServices = a.environments.length; - const bTotalServices = b.environments.length; + const aTotalServices = a.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + const bTotalServices = b.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); comparison = aTotalServices - bTotalServices; break; } @@ -106,10 +196,15 @@ export const ShowProjects = () => { } return direction === "asc" ? comparison : -comparison; }); - }, [data, searchQuery, sortBy]); + }, [data, debouncedSearchQuery, sortBy, selectedTagIds]); return ( <> + {!isCloud && ( +
+ +
+ )} @@ -126,8 +221,7 @@ export const ShowProjects = () => { Create and manage your projects - - {(auth?.role === "owner" || auth?.canCreateProjects) && ( + {permissions?.project.create && (
@@ -135,7 +229,7 @@ export const ShowProjects = () => {
- {isLoading ? ( + {isPending ? (
Loading... @@ -153,29 +247,44 @@ export const ShowProjects = () => {
-
- - +
+ ({ + id: tag.tagId, + name: tag.name, + color: tag.color || undefined, + })) || [] + } + selectedTags={selectedTagIds} + onTagsChange={setSelectedTagIds} + /> +
+ + +
{filteredProjects?.length === 0 && ( @@ -216,13 +325,12 @@ export const ShowProjects = () => { ) .reduce((acc, curr) => acc + curr, 0); - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0, - ) - .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 (
{ className="w-full lg:max-w-md" > { + if (hasNoEnvironments) { + e.preventDefault(); + } + }} > - {haveServicesWithDomains ? ( - - - - - e.stopPropagation()} - > - {project.environments.some( - (env) => env.applications.length > 0, - ) && ( - - - Applications - - {project.environments.map((env) => - env.applications.map((app) => ( -
- - - - {app.name} - - - - {app.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} - {project.environments.some( - (env) => env.compose.length > 0, - ) && ( - - - Compose - - {project.environments.map((env) => - env.compose.map((comp) => ( -
- - - - {comp.name} - - - - {comp.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} -
-
- ) : null} - - + +
@@ -347,9 +360,32 @@ export const ShowProjects = () => {
- + {project.description} + + {project.projectTags && + project.projectTags.length > 0 && ( +
+ {project.projectTags.map((pt) => ( + + ))} +
+ )} + + {hasNoEnvironments && ( +
+ + + You have access to this project but no + environments are available + +
+ )}
@@ -387,8 +423,7 @@ export const ShowProjects = () => {
e.stopPropagation()} > - {(auth?.role === "owner" || - auth?.canDeleteProjects) && ( + {permissions?.project.delete && ( { -
+
Created diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx index 8edd92389..ebc01200a 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -48,11 +48,11 @@ interface Props { export const ShowExternalRedisCredentials = ({ redisId }: Props) => { const { data: ip } = api.settings.getIp.useQuery(); const { data, refetch } = api.redis.one.useQuery({ redisId }); - const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation(); + const { mutateAsync, isPending } = api.redis.saveExternalPort.useMutation(); const [connectionUrl, setConnectionUrl] = useState(""); const getIp = data?.server?.ipAddress || ip; - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(DockerProviderSchema), }); @@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; @@ -134,7 +134,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { @@ -154,7 +154,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx index de70cc558..0018ddcde 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx @@ -21,6 +21,8 @@ interface Props { } export const ShowGeneralRedis = ({ redisId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; const { data, refetch } = api.redis.one.useQuery( { redisId, @@ -28,12 +30,12 @@ export const ShowGeneralRedis = ({ redisId }: Props) => { { enabled: !!redisId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.redis.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.redis.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.redis.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); @@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => { - { - setIsDeploying(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - refetch(); - }} - > - - - { - await reload({ - redisId: redisId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Redis reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading Redis"); - }); - }} - > - - - {data?.applicationStatus === "idle" ? ( + {canDeploy && ( { - await start({ - redisId: redisId, - }) - .then(() => { - toast.success("Redis started successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error starting Redis"); - }); + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); }} > - - ) : ( - { - await stop({ - redisId: redisId, - }) - .then(() => { - toast.success("Redis stopped successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error stopping Redis"); - }); - }} - > - )} + {canDeploy && ( + { + await reload({ + redisId: redisId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Redis reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Redis"); + }); + }} + > + + + )} + {canDeploy && + (data?.applicationStatus === "idle" ? ( + { + await start({ + redisId: redisId, + }) + .then(() => { + toast.success("Redis started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Redis"); + }); + }} + > + + + ) : ( + { + await stop({ + redisId: redisId, + }) + .then(() => { + toast.success("Redis stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Redis"); + }); + }} + > + + + ))} { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.redis.update.useMutation(); const { data } = api.redis.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => { utils.redis.one.invalidate({ redisId: redisId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating Redis"); @@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => { }; return ( - +
- Status: {log.OriginStatus} + Status: {formatStatusLabel(log.OriginStatus)} - Exec Time: {`${log.Duration / 1000000000}s`} + Exec Time: {formatDuration(log.Duration)} IP: {log.ClientAddr}
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/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 45a531324..e804b065b 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return JSON.stringify(value, null, 2); } if (key === "Duration" || key === "OriginDuration" || key === "Overhead") { - return `${value / 1000000000} s`; + const nanos = Number(value); + const ms = nanos / 1000000; + if (ms < 1) { + return `${(nanos / 1000).toFixed(2)} µs`; + } + if (ms < 1000) { + return `${ms.toFixed(2)} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; } if (key === "level") { return {value}; @@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return {value}; } if (key === "DownstreamStatus" || key === "OriginStatus") { - return {value}; + const num = Number(value); + if (num === 0) { + return N/A; + } + return {value}; } return value; }; 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) && ( - - )} + + +
+ + New plan +
+ + +
+ + {upgradeTier && ( +
+ + Servers + {upgradeTier === "startup" && + ` (min. ${STARTUP_SERVERS_INCLUDED})`} + +
+ + { + const v = + Number((e.target as HTMLInputElement).value) || + 0; + setUpgradeServerQty( + Math.max( + upgradeTier === "startup" + ? STARTUP_SERVERS_INCLUDED + : 1, + v, + ), + ); + }} + className="w-20 h-8" + /> + +
+

+ {upgradeTier === "hobby" + ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}` + : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`} +

+ +

+ Current plan: Legacy +

+

+ New plan:{" "} + {upgradeTier === "startup" + ? "Startup" + : "Hobby"}{" "} + · {upgradeServerQty} server + {upgradeServerQty !== 1 ? "s" : ""} · $ + {upgradeTier === "hobby" + ? calculatePriceHobby( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2) + : calculatePriceStartup( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2)} + /{updateFormAnnual ? "yr" : "mo"} ( + {updateFormAnnual ? "annual" : "monthly"}) +

+

+ Stripe will prorate the change. +

+
+ } + type="default" + onClick={async () => { + if (!upgradeTier) return; + try { + await upgradeSubscription({ + tier: upgradeTier, + serverQuantity: upgradeServerQty, + isAnnual: updateFormAnnual, + }); + await utils.stripe.getProducts.invalidate(); + await utils.user.get.invalidate(); + setUpgradeTier(null); + toast.success("Plan upgraded successfully"); + } catch { + toast.error("Error upgrading plan"); + } + }} + > + + +
+ )} +
+ )} + {/* Cambiar plan o cantidad de servidores (usuarios en Hobby o Startup; el portal no permite esto) */} + {useNewPricing && + (data?.currentPlan === "hobby" || + data?.currentPlan === "startup") && + data?.subscriptions?.length > 0 && ( +
+

+ Change plan or number of servers +

+

+ Your current plan:{" "} + + {data?.currentPlan === "startup" ? "Startup" : "Hobby"} + + {" · "} + + {admin?.user.serversQuantity ?? 0} server + {(admin?.user.serversQuantity ?? 0) !== 1 ? "s" : ""} + + {data?.currentPriceAmount != null && ( + <> + {" · "} + + ${data.currentPriceAmount.toFixed(2)}/ + {data?.isAnnualCurrent ? "yr" : "mo"} + + + )}{" "} + ({data?.isAnnualCurrent ? "annual" : "monthly"} billing). +

+

+ Add more servers, switch between Hobby and Startup, or + change to annual billing (20% off). Stripe will prorate + the change. +

+ + + Billing interval + +
+ + +
+ + Plan +
+ + +
+ + {upgradeTier && ( +
+ + Servers + {upgradeTier === "startup" && + ` (min. ${STARTUP_SERVERS_INCLUDED})`} + +
+ + { + const v = + Number((e.target as HTMLInputElement).value) || + 0; + setUpgradeServerQty( + Math.max( + upgradeTier === "startup" + ? STARTUP_SERVERS_INCLUDED + : 1, + v, + ), + ); + }} + className="w-20 h-8" + /> + +
+

+ {upgradeTier === "hobby" + ? `$${calculatePriceHobby(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}` + : `$${calculatePriceStartup(upgradeServerQty, updateFormAnnual).toFixed(2)} per ${updateFormAnnual ? "year" : "month"}`} +

+ +

+ Current plan:{" "} + {data?.currentPlan === "startup" + ? "Startup" + : "Hobby"}{" "} + · {admin?.user.serversQuantity ?? 0} server + {(admin?.user.serversQuantity ?? 0) !== 1 + ? "s" + : ""}{" "} + ·{" "} + {data?.currentPriceAmount != null + ? `$${data.currentPriceAmount.toFixed(2)}/${data?.isAnnualCurrent ? "yr" : "mo"}` + : ""}{" "} + ({data?.isAnnualCurrent ? "annual" : "monthly"}) +

+

+ New plan:{" "} + {upgradeTier === "startup" + ? "Startup" + : "Hobby"}{" "} + · {upgradeServerQty} server + {upgradeServerQty !== 1 ? "s" : ""} · $ + {upgradeTier === "hobby" + ? calculatePriceHobby( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2) + : calculatePriceStartup( + upgradeServerQty, + updateFormAnnual, + ).toFixed(2)} + /{updateFormAnnual ? "yr" : "mo"} ( + {updateFormAnnual ? "annual" : "monthly"}) +

+

+ Stripe will prorate the change. +

+
+ } + type="default" + onClick={async () => { + if (!upgradeTier) return; + try { + await upgradeSubscription({ + tier: upgradeTier, + serverQuantity: upgradeServerQty, + isAnnual: updateFormAnnual, + }); + await utils.stripe.getProducts.invalidate(); + + // add delay of 3 seconds + await new Promise((resolve) => + setTimeout(resolve, 3000), + ); + await utils.user.get.invalidate(); + setUpgradeTier(null); + toast.success( + "Subscription updated successfully", + ); + } catch { + toast.error("Error updating subscription"); + } + }} + > + + +
+ )} +
+ )}
Need Help? We are here to help you. @@ -145,13 +652,357 @@ export const ShowBilling = () => {
- {isLoading ? ( + {isPending ? ( Loading... + ) : useNewPricing ? ( + <> + setIsAnnual(e === "annual")} + > + + Monthly + Annual (20% off) + + +
+ {/* Hobby */} +
+ {isAnnual && ( + + 20% off + + )} +

+ Hobby +

+

+ Everything an individual developer needs +

+
+

+ $ + {calculatePriceHobby( + hobbyServerQuantity, + isAnnual, + ).toFixed(2)} + /{isAnnual ? "yr" : "mo"} +

+

+ Add more servers as you'd like for{" "} + {isAnnual ? "$43.20/yr" : "$4.50/mo"} +

+ {isAnnual && ( +

+ $ + {( + calculatePriceHobby(hobbyServerQuantity, true) / + 12 + ).toFixed(2)} + /mo +

+ )} +
+
    + {[ + "Unlimited Deployments", + "Unlimited Databases", + "Unlimited Applications", + "1 Server Included", + "1 Organization", + "1 User", + "2 Environments", + "1 Volume Backup per Application", + "1 Backup per Database", + "1 Scheduled Job per Application", + "Community Support (Discord)", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+
+ + Servers: + + + + setHobbyServerQuantity( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+
+ {admin?.user.stripeCustomerId && ( + + )} + {(data?.subscriptions?.length ?? 0) === 0 && ( + + )} +
+
+
+ + {/* Startup - Recommended */} +
+
+ + Recommended + + {isAnnual && ( + + 20% off + + )} +
+

+ Startup +

+

+ Perfect for small to mid-size teams +

+
+

+ $ + {calculatePriceStartup( + startupServerQuantity, + isAnnual, + ).toFixed(2)} + /{isAnnual ? "yr" : "mo"} +

+

+ Add more servers as you'd like for{" "} + {isAnnual ? "$43.20/yr" : "$4.50/mo"} +

+ {isAnnual && ( +

+ $ + {( + calculatePriceStartup( + startupServerQuantity, + true, + ) / 12 + ).toFixed(2)} + /mo +

+ )} +
+
    +
  • + + All the features of Hobby, plus… +
  • + {[ + "3 Servers Included", + "3 Organizations", + "Unlimited Users", + "Unlimited Environments", + "Unlimited Volume Backups", + "Unlimited Database Backups", + "Unlimited Scheduled Jobs", + "Basic RBAC (Admin, Developer)", + "2FA", + "Email and Chat Support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+
+ + Servers (min. {STARTUP_SERVERS_INCLUDED} included) + +
+ + + setStartupServerQuantity( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+
+ {admin?.user.stripeCustomerId && ( + + )} + {(data?.subscriptions?.length ?? 0) === 0 && ( + + )} +
+
+
+ + {/* Enterprise */} +
+

+ Enterprise +

+

+ For large organizations who want more control +

+
+

+ Contact Sales +

+
+
    +
  • + + All the features of Startup, plus… +
  • + {[ + "Up to Unlimited Servers", + "Up to Unlimited Organizations", + "Fine-grained RBAC", + "Complete Hosting Flexibility", + "SSO / SAML (Azure, OKTA, etc)", + "Audit Logs", + "MSA/SLA", + "White Labeling", + "Priority Support and Services", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+
+ ) : ( <> + setIsAnnual(e === "annual")} + > + + Monthly + Annual (20% off) + + {products?.map((product) => { const featured = true; return ( @@ -174,7 +1025,7 @@ export const ShowBilling = () => {

${" "} {calculatePrice( - serverQuantity, + hobbyServerQuantity, isAnnual, ).toFixed(2)}{" "} USD @@ -183,7 +1034,10 @@ export const ShowBilling = () => {

${" "} {( - calculatePrice(serverQuantity, isAnnual) / 12 + calculatePrice( + hobbyServerQuantity, + isAnnual, + ) / 12 ).toFixed(2)}{" "} / Month USD

@@ -191,9 +1045,10 @@ export const ShowBilling = () => { ) : (

${" "} - {calculatePrice(serverQuantity, isAnnual).toFixed( - 2, - )}{" "} + {calculatePrice( + hobbyServerQuantity, + isAnnual, + ).toFixed(2)}{" "} USD

)} @@ -236,26 +1091,28 @@ export const ShowBilling = () => {
- {serverQuantity} Servers + {hobbyServerQuantity} Servers
{ - setServerQuantity( + setHobbyServerQuantity( e.target.value as unknown as number, ); }} @@ -264,21 +1121,15 @@ export const ShowBilling = () => {
-
0 - ? "justify-between" - : "justify-end", - "flex flex-row items-center gap-2 mt-4", - )} - > +
{admin?.user.stripeCustomerId && ( )} - - {data?.subscriptions?.length === 0 && ( -
- -
+ {(data?.subscriptions?.length ?? 0) === 0 && ( + )}
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx new file mode 100644 index 000000000..b10e09596 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx @@ -0,0 +1,137 @@ +import { Download, ExternalLink, FileText, Loader2 } from "lucide-react"; +import type Stripe from "stripe"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +const formatDate = (timestamp: number | null) => { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +const formatAmount = (amount: number, currency: string) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); +}; + +const getStatusBadge = (status: Stripe.Invoice.Status | null) => { + const statusConfig: Record< + Stripe.Invoice.Status, + { label: string; variant: "default" | "secondary" | "destructive" } + > = { + paid: { label: "Paid", variant: "default" }, + open: { label: "Open", variant: "secondary" }, + draft: { label: "Draft", variant: "secondary" }, + void: { label: "Void", variant: "destructive" }, + uncollectible: { label: "Uncollectible", variant: "destructive" }, + }; + + if (!status) { + return Unknown; + } + + const config = statusConfig[status] || { + label: status, + variant: "secondary" as const, + }; + + return {config.label}; +}; + +export const ShowInvoices = () => { + const { data: invoices, isPending } = api.stripe.getInvoices.useQuery(); + + return ( +
+ {isPending ? ( +
+ + Loading invoices... + + +
+ ) : invoices && invoices.length > 0 ? ( +
+ + + + Invoice + Date + Due Date + Amount + Status + Actions + + + + {invoices.map((invoice) => ( + + + {invoice.number || invoice.id.slice(0, 12)} + + {formatDate(invoice.created)} + {formatDate(invoice.dueDate)} + + {formatAmount(invoice.amountDue, invoice.currency)} + + {getStatusBadge(invoice.status)} + +
+ {invoice.hostedInvoiceUrl && ( + + )} + {invoice.invoicePdf && ( + + )} +
+
+
+ ))} +
+
+
+ ) : ( +
+ +

No invoices found

+

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

+
+ )} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx index f87ca58c7..ca1407d7e 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx @@ -12,7 +12,7 @@ export const ShowWelcomeDokploy = () => { const { data } = api.user.get.useQuery(); const [open, setOpen] = useState(false); - const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); + const { data: isCloud, isPending } = api.settings.isCloud.useQuery(); if (!isCloud || data?.role !== "admin") { return null; @@ -20,14 +20,14 @@ export const ShowWelcomeDokploy = () => { useEffect(() => { if ( - !isLoading && + !isPending && isCloud && !localStorage.getItem("hasSeenCloudWelcomeModal") && data?.role === "owner" ) { setOpen(true); } - }, [isCloud, isLoading]); + }, [isCloud, isPending]); const handleClose = (isOpen: boolean) => { if (data?.role === "owner") { diff --git a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx index 6f7ef6821..bc29a4c95 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { HelpCircle, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -62,7 +62,7 @@ export const AddCertificate = () => { const utils = api.useUtils(); const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync, isError, error, isLoading } = + const { mutateAsync, isError, error, isPending } = api.certificates.create.useMutation(); const { data: servers } = api.server.withSSHKey.useQuery(); const hasServers = servers && servers.length > 0; @@ -247,7 +247,7 @@ export const AddCertificate = () => { - -
+ + +
+ )}
); })}
-
- -
+ {permissions?.certificate.create && ( +
+ +
+ )}
)} diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts index e2aa59ef3..79b763a97 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts +++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts @@ -14,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => { // Helper: read ASN.1 length field function readLength(pos: number): { length: number; offset: number } { - // biome-ignore lint/style/noParameterAssign: + // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation let len = der[pos++]; if (len & 0x80) { const bytes = len & 0x7f; len = 0; for (let i = 0; i < bytes; i++) { - // biome-ignore lint/style/noParameterAssign: + // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation len = (len << 8) + der[pos++]; } } diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx index 36dda311c..a24e4dc2f 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/manager/add-manager.tsx @@ -15,7 +15,7 @@ interface Props { } export const AddManager = ({ serverId }: Props) => { - const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({ + const { data, isPending, error, isError } = api.cluster.addManager.useQuery({ serverId, }); @@ -27,7 +27,7 @@ export const AddManager = ({ serverId }: Props) => { Add a new manager {isError && {error?.message}} - {isLoading ? ( + {isPending ? ( ) : ( <> diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx index b88fdd8e8..c7f580caa 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx @@ -48,7 +48,7 @@ interface Props { } export const ShowNodes = ({ serverId }: Props) => { - const { data, isLoading, refetch } = api.cluster.getNodes.useQuery({ + const { data, isPending, refetch } = api.cluster.getNodes.useQuery({ serverId, }); const { data: registry } = api.registry.all.useQuery(); @@ -75,7 +75,7 @@ export const ShowNodes = ({ serverId }: Props) => { )} - {isLoading ? ( + {isPending ? (
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx index c73e458ef..40a673265 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/workers/add-worker.tsx @@ -15,7 +15,7 @@ interface Props { } export const AddWorker = ({ serverId }: Props) => { - const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({ + const { data, isPending, error, isError } = api.cluster.addWorker.useQuery({ serverId, }); @@ -26,7 +26,7 @@ export const AddWorker = ({ serverId }: Props) => { Add a new worker {isError && {error?.message}} - {isLoading ? ( + {isPending ? ( ) : ( <> diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx index 4321088f2..e22285c73 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({ username: z.string().min(1, { message: "Username is required", }), - password: z.string().min(1, { - message: "Password is required", - }), - registryUrl: z.string(), + password: z.string(), + registryUrl: z + .string() + .optional() + .refine( + (val) => { + // If empty or undefined, skip validation (field is optional) + if (!val || val.trim().length === 0) { + return true; + } + // Validate that it's a valid hostname (no protocol, no path, optional port) + // Valid formats: example.com, registry.example.com, [::1], example.com:5000 + // Invalid: https://example.com, example.com/path + const trimmed = val.trim(); + // Check for protocol or path - these are not allowed + if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) { + return false; + } + // Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets + // Allow optional port at the end + const hostnameRegex = + /^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/; + return hostnameRegex.test(trimmed); + }, + { + message: + "Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.", + }, + ), imagePrefix: z.string(), serverId: z.string().optional(), + isEditing: z.boolean().optional(), }); type AddRegistry = z.infer; @@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => { const { mutateAsync, error, isError } = registryId ? api.registry.update.useMutation() : api.registry.create.useMutation(); - const { data: servers } = api.server.withSSHKey.useQuery(); + const { data: deployServers } = api.server.withSSHKey.useQuery(); + const { data: buildServers } = api.server.buildServers.useQuery(); + const servers = [...(deployServers || []), ...(buildServers || [])]; const { mutateAsync: testRegistry, - isLoading, + isPending, error: testRegistryError, isError: testRegistryIsError, } = api.registry.testRegistry.useMutation(); + const { + mutateAsync: testRegistryById, + isPending: isPendingById, + error: testRegistryByIdError, + isError: testRegistryByIdIsError, + } = api.registry.testRegistryById.useMutation(); const form = useForm({ defaultValues: { username: "", @@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => { imagePrefix: "", registryName: "", serverId: "", + isEditing: !!registryId, }, - resolver: zodResolver(AddRegistrySchema), + resolver: zodResolver( + AddRegistrySchema.refine( + (data) => { + // When creating a new registry, password is required + if ( + !data.isEditing && + (!data.password || data.password.length === 0) + ) { + return false; + } + return true; + }, + { + message: "Password is required", + path: ["password"], + }, + ), + ), }); const password = form.watch("password"); @@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => { const registryName = form.watch("registryName"); const imagePrefix = form.watch("imagePrefix"); const serverId = form.watch("serverId"); + const selectedServer = servers?.find( + (server) => server.serverId === serverId, + ); useEffect(() => { if (registry) { @@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => { registryUrl: registry.registryUrl, imagePrefix: registry.imagePrefix || "", registryName: registry.registryName, + isEditing: true, }); } else { form.reset({ @@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => { registryUrl: "", imagePrefix: "", serverId: "", + isEditing: false, }); } }, [form, form.reset, form.formState.isSubmitSuccessful, registry]); const onSubmit = async (data: AddRegistry) => { - await mutateAsync({ - password: data.password, + const payload: any = { registryName: data.registryName, username: data.username, - registryUrl: data.registryUrl, + registryUrl: data.registryUrl || "", registryType: "cloud", imagePrefix: data.imagePrefix, serverId: data.serverId, registryId: registryId || "", - }) + }; + + // Only include password if it's been provided (not empty) + // When editing, empty password means "keep the existing password" + if (data.password && data.password.length > 0) { + payload.password = data.password; + } + + await mutateAsync(payload) .then(async (_data) => { await utils.registry.all.invalidate(); toast.success(registryId ? "Registry updated" : "Registry added"); @@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => { Fill the next fields to add a external registry. - {(isError || testRegistryIsError) && ( + {(isError || testRegistryIsError || testRegistryByIdIsError) && (
- {testRegistryError?.message || error?.message || ""} + {testRegistryError?.message || + testRegistryByIdError?.message || + error?.message || + ""}
)} @@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => { name="password" render={({ field }) => ( - Password + Password{registryId && " (Optional)"} + {registryId && ( + + Leave blank to keep existing password. Enter new + password to test or update it. + + )} { render={({ field }) => ( Registry URL + + Enter only the hostname (e.g., + aws_account_id.dkr.ecr.us-west-2.amazonaws.com). + { Server {!isCloud && "(Optional)"} - Select a server to test the registry. this will run the - following command on the server + {!isCloud ? ( + <> + {serverId && serverId !== "none" && selectedServer ? ( + <> + Authentication will be performed on{" "} + {selectedServer.name}. This + registry will be available on this server. + + ) : ( + <> + Choose where to authenticate with the registry. By + default, authentication occurs on the Dokploy + server. Select a specific server to authenticate + from that server instead. + + )} + + ) : ( + <> + {serverId && serverId !== "none" && selectedServer ? ( + <> + Authentication will be performed on{" "} + {selectedServer.name}. This + registry will be available on this server. + + ) : ( + <> + Select a server to authenticate with the registry. + The authentication will be performed from the + selected server. + + )} + + )} @@ -179,14 +181,27 @@ export const AddBitbucketProvider = () => { ( - App Password + Bitbucket Email + + + + + + )} + /> + + ( + + API Token @@ -200,7 +215,7 @@ export const AddBitbucketProvider = () => { name="workspaceName" render={({ field }) => ( - Workspace Name (Optional) + Workspace Name (optional) ; @@ -55,24 +58,33 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { mutateAsync, error, isError } = api.bitbucket.update.useMutation(); - const { mutateAsync: testConnection, isLoading } = + const { mutateAsync: testConnection, isPending } = api.bitbucket.testConnection.useMutation(); const form = useForm({ defaultValues: { username: "", + email: "", workspaceName: "", + apiToken: "", + appPassword: "", }, resolver: zodResolver(Schema), }); const username = form.watch("username"); + const email = form.watch("email"); const workspaceName = form.watch("workspaceName"); + const apiToken = form.watch("apiToken"); + const appPassword = form.watch("appPassword"); useEffect(() => { form.reset({ username: bitbucket?.bitbucketUsername || "", + email: bitbucket?.bitbucketEmail || "", workspaceName: bitbucket?.bitbucketWorkspaceName || "", name: bitbucket?.gitProvider.name || "", + apiToken: bitbucket?.apiToken || "", + appPassword: bitbucket?.appPassword || "", }); }, [form, isOpen, bitbucket]); @@ -81,8 +93,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { bitbucketId, gitProviderId: bitbucket?.gitProviderId || "", bitbucketUsername: data.username, + bitbucketEmail: data.email || "", bitbucketWorkspaceName: data.workspaceName || "", name: data.name || "", + apiToken: data.apiToken || "", + appPassword: data.appPassword || "", }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -121,6 +136,12 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => { >
+

+ Update your Bitbucket authentication. Use API Token for + enhanced security (recommended) or App Password for legacy + support. +

+ { )} /> + ( + + Email (Required for API Tokens) + + + + + + )} + /> + { )} /> +
+

+ Authentication (Update to use API Token) +

+ ( + + API Token (Recommended) + + + + + + )} + /> + + ( + + + App Password (Legacy - will be deprecated June 2026) + + + + + + + )} + /> +
+
-
+
+ {isBitbucket && + gitProvider.bitbucket?.appPassword && + !gitProvider.bitbucket?.apiToken ? ( + Deprecated + ) : null} + {!haveGithubRequirements && isGithub && (
{ const utils = api.useUtils(); - const [error, setError] = useState(null); const [open, setOpen] = useState(false); + const [modelPopoverOpen, setModelPopoverOpen] = useState(false); + const [modelSearch, setModelSearch] = useState(""); const { data, refetch } = api.ai.one.useQuery( { aiId: aiId || "", @@ -61,7 +68,7 @@ export const HandleAi = ({ aiId }: Props) => { enabled: !!aiId, }, ); - const { mutateAsync, isLoading } = aiId + const { mutateAsync, isPending } = aiId ? api.ai.update.useMutation() : api.ai.create.useMutation(); @@ -77,40 +84,36 @@ export const HandleAi = ({ aiId }: Props) => { }); useEffect(() => { - form.reset({ - name: data?.name ?? "", - apiUrl: data?.apiUrl ?? "https://api.openai.com/v1", - apiKey: data?.apiKey ?? "", - model: data?.model ?? "", - isEnabled: data?.isEnabled ?? true, - }); + if (data) { + form.reset({ + name: data?.name ?? "", + apiUrl: data?.apiUrl ?? "https://api.openai.com/v1", + apiKey: data?.apiKey ?? "", + model: data?.model ?? "", + isEnabled: data?.isEnabled ?? true, + }); + } + setModelSearch(""); + setModelPopoverOpen(false); }, [aiId, form, data]); const apiUrl = form.watch("apiUrl"); const apiKey = form.watch("apiKey"); const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama"); - const { data: models, isLoading: isLoadingServerModels } = - api.ai.getModels.useQuery( - { - apiUrl: apiUrl ?? "", - apiKey: apiKey ?? "", - }, - { - enabled: !!apiUrl && (isOllama || !!apiKey), - onError: (error) => { - setError(`Failed to fetch models: ${error.message}`); - }, - }, - ); - - useEffect(() => { - const apiUrl = form.watch("apiUrl"); - const apiKey = form.watch("apiKey"); - if (apiUrl && apiKey) { - form.setValue("model", ""); - } - }, [form.watch("apiUrl"), form.watch("apiKey")]); + const { + data: models, + isPending: isLoadingServerModels, + error: modelsError, + } = api.ai.getModels.useQuery( + { + apiUrl: apiUrl ?? "", + apiKey: apiKey ?? "", + }, + { + enabled: !!apiUrl && (isOllama || !!apiKey), + }, + ); const onSubmit = async (data: Schema) => { try { @@ -131,7 +134,16 @@ export const HandleAi = ({ aiId }: Props) => { }; return ( - + { + setOpen(isOpen); + if (!isOpen) { + setModelSearch(""); + setModelPopoverOpen(false); + } + }} + > {aiId ? ( + + + + + + + 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 + + + + ); + }} /> )} @@ -283,7 +373,7 @@ export const HandleAi = ({ aiId }: Props) => { />
-
diff --git a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx new file mode 100644 index 000000000..dcfa0b04f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { Link2, Loader2, Unlink } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; + +const LINKING_CALLBACK_URL = "/dashboard/settings/profile"; + +const TRUSTED_PROVIDERS = ["google", "github"] as const; +type SocialProvider = (typeof TRUSTED_PROVIDERS)[number]; + +type AccountItem = { + providerId: string; + accountId?: string; +}; + +function providerLabel(providerId: string): string { + return providerId.charAt(0).toUpperCase() + providerId.slice(1); +} + +export function LinkingAccount() { + const [accounts, setAccounts] = useState([]); + const [accountsLoading, setAccountsLoading] = useState(true); + const [linkingProvider, setLinkingProvider] = useState( + null, + ); + const [unlinkingProviderId, setUnlinkingProviderId] = useState( + null, + ); + + const fetchAccounts = useCallback(async () => { + setAccountsLoading(true); + try { + const { data } = await authClient.listAccounts(); + const list = Array.isArray(data) + ? data + : ((data && typeof data === "object" && "accounts" in data + ? (data as { accounts?: AccountItem[] }).accounts + : null) ?? []); + setAccounts(Array.isArray(list) ? list : []); + } catch { + setAccounts([]); + } finally { + setAccountsLoading(false); + } + }, []); + + useEffect(() => { + fetchAccounts(); + }, [fetchAccounts]); + + const linkedProviderIds = new Set(accounts.map((a) => a.providerId)); + const socialAccounts = accounts.filter((a) => + TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider), + ); + + const handleLinkSocial = async (provider: SocialProvider) => { + setLinkingProvider(provider); + try { + const { error } = await authClient.linkSocial({ + provider, + callbackURL: LINKING_CALLBACK_URL, + }); + if (error) { + toast.error(error.message ?? "Failed to link account"); + setLinkingProvider(null); + return; + } + } catch (err) { + toast.error( + "Failed to link account", + err instanceof Error ? { description: err.message } : undefined, + ); + setLinkingProvider(null); + } + }; + + const handleUnlink = async (providerId: string, accountId?: string) => { + setUnlinkingProviderId(providerId); + try { + const { error } = await authClient.unlinkAccount({ + providerId, + ...(accountId && { accountId }), + }); + if (error) { + toast.error(error.message ?? "Failed to unlink account"); + return; + } + toast.success("Account unlinked"); + await fetchAccounts(); + } catch (err) { + toast.error( + "Failed to unlink account", + err instanceof Error ? { description: err.message } : undefined, + ); + } finally { + setUnlinkingProviderId(null); + } + }; + + const canUnlink = accounts.length > 1; + + return ( + +
+ +
+
+ + + Linking account + + + Link your Google or GitHub account to sign in with them. + +
+
+
+ + {/* Linked accounts */} +
+

Linked accounts

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

+ No social accounts linked yet. +

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

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

+
+ {!linkedProviderIds.has("google") && ( + + )} + {!linkedProviderIds.has("github") && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 4e4171bee..c4e549573 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1,10 +1,10 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Mail, - MessageCircleMore, PenBoxIcon, PlusIcon, + Trash2, } from "lucide-react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -12,7 +12,13 @@ import { toast } from "sonner"; import { z } from "zod"; import { DiscordIcon, + GotifyIcon, + LarkIcon, + NtfyIcon, + PushoverIcon, + ResendIcon, SlackIcon, + TeamsIcon, TelegramIcon, } from "@/components/icons/notification-icons"; import { Button } from "@/components/ui/button"; @@ -47,6 +53,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), @@ -92,6 +99,23 @@ export const notificationSchema = z.discriminatedUnion("type", [ .min(1, { message: "At least one email is required" }), }) .merge(notificationBaseSchema), + z + .object({ + type: z.literal("resend"), + apiKey: z.string().min(1, { message: "API Key is required" }), + fromAddress: z + .string() + .min(1, { message: "From Address is required" }) + .email({ message: "Email is invalid" }), + toAddresses: z + .array( + z.string().min(1, { message: "Email is required" }).email({ + message: "Email is invalid", + }), + ) + .min(1, { message: "At least one email is required" }), + }) + .merge(notificationBaseSchema), z .object({ type: z.literal("gotify"), @@ -106,10 +130,47 @@ 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), + 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" }), + }) + .merge(notificationBaseSchema), + z + .object({ + type: z.literal("teams"), + webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), + }) + .merge(notificationBaseSchema), ]); export const notificationsMap = { @@ -125,18 +186,38 @@ export const notificationsMap = { icon: , label: "Discord", }, + lark: { + icon: , + label: "Lark", + }, + teams: { + icon: , + label: "Microsoft Teams", + }, email: { icon: , label: "Email", }, + resend: { + icon: , + label: "Resend", + }, gotify: { - icon: , + icon: , label: "Gotify", }, ntfy: { - icon: , + icon: , label: "ntfy", }, + pushover: { + icon: , + label: "Pushover", + }, + custom: { + icon: , + label: "Custom", + }, }; export type NotificationSchema = z.infer; @@ -158,18 +239,34 @@ export const HandleNotifications = ({ notificationId }: Props) => { enabled: !!notificationId, }, ); - const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } = + const { mutateAsync: testSlackConnection, isPending: isLoadingSlack } = api.notification.testSlackConnection.useMutation(); - const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } = + const { mutateAsync: testTelegramConnection, isPending: isLoadingTelegram } = api.notification.testTelegramConnection.useMutation(); - const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } = + const { mutateAsync: testDiscordConnection, isPending: isLoadingDiscord } = api.notification.testDiscordConnection.useMutation(); - const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } = + const { mutateAsync: testEmailConnection, isPending: isLoadingEmail } = api.notification.testEmailConnection.useMutation(); - const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } = + const { mutateAsync: testResendConnection, isPending: isLoadingResend } = + api.notification.testResendConnection.useMutation(); + const { mutateAsync: testGotifyConnection, isPending: isLoadingGotify } = api.notification.testGotifyConnection.useMutation(); - const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } = + const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } = api.notification.testNtfyConnection.useMutation(); + const { mutateAsync: testLarkConnection, isPending: isLoadingLark } = + api.notification.testLarkConnection.useMutation(); + const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } = + api.notification.testTeamsConnection.useMutation(); + + const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } = + api.notification.testCustomConnection.useMutation(); + + const { mutateAsync: testPushoverConnection, isPending: 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(); @@ -182,14 +279,26 @@ export const HandleNotifications = ({ notificationId }: Props) => { const emailMutation = notificationId ? api.notification.updateEmail.useMutation() : api.notification.createEmail.useMutation(); + const resendMutation = notificationId + ? api.notification.updateResend.useMutation() + : api.notification.createResend.useMutation(); const gotifyMutation = notificationId ? api.notification.updateGotify.useMutation() : api.notification.createGotify.useMutation(); const ntfyMutation = notificationId ? api.notification.updateNtfy.useMutation() : api.notification.createNtfy.useMutation(); + const larkMutation = notificationId + ? api.notification.updateLark.useMutation() + : api.notification.createLark.useMutation(); + const teamsMutation = notificationId + ? api.notification.updateTeams.useMutation() + : api.notification.createTeams.useMutation(); + const pushoverMutation = notificationId + ? api.notification.updatePushover.useMutation() + : api.notification.createPushover.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { type: "slack", webhookUrl: "", @@ -205,11 +314,20 @@ 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") { + if ((type === "email" || type === "resend") && fields.length === 0) { append(""); } - }, [type, append]); + }, [type, append, fields.length]); useEffect(() => { if (notification) { @@ -219,6 +337,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 || "", @@ -232,6 +351,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, @@ -246,9 +366,10 @@ 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, + decoration: notification.discord?.decoration ?? undefined, name: notification.name, dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, @@ -259,6 +380,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, @@ -270,15 +392,31 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "resend") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + apiKey: notification.resend?.apiKey, + toAddresses: notification.resend?.toAddresses, + fromAddress: notification.resend?.fromAddress, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } else if (notification.notificationType === "gotify") { form.reset({ appBuildError: notification.appBuildError, appDeploy: notification.appDeploy, dokployRestart: notification.dokployRestart, databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, type: notification.notificationType, appToken: notification.gotify?.appToken, - decoration: notification.gotify?.decoration || undefined, + decoration: notification.gotify?.decoration ?? undefined, priority: notification.gotify?.priority, serverUrl: notification.gotify?.serverUrl, name: notification.name, @@ -290,13 +428,79 @@ 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, name: notification.name, dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); + } else if (notification.notificationType === "lark") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + type: notification.notificationType, + webhookUrl: notification.lark?.webhookUrl, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + volumeBackup: notification.volumeBackup, + serverThreshold: notification.serverThreshold, + }); + } else if (notification.notificationType === "teams") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + webhookUrl: notification.teams?.webhookUrl, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); + } else if (notification.notificationType === "custom") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + type: notification.notificationType, + 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, }); } } else { @@ -309,8 +513,13 @@ export const HandleNotifications = ({ notificationId }: Props) => { telegram: telegramMutation, discord: discordMutation, email: emailMutation, + resend: resendMutation, gotify: gotifyMutation, ntfy: ntfyMutation, + lark: larkMutation, + teams: teamsMutation, + custom: customMutation, + pushover: pushoverMutation, }; const onSubmit = async (data: NotificationSchema) => { @@ -319,6 +528,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy, dokployRestart, databaseBackup, + volumeBackup, dockerCleanup, serverThreshold, } = data; @@ -329,6 +539,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, channel: data.channel, name: data.name, @@ -343,6 +554,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, botToken: data.botToken, messageThreadId: data.messageThreadId || "", chatId: data.chatId, @@ -358,6 +570,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, webhookUrl: data.webhookUrl, decoration: data.decoration, name: data.name, @@ -372,6 +585,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, smtpServer: data.smtpServer, smtpPort: data.smtpPort, username: data.username, @@ -384,12 +598,29 @@ export const HandleNotifications = ({ notificationId }: Props) => { emailId: notification?.emailId || "", serverThreshold: serverThreshold, }); + } else if (data.type === "resend") { + promise = resendMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + resendId: notification?.resendId || "", + serverThreshold: serverThreshold, + }); } else if (data.type === "gotify") { promise = gotifyMutation.mutateAsync({ appBuildError: appBuildError, appDeploy: appDeploy, dokployRestart: dokployRestart, databaseBackup: databaseBackup, + volumeBackup: volumeBackup, serverUrl: data.serverUrl, appToken: data.appToken, priority: data.priority, @@ -405,8 +636,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, @@ -414,6 +646,83 @@ export const HandleNotifications = ({ notificationId }: Props) => { notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", }); + } else if (data.type === "lark") { + promise = larkMutation.mutateAsync({ + appBuildError: appBuildError, + 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 === "teams") { + promise = teamsMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + webhookUrl: data.webhookUrl, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + teamsId: notification?.teamsId || "", + 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) { @@ -428,6 +737,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); setVisible(false); await utils.notification.all.invalidate(); + if (notificationId) { + await utils.notification.one.invalidate({ notificationId }); + } }) .catch(() => { toast.error( @@ -502,7 +814,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { />
@@ -1070,6 +1749,27 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} /> + ( + +
+ Volume Backup + + Trigger the action when a volume backup is created. + +
+ + + +
+ )} + /> + { isLoadingTelegram || isLoadingDiscord || isLoadingEmail || + isLoadingResend || isLoadingGotify || - isLoadingNtfy + isLoadingNtfy || + isLoadingLark || + isLoadingTeams || + 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 === "resend") { + await testResendConnection({ + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + }); + } 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 ?? 0, + 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 ?? 0, + }); + } else if (data.type === "lark") { + await testLarkConnection({ + webhookUrl: data.webhookUrl, + }); + } else if (data.type === "teams") { + await testTeamsConnection({ + 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 ?? 0, + 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 fe31acc4c..3d62658ae 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -1,8 +1,13 @@ -import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react"; +import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { DiscordIcon, + GotifyIcon, + LarkIcon, + NtfyIcon, + ResendIcon, SlackIcon, + TeamsIcon, TelegramIcon, } from "@/components/icons/notification-icons"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -18,9 +23,10 @@ import { api } from "@/utils/api"; import { HandleNotifications } from "./handle-notifications"; export const ShowNotifications = () => { - const { data, isLoading, refetch } = api.notification.all.useQuery(); - const { mutateAsync, isLoading: isRemoving } = + const { data, isPending, refetch } = api.notification.all.useQuery(); + const { mutateAsync, isPending: isRemoving } = api.notification.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -33,11 +39,11 @@ export const ShowNotifications = () => { Add your providers to receive notifications, like Discord, Slack, - Telegram, Email. + Telegram, Teams, Email, Resend, Lark. - {isLoading ? ( + {isPending ? (
Loading... @@ -51,7 +57,9 @@ export const ShowNotifications = () => { To send notifications it is required to set at least 1 provider. - + {permissions?.notification.create && ( + + )}
) : (
@@ -83,14 +91,34 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "resend" && ( +
+ +
+ )} {notification.notificationType === "gotify" && (
- +
)} {notification.notificationType === "ntfy" && (
- + +
+ )} + {notification.notificationType === "custom" && ( +
+ +
+ )} + {notification.notificationType === "lark" && ( +
+ +
+ )} + {notification.notificationType === "teams" && ( +
+
)} @@ -101,45 +129,50 @@ export const ShowNotifications = () => { notificationId={notification.notificationId} /> - { - await mutateAsync({ - notificationId: notification.notificationId, - }) - .then(() => { - toast.success( - "Notification deleted successfully", - ); - refetch(); + {permissions?.notification.delete && ( + { + await mutateAsync({ + notificationId: + notification.notificationId, }) - .catch(() => { - toast.error( - "Error deleting notification", - ); - }); - }} - > - - + + + )}
))}
-
- -
+ {permissions?.notification.create && ( +
+ +
+ )}
)} diff --git a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx new file mode 100644 index 000000000..84a170434 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx @@ -0,0 +1,429 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import copy from "copy-to-clipboard"; +import { + CopyIcon, + DownloadIcon, + KeyRound, + RefreshCw, + ShieldOff, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + 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 { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; +import { + BACKUP_CODES_PLACEHOLDER, + backupCodeTemplate, + DATE_PLACEHOLDER, + USERNAME_PLACEHOLDER, +} from "./enable-2fa"; + +const PasswordSchema = z.object({ + password: z.string().min(8, { + message: "Password is required", + }), +}); + +type PasswordForm = z.infer; +type Step = "password" | "actions" | "backup-codes"; + +export const Configure2FA = () => { + const utils = api.useUtils(); + const { data: currentUser } = api.user.get.useQuery(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [step, setStep] = useState("password"); + const [password, setPassword] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [isDisabling, setIsDisabling] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + + const form = useForm({ + resolver: zodResolver(PasswordSchema), + defaultValues: { + password: "", + }, + }); + + useEffect(() => { + if (!isDialogOpen) { + setStep("password"); + setPassword(""); + setBackupCodes([]); + form.reset(); + } + }, [isDialogOpen, form]); + + const handlePasswordSubmit = async (formData: PasswordForm) => { + setIsRegenerating(true); + try { + // Verify password by attempting to generate backup codes + // This validates the password and checks if 2FA is enabled + const result = await authClient.twoFactor.generateBackupCodes({ + password: formData.password, + }); + + if (result.error) { + form.setError("password", { message: result.error.message }); + toast.error(result.error.message); + return; + } + + // If we get here, password is correct + setPassword(formData.password); + setStep("actions"); + } catch (error) { + form.setError("password", { + message: error instanceof Error ? error.message : "Incorrect password", + }); + toast.error("Incorrect password"); + } finally { + setIsRegenerating(false); + } + }; + + const handleRegenerateBackupCodes = async () => { + setIsRegenerating(true); + try { + const result = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (result.error) { + toast.error(result.error.message); + return; + } + + if (result.data?.backupCodes) { + setBackupCodes(result.data.backupCodes); + setStep("backup-codes"); + toast.success("Backup codes regenerated successfully"); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to regenerate backup codes", + ); + } finally { + setIsRegenerating(false); + } + }; + + const handleDisable2FA = async () => { + setIsDisabling(true); + try { + const result = await authClient.twoFactor.disable({ + password, + }); + + if (result.error) { + toast.error(result.error.message); + return; + } + + toast.success("2FA disabled successfully"); + utils.user.get.invalidate(); + setIsDialogOpen(false); + setShowDisableConfirm(false); + } catch (error) { + toast.error("Failed to disable 2FA. Please try again."); + } finally { + setIsDisabling(false); + } + }; + + const handleCloseDialog = () => { + if (step === "backup-codes") { + setStep("actions"); + } else { + setIsDialogOpen(false); + } + }; + + const handleDownloadBackupCodes = () => { + if (!backupCodes || backupCodes.length === 0) { + toast.error("No backup codes to download."); + return; + } + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + const blob = new Blob([backupCodesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopyBackupCodes = () => { + const date = new Date(); + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + copy(backupCodesText); + toast.success("Backup codes copied to clipboard"); + }; + + return ( + <> + + + + + + + + {step === "password" && "Verify Your Identity"} + {step === "actions" && "2FA Configuration"} + {step === "backup-codes" && "New Backup Codes"} + + + {step === "password" && + "Enter your password to manage your 2FA settings"} + {step === "actions" && + "Choose an action to manage your two-factor authentication"} + {step === "backup-codes" && + "Save these backup codes in a secure place"} + + + + {step === "password" && ( + + + ( + + Password + + + + + Enter your password to continue + + + + )} + /> +
+ + +
+ + + )} + + {step === "actions" && ( +
+
+
+
+
+

+ + Regenerate Backup Codes +

+

+ Generate new backup codes to replace your existing ones. + This will invalidate all previous backup codes. +

+
+
+ +
+ +
+
+
+

+ + Disable 2FA +

+

+ Completely disable two-factor authentication for your + account. This will make your account less secure. +

+
+
+ +
+
+ +
+ +
+
+ )} + + {step === "backup-codes" && ( +
+
+
+ {backupCodes.map((code, index) => ( + + {code} + + ))} +
+

+ Save these backup codes in a secure place. You can use them to + access your account if you lose access to your authenticator + device. Each code can only be used once. +

+
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ + + + + Are you absolutely sure? + + This will permanently disable Two-Factor Authentication for your + account. Your account will be less secure without 2FA enabled. + + + + Cancel + + {isDisabling ? "Disabling..." : "Disable 2FA"} + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx deleted file mode 100644 index 4055d4079..000000000 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -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 { authClient } from "@/lib/auth-client"; -import { api } from "@/utils/api"; - -const PasswordSchema = z.object({ - password: z.string().min(8, { - message: "Password is required", - }), -}); - -type PasswordForm = z.infer; - -export const Disable2FA = () => { - const utils = api.useUtils(); - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const form = useForm({ - resolver: zodResolver(PasswordSchema), - defaultValues: { - password: "", - }, - }); - - const handleSubmit = async (formData: PasswordForm) => { - setIsLoading(true); - try { - const result = await authClient.twoFactor.disable({ - password: formData.password, - }); - - if (result.error) { - form.setError("password", { - message: result.error.message, - }); - toast.error(result.error.message); - return; - } - - toast.success("2FA disabled successfully"); - utils.user.get.invalidate(); - setIsOpen(false); - } catch { - form.setError("password", { - message: "Connection error. Please try again.", - }); - toast.error("Connection error. Please try again."); - } finally { - setIsLoading(false); - } - }; - - return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently disable - Two-Factor Authentication for your account. - - - -
- - ( - - Password - - - - - Enter your password to disable 2FA - - - - )} - /> -
- - -
- - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index e630ec4f8..ff11d0322 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -1,5 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { Fingerprint, QrCode } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import copy from "copy-to-clipboard"; +import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react"; import QRCode from "qrcode"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -29,6 +30,12 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; @@ -54,6 +61,26 @@ type TwoFactorSetupData = { type PasswordForm = z.infer; type PinForm = z.infer; +export const USERNAME_PLACEHOLDER = "%username%"; +export const DATE_PLACEHOLDER = "%date%"; +export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%"; + +export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES + +Points to note +-------------- +# Each code can be used only once. +# Do not share these codes with anyone. + +Generated codes +--------------- +Username: ${USERNAME_PLACEHOLDER} +Generated on: ${DATE_PLACEHOLDER} + + +${BACKUP_CODES_PLACEHOLDER} +`; + export const Enable2FA = () => { const utils = api.useUtils(); const [data, setData] = useState(null); @@ -62,6 +89,7 @@ export const Enable2FA = () => { const [step, setStep] = useState<"password" | "verify">("password"); const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [otpValue, setOtpValue] = useState(""); + const { data: currentUser } = api.user.get.useQuery(); const handleVerifySubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -178,6 +206,54 @@ export const Enable2FA = () => { } }; + const handleDownloadBackupCodes = () => { + if (!backupCodes || backupCodes.length === 0) { + toast.error("No backup codes to download."); + return; + } + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + const blob = new Blob([backupCodesText], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopyBackupCodes = () => { + const date = new Date(); + + const backupCodesFormatted = backupCodes + .map((code, index) => ` ${index + 1}. ${code}`) + .join("\n"); + + const backupCodesText = backupCodeTemplate + .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") + .replace(DATE_PLACEHOLDER, date.toLocaleString()) + .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); + + copy(backupCodesText); + toast.success("Backup codes copied to clipboard"); + }; + return ( @@ -264,6 +340,7 @@ export const Enable2FA = () => { Scan this QR code with your authenticator app + {/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */} 2FA QR Code { {backupCodes && backupCodes.length > 0 && (
-

Backup Codes

+
+

Backup Codes

+
+ + + + + + +

Copy

+
+
+
+ + + + + + + +

Download

+
+
+
+
+
{backupCodes.map((code, index) => ( {code} diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index d040472d6..ceb90a560 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -1,7 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { Loader2, User } from "lucide-react"; -import { useTranslation } from "next-i18next"; -import { useEffect, useMemo, useState } from "react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Loader2, Palette, User } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -27,9 +26,10 @@ 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 { Disable2FA } from "./disable-2fa"; +import { Configure2FA } from "./configure-2fa"; import { Enable2FA } from "./enable-2fa"; const profileSchema = z.object({ @@ -40,7 +40,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), }); @@ -62,18 +63,17 @@ const randomImages = [ ]; export const ProfileForm = () => { - const _utils = api.useUtils(); - const { data, refetch, isLoading } = api.user.get.useQuery(); + const { data, refetch, isPending } = api.user.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); const { mutateAsync, - isLoading: isUpdating, + isPending: isUpdating, isError, error, } = 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; @@ -82,14 +82,15 @@ export const ProfileForm = () => { ]); }, [gravatarHash]); - const form = useForm({ + const form = useForm({ defaultValues: { email: data?.user?.email || "", password: "", 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), }); @@ -103,7 +104,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, @@ -120,28 +122,29 @@ export const ProfileForm = () => { }, [form, data]); const onSubmit = async (values: Profile) => { - await mutateAsync({ - email: values.email.toLowerCase(), - password: values.password || undefined, - image: values.image, - currentPassword: values.currentPassword || undefined, - allowImpersonation: values.allowImpersonation, - name: values.name || undefined, - }) - .then(async () => { - await refetch(); - toast.success("Profile Updated"); - form.reset({ - email: values.email, - password: "", - image: values.image, - currentPassword: "", - name: values.name || "", - }); - }) - .catch(() => { - toast.error("Error updating the profile"); + try { + await mutateAsync({ + email: values.email.toLowerCase(), + password: values.password || undefined, + image: values.image, + currentPassword: values.currentPassword || undefined, + allowImpersonation: values.allowImpersonation, + firstName: values.firstName || undefined, + lastName: values.lastName || undefined, }); + await refetch(); + toast.success("Profile Updated"); + form.reset({ + email: values.email, + password: "", + image: values.image, + currentPassword: "", + firstName: values.firstName || "", + lastName: values.lastName || "", + }); + } catch (error) { + toast.error("Error updating the profile"); + } }; return ( @@ -152,18 +155,19 @@ export const ProfileForm = () => {
- {t("settings.profile.title")} + Account - {t("settings.profile.description")} + Change the details of your profile here.
- {!data?.user.twoFactorEnabled ? : } + + {!data?.user.twoFactorEnabled ? : } {isError && {error?.message}} - {isLoading ? ( + {isPending ? (
Loading... @@ -178,12 +182,25 @@ export const ProfileForm = () => {
( - Name + First Name - + + + + + )} + /> + ( + + Last Name + + @@ -194,12 +211,9 @@ export const ProfileForm = () => { name="email" render={({ field }) => ( - {t("settings.profile.email")} + Email - + @@ -214,7 +228,7 @@ export const ProfileForm = () => { @@ -228,13 +242,11 @@ export const ProfileForm = () => { name="password" render={({ field }) => ( - - {t("settings.profile.password")} - + Password @@ -249,16 +261,14 @@ export const ProfileForm = () => { name="image" render={({ field }) => ( - - {t("settings.profile.avatar")} - + Avatar { field.onChange(e); }} - defaultValue={field.value} - value={field.value} + defaultValue={getAvatarType(field.value)} + value={getAvatarType(field.value)} className="flex flex-row flex-wrap gap-2 max-xl:justify-center" > @@ -273,12 +283,112 @@ export const ProfileForm = () => { {getFallbackAvatarInitials( - data?.user?.name, + `${data?.user?.firstName} ${data?.user?.lastName}`.trim(), )} + + + + + +
+ document + .getElementById("avatar-upload") + ?.click() + } + > + {field.value?.startsWith("data:") ? ( + // biome-ignore lint/performance/noImgElement: this is an justified use of img element + Custom avatar + ) : ( + + + + )} +
+ { + const file = e.target.files?.[0]; + if (file) { + // max file size 2mb + if (file.size > 2 * 1024 * 1024) { + toast.error( + "Image size must be less than 2MB", + ); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target + ?.result as string; + field.onChange(result); + }; + reader.readAsDataURL(file); + } + }} + /> +
+
+ + + + + +
+ colorInputRef.current?.click() + } + > + {!isSolidColorAvatar(field.value) && ( + + )} +
+ +
+
{availableAvatars.map((image) => ( @@ -289,6 +399,7 @@ export const ProfileForm = () => { />
+ {/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */} {
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx index 2bafe7e64..7d63de210 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip"; import { Button } from "@/components/ui/button"; @@ -17,24 +16,23 @@ import { TerminalModal } from "../../web-server/terminal-modal"; import { GPUSupportModal } from "../gpu-support-modal"; export const ShowDokployActions = () => { - const { t } = useTranslation("settings"); - const { mutateAsync: reloadServer, isLoading } = + const { mutateAsync: reloadServer, isPending } = api.settings.reloadServer.useMutation(); const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation(); const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation(); + const { mutateAsync: cleanAllDeploymentQueue } = + api.settings.cleanAllDeploymentQueue.useMutation(); return ( - - - - {t("settings.server.webServer.actions")} - + Actions { }} className="cursor-pointer" > - {t("settings.server.webServer.reload")} + Reload - {t("settings.common.enterTerminal")} + Terminal e.preventDefault()} > - {t("settings.server.webServer.watchLogs")} + View Logs @@ -68,7 +66,7 @@ export const ShowDokployActions = () => { className="cursor-pointer" onSelect={(e) => e.preventDefault()} > - {t("settings.server.webServer.updateServerIp")} + Update Server IP @@ -87,6 +85,21 @@ export const ShowDokployActions = () => { Clean Redis + { + await cleanAllDeploymentQueue() + .then(() => { + toast.success("Deployment queue cleaned"); + }) + .catch(() => { + toast.error("Error cleaning deployment queue"); + }); + }} + > + Clean all deployment queue + + { diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-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..2e69dfd23 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 @@ -1,4 +1,3 @@ -import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -16,61 +15,63 @@ interface Props { serverId?: string; } export const ShowStorageActions = ({ serverId }: Props) => { - const { t } = useTranslation("settings"); - const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } = + const { mutateAsync: cleanAll, isPending: cleanAllIsLoading } = api.settings.cleanAll.useMutation(); const { mutateAsync: cleanDockerBuilder, - isLoading: cleanDockerBuilderIsLoading, + isPending: cleanDockerBuilderIsPending, } = api.settings.cleanDockerBuilder.useMutation(); const { mutateAsync: cleanMonitoring } = api.settings.cleanMonitoring.useMutation(); const { mutateAsync: cleanUnusedImages, - isLoading: cleanUnusedImagesIsLoading, + isPending: cleanUnusedImagesIsPending, } = api.settings.cleanUnusedImages.useMutation(); const { mutateAsync: cleanUnusedVolumes, - isLoading: cleanUnusedVolumesIsLoading, + isPending: cleanUnusedVolumesIsPending, } = api.settings.cleanUnusedVolumes.useMutation(); const { mutateAsync: cleanStoppedContainers, - isLoading: cleanStoppedContainersIsLoading, + isPending: cleanStoppedContainersIsPending, } = api.settings.cleanStoppedContainers.useMutation(); + const { mutateAsync: cleanPatchRepos, isPending: cleanPatchReposIsLoading } = + api.patch.cleanPatchRepos.useMutation(); + return ( - - {t("settings.server.webServer.actions")} - + Actions { }); }} > - - {t("settings.server.webServer.storage.cleanUnusedImages")} - + Clean unused images { }); }} > - - {t("settings.server.webServer.storage.cleanUnusedVolumes")} - + Clean unused volumes { }); }} > - - {t("settings.server.webServer.storage.cleanStoppedContainers")} - + Clean stopped containers + + + { + await cleanPatchRepos({ + serverId: serverId, + }) + .then(async () => { + toast.success("Cleaned Patch Caches"); + }) + .catch(() => { + toast.error("Error cleaning Patch Caches"); + }); + }} + > + Clean Patch Caches { }); }} > - - {t("settings.server.webServer.storage.cleanDockerBuilder")} - + Clean Docker Builder & System {!serverId && ( { }); }} > - - {t("settings.server.webServer.storage.cleanMonitoring")} - + Clean Monitoring )} @@ -173,14 +181,14 @@ 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"); }); }} > - {t("settings.server.webServer.storage.cleanAll")} + Clean 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..65957a881 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,6 @@ -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, @@ -10,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; @@ -19,11 +21,10 @@ interface Props { serverId?: string; } export const ShowTraefikActions = ({ serverId }: Props) => { - const { t } = useTranslation("settings"); - const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } = + const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } = api.settings.reloadTraefik.useMutation(); - const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } = + const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } = api.settings.toggleDashboard.useMutation(); const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } = @@ -31,38 +32,71 @@ export const ShowTraefikActions = ({ serverId }: Props) => { serverId, }); + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik dashboard updated successfully", + onSuccess: () => { + refetchDashboard(); + }, + }); + + const { + execute: executeReloadWithHealthCheck, + isExecuting: isReloadHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik Reloaded", + }); + return ( - - {t("settings.server.webServer.actions")} - + Actions { - await reloadTraefik({ - serverId: serverId, - }) - .then(async () => { - toast.success("Traefik Reloaded"); - }) - .catch(() => {}); + try { + await executeReloadWithHealthCheck(() => + reloadTraefik({ serverId }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to reload Traefik. Please try again."; + toast.error(errorMessage); + } }} className="cursor-pointer" + disabled={isReloadHealthCheckExecuting} > - {t("settings.server.webServer.reload")} + Reload { onSelect={(e) => e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.watchLogs")} + View Logs @@ -81,36 +115,64 @@ export const ShowTraefikActions = ({ serverId }: Props) => { onSelect={(e) => e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.traefik.modifyEnv")} + Modify Environment - + + 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, - serverId: serverId, - }) - .then(async () => { - toast.success( - `${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`, - ); - refetchDashboard(); - }) - .catch(() => {}); + try { + await executeWithHealthCheck(() => + toggleDashboard({ + enableDashboard: !haveTraefikDashboardPortEnabled, + serverId: serverId, + }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + } }} - className="w-full cursor-pointer space-x-3" + disabled={toggleDashboardIsLoading || isHealthCheckExecuting} + type="default" > - - {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard - -
+ e.preventDefault()} + className="w-full cursor-pointer space-x-3" + > + + {haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "} + Dashboard + + + e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.traefik.managePorts")} + Additional Port Mappings
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/edit-script.tsx b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx index a21f76cb3..92a2294ee 100644 --- a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { FileTerminal } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -49,7 +49,7 @@ export const EditScript = ({ serverId }: Props) => { }, ); - const { mutateAsync, isLoading } = api.server.update.useMutation(); + const { mutateAsync, isPending } = api.server.update.useMutation(); const { data: defaultCommand } = api.server.getDefaultCommand.useQuery( { @@ -155,7 +155,7 @@ echo "Hello world" Reset + + ) : ( e.preventDefault()} + onSelect={(e) => { + e.preventDefault(); + setIsOpen(true); + }} > Edit Server - ) : ( + ) + ) : ( + - )} - + + )} {serverId ? "Edit" : "Create"} Server @@ -158,9 +171,8 @@ export const HandleServers = ({ serverId }: Props) => {

- You will need to purchase or rent a Virtual Private Server (VPS) to - proceed, we recommend to use one of these providers since has been - heavily tested. + You may need to purchase or rent a Virtual Private Server (VPS) to + proceed. We recommend using one of these heavily tested providers:

  • @@ -266,6 +278,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. + + )} + + ); + }} + /> { name="ipAddress" render={({ field }) => ( - {t("settings.terminal.ipAddress")} + IP Address @@ -319,7 +375,7 @@ export const HandleServers = ({ serverId }: Props) => { name="port" render={({ field }) => ( - {t("settings.terminal.port")} + Port { name="username" render={({ field }) => ( - {t("settings.terminal.username")} + Username @@ -362,7 +418,7 @@ export const HandleServers = ({ serverId }: Props) => { + + ) : ( + + )}
    @@ -117,17 +127,26 @@ export const SetupServer = ({ serverId }: Props) => { SSH Keys Deployments Validate - Security - {isCloud && ( - Monitoring + + {!isBuildServer && ( + <> + Security + {isCloud && ( + Monitoring + )} + GPU Setup + )} - GPU Setup { Automatic process @@ -311,32 +330,36 @@ export const SetupServer = ({ serverId }: Props) => {
    - -
    - -
    -
    - -
    -
    - -
    -
    -
    - -
    - -
    -
    + {!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..859098394 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -1,8 +1,18 @@ 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"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -18,20 +28,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"; @@ -46,20 +51,20 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; export const ShowServers = () => { - const { t } = useTranslation("settings"); const router = useRouter(); const query = router.query; - const { data, refetch, isLoading } = api.server.all.useQuery(); + const { data, refetch, isPending } = api.server.all.useQuery(); const { mutateAsync } = api.server.remove.useMutation(); const { data: sshKeys } = api.sshKey.all.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: canCreateMoreServers } = api.stripe.canCreateMoreServers.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
{query?.success && isCloud && } - +
@@ -82,7 +87,7 @@ export const ShowServers = () => { )} - {isLoading ? ( + {isPending ? (
Loading... @@ -111,188 +116,47 @@ export const ShowServers = () => { Start adding servers to deploy your applications remotely. - + {permissions?.server.create && }
) : ( -
- - -
- 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,35 +172,280 @@ 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", + )} + +
-
- {data && data?.length > 0 && ( -
- -
- )} + {/* 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

+
+
+ )} + +
+ + {permissions?.server.delete && ( + + +
+ + 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"} +

+
+ + )} +
+
+ )} + + + ); + })}
+ + {permissions?.server.create && ( +
+ {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..f36c1b54e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/validate-server.tsx @@ -18,13 +18,20 @@ interface Props { export const ValidateServer = ({ serverId }: Props) => { const [isRefreshing, setIsRefreshing] = useState(false); - const { data, refetch, error, isLoading, isError } = + const { data, refetch, error, isPending, isError } = api.server.validate.useQuery( { serverId }, { enabled: !!serverId, }, ); + const { data: server } = api.server.one.useQuery( + { serverId }, + { + enabled: !!serverId, + }, + ); + const isBuildServer = server?.serverType === "build"; const _utils = api.useUtils(); return ( @@ -63,7 +70,7 @@ export const ValidateServer = ({ serverId }: Props) => { - {isLoading ? ( + {isPending ? (
Checking Server configuration @@ -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..41aac01d3 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 @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -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/servers/welcome-stripe/create-ssh-key.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx index ad386cc49..5924bba50 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/create-ssh-key.tsx @@ -12,7 +12,7 @@ import { api } from "@/utils/api"; export const CreateSSHKey = () => { const { data, refetch } = api.sshKey.all.useQuery(); const generateMutation = api.sshKey.generate.useMutation(); - const { mutateAsync, isLoading } = api.sshKey.create.useMutation(); + const { mutateAsync, isPending } = api.sshKey.create.useMutation(); const hasCreatedKey = useRef(false); const [selectedOption, setSelectedOption] = useState<"manual" | "provider">( "manual", @@ -24,7 +24,7 @@ export const CreateSSHKey = () => { useEffect(() => { const createKey = async () => { - if (!data || cloudSSHKey || hasCreatedKey.current || isLoading) { + if (!data || cloudSSHKey || hasCreatedKey.current || isPending) { return; } @@ -55,7 +55,7 @@ export const CreateSSHKey = () => {
- {isLoading || !cloudSSHKey ? ( + {isPending || !cloudSSHKey ? (
{ etc.)

diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx index 03a2810b9..debdfcbb0 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/verify.tsx @@ -27,7 +27,7 @@ export const Verify = () => { const [serverId, setServerId] = useState( servers?.[0]?.serverId || "", ); - const { data, refetch, error, isLoading, isError } = + const { data, refetch, error, isPending, isError } = api.server.validate.useQuery( { serverId }, { @@ -91,7 +91,7 @@ export const Verify = () => { - {isLoading ? ( + {isPending ? (
Checking Server configuration diff --git a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx index 3c1004143..004f79f74 100644 --- a/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/welcome-stripe/welcome-suscription.tsx @@ -307,9 +307,9 @@ export const WelcomeSuscription = () => {
- {features.map((feature, index) => ( + {features.map((feature) => (
{feature.icon}
diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx index 73c807685..68e7d764e 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/handle-ssh-keys.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { DownloadIcon, PenBoxIcon, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -48,7 +48,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => { }, ); - const { mutateAsync, isError, error, isLoading } = sshKeyId + const { mutateAsync, isError, error, isPending } = sshKeyId ? api.sshKey.update.useMutation() : api.sshKey.create.useMutation(); @@ -164,7 +164,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
)}
- diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx index 0a29f2d8f..86ea0a2ea 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx @@ -14,9 +14,10 @@ import { api } from "@/utils/api"; import { HandleSSHKeys } from "./handle-ssh-keys"; export const ShowDestinations = () => { - const { data, isLoading, refetch } = api.sshKey.all.useQuery(); - const { mutateAsync, isLoading: isRemoving } = + const { data, isPending, refetch } = api.sshKey.all.useQuery(); + const { mutateAsync, isPending: isRemoving } = api.sshKey.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); return (
@@ -33,7 +34,7 @@ export const ShowDestinations = () => { - {isLoading ? ( + {isPending ? (
Loading... @@ -46,7 +47,7 @@ export const ShowDestinations = () => { You don't have any SSH keys - + {permissions?.sshKeys.create && }
) : (
@@ -84,43 +85,47 @@ export const ShowDestinations = () => {
- { - await mutateAsync({ - sshKeyId: sshKey.sshKeyId, - }) - .then(() => { - toast.success( - "SSH Key deleted successfully", - ); - refetch(); + {permissions?.sshKeys.delete && ( + { + await mutateAsync({ + sshKeyId: sshKey.sshKeyId, }) - .catch(() => { - toast.error("Error deleting SSH Key"); - }); - }} - > - - + + + )}
))}
-
- -
+ {permissions?.sshKeys.create && ( +
+ +
+ )}
)} diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx new file mode 100644 index 000000000..343e0f93b --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -0,0 +1,239 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Palette, PenBoxIcon, PlusIcon } from "lucide-react"; +import { useEffect, useRef, 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 { TagBadge } from "@/components/shared/tag-badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const TagSchema = z.object({ + name: z + .string() + .min(1, "Tag name is required") + .max(50, "Tag name must be less than 50 characters") + .refine( + (name) => { + const trimmedName = name.trim(); + const validNameRegex = + /^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u; + return validNameRegex.test(trimmedName); + }, + { + message: + "Tag name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.", + }, + ) + .transform((name) => name.trim()), + color: z.string().optional(), +}); + +type Tag = z.infer; + +interface HandleTagProps { + tagId?: string; +} + +export const HandleTag = ({ tagId }: HandleTagProps) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + const colorInputRef = useRef(null); + + const { mutateAsync, error, isError } = tagId + ? api.tag.update.useMutation() + : api.tag.create.useMutation(); + + const { data: tag } = api.tag.one.useQuery( + { + tagId: tagId || "", + }, + { + enabled: !!tagId, + }, + ); + + const form = useForm({ + defaultValues: { + name: "", + color: "#3b82f6", + }, + resolver: zodResolver(TagSchema), + }); + + useEffect(() => { + if (tag) { + form.reset({ + name: tag.name ?? "", + color: tag.color ?? "#3b82f6", + }); + } else { + form.reset({ + name: "", + color: "#3b82f6", + }); + } + }, [form, form.reset, tag]); + + const onSubmit = async (data: Tag) => { + await mutateAsync({ + name: data.name, + color: data.color, + tagId: tagId || "", + }) + .then(async () => { + await utils.tag.all.invalidate(); + toast.success(tagId ? "Tag Updated" : "Tag Created"); + setIsOpen(false); + form.reset(); + }) + .catch(() => { + toast.error(tagId ? "Error updating tag" : "Error creating tag"); + }); + }; + + const colorValue = form.watch("color"); + + return ( + + + {tagId ? ( + + ) : ( + + )} + + + + {tagId ? "Update" : "Create"} Tag + + {tagId + ? "Update the tag name and color" + : "Create a new tag to organize your projects"} + + + {isError && {error?.message}} +
+ + ( + + Name + + + + + + )} + /> + + ( + + Color (Optional) + +
+ colorInputRef.current?.click()} + > +
+ {!field.value && ( + + )} +
+ +
+
+ { + const value = e.target.value; + if (value.startsWith("#") || value === "") { + field.onChange(value); + } + }} + /> + + Choose a color to easily identify this tag + +
+
+
+ +
+ )} + /> + + {colorValue && ( +
+ Preview: + +
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx new file mode 100644 index 000000000..07eb9a7f3 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx @@ -0,0 +1,124 @@ +import { Loader2, TagIcon, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { TagBadge } from "@/components/shared/tag-badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { HandleTag } from "./handle-tag"; + +export const TagManager = () => { + const utils = api.useUtils(); + const { data: tags, isPending } = api.tag.all.useQuery(); + const { mutateAsync: deleteTag, isPending: isRemoving } = + api.tag.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); + + return ( +
+ +
+ + + + Tags + + + Create and manage tags to organize your projects + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {!tags || tags.length === 0 ? ( +
+ + + No tags yet. Create your first tag to start organizing + projects. + + {permissions?.tag.create && } +
+ ) : ( +
+
+ {tags.map((tag) => ( +
+
+
+ + {tag.color && ( + + {tag.color} + + )} +
+
+ {permissions?.tag.update && ( + + )} + {permissions?.tag.delete && ( + { + await deleteTag({ + tagId: tag.tagId, + }) + .then(async () => { + await utils.tag.all.invalidate(); + toast.success( + "Tag deleted successfully", + ); + }) + .catch(() => { + toast.error("Error deleting tag"); + }); + }} + > + + + )} +
+
+
+ ))} +
+ + {permissions?.tag.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 6e0384554..f9dce77c9 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -32,7 +32,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; const addInvitation = z.object({ @@ -40,7 +39,7 @@ const addInvitation = z.object({ .string() .min(1, "Email is required") .email({ message: "Invalid email" }), - role: z.enum(["member", "admin"]), + role: z.string().min(1, "Role is required"), notificationId: z.string().optional(), }); @@ -49,13 +48,14 @@ type AddInvitation = z.infer; export const AddInvitation = () => { const [open, setOpen] = useState(false); const utils = api.useUtils(); - const [isLoading, setIsLoading] = useState(false); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: emailProviders } = api.notification.getEmailProviders.useQuery(); + const { mutateAsync: inviteMember, isPending: isInviting } = + api.organization.inviteMember.useMutation(); const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); + const { data: customRoles } = api.customRole.all.useQuery(); const [error, setError] = useState(null); - const { data: activeOrganization } = authClient.useActiveOrganization(); const form = useForm({ defaultValues: { @@ -70,19 +70,15 @@ export const AddInvitation = () => { }, [form, form.formState.isSubmitSuccessful, form.reset]); const onSubmit = async (data: AddInvitation) => { - setIsLoading(true); - const result = await authClient.organization.inviteMember({ - email: data.email.toLowerCase(), - role: data.role, - organizationId: activeOrganization?.id, - }); + try { + const result = await inviteMember({ + email: data.email.toLowerCase(), + role: data.role, + }); - if (result.error) { - setError(result.error.message || ""); - } else { if (!isCloud && data.notificationId) { await sendInvitation({ - invitationId: result.data.id, + invitationId: result!.id, notificationId: data.notificationId || "", }) .then(() => { @@ -96,10 +92,11 @@ export const AddInvitation = () => { } setError(null); setOpen(false); + } catch (error: any) { + setError(error.message || "Failed to create invitation"); } utils.organization.allInvitations.invalidate(); - setIsLoading(false); }; return ( @@ -158,6 +155,12 @@ export const AddInvitation = () => { Member + Admin + {customRoles?.map((role) => ( + + {role.role} + + ))} @@ -211,7 +214,7 @@ export const AddInvitation = () => { )} + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx index 6e56f4c70..b6e95cb75 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx @@ -32,7 +32,7 @@ import { api } from "@/utils/api"; import { AddInvitation } from "./add-invitation"; export const ShowInvitations = () => { - const { data, isLoading, refetch } = + const { data, isPending, refetch } = api.organization.allInvitations.useQuery(); const { mutateAsync: removeInvitation } = @@ -52,7 +52,7 @@ export const ShowInvitations = () => { - {isLoading ? ( + {isPending ? (
Loading... diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 51d8704a3..75aa839f9 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -1,6 +1,7 @@ import { format } from "date-fns"; import { Loader2, MoreHorizontal, Users } from "lucide-react"; import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -21,7 +22,6 @@ import { import { Table, TableBody, - TableCaption, TableCell, TableHead, TableHeader, @@ -30,12 +30,25 @@ 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 { data, isPending, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const { data: hasValidLicense } = + api.licenseKey.haveValidLicenseKey.useQuery(); + const utils = api.useUtils(); + const { data: session } = api.user.session.useQuery(); + + const FREE_ROLES = ["owner", "admin", "member"]; + const membersWithCustomRoles = data?.filter( + (member) => !FREE_ROLES.includes(member.role), + ); + const hasCustomRolesWithoutLicense = + !hasValidLicense && (membersWithCustomRoles?.length ?? 0) > 0; return (
@@ -51,7 +64,7 @@ export const ShowUsers = () => { - {isLoading ? ( + {isPending ? (
Loading... @@ -67,8 +80,19 @@ export const ShowUsers = () => {
) : (
+ {hasCustomRolesWithoutLicense && ( + + You have{" "} + {membersWithCustomRoles?.length === 1 + ? "1 user" + : `${membersWithCustomRoles?.length} users`}{" "} + assigned to custom roles. Custom roles will not work + without a valid Enterprise license. Please activate your + license or change these users to a free role (Admin or + Member). + + )} - See all users Email @@ -83,10 +107,60 @@ 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/custom role + const isStaticAdminOrOwner = + member.role === "owner" || member.role === "admin"; + const canEditPermissions = + !isStaticAdminOrOwner && + 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/custom 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 !== "admin")); + + const canDeleteMember = + permissions?.member.delete ?? false; + + // Self-hosted: "Delete User" removes the user entirely + // Cloud: "Unlink User" removes from the organization only + const canRemove = + member.role !== "owner" && + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role !== "admin") || + (canDeleteMember && !isStaticAdminOrOwner)); + + const canDelete = canRemove && !isCloud; + const canUnlink = canRemove && !!isCloud; + + const hasAnyAction = + canEditPermissions || + canChangeRole || + canDelete || + canUnlink; + return ( {member.user.email} + {member.user.id === session?.user?.id && ( + + (You) + + )} { - - - - - - - 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 +309,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 cafb95f53..29c7be5eb 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -1,10 +1,10 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { GlobeIcon } from "lucide-react"; -import { useTranslation } from "next-i18next"; import { useEffect } 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 { Card, @@ -35,7 +35,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"]), @@ -48,7 +48,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: @@ -61,9 +65,8 @@ const addServerDomain = z type AddServerDomain = z.infer; export const WebDomain = () => { - const { t } = useTranslation("settings"); - const { data, refetch } = api.user.get.useQuery(); - const { mutateAsync, isLoading } = + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync, isPending } = api.settings.assignDomainServer.useMutation(); const form = useForm({ @@ -76,13 +79,16 @@ export const WebDomain = () => { resolver: zodResolver(addServerDomain), }); const https = form.watch("https"); + const domain = form.watch("domain") || ""; + 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]); @@ -111,14 +117,27 @@ export const WebDomain = () => {
- {t("settings.server.domain.title")} + Server Domain - {t("settings.server.domain.description")} + Add a domain to your server application.
+ {/* Warning for GitHub webhook URL changes */} + {hasChanged && ( + +
+

⚠️ Important: URL Change Impact

+

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

+
+
+ )}
{ render={({ field }) => { return ( - - {t("settings.server.domain.form.domain")} - + Domain { render={({ field }) => { return ( - - {t("settings.server.domain.form.letsEncryptEmail")} - + Let's Encrypt Email { render={({ field }) => { return ( - - {t("settings.server.domain.form.certificate.label")} - + Certificate Provider @@ -232,8 +235,8 @@ export const WebDomain = () => { )}
-
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index 2a2ce4ab1..d9df975e7 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -1,5 +1,4 @@ import { ServerIcon } from "lucide-react"; -import { useTranslation } from "next-i18next"; import { Card, CardContent, @@ -15,8 +14,8 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup"; 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(); @@ -28,18 +27,16 @@ export const WebServer = () => { - {t("settings.server.webServer.title")} + Web Server - - {t("settings.server.webServer.description")} - + Reload or clean the web server. {/* - {t("settings.server.webServer.title")} + Web Server - {t("settings.server.webServer.description")} + Reload or clean the web server. */} @@ -53,7 +50,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/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx index 2fe0c0af3..599dba57b 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx @@ -13,7 +13,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -40,18 +39,26 @@ interface Props { appName: string; children?: React.ReactNode; serverId?: string; + appType?: "stack" | "docker-compose"; } -export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { - const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( +export const DockerTerminalModal = ({ + children, + appName, + serverId, + appType, +}: Props) => { + const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { appName, + appType, serverId, }, { enabled: !!appName, }, ); + const [containerId, setContainerId] = useState(); const [mainDialogOpen, setMainDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); @@ -83,7 +90,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { {children} event.preventDefault()} > @@ -92,10 +99,9 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { Easy way to access to docker container - { name="username" render={({ field }) => ( - {t("settings.terminal.username")} + Username @@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => { className="ml-auto" disabled={!form.formState.isDirty} > - {t("settings.common.save")} + Save 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..6f42c804b 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 @@ -1,6 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { ArrowRightLeft, Plus, Trash2 } from "lucide-react"; -import { useTranslation } from "next-i18next"; import type React from "react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -35,6 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; interface Props { @@ -55,7 +55,6 @@ const TraefikPortsSchema = z.object({ type TraefikPortsForm = z.infer; export const ManageTraefikPorts = ({ children, serverId }: Props) => { - const { t } = useTranslation("settings"); const [open, setOpen] = useState(false); const form = useForm({ @@ -75,12 +74,20 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { serverId, }); - const { mutateAsync: updatePorts, isLoading } = - api.settings.updateTraefikPorts.useMutation({ - onSuccess: () => { - refetchPorts(); - }, - }); + const { mutateAsync: updatePorts, isPending } = + api.settings.updateTraefikPorts.useMutation(); + + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + successMessage: "Ports updated successfully", + onSuccess: () => { + refetchPorts(); + setOpen(false); + }, + }); useEffect(() => { if (currentPorts) { @@ -99,13 +106,16 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { const onSubmit = async (data: TraefikPortsForm) => { try { - await updatePorts({ - serverId, - additionalPorts: data.ports, - }); - toast.success(t("settings.server.webServer.traefik.portsUpdated")); + await executeWithHealthCheck(() => + updatePorts({ + serverId, + additionalPorts: data.ports, + }), + ); setOpen(false); - } catch {} + } catch (error) { + toast.error((error as Error).message || "Error updating Traefik ports"); + } }; return ( @@ -117,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { - {t("settings.server.webServer.traefik.managePorts")} + Additional Port Mappings
- {t( - "settings.server.webServer.traefik.managePortsDescription", - )} + Add or remove additional ports for Traefik {fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "} configured @@ -156,20 +164,18 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {

) : ( - +
{fields.map((field, index) => ( - + ( - {t( - "settings.server.webServer.traefik.targetPort", - )} + Target Port { render={({ field }) => ( - {t( - "settings.server.webServer.traefik.publishedPort", - )} + Published Port {
)} + + + 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. +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx index 9aac25820..9b9d6fb7f 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx @@ -47,7 +47,7 @@ export const ShowModalLogs = ({ serverId, type = "swarm", }: Props) => { - const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery( + const { data, isPending } = api.docker.getContainersByAppLabel.useQuery( { appName, serverId, @@ -76,7 +76,7 @@ export const ShowModalLogs = ({ - - - - - {Object.values(Languages).map((language) => ( - - {language.name} - - ))} - - -
- + { + await authClient.signOut().then(() => { + router.push("/"); + }); + // await mutateAsync().then(() => { + // router.push("/"); + // }); + }} + > + Log out + ); diff --git a/apps/dokploy/components/proprietary/audit-logs/columns.tsx b/apps/dokploy/components/proprietary/audit-logs/columns.tsx new file mode 100644 index 000000000..dacb0284c --- /dev/null +++ b/apps/dokploy/components/proprietary/audit-logs/columns.tsx @@ -0,0 +1,230 @@ +"use client"; + +import type { AuditLog } from "@dokploy/server/db/schema"; +import type { ColumnDef } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { + ArrowUpDown, + FileJson, + LogIn, + LogOut, + PlusCircle, + RefreshCw, + RotateCcw, + Trash2, + Upload, + XCircle, +} from "lucide-react"; +import React from "react"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +const ACTION_CONFIG: Record< + string, + { label: string; icon: React.ElementType; className: string } +> = { + create: { + label: "Created", + icon: PlusCircle, + className: + "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", + }, + update: { + label: "Updated", + icon: RefreshCw, + className: + "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + }, + delete: { + label: "Deleted", + icon: Trash2, + className: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + }, + deploy: { + label: "Deployed", + icon: Upload, + className: + "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20", + }, + cancel: { + label: "Cancelled", + icon: XCircle, + className: + "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20", + }, + redeploy: { + label: "Redeployed", + icon: RotateCcw, + className: + "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20", + }, + login: { + label: "Login", + icon: LogIn, + className: + "bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20", + }, + logout: { + label: "Logout", + icon: LogOut, + className: + "bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20", + }, +}; + +const RESOURCE_LABELS: Record = { + project: "Project", + service: "Service", + environment: "Environment", + deployment: "Deployment", + user: "User", + customRole: "Custom Role", + domain: "Domain", + certificate: "Certificate", + registry: "Registry", + server: "Server", + sshKey: "SSH Key", + gitProvider: "Git Provider", + notification: "Notification", + settings: "Settings", + session: "Session", +}; + +function MetadataCell({ metadata }: { metadata: string | null }) { + if (!metadata) + return ; + + const formatted = React.useMemo(() => { + try { + return JSON.stringify(JSON.parse(metadata), null, 2); + } catch { + return metadata; + } + }, [metadata]); + + return ( + + + + + + + Metadata + + + + + ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {format(new Date(row.getValue("createdAt")), "MMM d, yyyy HH:mm")} + + ), + }, + { + accessorKey: "userEmail", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.getValue("userEmail")} + ), + }, + { + accessorKey: "action", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const action = row.getValue("action") as string; + const config = ACTION_CONFIG[action]; + if (!config) { + return {action}; + } + const Icon = config.icon; + return ( + + + {config.label} + + ); + }, + }, + { + accessorKey: "resourceType", + header: "Resource", + cell: ({ row }) => ( + + {RESOURCE_LABELS[row.getValue("resourceType") as string] ?? + row.getValue("resourceType")} + + ), + }, + { + accessorKey: "resourceName", + header: "Name", + cell: ({ row }) => ( + + {(row.getValue("resourceName") as string) ?? "—"} + + ), + }, + { + accessorKey: "userRole", + header: "Role", + cell: ({ row }) => ( + + {row.getValue("userRole")} + + ), + }, + { + accessorKey: "metadata", + header: "Metadata", + cell: ({ row }) => , + }, +]; diff --git a/apps/dokploy/components/proprietary/audit-logs/data-table.tsx b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx new file mode 100644 index 000000000..dec967891 --- /dev/null +++ b/apps/dokploy/components/proprietary/audit-logs/data-table.tsx @@ -0,0 +1,400 @@ +"use client"; + +import type { AuditLog } from "@dokploy/server/db/schema"; +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type SortingState, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table"; +import { format } from "date-fns"; +import { CalendarIcon, ChevronDown, X } from "lucide-react"; +import React from "react"; +import type { DateRange } from "react-day-picker"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +const ACTION_OPTIONS = [ + { value: "create", label: "Created" }, + { value: "update", label: "Updated" }, + { value: "delete", label: "Deleted" }, + { value: "deploy", label: "Deployed" }, + { value: "cancel", label: "Cancelled" }, + { value: "redeploy", label: "Redeployed" }, + { value: "login", label: "Login" }, + { value: "logout", label: "Logout" }, +]; + +const RESOURCE_OPTIONS = [ + { value: "project", label: "Projects" }, + { value: "service", label: "Applications / Services" }, + { value: "environment", label: "Environments" }, + { value: "deployment", label: "Deployments" }, + { value: "user", label: "Users" }, + { value: "customRole", label: "Custom Roles" }, + { value: "domain", label: "Domains" }, + { value: "certificate", label: "Certificates" }, + { value: "registry", label: "Registries" }, + { value: "server", label: "Remote Servers" }, + { value: "sshKey", label: "SSH Keys" }, + { value: "gitProvider", label: "Git Providers" }, + { value: "notification", label: "Notifications" }, + { value: "settings", label: "Settings" }, + { value: "session", label: "Sessions (Login/Logout)" }, +]; + +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +type AuditAction = + | "create" + | "update" + | "delete" + | "deploy" + | "cancel" + | "redeploy" + | "login" + | "logout"; +type AuditResourceType = + | "project" + | "service" + | "environment" + | "deployment" + | "user" + | "customRole" + | "domain" + | "certificate" + | "registry" + | "server" + | "sshKey" + | "gitProvider" + | "notification" + | "settings" + | "session"; + +export interface AuditLogFilters { + userEmail: string; + resourceName: string; + action: AuditAction | ""; + resourceType: AuditResourceType | ""; + dateRange: DateRange | undefined; +} + +interface DataTableProps { + columns: ColumnDef[]; + data: AuditLog[]; + total: number; + pageIndex: number; + pageSize: number; + filters: AuditLogFilters; + onPageChange: (page: number) => void; + onPageSizeChange: (size: number) => void; + onFilterChange: ( + key: K, + value: AuditLogFilters[K], + ) => void; + isLoading?: boolean; +} + +export function DataTable({ + columns, + data, + total, + pageIndex, + pageSize, + filters, + onPageChange, + onPageSizeChange, + onFilterChange, + isLoading, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([ + { id: "createdAt", desc: true }, + ]); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onColumnVisibilityChange: setColumnVisibility, + manualPagination: true, + manualFiltering: true, + rowCount: total, + state: { + sorting, + columnVisibility, + }, + }); + + const pageCount = Math.ceil(total / pageSize); + const hasFilters = + filters.userEmail || + filters.resourceName || + filters.action || + filters.resourceType || + filters.dateRange; + + return ( +
+
+ onFilterChange("userEmail", e.target.value)} + className="max-w-xs" + /> + onFilterChange("resourceName", e.target.value)} + className="max-w-xs" + /> + + + + + + + + onFilterChange("dateRange", range)} + numberOfMonths={2} + initialFocus + /> + + + {hasFilters && ( + + )} + + + + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + col.toggleVisibility(!!value)} + > + {col.id} + + ))} + + +
+ +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {isLoading ? ( + + + Loading... + + + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No audit logs found. + + + )} + +
+
+ +
+ + {total} {total === 1 ? "entry" : "entries"} total + +
+
+ Rows per page + +
+ + Page {pageIndex + 1} of {Math.max(1, pageCount)} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx new file mode 100644 index 000000000..7f1851493 --- /dev/null +++ b/apps/dokploy/components/proprietary/audit-logs/show-audit-logs.tsx @@ -0,0 +1,112 @@ +import { ClipboardList } from "lucide-react"; +import React from "react"; +import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { columns } from "./columns"; +import { type AuditLogFilters, DataTable } from "./data-table"; + +function AuditLogsContent() { + const [pageIndex, setPageIndex] = React.useState(0); + const [pageSize, setPageSize] = React.useState(50); + const [filters, setFilters] = React.useState({ + userEmail: "", + resourceName: "", + action: "", + resourceType: "", + dateRange: undefined, + }); + + const [debouncedText, setDebouncedText] = React.useState({ + userEmail: "", + resourceName: "", + }); + + React.useEffect(() => { + const t = setTimeout(() => { + setDebouncedText({ + userEmail: filters.userEmail, + resourceName: filters.resourceName, + }); + setPageIndex(0); + }, 400); + return () => clearTimeout(t); + }, [filters.userEmail, filters.resourceName]); + + const handleFilterChange = ( + key: K, + value: AuditLogFilters[K], + ) => { + setFilters((prev) => ({ ...prev, [key]: value })); + if (key !== "userEmail" && key !== "resourceName") { + setPageIndex(0); + } + }; + + const handlePageSizeChange = (size: number) => { + setPageSize(size); + setPageIndex(0); + }; + + const { data, isLoading } = api.auditLog.all.useQuery({ + userEmail: debouncedText.userEmail || undefined, + resourceName: debouncedText.resourceName || undefined, + action: filters.action || undefined, + resourceType: filters.resourceType || undefined, + from: filters.dateRange?.from, + to: filters.dateRange?.to, + limit: pageSize, + offset: pageIndex * pageSize, + }); + + return ( + + ); +} + +export function ShowAuditLogs() { + return ( + +
+ + + + + Audit Logs + + + Track all actions performed by members in your organization. + + + + + + +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx new file mode 100644 index 000000000..22b0aef81 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +export function SignInWithGithub() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "github", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with GitHub", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx new file mode 100644 index 000000000..e40d8d9b5 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +export function SignInWithGoogle() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "google", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with Google", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx new file mode 100644 index 000000000..f4094c8d9 --- /dev/null +++ b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Loader2, Lock } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; + +interface EnterpriseFeatureLockedProps { + /** Optional title override */ + title?: string; + /** Optional description override */ + description?: string; + /** Optional custom CTA label */ + ctaLabel?: string; + /** Optional CTA href (default: /dashboard/settings/license) */ + ctaHref?: string; + /** Compact variant (less padding, smaller icon) */ + compact?: boolean; +} + +/** + * Displays a locked state for enterprise features when the user has no valid license. + * Use standalone or via EnterpriseFeatureGate. + */ +export function EnterpriseFeatureLocked({ + title = "Enterprise feature", + description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.", + ctaLabel = "Go to License", + ctaHref = "/dashboard/settings/license", + compact = false, +}: EnterpriseFeatureLockedProps) { + return ( + + +
+
+ +
+
+ {title} + + {description} + +
+
+
+ +
+ +
+
+
+ ); +} + +interface EnterpriseFeatureGateProps { + children: React.ReactNode; + /** Props for the locked state when license is invalid */ + lockedProps?: Omit; + /** Show loading spinner while checking license */ + fallback?: React.ReactNode; +} + +/** + * Renders children only when the instance has a valid enterprise license. + * Otherwise shows EnterpriseFeatureLocked. + */ +export function EnterpriseFeatureGate({ + children, + lockedProps, + fallback, +}: EnterpriseFeatureGateProps) { + const { data: haveValidLicense, isPending } = + api.licenseKey.haveValidLicenseKey.useQuery(); + + if (isPending) { + if (fallback) return <>{fallback}; + return ( +
+ + + Checking license... + +
+ ); + } + + if (!haveValidLicense) { + return ; + } + + return <>{children}; +} diff --git a/apps/dokploy/components/proprietary/license-keys/license-key.tsx b/apps/dokploy/components/proprietary/license-keys/license-key.tsx new file mode 100644 index 000000000..26bb3fa20 --- /dev/null +++ b/apps/dokploy/components/proprietary/license-keys/license-key.tsx @@ -0,0 +1,237 @@ +import { Key, Loader2, ShieldCheck } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +export function LicenseKeySettings() { + const utils = api.useUtils(); + const { data, isPending } = api.licenseKey.getEnterpriseSettings.useQuery(); + const { mutateAsync: updateEnterpriseSettings, isPending: isSaving } = + api.licenseKey.updateEnterpriseSettings.useMutation(); + const { mutateAsync: activateLicenseKey, isPending: isActivating } = + api.licenseKey.activate.useMutation(); + const { mutateAsync: validateLicenseKey, isPending: isValidating } = + api.licenseKey.validate.useMutation(); + const { mutateAsync: deactivateLicenseKey, isPending: isDeactivating } = + api.licenseKey.deactivate.useMutation(); + const { data: haveValidLicenseKey, isPending: isCheckingLicenseKey } = + api.licenseKey.haveValidLicenseKey.useQuery(); + const [licenseKey, setLicenseKey] = useState(""); + + useEffect(() => { + if (data?.licenseKey) { + setLicenseKey(data.licenseKey); + } + }, [data?.licenseKey]); + + const enabled = !!data?.enableEnterpriseFeatures; + + return ( +
+ {isCheckingLicenseKey ? ( +
+ + + Checking license key... + +
+ ) : ( + <> +
+
+
+ + License Key +
+ + {enabled && ( +
+ + {enabled ? "Enabled" : "Disabled"} + + { + try { + await updateEnterpriseSettings({ + enableEnterpriseFeatures: next, + }); + await utils.licenseKey.getEnterpriseSettings.invalidate(); + toast.success("Enterprise features updated"); + } catch (error) { + console.error(error); + toast.error("Failed to update enterprise features"); + } + }} + /> +
+ )} +
+ +

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

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

Enterprise Features

+

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

+
+
+ + +
+ )} + + )} +
+ ); +} diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx new file mode 100644 index 000000000..51b31b84c --- /dev/null +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -0,0 +1,1044 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { + Loader2, + PlusIcon, + ShieldCheck, + Sparkles, + TrashIcon, + Users, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +/** Labels and descriptions for each resource */ +const RESOURCE_META: Record = { + project: { + label: "Projects", + description: "Manage project creation and deletion", + }, + service: { + label: "Services", + description: + "Manage services (applications, databases, compose) within projects", + }, + environment: { + label: "Environments", + description: "Manage environment creation, viewing, and deletion", + }, + docker: { + label: "Docker", + description: "Access to Docker containers, images, and volumes management", + }, + sshKeys: { + label: "SSH Keys", + description: "Manage SSH key configurations for servers and repositories", + }, + gitProviders: { + label: "Git Providers", + description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)", + }, + traefikFiles: { + label: "Traefik Files", + description: "Access to the Traefik file system configuration", + }, + api: { + label: "API / CLI", + description: "Access to API keys and CLI usage", + }, + // Enterprise-only resources + volume: { + label: "Volumes", + description: "Manage persistent volumes and mounts attached to services", + }, + deployment: { + label: "Deployments", + description: "Trigger, view, and cancel service deployments", + }, + envVars: { + label: "Service Env Vars", + description: "View and edit environment variables of services", + }, + projectEnvVars: { + label: "Project Shared Env Vars", + description: "View and edit shared environment variables at project level", + }, + environmentEnvVars: { + label: "Environment Shared Env Vars", + description: + "View and edit shared environment variables at environment level", + }, + server: { + label: "Servers", + description: "Manage remote servers and nodes", + }, + registry: { + label: "Registries", + description: "Manage Docker image registries", + }, + certificate: { + label: "Certificates", + description: "Manage SSL/TLS certificates", + }, + backup: { + label: "Backups", + description: "Manage database backups and restores", + }, + volumeBackup: { + label: "Volume Backups", + description: "Manage Docker volume backups and restores", + }, + schedule: { + label: "Schedules", + description: "Manage scheduled jobs (commands, deployments, scripts)", + }, + domain: { + label: "Domains", + description: "Manage custom domains assigned to services", + }, + destination: { + label: "S3 Destinations", + description: + "Manage S3-compatible backup destinations (AWS, Cloudflare R2, etc.)", + }, + notification: { + label: "Notifications", + description: + "Manage notification providers (Slack, Discord, Telegram, etc.)", + }, + tag: { + label: "Tags", + description: "Manage tags to organize and categorize projects", + }, + member: { + label: "Users", + description: "Manage organization members, invitations, and roles", + }, + logs: { + label: "Logs", + description: "View service and deployment logs", + }, + monitoring: { + label: "Monitoring", + description: "View server and service metrics (CPU, RAM, disk)", + }, + auditLog: { + label: "Audit Logs", + description: "View the audit log of actions performed in the organization", + }, +}; + +/** Descriptions for each action within a resource */ +const ACTION_META: Record< + string, + Record +> = { + project: { + create: { label: "Create", description: "Create new projects" }, + delete: { + label: "Delete", + description: "Delete projects and all their content", + }, + }, + service: { + create: { + label: "Create", + description: "Create new services inside projects", + }, + read: { + label: "Read", + description: "View services, logs, and deployments", + }, + delete: { + label: "Delete", + description: "Delete services from projects", + }, + }, + environment: { + create: { + label: "Create", + description: "Create new environments in projects", + }, + read: { + label: "Read", + description: "View environments and their services", + }, + delete: { + label: "Delete", + description: "Delete environments and their content", + }, + }, + docker: { + read: { + label: "Read", + description: "View Docker containers, images, networks, and volumes", + }, + }, + sshKeys: { + read: { + label: "Read", + description: "View SSH key configurations", + }, + create: { + label: "Create", + description: "Create and edit SSH keys", + }, + delete: { + label: "Delete", + description: "Remove SSH keys", + }, + }, + gitProviders: { + read: { + label: "Read", + description: "View Git provider connections", + }, + create: { + label: "Create", + description: "Create and update Git provider connections", + }, + delete: { + label: "Delete", + description: "Remove Git provider connections", + }, + }, + traefikFiles: { + read: { + label: "Read", + description: "View Traefik configuration files", + }, + write: { + label: "Write", + description: "Edit and save Traefik configuration files", + }, + }, + api: { + read: { + label: "Read", + description: "Create and manage API keys for CLI access", + }, + }, + volume: { + read: { + label: "Read", + description: "View volumes and mounts attached to services", + }, + create: { label: "Create", description: "Add and edit volumes and mounts" }, + delete: { + label: "Delete", + description: "Remove volumes and mounts from services", + }, + }, + deployment: { + read: { label: "Read", description: "View deployment history and status" }, + create: { + label: "Deploy", + description: "Trigger new deployments manually", + }, + cancel: { label: "Cancel", description: "Cancel running deployments" }, + }, + envVars: { + read: { label: "Read", description: "View environment variable values" }, + write: { + label: "Write", + description: "Create, update, and delete environment variables", + }, + }, + projectEnvVars: { + read: { + label: "Read", + description: "View project-level shared environment variables", + }, + write: { + label: "Write", + description: "Edit project-level shared environment variables", + }, + }, + environmentEnvVars: { + read: { + label: "Read", + description: "View environment-level shared environment variables", + }, + write: { + label: "Write", + description: "Edit environment-level shared environment variables", + }, + }, + server: { + read: { + label: "Read", + description: "View server list and connection details", + }, + create: { label: "Create", description: "Add new remote servers" }, + delete: { + label: "Delete", + description: "Remove servers from the organization", + }, + }, + registry: { + read: { label: "Read", description: "View configured Docker registries" }, + create: { label: "Create", description: "Add new Docker registries" }, + delete: { label: "Delete", description: "Remove Docker registries" }, + }, + certificate: { + read: { label: "Read", description: "View SSL/TLS certificates" }, + create: { + label: "Create", + description: "Issue and configure new certificates", + }, + delete: { label: "Delete", description: "Remove certificates" }, + }, + backup: { + read: { label: "Read", description: "View backup history and status" }, + create: { label: "Create", description: "Trigger manual backups" }, + delete: { label: "Delete", description: "Delete backup files" }, + restore: { + label: "Restore", + description: "Restore a database from a backup", + }, + }, + volumeBackup: { + read: { + label: "Read", + description: "View volume backup history and status", + }, + create: { + label: "Create", + description: "Create and trigger volume backups", + }, + update: { + label: "Update", + description: "Update volume backup configuration", + }, + delete: { label: "Delete", description: "Delete volume backup files" }, + restore: { + label: "Restore", + description: "Restore a Docker volume from a backup", + }, + }, + schedule: { + read: { + label: "Read", + description: "View scheduled jobs and their history", + }, + create: { label: "Create", description: "Create and run scheduled jobs" }, + update: { + label: "Update", + description: "Update scheduled job configuration", + }, + delete: { label: "Delete", description: "Delete scheduled jobs" }, + }, + domain: { + read: { label: "Read", description: "View domains assigned to services" }, + create: { label: "Create", description: "Assign new domains to services" }, + delete: { label: "Delete", description: "Remove domains from services" }, + }, + destination: { + read: { label: "Read", description: "View S3 backup destinations" }, + create: { label: "Create", description: "Add and edit S3 destinations" }, + delete: { label: "Delete", description: "Remove S3 destinations" }, + }, + notification: { + read: { label: "Read", description: "View notification providers" }, + create: { + label: "Create", + description: "Add and edit notification providers", + }, + delete: { label: "Delete", description: "Remove notification providers" }, + }, + tag: { + read: { label: "Read", description: "View tags" }, + create: { label: "Create", description: "Create new tags" }, + update: { label: "Update", description: "Edit existing tags" }, + delete: { label: "Delete", description: "Delete tags" }, + }, + member: { + read: { + label: "Read", + description: "View the list of organization members", + }, + create: { + label: "Create", + description: "Invite new members to the organization", + }, + update: { + label: "Update", + description: "Change member roles and permissions", + }, + delete: { + label: "Delete", + description: "Remove members from the organization", + }, + }, + logs: { + read: { label: "Read", description: "View real-time and historical logs" }, + }, + monitoring: { + read: { + label: "Read", + description: "View CPU, RAM, disk, and network metrics", + }, + }, + auditLog: { + read: { label: "Read", description: "View the audit log history" }, + }, +}; + +/** Resources that should be hidden from the custom role editor (better-auth internals) */ +const HIDDEN_RESOURCES = ["organization", "invitation", "team", "ac"]; + +/** Predefined role presets with sensible permission defaults */ +const ROLE_PRESETS: { + name: string; + label: string; + description: string; + permissions: Record; +}[] = [ + { + name: "viewer", + label: "Viewer", + description: "Read-only access across all resources", + permissions: { + service: ["read"], + environment: ["read"], + docker: ["read"], + sshKeys: ["read"], + gitProviders: ["read"], + traefikFiles: ["read"], + api: ["read"], + volume: ["read"], + deployment: ["read"], + envVars: ["read"], + projectEnvVars: ["read"], + environmentEnvVars: ["read"], + server: ["read"], + registry: ["read"], + certificate: ["read"], + backup: ["read"], + volumeBackup: ["read"], + schedule: ["read"], + domain: ["read"], + destination: ["read"], + notification: ["read"], + tag: ["read"], + member: ["read"], + logs: ["read"], + monitoring: ["read"], + auditLog: ["read"], + }, + }, + { + name: "developer", + label: "Developer", + description: "Deploy services, manage env vars, domains, and view logs", + permissions: { + project: ["create"], + service: ["create", "read"], + environment: ["create", "read"], + docker: ["read"], + gitProviders: ["read"], + api: ["read"], + volume: ["read", "create", "delete"], + deployment: ["read", "create", "cancel"], + envVars: ["read", "write"], + projectEnvVars: ["read"], + environmentEnvVars: ["read"], + domain: ["read", "create", "delete"], + schedule: ["read", "create", "update", "delete"], + logs: ["read"], + monitoring: ["read"], + }, + }, + { + name: "deployer", + label: "Deployer", + description: "Trigger and manage deployments only", + permissions: { + service: ["read"], + environment: ["read"], + deployment: ["read", "create", "cancel"], + logs: ["read"], + monitoring: ["read"], + }, + }, + { + name: "devops", + label: "DevOps", + description: + "Full infrastructure access: servers, registries, certs, backups, and deployments", + permissions: { + project: ["create", "delete"], + service: ["create", "read", "delete"], + environment: ["create", "read", "delete"], + docker: ["read"], + sshKeys: ["read", "create", "delete"], + gitProviders: ["read", "create", "delete"], + traefikFiles: ["read", "write"], + api: ["read"], + volume: ["read", "create", "delete"], + deployment: ["read", "create", "cancel"], + envVars: ["read", "write"], + projectEnvVars: ["read", "write"], + environmentEnvVars: ["read", "write"], + server: ["read", "create", "delete"], + registry: ["read", "create", "delete"], + certificate: ["read", "create", "delete"], + backup: ["read", "create", "delete", "restore"], + volumeBackup: ["read", "create", "update", "delete", "restore"], + schedule: ["read", "create", "update", "delete"], + domain: ["read", "create", "delete"], + destination: ["read", "create", "delete"], + notification: ["read", "create", "delete"], + tag: ["read", "create", "update", "delete"], + logs: ["read"], + monitoring: ["read"], + auditLog: ["read"], + }, + }, +]; + +const createRoleSchema = z.object({ + roleName: z + .string() + .min(1, "Role name is required") + .max(50, "Role name must be 50 characters or less") + .regex( + /^[a-zA-Z0-9_-]+$/, + "Only letters, numbers, hyphens, and underscores allowed", + ), +}); + +type CreateRoleSchema = z.infer; + +export const ManageCustomRoles = () => { + return ( + +
+ + + + Custom Roles + + + Create and manage custom roles with fine-grained permissions + + + + + + + +
+
+ ); +}; + +interface HandleCustomRoleProps { + roleName?: string; + initialPermissions?: Record; + onSuccess: () => void; +} + +function HandleCustomRole({ + roleName, + initialPermissions, + onSuccess, +}: HandleCustomRoleProps) { + const [open, setOpen] = useState(false); + const [permissions, setPermissions] = useState>({}); + const { data: statements } = api.customRole.getStatements.useQuery(); + const isEdit = !!roleName; + + const form = useForm({ + defaultValues: { roleName: "" }, + resolver: zodResolver(createRoleSchema), + }); + + useEffect(() => { + if (open) { + setPermissions(initialPermissions ? { ...initialPermissions } : {}); + form.reset({ roleName: isEdit ? (roleName ?? "") : "" }); + } + }, [open]); + + const { mutateAsync: createRole, isPending: isCreating } = + api.customRole.create.useMutation(); + const { mutateAsync: updateRole, isPending: isUpdating } = + api.customRole.update.useMutation(); + + const visibleResources = statements + ? Object.entries(statements).filter( + ([key]) => !HIDDEN_RESOURCES.includes(key), + ) + : []; + + const togglePermission = (resource: string, action: string) => { + setPermissions((prev) => { + const current = prev[resource] || []; + const has = current.includes(action); + return { + ...prev, + [resource]: has + ? current.filter((a) => a !== action) + : [...current, action], + }; + }); + }; + + const handleSubmit = async (data: CreateRoleSchema) => { + try { + if (isEdit) { + const newName = data.roleName !== roleName ? data.roleName : undefined; + await updateRole({ + roleName: roleName!, + newRoleName: newName, + permissions, + }); + toast.success(`Role "${newName ?? roleName}" updated`); + } else { + await createRole({ roleName: data.roleName, permissions }); + toast.success(`Role "${data.roleName}" created`); + } + if (!isEdit) { + setOpen(false); + } + onSuccess(); + } catch (error) { + let message = `Error ${isEdit ? "updating" : "creating"} role`; + if (error instanceof Error) { + try { + const parsed = JSON.parse(error.message); + if (Array.isArray(parsed) && parsed[0]?.message) { + message = parsed[0].message; + } else { + message = error.message; + } + } catch { + message = error.message; + } + } + toast.error(message); + } + }; + + return ( + + + {isEdit ? ( + + ) : ( + + )} + + + + + {isEdit ? "Edit Role" : "Create Custom Role"} + + + {isEdit + ? "Update permissions for this role" + : "Define a new role with specific permissions"} + + +
+ + ( + + Role Name + + + + + + )} + /> + + + {!isEdit && ( +
+

+ + Start from a preset +

+
+ {ROLE_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + + + +
+
+ ); +} + +const CustomRolesContent = () => { + const { + data: customRoles, + isPending, + refetch, + } = api.customRole.all.useQuery(); + const { mutateAsync: deleteRole } = api.customRole.remove.useMutation(); + + const handleDelete = async (roleName: string) => { + try { + await deleteRole({ roleName }); + toast.success(`Role "${roleName}" deleted`); + refetch(); + } catch (error) { + let message = "Error deleting role"; + if (error instanceof Error) { + try { + const parsed = JSON.parse(error.message); + message = + Array.isArray(parsed) && parsed[0]?.message + ? parsed[0].message + : error.message; + } catch { + message = error.message; + } + } + toast.error(message); + } + }; + + if (isPending) { + return ( +
+ Loading... + +
+ ); + } + + return ( +
+
+ +
+ + {customRoles?.length === 0 ? ( +
+
+ +
+
+

No custom roles yet

+

+ Create a role to define fine-grained access for your team members. +

+
+
+ ) : ( +
+ {customRoles?.map((role) => { + const totalPermissions = Object.values(role.permissions).flat() + .length; + const enabledResources = Object.entries(role.permissions).filter( + ([, actions]) => (actions as string[]).length > 0, + ); + return ( +
+
+
+
+ +
+
+
+

+ {role.role} +

+ {role.memberCount > 0 && ( + + )} +
+

+ {enabledResources.length} resource + {enabledResources.length !== 1 ? "s" : ""} ·{" "} + {totalPermissions} permission + {totalPermissions !== 1 ? "s" : ""} +

+
+
+
+ + + {role.memberCount > 0 && ( + + + {role.memberCount} member + {role.memberCount !== 1 ? "s are" : " is"}{" "} + currently assigned + {" "} + to this role. Reassign them before deleting. + + )} + + Are you sure you want to delete the{" "} + "{role.role}" role? This action + cannot be undone. + +
+ } + disabled={role.memberCount > 0} + type="destructive" + onClick={() => handleDelete(role.role)} + > + + +
+
+ + {enabledResources.length > 0 && ( +
+ {enabledResources.map(([resource, actions]) => ( +
+ + {RESOURCE_META[resource]?.label || resource} + + · + + {(actions as string[]) + .map((a) => ACTION_META[resource]?.[a]?.label || a) + .join(", ")} + +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; + +function MembersBadge({ + roleName, + count, +}: { + roleName: string; + count: number; +}) { + const [open, setOpen] = useState(false); + const { data: members, isLoading } = api.customRole.membersByRole.useQuery( + { roleName }, + { enabled: open }, + ); + return ( + + + + + +

+ Assigned members +

+ {isLoading ? ( +
+ +
+ ) : members && members.length > 0 ? ( +
    + {members.map((m) => ( +
  • +
    + {(m.firstName?.[0] || m.email?.[0] || "?").toUpperCase()} +
    +
    + {(m.firstName || m.lastName) && ( +

    + {[m.firstName, m.lastName].filter(Boolean).join(" ")} +

    + )} +

    + {m.email} +

    +
    +
  • + ))} +
+ ) : ( +

+ No members found. +

+ )} +
+
+ ); +} + +/** Reusable permission toggle grid with descriptions */ +function PermissionEditor({ + resources, + permissions, + onToggle, +}: { + resources: [string, readonly string[]][]; + permissions: Record; + onToggle: (resource: string, action: string) => void; +}) { + return ( +
+

Permissions

+
+ {resources.map(([resource, actions]) => { + const meta = RESOURCE_META[resource]; + return ( +
+
+

{meta?.label || resource}

+ {meta?.description && ( +

+ {meta.description} +

+ )} +
+
+ {actions.map((action) => { + const actionMeta = ACTION_META[resource]?.[action]; + return ( +
onToggle(resource, action)} + > + onToggle(resource, action)} + /> +
+ + {actionMeta?.label || action} + +
+
+ ); + })} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx new file mode 100644 index 000000000..fa1d33b89 --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx @@ -0,0 +1,447 @@ +"use client"; + +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { FieldArrayPath } from "react-hook-form"; +import { useFieldArray, useForm, useWatch } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; +import { useUrl } from "@/utils/hooks/use-url"; + +const DEFAULT_SCOPES = ["openid", "email", "profile"]; + +const domainsArraySchema = z + .array(z.string().trim()) + .superRefine((arr, ctx) => { + const filled = arr.filter((s) => s.length > 0); + if (filled.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one domain is required", + path: [], + }); + } + }); + +const scopesArraySchema = z.array(z.string().trim()); + +const oidcProviderSchema = z.object({ + providerId: z.string().min(1, "Provider ID is required").trim(), + issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(), + domains: domainsArraySchema, + clientId: z.string().min(1, "Client ID is required").trim(), + clientSecret: z.string().min(1, "Client secret is required"), + scopes: scopesArraySchema, +}); + +type OidcProviderForm = z.infer; + +interface RegisterOidcDialogProps { + providerId?: string; + children: React.ReactNode; +} + +const formDefaultValues = { + providerId: "", + issuer: "", + domains: [""], + clientId: "", + clientSecret: "", + scopes: [...DEFAULT_SCOPES], +}; + +function parseOidcConfig(oidcConfig: string | null): { + clientId?: string; + clientSecret?: string; + scopes?: string[]; +} | null { + if (!oidcConfig) return null; + try { + const parsed = JSON.parse(oidcConfig) as { + clientId?: string; + clientSecret?: string; + scopes?: string[]; + }; + return { + clientId: parsed.clientId, + clientSecret: parsed.clientSecret, + scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined, + }; + } catch { + return null; + } +} + +export function RegisterOidcDialog({ + providerId, + children, +}: RegisterOidcDialogProps) { + const utils = api.useUtils(); + const [open, setOpen] = useState(false); + + const { data } = api.sso.one.useQuery( + { providerId: providerId ?? "" }, + { enabled: !!providerId && open }, + ); + const registerMutation = api.sso.register.useMutation(); + const updateMutation = api.sso.update.useMutation(); + + const isEdit = !!providerId; + const mutateAsync = isEdit + ? updateMutation.mutateAsync + : registerMutation.mutateAsync; + const isLoading = isEdit + ? updateMutation.isPending + : registerMutation.isPending; + + const form = useForm({ + resolver: zodResolver(oidcProviderSchema), + defaultValues: formDefaultValues, + }); + + const watchedProviderId = useWatch({ + control: form.control, + name: "providerId", + defaultValue: "", + }); + + const baseURL = useUrl(); + + useEffect(() => { + if (!data || !open) return; + const domains = data.domain + ? data.domain + .split(",") + .map((d) => d.trim()) + .filter(Boolean) + : [""]; + if (domains.length === 0) domains.push(""); + const oidc = parseOidcConfig(data.oidcConfig); + form.reset({ + providerId: data.providerId, + issuer: data.issuer, + domains, + clientId: oidc?.clientId ?? "", + clientSecret: oidc?.clientSecret ?? "", + scopes: + oidc?.scopes && oidc.scopes.length > 0 + ? oidc.scopes + : [...DEFAULT_SCOPES], + }); + }, [data, open, form]); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "domains" as FieldArrayPath, + }); + + const { + fields: scopeFields, + append: appendScope, + remove: removeScope, + } = useFieldArray({ + control: form.control, + name: "scopes" as FieldArrayPath, + }); + + const isSubmitting = form.formState.isSubmitting; + + const onSubmit = async (data: OidcProviderForm) => { + try { + const scopes = data.scopes.filter(Boolean).length + ? data.scopes.filter(Boolean) + : DEFAULT_SCOPES; + + const isAzure = data.issuer.includes("login.microsoftonline.com"); + const mapping = isAzure + ? { + id: "sub", + email: "preferred_username", + emailVerified: "email_verified", + name: "name", + } + : { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "preferred_username", + image: "picture", + }; + await mutateAsync({ + providerId: data.providerId, + issuer: data.issuer, + domains: data.domains, + oidcConfig: { + clientId: data.clientId, + clientSecret: data.clientSecret, + scopes, + pkce: true, + mapping, + }, + }); + + toast.success( + isEdit + ? "OIDC provider updated successfully" + : "OIDC provider registered successfully", + ); + form.reset(formDefaultValues); + setOpen(false); + await utils.sso.listProviders.invalidate(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to register SSO provider", + ); + } + }; + + return ( + + {children} + + + + {isEdit ? "Update OIDC provider" : "Register OIDC provider"} + + + {isEdit + ? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed." + : "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."} + + +
+ + ( + + Provider ID + + + + + Unique identifier; used in callback URL path. + {isEdit && " Cannot be changed when editing."} + + {baseURL && ( +
+

+ Callback URL (configure in your IdP) +

+

+ {baseURL}/api/auth/sso/callback/ + {watchedProviderId?.trim() || "..."} +

+
+ )} + +
+ )} + /> + ( + + Issuer URL + + + + + Discovery document is fetched from{" "} + + {"{issuer}"}/.well-known/openid-configuration + + + + + )} + /> +
+
+ Domains + +
+

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

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

{msg}

+ ) : null; + })()} +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client secret + + + + + + )} + /> +
+
+ Scopes (optional) + +
+ + OIDC scopes to request (e.g. openid, email, profile). If empty, + openid, email and profile are used. + + {scopeFields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} +
+ + + + + + +
+
+ ); +} diff --git a/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx new file mode 100644 index 000000000..a2f9d477e --- /dev/null +++ b/apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + type FieldArrayPath, + useFieldArray, + useForm, + useWatch, +} from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { useUrl } from "@/utils/hooks/use-url"; + +const domainsArraySchema = z + .array(z.string().trim()) + .superRefine((arr, ctx) => { + const filled = arr.filter((s) => s.length > 0); + if (filled.length < 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one domain is required", + path: [], + }); + } + }); + +const samlProviderSchema = z.object({ + providerId: z.string().min(1, "Provider ID is required").trim(), + issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(), + domains: domainsArraySchema, + entryPoint: z + .string() + .min(1, "IdP SSO URL is required") + .url("Invalid URL") + .trim(), + cert: z.string().min(1, "IdP signing certificate is required"), + idpMetadataXml: z.string().optional(), +}); + +type SamlProviderForm = z.infer; + +interface RegisterSamlDialogProps { + providerId?: string; + children: React.ReactNode; +} + +const formDefaultValues: SamlProviderForm = { + providerId: "", + issuer: "", + domains: [""], + entryPoint: "", + cert: "", + idpMetadataXml: "", +}; + +function parseSamlConfig(samlConfig: string | null): { + entryPoint?: string; + cert?: string; + idpMetadataXml?: string; +} | null { + if (!samlConfig) return null; + try { + const parsed = JSON.parse(samlConfig) as { + entryPoint?: string; + cert?: string; + idpMetadata?: { metadata?: string }; + }; + return { + entryPoint: parsed.entryPoint, + cert: parsed.cert, + idpMetadataXml: parsed.idpMetadata?.metadata, + }; + } catch { + return null; + } +} + +export function RegisterSamlDialog({ + providerId, + children, +}: RegisterSamlDialogProps) { + const utils = api.useUtils(); + const [open, setOpen] = useState(false); + + const { data } = api.sso.one.useQuery( + { providerId: providerId ?? "" }, + { enabled: !!providerId && open }, + ); + const registerMutation = api.sso.register.useMutation(); + const updateMutation = api.sso.update.useMutation(); + + const isEdit = !!providerId; + const mutateAsync = isEdit + ? updateMutation.mutateAsync + : registerMutation.mutateAsync; + const isPending = isEdit + ? updateMutation.isPending + : registerMutation.isPending; + + const baseURL = useUrl(); + + const form = useForm({ + resolver: zodResolver(samlProviderSchema), + defaultValues: formDefaultValues, + }); + + useEffect(() => { + if (!data || !open) return; + const domains = data.domain + ? data.domain + .split(",") + .map((d) => d.trim()) + .filter(Boolean) + : [""]; + if (domains.length === 0) domains.push(""); + const saml = parseSamlConfig(data.samlConfig); + form.reset({ + providerId: data.providerId, + issuer: data.issuer, + domains, + entryPoint: saml?.entryPoint ?? "", + cert: saml?.cert ?? "", + idpMetadataXml: saml?.idpMetadataXml ?? "", + }); + }, [data, open, form]); + + const watchedProviderId = useWatch({ + control: form.control, + name: "providerId", + defaultValue: "", + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "domains" as FieldArrayPath, + }); + + const isSubmitting = form.formState.isSubmitting; + + const onSubmit = async (data: SamlProviderForm) => { + try { + // maybe add the /saml/metadata endpoint to the baseURL + const baseURLWithMetadata = `${baseURL}/saml/metadata`; + const generateSpMetadata = (providerId: string) => { + return ` + + + + +`; + }; + + await mutateAsync({ + providerId: data.providerId, + issuer: data.issuer, + domains: data.domains, + samlConfig: { + entryPoint: data.entryPoint, + cert: data.cert, + callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`, + audience: baseURL, + idpMetadata: data.idpMetadataXml?.trim() + ? { metadata: data.idpMetadataXml.trim() } + : undefined, + spMetadata: { + metadata: generateSpMetadata(data.providerId), + }, + mapping: { + id: "nameID", + email: "email", + name: "displayName", + firstName: "givenName", + lastName: "surname", + }, + }, + }); + + toast.success( + isEdit + ? "SAML provider updated successfully" + : "SAML provider registered successfully", + ); + form.reset(formDefaultValues); + setOpen(false); + await utils.sso.listProviders.invalidate(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to register SAML provider", + ); + } + }; + + return ( + + {children} + + + + {isEdit ? "Update SAML provider" : "Register SAML provider"} + + + {isEdit + ? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed." + : "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."} + + +
+ + ( + + Provider ID + + + + {isEdit && ( + + Cannot be changed when editing. + + )} + {baseURL && ( +
+

+ Callback URL (configure in your IdP) +

+

+ {baseURL}/api/auth/sso/saml2/callback/ + {watchedProviderId?.trim() || "..."} +

+
+ )} + +
+ )} + /> + ( + + Issuer URL + + + + + + )} + /> +
+
+ Domains + +
+ + Email domains that use this provider (sign-in by email and org + assignment; subdomains matched automatically). + + {fields.map((field, index) => ( + ( + + +
+ + +
+
+ +
+ )} + /> + ))} + {(() => { + const err = form.formState.errors.domains; + const msg = + typeof err?.message === "string" + ? err.message + : (err as { root?: { message?: string } } | undefined)?.root + ?.message; + return msg ? ( +

{msg}

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