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 d45c3dac0..e210811b0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ Before submitting this PR, please make sure that: - [ ] You created a dedicated branch based on the `canary` branch. - [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request -- [ ] You have tested this PR in your local instance. +- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you. ## Issues related (if applicable) diff --git a/.github/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..6de85fa27 --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,21 @@ + +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: + 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 31dbc48fb..2ad24fc0c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -18,20 +18,20 @@ 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.39.0 + export NIXPACKS_VERSION=1.41.0 curl -sSL https://nixpacks.com/install.sh | bash echo "Nixpacks installed $NIXPACKS_VERSION" - + - name: Install Railpack if: matrix.job == 'test' run: | - export RAILPACK_VERSION=0.15.0 + export RAILPACK_VERSION=0.15.4 curl -sSL https://railpack.com/install.sh | bash echo "Railpack installed $RAILPACK_VERSION" diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index ddc51355a..549af945b 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -24,7 +24,7 @@ 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 dependencies 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..4fa0dd358 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 @@ -98,7 +99,14 @@ pnpm run dokploy:build ## Docker -To build the docker image +To build the docker image first run commands to copy .env files + +```bash +cp apps/dokploy/.env.production.example .env.production +cp apps/dokploy/.env.production.example apps/dokploy/.env.production +``` + +then run build command ```bash pnpm run docker:build @@ -148,7 +156,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 +170,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 ae8c997f8..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 @@ -51,18 +51,22 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver # Install Nixpacks and tsx # | VERBOSE=1 VERSION=1.21.0 bash -ARG NIXPACKS_VERSION=1.39.0 +ARG NIXPACKS_VERSION=1.41.0 RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ && chmod +x install.sh \ && ./install.sh \ && pnpm install -g tsx # Install Railpack -ARG RAILPACK_VERSION=0.2.2 +ARG RAILPACK_VERSION=0.15.4 RUN curl -sSL https://railpack.com/install.sh | bash # Install buildpacks -COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack +COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 -CMD [ "pnpm", "start" ] + +HEALTHCHECK --interval=10s --timeout=3s --retries=10 \ + CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 + + CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index 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 d60962cff..2ddc1f498 100644 --- a/README.md +++ b/README.md @@ -12,30 +12,14 @@
- -
- Special thanks to: -
-
- - Tuple's sponsorship image - - -### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy) -[Available for MacOS & Windows](https://tuple.app/dokploy)
- -
- - Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. - ## ✨ Features Dokploy includes multiple features to make your life easier. - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). -- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis. +- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis. - **Backups**: Automate backups for databases to an external storage destination. - **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. @@ -60,70 +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 0d0b574fc..b99ec492a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -4,11 +4,12 @@ import { 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 { @@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => { previewStatus: "running", }); if (job.server) { - if (job.type === "deploy") { + if (job.type === "redeploy") { + await rebuildPreviewApplication({ + applicationId: job.applicationId, + titleLog: job.titleLog || "Rebuild Preview Deployment", + descriptionLog: job.descriptionLog || "", + previewDeploymentId: job.previewDeploymentId, + }); + } else if (job.type === "deploy") { await deployPreviewApplication({ applicationId: job.applicationId, titleLog: job.titleLog || "Preview Deployment", diff --git a/apps/dokploy/.env.production.example b/apps/dokploy/.env.production.example index 41e934c3a..560faf9e6 100644 --- a/apps/dokploy/.env.production.example +++ b/apps/dokploy/.env.production.example @@ -1,3 +1,2 @@ -DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy" PORT=3000 NODE_ENV=production \ No newline at end of file diff --git a/apps/dokploy/.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..9f984b6f1 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -0,0 +1,216 @@ +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, + customEntrypoint: null, + }; + + 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/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 9a75e0a84..684b83890 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -7,6 +7,7 @@ describe("createDomainLabels", () => { const baseDomain: Domain = { host: "example.com", port: 8080, + customEntrypoint: null, https: false, uniqueConfigKey: 1, customCertResolver: null, @@ -240,4 +241,134 @@ describe("createDomainLabels", () => { "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", ); }); + + it("should create basic labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { ...baseDomain, customEntrypoint: "custom" }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + ]); + }); + + it("should create https labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + "traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt", + ]); + }); + + it("should add stripPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1", + ); + }); + + it("should add internalPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1", + ); + }); + + it("should add path prefix in rule for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)", + ); + }); + + it("should combine all middlewares for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1", + ); + }); + + it("should not add redirect-to-https for custom entrypoint even with https", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + https: true, + certificateType: "letsencrypt", + }, + "custom", + ); + + const middlewareLabel = labels.find((l) => l.includes(".middlewares=")); + // Should not contain redirect-to-https since there's only one router + expect(middlewareLabel).toBeUndefined(); + }); }); 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__/compose/network/network-root.test.ts b/apps/dokploy/__test__/compose/network/network-root.test.ts index 0d3c841d4..1a6817913 100644 --- a/apps/dokploy/__test__/compose/network/network-root.test.ts +++ b/apps/dokploy/__test__/compose/network/network-root.test.ts @@ -292,7 +292,7 @@ networks: dokploy-network: `; -test("It shoudn't add suffix to dokploy-network", () => { +test("It shouldn't add suffix to dokploy-network", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/network/network-service.test.ts b/apps/dokploy/__test__/compose/network/network-service.test.ts index e07fa1546..073e61615 100644 --- a/apps/dokploy/__test__/compose/network/network-service.test.ts +++ b/apps/dokploy/__test__/compose/network/network-service.test.ts @@ -195,7 +195,7 @@ services: - dokploy-network `; -test("It shoudn't add suffix to dokploy-network in services", () => { +test("It shouldn't add suffix to dokploy-network in services", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); @@ -241,10 +241,10 @@ services: dokploy-network: aliases: - apid - + `; -test("It shoudn't add suffix to dokploy-network in services multiples cases", () => { +test("It shouldn't add suffix to dokploy-network in services multiples cases", () => { const composeData = parse(composeFile8) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts b/apps/dokploy/__test__/compose/service/service-volumes-from.test.ts similarity index 100% rename from apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts rename to apps/dokploy/__test__/compose/service/service-volumes-from.test.ts diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index a0c4387c8..1a33489b5 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => { 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(), + select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), @@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }, }; @@ -189,7 +200,7 @@ describe("deployApplication - Command Generation Tests", () => { 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).mockReturnValue(mockNixpacksCommand); + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand); await deployApplication({ applicationId: "test-app-id", @@ -220,7 +231,7 @@ describe("deployApplication - Command Generation Tests", () => { ); const mockRailpackCommand = "railpack prepare /path/to/app"; - vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand); + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand); await deployApplication({ applicationId: "test-app-id", @@ -241,7 +252,7 @@ describe("deployApplication - Command Generation Tests", () => { it("should execute commands in correct order", async () => { const mockNixpacksCommand = "nixpacks build"; - vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand); + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand); await deployApplication({ applicationId: "test-app-id", @@ -260,7 +271,7 @@ describe("deployApplication - Command Generation Tests", () => { it("should include log redirection in command", async () => { const mockCommand = "nixpacks build"; - vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand); + vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand); await deployApplication({ applicationId: "test-app-id", diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 43ff07836..4adff6f07 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => { 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(), + select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), @@ -29,6 +34,12 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }, }; diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts index 46be44883..d2e773dfc 100644 --- a/apps/dokploy/__test__/deploy/github.test.ts +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -83,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", () => { @@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => { expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( "NEW COMMIT", ); + expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe( + "NEW COMMIT", + ); }); }); 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 bd2d3c981..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,9 +29,11 @@ if (typeof window === "undefined") { } const baseApp: ApplicationNested = { - railpackVersion: "0.2.2", + railpackVersion: "0.15.4", applicationId: "", previewLabels: [], + createEnvFile: true, + bitbucketRepositorySlug: "", herokuVersion: "", giteaBranch: "", buildServerId: "", @@ -41,6 +47,9 @@ const baseApp: ApplicationNested = { giteaRepository: "", cleanCache: false, watchPaths: [], + rollbackRegistryId: "", + rollbackRegistry: null, + deployments: [], enableSubmodules: false, applicationStatus: "done", triggerType: "push", @@ -64,6 +73,7 @@ const baseApp: ApplicationNested = { previewWildcard: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", @@ -141,8 +151,179 @@ const baseApp: ApplicationNested = { 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 () => { @@ -159,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/stack-environment.test.ts b/apps/dokploy/__test__/env/stack-environment.test.ts new file mode 100644 index 000000000..773adf3ed --- /dev/null +++ b/apps/dokploy/__test__/env/stack-environment.test.ts @@ -0,0 +1,184 @@ +import { getEnvironmentVariablesObject } 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("getEnvironmentVariablesObject 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 = getEnvironmentVariablesObject( + 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 = getEnvironmentVariablesObject( + 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 = getEnvironmentVariablesObject(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(() => + getEnvironmentVariablesObject(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 = getEnvironmentVariablesObject( + 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 = getEnvironmentVariablesObject( + 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 = getEnvironmentVariablesObject( + 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 = getEnvironmentVariablesObject( + 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..bb6f5f18b --- /dev/null +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -0,0 +1,79 @@ +import { + enterpriseOnlyResources, + statements, +} from "@dokploy/server/lib/access-control"; +import { describe, expect, it } from "vitest"; + +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", + "tag", + "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 index 38948ac5c..fb448e3af 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -1,12 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - import type { ApplicationNested } from "@dokploy/server/utils/builders"; import { mechanizeDockerContainer } from "@dokploy/server/utils/builders"; +import { beforeEach, describe, expect, it, vi } from "vitest"; type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; @@ -14,11 +14,11 @@ type MockCreateServiceOptions = { const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = vi.hoisted(() => { - const inspect = vi.fn<[], Promise>(); + const inspect = vi.fn<() => Promise>(); const getService = vi.fn(() => ({ inspect })); - const createService = vi.fn<[MockCreateServiceOptions], Promise>( - async () => undefined, - ); + const createService = vi.fn< + (opts: MockCreateServiceOptions) => Promise + >(async () => undefined); const getRemoteDocker = vi.fn(async () => ({ getService, createService, @@ -58,6 +58,7 @@ const createApplication = ( }, replicas: 1, stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; @@ -81,7 +82,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -98,7 +101,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -107,4 +112,50 @@ describe("mechanizeDockerContainer", () => { "StopGracePeriod", ); }); + + it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { + const ulimits = [ + { Name: "nofile", Soft: 10000, Hard: 20000 }, + { Name: "nproc", Soft: 4096, Hard: 8192 }, + ]; + const application = createApplication({ ulimitsSwarm: ulimits }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); + }); + + it("omits Ulimits when ulimitsSwarm is null", async () => { + const application = createApplication({ ulimitsSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); + + it("omits Ulimits when ulimitsSwarm is an empty array", async () => { + const application = createApplication({ ulimitsSwarm: [] }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); }); diff --git a/apps/dokploy/__test__/setup.ts b/apps/dokploy/__test__/setup.ts new file mode 100644 index 000000000..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 3ae92ae20..f2af2717b 100644 --- a/apps/dokploy/__test__/templates/helpers.template.test.ts +++ b/apps/dokploy/__test__/templates/helpers.template.test.ts @@ -161,6 +161,50 @@ describe("helpers functions", () => { }); }); + describe("Empty string variables", () => { + it("should replace variables with empty string values correctly", () => { + const variables = { + smtp_username: "", + smtp_password: "", + non_empty: "value", + }; + + const result1 = processValue("${smtp_username}", variables, mockSchema); + expect(result1).toBe(""); + + const result2 = processValue("${smtp_password}", variables, mockSchema); + expect(result2).toBe(""); + + const result3 = processValue("${non_empty}", variables, mockSchema); + expect(result3).toBe("value"); + }); + + it("should not replace undefined variables", () => { + const variables = { + defined_var: "", + }; + + const result = processValue("${undefined_var}", variables, mockSchema); + expect(result).toBe("${undefined_var}"); + }); + + it("should handle mixed empty and non-empty variables in template", () => { + const variables = { + smtp_address: "smtp.example.com", + smtp_port: "2525", + smtp_username: "", + smtp_password: "", + }; + + const template = + "SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}"; + const result = processValue(template, variables, mockSchema); + expect(result).toBe( + "SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=", + ); + }); + }); + describe("${jwt}", () => { it("should generate a JWT string", () => { const jwt = processValue("${jwt}", {}, mockSchema); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index 6858f0f00..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 e6b98c3ba..ae08ca256 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -3,10 +3,12 @@ import { createRouterConfig } from "@dokploy/server"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { - railpackVersion: "0.2.2", + railpackVersion: "0.15.4", rollbackActive: false, applicationId: "", previewLabels: [], + createEnvFile: true, + bitbucketRepositorySlug: "", herokuVersion: "", giteaRepository: "", giteaOwner: "", @@ -17,6 +19,9 @@ const baseApp: ApplicationNested = { giteaBuildPath: "", giteaId: "", args: [], + rollbackRegistryId: "", + rollbackRegistry: null, + deployments: [], cleanCache: false, applicationStatus: "done", endpointSpecSwarm: null, @@ -46,6 +51,7 @@ const baseApp: ApplicationNested = { environmentId: "", environment: { env: "", + isDefault: false, environmentId: "", name: "", createdAt: "", @@ -119,6 +125,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { @@ -130,6 +137,7 @@ const baseDomain: Domain = { https: false, path: null, port: null, + customEntrypoint: null, serviceName: "", composeId: "", customCertResolver: null, @@ -268,3 +276,155 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); + +test("Custom entrypoint on http domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: false, customEntrypoint: "custom" }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls).toBeUndefined(); +}); + +test("Custom entrypoint on https domain", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls?.certResolver).toBe("letsencrypt"); +}); + +test("Custom entrypoint with path includes PathPrefix in rule", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, customEntrypoint: "custom", path: "/api" }, + "custom", + ); + + expect(router.rule).toContain("PathPrefix(`/api`)"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with stripPath adds stripprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(router.middlewares).toContain("stripprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with internalPath adds addprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(router.middlewares).toContain("addprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with https and custom cert resolver", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "custom", + customCertResolver: "myresolver", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls?.certResolver).toBe("myresolver"); +}); + +test("Custom entrypoint without https should not have tls", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: false, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls).toBeUndefined(); +}); + +/** 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 7270b828a..65eb374ea 100644 --- a/apps/dokploy/__test__/vitest.config.ts +++ b/apps/dokploy/__test__/vitest.config.ts @@ -7,10 +7,15 @@ export default defineConfig({ include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__ exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"], pool: "forks", + setupFiles: [path.resolve(__dirname, "setup.ts")], }, define: { "process.env": { NODE: "test", + GITHUB_CLIENT_ID: "test", + GITHUB_CLIENT_SECRET: "test", + GOOGLE_CLIENT_ID: "test", + GOOGLE_CLIENT_SECRET: "test", }, }, plugins: [ diff --git a/apps/dokploy/__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 739bd87a5..81a09ec0f 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,350 +1,145 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { HelpCircle, Settings } from "lucide-react"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; +import { Settings } from "lucide-react"; +import { useState } from "react"; import { AlertBlock } from "@/components/shared/alert-block"; -import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { api } from "@/utils/api"; +import { cn } from "@/lib/utils"; +import { + EndpointSpecForm, + HealthCheckForm, + LabelsForm, + ModeForm, + NetworkForm, + PlacementForm, + RestartPolicyForm, + RollbackConfigForm, + StopGracePeriodForm, + UpdateConfigForm, +} from "./swarm-forms"; -const HealthCheckSwarmSchema = z - .object({ - Test: z.array(z.string()).optional(), - Interval: z.number().optional(), - Timeout: z.number().optional(), - StartPeriod: z.number().optional(), - Retries: z.number().optional(), - }) - .strict(); - -const RestartPolicySwarmSchema = z - .object({ - Condition: z.string().optional(), - Delay: z.number().optional(), - MaxAttempts: z.number().optional(), - Window: z.number().optional(), - }) - .strict(); - -const PreferenceSchema = z - .object({ - Spread: z.object({ - SpreadDescriptor: z.string(), - }), - }) - .strict(); - -const PlatformSchema = z - .object({ - Architecture: z.string(), - OS: z.string(), - }) - .strict(); - -const PlacementSwarmSchema = z - .object({ - Constraints: z.array(z.string()).optional(), - Preferences: z.array(PreferenceSchema).optional(), - MaxReplicas: z.number().optional(), - Platforms: z.array(PlatformSchema).optional(), - }) - .strict(); - -const UpdateConfigSwarmSchema = z - .object({ - Parallelism: z.number(), - Delay: z.number().optional(), - FailureAction: z.string().optional(), - Monitor: z.number().optional(), - MaxFailureRatio: z.number().optional(), - Order: z.string(), - }) - .strict(); - -const ReplicatedSchema = z - .object({ - Replicas: z.number().optional(), - }) - .strict(); - -const ReplicatedJobSchema = z - .object({ - MaxConcurrent: z.number().optional(), - TotalCompletions: z.number().optional(), - }) - .strict(); - -const ServiceModeSwarmSchema = z - .object({ - Replicated: ReplicatedSchema.optional(), - Global: z.object({}).optional(), - ReplicatedJob: ReplicatedJobSchema.optional(), - GlobalJob: z.object({}).optional(), - }) - .strict(); - -const NetworkSwarmSchema = z.array( - z - .object({ - Target: z.string().optional(), - Aliases: z.array(z.string()).optional(), - DriverOpts: z.object({}).optional(), - }) - .strict(), -); - -const LabelsSwarmSchema = z.record(z.string()); - -const EndpointPortConfigSwarmSchema = z - .object({ - Protocol: z.string().optional(), - TargetPort: z.number().optional(), - PublishedPort: z.number().optional(), - PublishMode: z.string().optional(), - }) - .strict(); - -const EndpointSpecSwarmSchema = z - .object({ - Mode: z.string().optional(), - Ports: z.array(EndpointPortConfigSwarmSchema).optional(), - }) - .strict(); - -const createStringToJSONSchema = (schema: z.ZodTypeAny) => { - return z - .string() - .transform((str, ctx) => { - if (str === null || str === "") { - return null; - } - try { - return JSON.parse(str); - } catch { - ctx.addIssue({ code: "custom", message: "Invalid JSON format" }); - return z.NEVER; - } - }) - .superRefine((data, ctx) => { - if (data === null) { - return; - } - - if (Object.keys(data).length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Object cannot be empty", - }); - return; - } - - const parseResult = schema.safeParse(data); - if (!parseResult.success) { - for (const error of parseResult.error.issues) { - const path = error.path.join("."); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${path} ${error.message}`, - }); - } - } - }); +type MenuItem = { + id: string; + label: string; + description: string; + docDescription: string; }; -const addSwarmSettings = z.object({ - healthCheckSwarm: createStringToJSONSchema(HealthCheckSwarmSchema).nullable(), - restartPolicySwarm: createStringToJSONSchema( - RestartPolicySwarmSchema, - ).nullable(), - placementSwarm: createStringToJSONSchema(PlacementSwarmSchema).nullable(), - updateConfigSwarm: createStringToJSONSchema( - UpdateConfigSwarmSchema, - ).nullable(), - rollbackConfigSwarm: createStringToJSONSchema( - UpdateConfigSwarmSchema, - ).nullable(), - modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(), - labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(), - networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(), - stopGracePeriodSwarm: z.bigint().nullable(), - endpointSpecSwarm: createStringToJSONSchema( - EndpointSpecSwarmSchema, - ).nullable(), -}); - -type AddSwarmSettings = z.infer; - -const hasStopGracePeriodSwarm = ( - value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => - typeof value === "object" && - value !== null && - "stopGracePeriodSwarm" in value; +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.", + }, +]; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "application" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const AddSwarmSettings = ({ id, type }: Props) => { - const queryMap = { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), - }; - const { data, refetch } = queryMap[type] - ? queryMap[type]() - : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); - - const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), - }; - - const { mutateAsync, isError, error, isLoading } = mutationMap[type] - ? mutationMap[type]() - : api.mongo.update.useMutation(); - - const form = useForm({ - defaultValues: { - healthCheckSwarm: null, - restartPolicySwarm: null, - placementSwarm: null, - updateConfigSwarm: null, - rollbackConfigSwarm: null, - modeSwarm: null, - labelsSwarm: null, - networkSwarm: null, - stopGracePeriodSwarm: null, - endpointSpecSwarm: null, - }, - resolver: zodResolver(addSwarmSettings), - }); - - useEffect(() => { - if (data) { - const stopGracePeriodValue = hasStopGracePeriodSwarm(data) - ? data.stopGracePeriodSwarm - : null; - const normalizedStopGracePeriod = - stopGracePeriodValue === null || stopGracePeriodValue === undefined - ? null - : typeof stopGracePeriodValue === "bigint" - ? stopGracePeriodValue - : BigInt(stopGracePeriodValue); - form.reset({ - healthCheckSwarm: data.healthCheckSwarm - ? JSON.stringify(data.healthCheckSwarm, null, 2) - : null, - restartPolicySwarm: data.restartPolicySwarm - ? JSON.stringify(data.restartPolicySwarm, null, 2) - : null, - placementSwarm: data.placementSwarm - ? JSON.stringify(data.placementSwarm, null, 2) - : null, - updateConfigSwarm: data.updateConfigSwarm - ? JSON.stringify(data.updateConfigSwarm, null, 2) - : null, - rollbackConfigSwarm: data.rollbackConfigSwarm - ? JSON.stringify(data.rollbackConfigSwarm, null, 2) - : null, - modeSwarm: data.modeSwarm - ? JSON.stringify(data.modeSwarm, null, 2) - : null, - labelsSwarm: data.labelsSwarm - ? JSON.stringify(data.labelsSwarm, null, 2) - : null, - networkSwarm: data.networkSwarm - ? JSON.stringify(data.networkSwarm, null, 2) - : null, - stopGracePeriodSwarm: normalizedStopGracePeriod, - endpointSpecSwarm: data.endpointSpecSwarm - ? JSON.stringify(data.endpointSpecSwarm, null, 2) - : null, - }); - } - }, [form, form.reset, data]); - - const onSubmit = async (data: AddSwarmSettings) => { - await mutateAsync({ - applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - mongoId: id || "", - healthCheckSwarm: data.healthCheckSwarm, - restartPolicySwarm: data.restartPolicySwarm, - placementSwarm: data.placementSwarm, - updateConfigSwarm: data.updateConfigSwarm, - rollbackConfigSwarm: data.rollbackConfigSwarm, - modeSwarm: data.modeSwarm, - labelsSwarm: data.labelsSwarm, - networkSwarm: data.networkSwarm, - stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null, - endpointSpecSwarm: data.endpointSpecSwarm, - }) - .then(async () => { - toast.success("Swarm settings updated"); - refetch(); - }) - .catch(() => { - toast.error("Error updating the swarm settings"); - }); - }; + const [activeMenu, setActiveMenu] = useState("health-check"); + const [open, setOpen] = useState(false); return ( - + - + Swarm Settings - Update certain settings using a json object. + Configure swarm settings for your service. - {isError && {error?.message}}
Changing settings such as placements may cause the logs/monitoring, @@ -352,596 +147,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
-
- - ( - - Health Check - - - - - Check the interface - - - - + {/* Left Column - Menu */} +
+ +
- - - -
-										
-									
-
- )} - /> - - ( - - Restart Policy - - - - - Check the interface - - - - - -
-														{`{
-	Condition?: string | undefined;
-	Delay?: number | undefined;
-	MaxAttempts?: number | undefined;
-	Window?: number | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Placement - - - - - Check the interface - - - - - -
-														{`{
-	Constraints?: string[] | undefined;
-	Preferences?: Array<{ Spread: { SpreadDescriptor: string } }> | undefined;
-	MaxReplicas?: number | undefined;
-	Platforms?:
-		| Array<{
-				Architecture: string;
-				OS: string;
-		  }>
-		| undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Update Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Rollback Config - - - - - Check the interface - - - - - -
-														{`{
-	Parallelism?: number;
-	Delay?: number | undefined;
-	FailureAction?: string | undefined;
-	Monitor?: number | undefined;
-	MaxFailureRatio?: number | undefined;
-	Order: string;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - ( - - Mode - - - - - Check the interface - - - - - -
-														{`{
-	Replicated?: { Replicas?: number | undefined } | undefined;
-	Global?: {} | undefined;
-	ReplicatedJob?:
-		| {
-				MaxConcurrent?: number | undefined;
-				TotalCompletions?: number | undefined;
-		  }
-		| undefined;
-	GlobalJob?: {} | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - ( - - Network - - - - - Check the interface - - - - - -
-														{`[
-  {
-	"Target" : string | undefined;
-	"Aliases" : string[] | undefined;
-	"DriverOpts" : { [key: string]: string } | undefined;
-  }
-]`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - ( - - Labels - - - - - Check the interface - - - - - -
-														{`{
-	[name: string]: string;
-}`}
-													
-
-
-
-
- - - -
-										
-									
-
- )} - /> - ( - - Stop Grace Period (nanoseconds) - - - - - Duration in nanoseconds - - - - - -
-														{`Enter duration in nanoseconds:
-														• 30000000000 - 30 seconds
-														• 120000000000 - 2 minutes  
-														• 3600000000000 - 1 hour
-														• 0 - no grace period`}
-													
-
-
-
-
- - - field.onChange( - e.target.value ? BigInt(e.target.value) : null, - ) - } - /> - -
-										
-									
-
- )} - /> - ( - - Endpoint Spec - - - - - Check the interface - - - - - -
-														{`{
-	Mode?: string | undefined;
-	Ports?: Array<{
-		Protocol?: string | undefined;
-		TargetPort?: number | undefined;
-		PublishedPort?: number | undefined;
-		PublishMode?: string | undefined;
-	}> | undefined;
-}`}
-													
-
-
-
-
- - - - -
-										
-									
-
- )} - /> - - - - - + {/* Right Column - Form */} +
+ {activeMenu === "health-check" && ( + + )} + {activeMenu === "restart-policy" && ( + + )} + {activeMenu === "placement" && ( + + )} + {activeMenu === "update-config" && ( + + )} + {activeMenu === "rollback-config" && ( + + )} + {activeMenu === "mode" && } + {activeMenu === "network" && } + {activeMenu === "labels" && } + {activeMenu === "stop-grace-period" && ( + + )} + {activeMenu === "endpoint-spec" && ( + + )} +
+
); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index a3bc8079a..95f849480 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"; @@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings"; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis"; } -const AddRedirectchema = z.object({ +const AddRedirectSchema = z.object({ replicas: z.number().min(1, "Replicas must be at least 1"), registryId: z.string().optional(), }); -type AddCommand = z.infer; +type AddCommand = z.infer; export const ShowClusterSettings = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), 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]() @@ -65,15 +65,16 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const { data: registries } = api.registry.all.useQuery(); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), 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, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); @@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { : {}), replicas: data?.replicas || 1, }, - resolver: zodResolver(AddRedirectchema), + resolver: zodResolver(AddRedirectSchema), }); useEffect(() => { @@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const onSubmit = async (data: AddCommand) => { await mutateAsync({ applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", ...(type === "application" ? { registryId: @@ -236,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..6ea18c653 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx @@ -0,0 +1,164 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..06c8eb94a --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -0,0 +1,280 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..02a480a03 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx @@ -0,0 +1,210 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..bd2eca18e --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx @@ -0,0 +1,213 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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 || "", + libsqlId: 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..269d6f784 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -0,0 +1,323 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..a4a650020 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -0,0 +1,357 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..4aba01f03 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx @@ -0,0 +1,229 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..081825e64 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx @@ -0,0 +1,267 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..58b36fbae --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx @@ -0,0 +1,168 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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..ef9fe34bb --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx @@ -0,0 +1,274 @@ +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" + | "libsql"; +} + +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 }), + libsql: () => api.libsql.one.useQuery({ libsqlId: 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(), + libsql: () => api.libsql.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 || "", + libsqlId: 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 a7c5f7288..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,4 +1,4 @@ -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 { useFieldArray, useForm } from "react-hook-form"; @@ -50,7 +50,7 @@ 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: { @@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
-
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 3beedcdbc..fa2bda629 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,20 +41,61 @@ 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 = - | "postgres" - | "mongo" - | "redis" - | "mysql" + | "application" + | "libsql" | "mariadb" - | "application"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; interface Props { id: string; @@ -51,45 +103,54 @@ interface Props { } type AddResources = z.infer; + export const ShowResources = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), 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 = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), 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, 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({ @@ -97,22 +158,28 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: (data as any)?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); const onSubmit = async (formData: AddResources) => { await mutateAsync({ + applicationId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - applicationId: id || "", cpuLimit: formData.cpuLimit || null, 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"); @@ -163,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.

- @@ -198,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.

- @@ -234,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.

- @@ -271,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.

- + @@ -286,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}}"}). +

+
- + + + )} 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 44fb050bc..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"; @@ -67,13 +67,13 @@ interface Props { refetch: () => void; serviceType: | "application" - | "postgres" - | "redis" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const UpdateVolume = ({ @@ -93,7 +93,7 @@ export const UpdateVolume = ({ }, ); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.mounts.update.useMutation(); const form = useForm({ @@ -187,7 +187,7 @@ export const UpdateVolume = ({ variant="ghost" size="icon" className="group hover:bg-blue-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -253,7 +253,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content @@ -310,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 index 784534dd6..ad5e9b058 100644 --- a/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx @@ -20,7 +20,7 @@ interface Props { } export const KillBuild = ({ id, type }: Props) => { - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = type === "application" ? api.application.killBuild.useMutation() : api.compose.killBuild.useMutation(); @@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 0d403ecd2..4285f04c4 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -194,13 +194,21 @@ export const ShowDeployment = ({ {" "} {filteredLogs.length > 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 8abf8cbf1..ccf2564b0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,11 +1,14 @@ +import copy from "copy-to-clipboard"; import { ChevronDown, ChevronUp, Clock, + Copy, Loader2, RefreshCcw, RocketIcon, Settings, + Trash2, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -25,6 +28,7 @@ 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"; @@ -59,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, @@ -73,19 +77,21 @@ 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(""); @@ -93,6 +99,12 @@ export const ShowDeployments = ({ 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 => { @@ -143,7 +155,10 @@ export const ShowDeployments = ({ See the last 10 deployments for this {type}
-
+
+ {(type === "application" || type === "compose") && ( + + )} {(type === "application" || type === "compose") && ( )} @@ -217,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") && ( )} @@ -252,13 +283,15 @@ export const ShowDeployments = ({ const isExpanded = expandedDescriptions.has( deployment.deploymentId, ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; return (
-
+
{index + 1}. {deployment.status}
-
-
+
+
{deployment.startedAt && deployment.finishedAt && ( -
+
{deployment.pid && deployment.status === "running" && ( Kill Process @@ -364,16 +398,56 @@ export const ShowDeployments = ({ onClick={() => { setActiveLog(deployment); }} + className="w-full sm:w-auto" > View + {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({ @@ -393,6 +467,7 @@ export const ShowDeployments = ({ variant="secondary" size="sm" isLoading={isRollingBack} + className="w-full sm:w-auto" > Rollback diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index bb5366c33..9b2aa4bd3 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"; @@ -61,6 +61,8 @@ export const domain = z .min(1, { message: "Port must be at least 1" }) .max(65535, { message: "Port must be 65535 or below" }) .optional(), + useCustomEntrypoint: z.boolean(), + customEntrypoint: z.string().optional(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), customCertResolver: z.string().optional(), @@ -114,6 +116,14 @@ export const domain = z message: "Internal path must start with '/'", }); } + + if (input.useCustomEntrypoint && !input.customEntrypoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customEntrypoint"], + message: "Custom entry point must be specified", + }); + } }); type Domain = z.infer; @@ -159,11 +169,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 } = @@ -196,6 +206,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: undefined, stripPath: false, port: undefined, + useCustomEntrypoint: false, + customEntrypoint: undefined, https: false, certificateType: undefined, customCertResolver: undefined, @@ -206,8 +218,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }); const certificateType = form.watch("certificateType"); + const useCustomEntrypoint = form.watch("useCustomEntrypoint"); 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) { @@ -218,6 +233,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: data?.internalPath || undefined, stripPath: data?.stripPath || false, port: data?.port || undefined, + useCustomEntrypoint: !!data.customEntrypoint, + customEntrypoint: data.customEntrypoint || undefined, certificateType: data?.certificateType || undefined, customCertResolver: data?.customCertResolver || undefined, serviceName: data?.serviceName || undefined, @@ -232,13 +249,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: undefined, stripPath: false, port: undefined, + useCustomEntrypoint: false, + customEntrypoint: undefined, https: false, certificateType: undefined, customCertResolver: undefined, domainType: type, }); } - }, [form, data, isLoading, domainId]); + }, [form, data, isPending, domainId]); // Separate effect for handling custom cert resolver validation useEffect(() => { @@ -502,6 +521,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { 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
@@ -626,6 +652,50 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> + ( + +
+ Custom Entrypoint + + Use custom entrypoint for domina +
+ "web" and/or "websecure" is used by default. +
+ +
+ + + +
+ )} + /> + + {useCustomEntrypoint && ( + ( + + Entrypoint Name + + + + + + )} + /> + )} + { - diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 411817883..0db95d358 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -80,6 +80,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( @@ -132,7 +135,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 handleDeleteDomain = async (domainId: string) => { @@ -238,11 +241,13 @@ export const ShowDomains = ({ id, type }: Props) => { )} - - - + {canCreateDomain && ( + + + + )} )}
@@ -262,13 +267,15 @@ export const ShowDomains = ({ id, type }: Props) => { To access the application it is required to set at least 1 domain -
- - - -
+ {canCreateDomain && ( +
+ + + +
+ )}
) : viewMode === "table" ? (
@@ -419,47 +426,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-environment.tsx similarity index 84% rename from apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx rename to apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 797a317a8..86f7a0dff 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.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,16 +36,19 @@ interface Props { } export const ShowEnvironment = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? 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 }), + compose: () => + api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), - compose: () => - api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -53,14 +56,15 @@ export const ShowEnvironment = ({ id, type }: Props) => { const [isEnvVisible, setIsEnvVisible] = useState(true); const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), + compose: () => api.compose.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(), mongo: () => api.mongo.update.useMutation(), - compose: () => api.compose.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + 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(); @@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => { const onSubmit = async (formData: EnvironmentSchema) => { mutateAsync({ + composeId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - composeId: id || "", env: formData.environment, }) .then(async () => { @@ -111,7 +116,7 @@ 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" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -121,7 +126,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return (
@@ -185,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 48e978880..fcfd81778 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -1,18 +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; @@ -22,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( @@ -39,6 +50,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: "", buildArgs: "", buildSecrets: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); @@ -47,10 +59,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => { const currentEnv = form.watch("env"); const currentBuildArgs = form.watch("buildArgs"); const currentBuildSecrets = form.watch("buildSecrets"); + const currentCreateEnvFile = form.watch("createEnvFile"); const hasChanges = currentEnv !== (data?.env || "") || currentBuildArgs !== (data?.buildArgs || "") || - currentBuildSecrets !== (data?.buildSecrets || ""); + currentBuildSecrets !== (data?.buildSecrets || "") || + currentCreateEnvFile !== (data?.createEnvFile ?? true); useEffect(() => { if (data) { @@ -58,6 +72,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: data.env || "", buildArgs: data.buildArgs || "", buildSecrets: data.buildSecrets || "", + createEnvFile: data.createEnvFile ?? true, }); } }, [data, form]); @@ -67,6 +82,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { env: formData.env, buildArgs: formData.buildArgs, buildSecrets: formData.buildSecrets, + createEnvFile: formData.createEnvFile, applicationId, }) .then(async () => { @@ -83,13 +99,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => { 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" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -99,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return ( @@ -167,21 +184,49 @@ 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 1f54ddd58..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, @@ -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 e9be3a2f5..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 } = + 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 80d6850ca..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, @@ -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 d6f65caf3..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: { @@ -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..06b257766 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, @@ -90,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, [option, services, containers]); const isLoading = option === "native" ? containersLoading : servicesLoading; - const containersLenght = + const containersLength = option === "native" ? containers?.length : services?.length; return ( @@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))}
@@ -157,15 +159,25 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} )} - Containers ({containersLenght}) + Containers ({containersLength}) + {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..053e644b7 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -0,0 +1,2 @@ +export * from "./patch-editor"; +export * from "./show-patches"; 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 9c2e48931..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, }, ); @@ -193,6 +205,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { + { + await redeployPreviewDeployment({ + previewDeploymentId: + deployment.previewDeploymentId, + }) + .then(() => { + toast.success( + "Preview deployment rebuild started", + ); + refetchPreviewDeployments(); + }) + .catch(() => { + toast.error( + "Error rebuilding preview deployment", + ); + }); + }} + > + + + { diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index 8273d0e2b..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, @@ -13,6 +15,14 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Dialog, DialogContent, @@ -31,6 +41,12 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -48,6 +64,7 @@ import { import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import type { CacheType } from "../domains/handle-domain"; +import { getTimezoneLabel, TIMEZONES } from "./timezones"; export const commonCronExpressions = [ { label: "Every minute", value: "* * * * *" }, @@ -75,6 +92,7 @@ const formSchema = z "dokploy-server", ]), script: z.string(), + timezone: z.string().optional(), }) .superRefine((data, ctx) => { if (data.scheduleType === "compose" && !data.serviceName) { @@ -202,8 +220,8 @@ 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: "", @@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: "", scheduleType: scheduleType || "application", script: "", + timezone: undefined, }, }); @@ -251,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({ @@ -464,6 +484,89 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { formControl={form.control} /> + ( + + + Timezone + + + + + + +

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

+
+
+
+
+ + + + + + + + + + + No timezone found. + + {Object.entries(TIMEZONES).map( + ([region, zones]) => ( + + {zones.map((tz) => ( + { + field.onChange(tz.value); + }} + > + {tz.value} + + + ))} + + ), + )} + + + + + + + Optional: Choose a timezone for the schedule execution time + + +
+ )} + /> + {(scheduleTypeForm === "application" || scheduleTypeForm === "compose") && ( <> @@ -559,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 26bfa9421..a9550fda2 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { }, ); 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(); 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 2e4dac472..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 @@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({ }, ); const utils = api.useUtils(); - const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = + const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } = api.volumeBackups.delete.useMutation(); const { mutateAsync: runManually } = api.volumeBackups.runManually.useMutation(); 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 5c8577dff..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 = { @@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => { mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), @@ -70,11 +73,12 @@ export const DeleteService = ({ id, type }: Props) => { redis: () => api.redis.remove.useMutation(), mysql: () => api.mysql.remove.useMutation(), mariadb: () => api.mariadb.remove.useMutation(), + libsql: () => api.libsql.remove.useMutation(), application: () => api.application.delete.useMutation(), 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(); @@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => { redisId: id || "", mysqlId: id || "", mariadbId: id || "", + libsqlId: id || "", applicationId: id || "", composeId: id || "", deleteVolumes, @@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => { data?.applicationStatus === "running") || (data && "composeStatus" in data && data?.composeStatus === "running"); + if (!canDelete) return null; + return ( @@ -130,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => { variant="ghost" size="icon" className="group hover:bg-red-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -228,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"); + }); + }} + > + + + ))} { Open Terminal -
- 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 cb727e2a9..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,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"; @@ -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,7 @@ 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({ @@ -93,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)(); } @@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return ( <> @@ -164,14 +166,16 @@ services:
- + {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 06c88fff4..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, @@ -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 5ad950e4c..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, @@ -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 933abd1a2..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, @@ -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) => {
@@ -143,15 +144,25 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} )} - Containers ({containersLenght}) + Containers ({containersLength}) + {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 = ({ @@ -613,6 +633,7 @@ export const HandleBackup = ({ type="number" placeholder={"keeps all the backups if left empty"} {...field} + value={field.value as string} /> diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 01f6944e1..7b212acb9 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 _ from "lodash"; +import debounce from "lodash/debounce"; import { CheckIcon, ChevronsUpDown, @@ -78,29 +78,17 @@ 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"]) + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .optional(), backupType: z.enum(["database", "compose"]).default("database"), metadata: z @@ -219,11 +207,16 @@ export const RestoreBackup = ({ const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm>({ + const form = useForm({ defaultValues: { destinationId: "", backupFile: "", - databaseName: databaseType === "web-server" ? "dokploy" : "", + databaseName: + databaseType === "web-server" + ? "dokploy" + : databaseType === "libsql" + ? "iku.db" + : "", databaseType: backupType === "compose" ? ("postgres" as DatabaseType) : databaseType, backupType: backupType, @@ -232,11 +225,11 @@ export const RestoreBackup = ({ resolver: zodResolver(RestoreBackupSchema), }); - const destionationId = form.watch("destinationId"); + const destinationId = form.watch("destinationId"); const currentDatabaseType = form.watch("databaseType"); const metadata = form.watch("metadata"); - const debouncedSetSearch = _.debounce((value: string) => { + const debouncedSetSearch = debounce((value: string) => { setDebouncedSearchTerm(value); }, 350); @@ -245,14 +238,14 @@ export const RestoreBackup = ({ debouncedSetSearch(value); }; - const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { - destinationId: destionationId, + destinationId: destinationId, search: debouncedSearchTerm, serverId: serverId ?? "", }, { - enabled: isOpen && !!destionationId, + enabled: isOpen && !!destinationId, }, ); @@ -454,7 +447,7 @@ export const RestoreBackup = ({ onValueChange={handleSearchChange} className="h-9" /> - {isLoading ? ( + {isPending ? (
Loading backup files...
@@ -535,7 +528,10 @@ export const RestoreBackup = ({ diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 55a09b25f..ebffaccb3 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -53,14 +53,16 @@ export const ShowBackups = ({ const queryMap = backupType === "database" ? { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - mysql: () => - api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => + api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + libsql: () => + api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), "web-server": () => api.user.getBackups.useQuery(), } : { @@ -77,10 +79,11 @@ export const ShowBackups = ({ const mutationMap = backupType === "database" ? { - postgres: api.backup.manualBackupPostgres.useMutation(), - mysql: api.backup.manualBackupMySql.useMutation(), mariadb: api.backup.manualBackupMariadb.useMutation(), mongo: api.backup.manualBackupMongo.useMutation(), + mysql: api.backup.manualBackupMySql.useMutation(), + postgres: api.backup.manualBackupPostgres.useMutation(), + libsql: api.backup.manualBackupLibsql.useMutation(), "web-server": api.backup.manualBackupWebServer.useMutation(), } : { @@ -89,11 +92,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..22b132f16 --- /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 { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react"; +import Link from "next/link"; +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 bf0173956..59b939008 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -402,7 +402,7 @@ export const DockerLogsId: React.FC = ({ {filteredLogs.length > 0 ? ( filteredLogs.map((filteredLog: LogLine, index: number) => ( {" "}
- {/* Icon to expand the log item maybe implement a colapsible later */} + {/* Icon to expand the log item maybe implement a collapsible later */} {/* */} {tooltip(color, rawTimestamp)} {!noTimestamp && ( diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 5e97edfe2..80a79eb2b 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] { // { timestamp: new Date("2024-12-10T10:00:00.000Z"), // message: "The server is running on port 8080" } const logRegex = - /^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/; + /^(?:(?\d+)\s+)?(?(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?[\s\S]*)$/; return logString .split("\n") @@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] { const match = line.match(logRegex); if (!match) return null; - const [, , timestamp, message] = match; + const { timestamp, message } = match.groups ?? {}; if (!message?.trim()) return null; @@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => { /(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) || /(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) || /\b(?:deprecated|obsolete)\b/i.test(lowerMessage) || - /\b(?:unstable|experimental)\b/i.test(lowerMessage) + /\b(?:unstable|experimental)\b/i.test(lowerMessage) || + /⚠|⚠️/i.test(lowerMessage) ) { return LOG_STYLES.warning; } diff --git a/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx new file mode 100644 index 000000000..3b6cd9875 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx @@ -0,0 +1,66 @@ +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +interface Props { + containerId: string; + serverId?: string; +} + +export const RemoveContainerDialog = ({ containerId, serverId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, isPending } = api.docker.removeContainer.useMutation(); + + return ( + + + e.preventDefault()} + > + Remove Container + + + + + Are you sure? + + This will permanently remove the container{" "} + {containerId}. If the + container is running, it will be forcefully stopped and removed. + This action cannot be undone. + + + + Cancel + { + await mutateAsync({ containerId, serverId }) + .then(async () => { + toast.success("Container removed successfully"); + await utils.docker.getContainers.invalidate(); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + Confirm + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/docker/show/colums.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx similarity index 88% rename from apps/dokploy/components/dashboard/docker/show/colums.tsx rename to apps/dokploy/components/dashboard/docker/show/columns.tsx index 74fe6819e..33c104d97 100644 --- a/apps/dokploy/components/dashboard/docker/show/colums.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -10,7 +10,9 @@ import { } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +import { RemoveContainerDialog } from "../remove/remove-container"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +import { UploadFileModal } from "../upload/upload-file-modal"; import type { Container } from "./show-containers"; export const columns: ColumnDef[] = [ @@ -127,6 +129,16 @@ export const columns: ColumnDef[] = [ > Terminal + + Upload File + + ); diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index 52398aabe..8a19566e8 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -35,7 +35,7 @@ import { TableRow, } from "@/components/ui/table"; import { api, type RouterOutputs } from "@/utils/api"; -import { columns } from "./colums"; +import { columns } from "./columns"; export type Container = NonNullable< RouterOutputs["docker"]["getContainers"] >[0]; @@ -45,7 +45,7 @@ interface Props { } export const ShowContainers = ({ serverId }: Props) => { - const { data, isLoading } = api.docker.getContainers.useQuery({ + const { data, isPending } = api.docker.getContainers.useQuery({ serverId, }); @@ -137,7 +137,7 @@ export const ShowContainers = ({ serverId }: Props) => {
- {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/upload/upload-file-modal.tsx b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx new file mode 100644 index 000000000..8838ac094 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx @@ -0,0 +1,187 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Upload } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Dropzone } from "@/components/ui/dropzone"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; +import { + uploadFileToContainerSchema, + type UploadFileToContainer, +} from "@/utils/schema"; + +interface Props { + containerId: string; + serverId?: string; + children?: React.ReactNode; +} + +export const UploadFileModal = ({ children, containerId, serverId }: Props) => { + const [open, setOpen] = useState(false); + + const { mutateAsync: uploadFile, isPending: isLoading } = + api.docker.uploadFileToContainer.useMutation({ + onSuccess: () => { + toast.success("File uploaded successfully"); + setOpen(false); + form.reset(); + }, + onError: (error) => { + toast.error(error.message || "Failed to upload file to container"); + }, + }); + + const form = useForm({ + resolver: zodResolver(uploadFileToContainerSchema), + defaultValues: { + containerId, + destinationPath: "/", + serverId: serverId || undefined, + }, + }); + + const file = form.watch("file"); + + const onSubmit = async (values: UploadFileToContainer) => { + if (!values.file) { + toast.error("Please select a file to upload"); + return; + } + + const formData = new FormData(); + formData.append("containerId", values.containerId); + formData.append("file", values.file); + formData.append("destinationPath", values.destinationPath); + if (values.serverId) { + formData.append("serverId", values.serverId); + } + + await uploadFile(formData); + }; + + return ( + + + e.preventDefault()} + > + {children} + + + + + + + Upload File to Container + + + Upload a file directly into the container's filesystem + + + +
+ + ( + + Destination Path + + + + +

+ Enter the full path where the file should be uploaded in the + container (e.g., /app/config.json) +

+
+ )} + /> + + ( + + File + + { + if (files && files.length > 0) { + field.onChange(files[0]); + } else { + field.onChange(null); + } + }} + /> + + + {file instanceof File && ( +
+ + {file.name} ({(file.size / 1024).toFixed(2)} KB) + + +
+ )} +
+ )} + /> + + + + + + + +
+
+ ); +}; 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 b96b7c866..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} @@ -283,10 +290,14 @@ export const ImpersonationBar = () => { - {data?.user?.name?.slice(0, 2).toUpperCase() || "U"} + {`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() || + "U"}
@@ -299,7 +310,8 @@ export const ImpersonationBar = () => { Impersonating - {data?.user?.name || ""} + {`${data?.user?.firstName} ${data?.user?.lastName}`.trim() || + ""}
diff --git a/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx new file mode 100644 index 000000000..378d0d944 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx @@ -0,0 +1,251 @@ +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"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const DockerProviderSchema = z.object({ + externalPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalGRPCPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalAdminPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), +}); + +type DockerProvider = z.infer; + +interface Props { + libsqlId: string; +} +export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data: ip } = api.settings.getIp.useQuery(); + const { data, refetch } = api.libsql.one.useQuery({ libsqlId }); + const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation(); + const [connectionUrl, setConnectionUrl] = useState(""); + const [connectionGRPCUrl, setGRPCConnectionUrl] = useState(""); + const getIp = data?.server?.ipAddress || ip; + + const form = useForm({ + defaultValues: {}, + resolver: zodResolver(DockerProviderSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + externalPort: data.externalPort, + externalGRPCPort: data.externalGRPCPort, + externalAdminPort: data.externalAdminPort, + }); + } + }, [form.reset, data, form]); + + const onSubmit = async (values: DockerProvider) => { + await mutateAsync({ + externalPort: values.externalPort, + externalGRPCPort: values.externalGRPCPort, + externalAdminPort: values.externalAdminPort, + libsqlId, + }) + .then(async () => { + toast.success("External port/ports updated"); + await refetch(); + }) + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port/ports"); + }); + }; + + useEffect(() => { + const port = form.watch("externalPort") || data?.externalPort; + setConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`, + ); + + if (data?.sqldNode !== "replica") { + const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort; + setGRPCConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`, + ); + } + }, [ + data?.externalGRPCPort, + data?.databasePassword, + form, + data?.databaseUser, + getIp, + ]); + + return ( +
+ + + External Credentials + + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database + + + + {!getIp && ( + + You need to set an IP address in your{" "} + + {data?.serverId + ? "Remote Servers -> Server -> Edit Server -> Update IP Address" + : "Web Server -> Server -> Update Server IP"} + {" "} + to fix the database url connection. + + )} +
+ +
+
+ ( + + External Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalPort && ( +
+
+ + +
+
+ )} + +
+
+ ( + + External Admin Port (Internet) + + + + + + )} + /> +
+
+ + {data?.sqldNode !== "replica" && ( + <> +
+
+ ( + + External GRPC Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalGRPCPort && ( +
+
+ + +
+
+ )} + + )} + +
+ +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx new file mode 100644 index 000000000..1727bb2b1 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx @@ -0,0 +1,268 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; + +interface Props { + libsqlId: string; +} + +export const ShowGeneralLibsql = ({ libsqlId }: Props) => { + const { data, refetch } = api.libsql.one.useQuery( + { + libsqlId, + }, + { enabled: !!libsqlId }, + ); + + const { mutateAsync: reload, isPending: isReloading } = + api.libsql.reload.useMutation(); + + const { mutateAsync: start, isPending: isStarting } = + api.libsql.start.useMutation(); + + const { mutateAsync: stop, isPending: isStopping } = + api.libsql.stop.useMutation(); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [filteredLogs, setFilteredLogs] = useState([]); + const [isDeploying, setIsDeploying] = useState(false); + api.libsql.deployWithLogs.useSubscription( + { + libsqlId: libsqlId, + }, + { + enabled: isDeploying, + onData(log) { + if (!isDrawerOpen) { + setIsDrawerOpen(true); + } + + if (log === "Deployment completed successfully!") { + setIsDeploying(false); + } + const parsedLogs = parseLogs(log); + setFilteredLogs((prev) => [...prev, ...parsedLogs]); + }, + onError(error) { + console.error("Deployment logs error:", error); + setIsDeploying(false); + }, + }, + ); + + return ( + <> +
+ + + Deploy Settings + + + + { + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); + }} + > + + + + + { + await reload({ + libsqlId: libsqlId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Libsql reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Libsql"); + }); + }} + > + + + + {data?.applicationStatus === "idle" ? ( + + { + await start({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Libsql"); + }); + }} + > + + + + ) : ( + + { + await stop({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Libsql"); + }); + }} + > + + + + )} + + + + + + { + setIsDrawerOpen(false); + setFilteredLogs([]); + setIsDeploying(false); + refetch(); + }} + filteredLogs={filteredLogs} + /> +
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx new file mode 100644 index 000000000..6c1350242 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx @@ -0,0 +1,121 @@ +import { SelectGroup } from "@radix-ui/react-select"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface Props { + libsqlId: string; +} +export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data } = api.libsql.one.useQuery({ libsqlId }); + return ( + <> +
+ + + Internal Credentials + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/update-libsql.tsx b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx new file mode 100644 index 000000000..99455531a --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx @@ -0,0 +1,163 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon } from "lucide-react"; +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 { + 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 { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const updateLibsqlSchema = z.object({ + name: z.string().min(1, { + message: "Name is required", + }), + description: z.string().optional(), +}); + +type UpdateLibsql = z.infer; + +interface Props { + libsqlId: string; +} + +export const UpdateLibsql = ({ libsqlId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, error, isError, isPending } = + api.libsql.update.useMutation(); + const { data } = api.libsql.one.useQuery( + { + libsqlId, + }, + { + enabled: !!libsqlId, + }, + ); + const form = useForm({ + defaultValues: { + description: data?.description ?? "", + name: data?.name ?? "", + }, + resolver: zodResolver(updateLibsqlSchema), + }); + useEffect(() => { + if (data) { + form.reset({ + description: data.description ?? "", + name: data.name, + }); + } + }, [data, form, form.reset]); + + const onSubmit = async (formData: UpdateLibsql) => { + await mutateAsync({ + name: formData.name, + libsqlId: libsqlId, + description: formData.description || "", + }) + .then(() => { + toast.success("Libsql updated successfully"); + utils.libsql.one.invalidate({ + libsqlId: libsqlId, + }); + }) + .catch(() => { + toast.error("Error updating the Libsql"); + }) + .finally(() => {}); + }; + + return ( + + + + + + + Modify Libsql + Update the Libsql data + + {isError && {error?.message}} + +
+
+
+ + ( + + Name + + + + + + + )} + /> + ( + + Description + +