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/workflows/deploy.yml b/.github/workflows/deploy.yml index 3ed957b72..321fb2029 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-cloud + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -25,8 +36,7 @@ jobs: context: . file: ./Dockerfile.cloud push: true - tags: | - siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-cloud.outputs.tags }} platforms: linux/amd64 build-args: | NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} @@ -40,6 +50,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-schedule + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -52,8 +72,7 @@ jobs: context: . file: ./Dockerfile.schedule push: true - tags: | - siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-schedule.outputs.tags }} platforms: linux/amd64 build-and-push-server-image: @@ -63,6 +82,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set tag and version + id: meta-server + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT + else + echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT + fi + - name: Log in to Docker Hub uses: docker/login-action@v2 with: @@ -75,6 +104,5 @@ jobs: context: . file: ./Dockerfile.server push: true - tags: | - siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-server.outputs.tags }} platforms: linux/amd64 diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 000000000..3554babb2 --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -0,0 +1,22 @@ + +name: PR Quality + +permissions: + contents: read + issues: read + pull-requests: write + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + anti-slop: + runs-on: ubuntu-latest + steps: + - uses: peakoss/anti-slop@v0 + with: + max-failures: 4 + blocked-commit-authors: "claude,copilot" + require-description: true + min-account-age: 5 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bfdc8c48b..2ad24fc0c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -18,7 +18,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 Nixpacks 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/.gitignore b/.gitignore index ab2fe76c6..d531bab01 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,4 @@ yarn-error.log* *.pem -.db - -# Development environment -.devcontainer \ No newline at end of file +.db \ No newline at end of file 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 4c1f832db..ad37899e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute. -Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues. +Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues. We have a few guidelines to follow when contributing to this project: @@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project: - [Development](#development) - [Build](#build) - [Pull Request](#pull-request) +- [Important Considerations](#important-considerations-for-pull-requests) ## Commit Convention @@ -52,7 +53,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory. +We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git @@ -162,11 +163,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0. - If your pull request fixes an open issue, please reference the issue in the pull request description. - Once your pull request is merged, you will be automatically added as a contributor to the project. -**Important Considerations for Pull Requests:** +### Important Considerations for Pull Requests +- **Testing is Mandatory:** All Pull Requests **must be tested** 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 5d7bb6770..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 @@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack EXPOSE 3000 -CMD [ "pnpm", "start" ] + +HEALTHCHECK --interval=10s --timeout=3s --retries=10 \ + CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1 + + CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index a0de32021..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 @@ -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 diff --git a/Dockerfile.schedule b/Dockerfile.schedule index ce1f96edf..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 diff --git a/Dockerfile.server b/Dockerfile.server index f5aa25c1e..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 diff --git a/README.md b/README.md index e97735597..927e6ebc6 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,8 @@
- -
- Special thanks to: -
-
- - Tuple's sponsorship image - - -### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy) -[Available for MacOS & Windows](https://tuple.app/dokploy)
- -
- - Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. - ## ✨ Features Dokploy includes multiple features to make your life easier. @@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). -## ♥️ Sponsors - -🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features. - -[Dokploy Open Collective](https://opencollective.com/dokploy) [Github Sponsors](https://github.com/sponsors/Siumauricio) -## Sponsors - -| Sponsor | Logo | Supporter Level | -|---------|:----:|----------------| -| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | Hostinger | 🎖 Hero Sponsor | -| [LX Aer](https://www.lxaer.com/?ref=dokploy) | LX Aer | 🎖 Hero Sponsor | -| [LinkDR](https://linkdr.com/?ref=dokploy) | LinkDR | 🎖 Hero Sponsor | -| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | LambdaTest | 🎖 Hero Sponsor | -| [Awesome Tools](https://awesome.tools/) | Awesome Tools | 🎖 Hero Sponsor | -| [Supafort](https://supafort.com/?ref=dokploy) | Supafort.com | 🥇 Premium Supporter | -| [Agentdock](https://agentdock.ai/?ref=dokploy) | agentdock.ai | 🥇 Premium Supporter | -| [AmericanCloud](https://americancloud.com/?ref=dokploy) | AmericanCloud | 🥈 Elite Contributor | -| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | Tolgee | 🥈 Elite Contributor | -| [Cloudblast](https://cloudblast.io/?ref=dokploy) | Cloudblast.io | 🥉 Supporting Member | -| [Synexa](https://synexa.ai/?ref=dokploy) | Synexa | 🥉 Supporting Member | - -### Community Backers 🤝 - -#### Organizations: - -[Sponsors on Open Collective](https://opencollective.com/dokploy) - -#### Individuals: - -[![Individual Contributors on Open Collective](https://opencollective.com/dokploy/individuals.svg?width=890)](https://opencollective.com/dokploy) - ### Contributors 🤝 diff --git a/apps/api/package.json b/apps/api/package.json index 0f4b1044f..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,26 +12,27 @@ "inngest": "3.40.1", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.14.3", - "@hono/zod-validator": "0.3.0", + "@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/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__/compose/domain/network-service.test.ts b/apps/dokploy/__test__/compose/domain/network-service.test.ts index b8d03c751..83fe8a166 100644 --- a/apps/dokploy/__test__/compose/domain/network-service.test.ts +++ b/apps/dokploy/__test__/compose/domain/network-service.test.ts @@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest"; describe("addDokployNetworkToService", () => { it("should add network to an empty array", () => { const result = addDokployNetworkToService([]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should not add duplicate network to an array", () => { const result = addDokployNetworkToService(["dokploy-network"]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should add network to an existing array with other networks", () => { const result = addDokployNetworkToService(["other-network"]); - expect(result).toEqual(["other-network", "dokploy-network"]); + expect(result).toEqual(["other-network", "dokploy-network", "default"]); }); it("should add network to an object if networks is an object", () => { const result = addDokployNetworkToService({ "other-network": {} }); - expect(result).toEqual({ "other-network": {}, "dokploy-network": {} }); + expect(result).toEqual({ + "other-network": {}, + "dokploy-network": {}, + default: {}, + }); + }); + + it("should not duplicate default network when already present", () => { + const result = addDokployNetworkToService(["default", "dokploy-network"]); + expect(result).toEqual(["default", "dokploy-network"]); }); }); diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index be29748eb..c81fab44c 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -28,6 +28,9 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }, }; diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 43ff07836..498281776 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -29,6 +29,9 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + 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 85b9b2c61..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, }), }; }); @@ -147,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 () => { @@ -165,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__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index c12a272bc..fb448e3af 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -6,6 +6,7 @@ type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; @@ -13,11 +14,11 @@ type MockCreateServiceOptions = { const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } = vi.hoisted(() => { - const inspect = vi.fn<[], Promise>(); + const inspect = vi.fn<() => Promise>(); const getService = vi.fn(() => ({ inspect })); - const createService = vi.fn<[MockCreateServiceOptions], Promise>( - async () => undefined, - ); + const createService = vi.fn< + (opts: MockCreateServiceOptions) => Promise + >(async () => undefined); const getRemoteDocker = vi.fn(async () => ({ getService, createService, @@ -57,6 +58,7 @@ const createApplication = ( }, replicas: 1, stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; @@ -80,7 +82,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -97,7 +101,9 @@ describe("mechanizeDockerContainer", () => { await mechanizeDockerContainer(application); expect(createServiceMock).toHaveBeenCalledTimes(1); - const call = createServiceMock.mock.calls[0]; + const call = createServiceMock.mock.calls[0] as + | [MockCreateServiceOptions] + | undefined; if (!call) { throw new Error("createServiceMock should have been called once"); } @@ -106,4 +112,50 @@ describe("mechanizeDockerContainer", () => { "StopGracePeriod", ); }); + + it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => { + const ulimits = [ + { Name: "nofile", Soft: 10000, Hard: 20000 }, + { Name: "nproc", Soft: 4096, Hard: 8192 }, + ]; + const application = createApplication({ ulimitsSwarm: ulimits }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits); + }); + + it("omits Ulimits when ulimitsSwarm is null", async () => { + const application = createApplication({ ulimitsSwarm: null }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); + + it("omits Ulimits when ulimitsSwarm is an empty array", async () => { + const application = createApplication({ ulimitsSwarm: [] }); + + await mechanizeDockerContainer(application); + + expect(createServiceMock).toHaveBeenCalledTimes(1); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits"); + }); }); diff --git a/apps/dokploy/__test__/setup.ts b/apps/dokploy/__test__/setup.ts new file mode 100644 index 000000000..5af01d147 --- /dev/null +++ b/apps/dokploy/__test__/setup.ts @@ -0,0 +1,40 @@ +import { vi } from "vitest"; + +/** + * Mock the DB module so tests that import from @dokploy/server (barrel) + * never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs). + * Without this, loading the server barrel pulls in lib/auth and db, which + * connect to localhost:5432 and cause ECONNREFUSED. + */ +vi.mock("@dokploy/server/db", () => { + const chain = () => chain; + chain.set = () => chain; + chain.where = () => chain; + chain.values = () => chain; + chain.returning = () => Promise.resolve([{}]); + chain.then = undefined; + + const tableMock = { + findFirst: vi.fn(() => Promise.resolve(undefined)), + findMany: vi.fn(() => Promise.resolve([])), + insert: vi.fn(() => Promise.resolve([{}])), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + }; + const createQueryMock = () => tableMock; + + return { + db: { + select: vi.fn(() => chain), + insert: vi.fn(() => ({ + values: () => ({ returning: () => Promise.resolve([{}]) }), + })), + update: vi.fn(() => chain), + delete: vi.fn(() => chain), + query: new Proxy({} as Record, { + get: () => tableMock, + }), + }, + dbUrl: "postgres://mock:mock@localhost:5432/mock", + }; +}); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 0e6e529b0..9121dc8a1 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -125,6 +125,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { @@ -274,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); + +/** IDN/Punycode */ + +test("Internationalized domain name is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "тест.рф" }, + "web", + ); + + // тест.рф in punycode is xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); + +test("ASCII domain remains unchanged", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "example.com" }, + "web", + ); + + expect(router.rule).toContain("Host(`example.com`)"); +}); + +test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "сайт.ru" }, + "web", + ); + + // сайт in punycode is xn--80aswg + expect(router.rule).toContain("Host(`xn--80aswg.ru`)"); + expect(router.rule).not.toContain("сайт"); +}); + +test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "app.тест.рф" }, + "web", + ); + + // app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); diff --git a/apps/dokploy/__test__/vitest.config.ts b/apps/dokploy/__test__/vitest.config.ts index 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 ee427feca..4c6fc60c7 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -22,6 +22,7 @@ import { HealthCheckForm, LabelsForm, ModeForm, + NetworkForm, PlacementForm, RestartPolicyForm, RollbackConfigForm, @@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [ docDescription: "Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).", }, + { + id: "network", + label: "Network", + description: "Configure network attachments", + docDescription: + "Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.", + }, { id: "labels", label: "Labels", @@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { )} {activeMenu === "mode" && } + {activeMenu === "network" && } {activeMenu === "labels" && } {activeMenu === "stop-grace-period" && ( diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index a3bc8079a..8de863957 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"; @@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { mongo: () => api.mongo.update.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); @@ -236,7 +236,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 index 7ee31e5b6..6d95634be 100644 --- 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 @@ -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"; 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 index b2fc49ef3..f62037fca 100644 --- 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 @@ -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"; @@ -31,7 +31,6 @@ interface HealthCheckFormProps { export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { const [isLoading, setIsLoading] = useState(false); - const [testCommands, setTestCommands] = useState([]); const queryMap = { postgres: () => @@ -72,6 +71,8 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { }, }); + const testCommands = form.watch("Test") || []; + useEffect(() => { if (data?.healthCheckSwarm) { const hc = data.healthCheckSwarm; @@ -82,7 +83,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { StartPeriod: hc.StartPeriod, Retries: hc.Retries, }); - setTestCommands(hc.Test || []); } }, [data, form]); @@ -117,17 +117,20 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { }; const addTestCommand = () => { - setTestCommands([...testCommands, ""]); + form.setValue("Test", [...testCommands, ""]); }; const updateTestCommand = (index: number, value: string) => { const newCommands = [...testCommands]; newCommands[index] = value; - setTestCommands(newCommands); + form.setValue("Test", newCommands); }; const removeTestCommand = (index: number) => { - setTestCommands(testCommands.filter((_, i) => i !== index)); + form.setValue( + "Test", + testCommands.filter((_: string, i: number) => i !== index), + ); }; return ( @@ -140,7 +143,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { http://localhost:3000/health"])
- {testCommands.map((cmd, index) => ( + {testCommands.map((cmd: string, index: number) => (
{ const modeData = formData.type === "Replicated" - ? { Replicated: { Replicas: formData.Replicas } } + ? { + Replicated: { + Replicas: + formData.Replicas !== undefined && formData.Replicas !== "" + ? Number(formData.Replicas) + : undefined, + }, + } : { Global: {} }; await mutateAsync({ diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx new file mode 100644 index 000000000..7d6ebbaf3 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -0,0 +1,313 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useEffect, useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; + +const driverOptEntrySchema = z.object({ + key: z.string(), + value: z.string(), +}); + +export const networkFormSchema = z.object({ + networks: z + .array( + z.object({ + Target: z.string().optional(), + Aliases: z.string().optional(), + DriverOptsEntries: z.array(driverOptEntrySchema).optional(), + }), + ) + .optional(), +}); + +interface NetworkFormProps { + id: string; + type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; +} + +export const NetworkForm = ({ id, type }: NetworkFormProps) => { + const [isLoading, setIsLoading] = useState(false); + + const queryMap = { + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + }; + const { data, refetch } = queryMap[type] + ? queryMap[type]() + : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); + + const mutationMap = { + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + application: () => api.application.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + }; + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.mongo.update.useMutation(); + + const form = useForm>({ + resolver: zodResolver(networkFormSchema), + defaultValues: { + networks: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "networks", + }); + + useEffect(() => { + if (data?.networkSwarm && Array.isArray(data.networkSwarm)) { + const networkEntries = data.networkSwarm.map((network) => ({ + Target: network.Target || "", + Aliases: network.Aliases?.join(", ") || "", + DriverOptsEntries: network.DriverOpts + ? Object.entries(network.DriverOpts).map(([key, value]) => ({ + key, + value: value ?? "", + })) + : [], + })); + form.reset({ networks: networkEntries }); + } + }, [data, form]); + + const onSubmit = async (formData: z.infer) => { + setIsLoading(true); + try { + const networksArray = + formData.networks + ?.filter((network) => network.Target) + .map((network) => { + const entries = (network.DriverOptsEntries ?? []).filter( + (e) => e.key.trim() !== "", + ); + const driverOpts = + entries.length > 0 + ? Object.fromEntries( + entries.map((e) => [e.key.trim(), e.value]), + ) + : undefined; + return { + Target: network.Target, + Aliases: network.Aliases + ? network.Aliases.split(",").map((alias) => alias.trim()) + : undefined, + DriverOpts: driverOpts, + }; + }) || []; + + // If no networks, send null to clear the database + const networksToSend = networksArray.length > 0 ? networksArray : null; + + await mutateAsync({ + applicationId: id || "", + postgresId: id || "", + redisId: id || "", + mysqlId: id || "", + mariadbId: id || "", + mongoId: id || "", + networkSwarm: networksToSend, + }); + + toast.success("Network configuration updated successfully"); + refetch(); + } catch { + toast.error("Error updating network configuration"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+ Networks + + Configure network attachments for your service + +
+ {fields.map((field, index) => ( +
+ ( + + Network Name + + + + + The name of the network to attach to + + + + )} + /> + ( + + Aliases (optional) + + + + + Comma-separated list of network aliases + + + + )} + /> +
+ Driver options (optional) + + e.g. com.docker.network.driver.mtu, + com.docker.network.driver.host_binding + + {( + form.watch(`networks.${index}.DriverOptsEntries`) ?? [] + ).map((_, optIndex) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+ +
+ ))} + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx index b0c354513..b4091aac0 100644 --- 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 @@ -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"; @@ -17,9 +17,7 @@ import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; const PreferenceSchema = z.object({ - Spread: z.object({ - SpreadDescriptor: z.string(), - }), + SpreadDescriptor: z.string(), }); const PlatformSchema = z.object({ @@ -116,7 +114,14 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", - placementSwarm: hasAnyValue ? formData : null, + placementSwarm: hasAnyValue + ? { + ...formData, + Preferences: formData.Preferences?.map((p) => ({ + Spread: { SpreadDescriptor: p.SpreadDescriptor }, + })), + } + : null, }); toast.success("Placement updated successfully"); 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 index b7fb649be..db7be5629 100644 --- 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 @@ -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"; 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 index d53215348..528b9d1cc 100644 --- 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 @@ -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"; 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 index 4119c41f8..af2d826db 100644 --- 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 @@ -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"; 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 aea30e49b..3b30155bf 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"; @@ -21,10 +21,18 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { createConverter, NumberInputWithSteps, } from "@/components/ui/number-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tooltip, TooltipContent, @@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => { : `${formatNumber(mb)} MB`; }); +const ulimitSchema = z.object({ + Name: z.string().min(1, "Name is required"), + Soft: z.coerce.number().int().min(-1, "Must be >= -1"), + Hard: z.coerce.number().int().min(-1, "Must be >= -1"), +}); + const addResourcesSchema = z.object({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: z.string().optional(), cpuReservation: z.string().optional(), + ulimitsSwarm: z.array(ulimitSchema).optional(), }); +const ULIMIT_PRESETS = [ + { value: "nofile", label: "nofile (Open Files)" }, + { value: "nproc", label: "nproc (Processes)" }, + { value: "memlock", label: "memlock (Locked Memory)" }, + { value: "stack", label: "stack (Stack Size)" }, + { value: "core", label: "core (Core File Size)" }, + { value: "cpu", label: "cpu (CPU Time)" }, + { value: "data", label: "data (Data Segment)" }, + { value: "fsize", label: "fsize (File Size)" }, + { value: "locks", label: "locks (File Locks)" }, + { value: "msgqueue", label: "msgqueue (Message Queues)" }, + { value: "nice", label: "nice (Nice Priority)" }, + { value: "rtprio", label: "rtprio (Real-time Priority)" }, + { value: "sigpending", label: "sigpending (Pending Signals)" }, +]; + export type ServiceType = | "postgres" | "mongo" @@ -97,20 +128,26 @@ export const ShowResources = ({ id, type }: Props) => { 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({ @@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: data?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); @@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, memoryReservation: formData.memoryReservation || null, + ulimitsSwarm: + formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0 + ? formData.ulimitsSwarm + : null, }) .then(async () => { toast.success("Resources Updated"); @@ -325,8 +367,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..5d8943197 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,7 +15,7 @@ interface Props { } export const ShowTraefikConfig = ({ applicationId }: Props) => { - const { data, isLoading } = api.application.readTraefikConfig.useQuery( + const { data, isPending } = api.application.readTraefikConfig.useQuery( { applicationId, }, @@ -35,7 +35,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..a8ec9053f 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({ @@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => { export const UpdateTraefikConfig = ({ applicationId }: Props) => { const [open, setOpen] = useState(false); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( { applicationId, @@ -66,7 +69,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 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { }, [data]); const onSubmit = async (data: UpdateTraefikConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: (error as string) || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: (error as string) || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -116,11 +121,12 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { setOpen(open); if (!open) { form.reset(); + setSkipYamlValidation(false); } }} > - + @@ -169,9 +175,30 @@ routers: - + +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

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

+
@@ -310,7 +310,7 @@ PORT=3000 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 cfe747d27..61841e294 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -6,6 +6,7 @@ import { RefreshCcw, RocketIcon, Settings, + Trash2, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -25,6 +26,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 +61,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 +75,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(""); @@ -144,6 +148,9 @@ export const ShowDeployments = ({
+ {(type === "application" || type === "compose") && ( + + )} {(type === "application" || type === "compose") && ( )} @@ -252,6 +259,8 @@ export const ShowDeployments = ({ const isExpanded = expandedDescriptions.has( deployment.deploymentId, ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; return (
+ {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" && ( diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 6af0e1e8c..00eb62272 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -159,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }, ); - const { mutateAsync, isError, error, isLoading } = domainId + const { mutateAsync, isError, error, isPending } = domainId ? api.domain.update.useMutation() : api.domain.create.useMutation(); - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = @@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { domainType: type, }); } - }, [form, data, isLoading, domainId]); + }, [form, data, isPending, domainId]); // Separate effect for handling custom cert resolver validation useEffect(() => { @@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { - diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 1fd3d82e9..c207ba59c 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -97,7 +97,7 @@ export const ShowDomains = ({ id, type }: Props) => { const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); - const { mutateAsync: deleteDomain, isLoading: isRemoving } = + const { mutateAsync: deleteDomain, isPending: isRemoving } = api.domain.delete.useMutation(); const handleValidateDomain = async (host: string) => { diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx index 797a317a8..8ff0f6a63 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { EyeIcon, EyeOffIcon } from "lucide-react"; import { type CSSProperties, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -60,7 +60,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { mongo: () => api.mongo.update.useMutation(), compose: () => api.compose.update.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.update.useMutation(); @@ -111,7 +111,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 +121,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return (
@@ -196,7 +196,7 @@ PORT=3000 )} )} @@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -329,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( @@ -346,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... 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..624adeb55 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,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -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: "/", @@ -317,7 +317,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 6197fc49f..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: { @@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -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..ee42caa5e 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -37,14 +37,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(); diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index e5dff075e..cbb6bce09 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -34,6 +34,7 @@ export const DockerLogs = dynamic( export const badgeStateColor = (state: string) => { switch (state) { case "running": + case "ready": return "green"; case "exited": case "shutdown": @@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { const [containerId, setContainerId] = useState(); const [option, setOption] = useState<"swarm" | "native">("native"); - const { data: services, isLoading: servicesLoading } = + const { data: services, isPending: servicesLoading } = api.docker.getServiceContainersByAppName.useQuery( { appName, @@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, ); - const { data: containers, isLoading: containersLoading } = + const { data: containers, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))}
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services?.find((c) => c.containerId === containerId)?.error} +
+ )} void; + onOpenChange: (open: boolean) => void; + alwaysVisible?: boolean; +} + +export const CreateFileDialog = ({ + folderPath, + onCreate, + onOpenChange, + alwaysVisible = false, +}: Props) => { + const [filename, setFilename] = useState(""); + const [content, setContent] = useState(""); + + const handleCreate = () => { + if (!filename.trim()) return; + onCreate(filename.trim(), content); + setFilename(""); + setContent(""); + onOpenChange(false); + }; + + return ( + + + + + +
{ + e.preventDefault(); + handleCreate(); + }} + > + + Create file + + {folderPath ? `New file in ${folderPath}/` : "New file in root"} + + +
+
+ + setFilename(e.target.value)} + /> +
+
+ +
+ setContent(v ?? "")} + className="h-full" + wrapperClassName="h-[200px]" + lineWrapping + /> +
+
+
+ + + + + + + + +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx new file mode 100644 index 000000000..8c5a42836 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx @@ -0,0 +1,102 @@ +import { Loader2, Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + patchId: string; + entityId: string; + type: "application" | "compose"; + onSuccess?: () => void; +} + +export const EditPatchDialog = ({ + patchId, + entityId, + type, + onSuccess, +}: Props) => { + const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery( + { patchId }, + { enabled: !!patchId }, + ); + const [content, setContent] = useState(""); + + useEffect(() => { + if (patch) { + setContent(patch.content); + } + }, [patch]); + + const utils = api.useUtils(); + const updatePatch = api.patch.update.useMutation(); + + const handleSave = () => { + updatePatch + .mutateAsync({ patchId, content }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id: entityId, type }); + onSuccess?.(); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + + return ( + + + + + + + Edit Patch + + {patch ? `Editing: ${patch.filePath}` : "Loading patch..."} + + + {isPatchLoading ? ( +
+ +
+ ) : ( +
+ setContent(value ?? "")} + className="h-[400px] w-full" + wrapperClassName="h-[400px]" + lineWrapping + /> +
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts new file mode 100644 index 000000000..1854bd3e5 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -0,0 +1,2 @@ +export * from "./show-patches"; +export * from "./patch-editor"; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx new file mode 100644 index 000000000..4b212b004 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -0,0 +1,368 @@ +import { + ArrowLeft, + ChevronRight, + File, + Folder, + Loader2, + Save, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { api } from "@/utils/api"; +import { CreateFileDialog } from "./create-file-dialog"; + +interface Props { + id: string; + type: "application" | "compose"; + repoPath: string; + onClose: () => void; +} + +type DirectoryEntry = { + name: string; + path: string; + type: "file" | "directory"; + children?: DirectoryEntry[]; +}; + +export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [createFolderPath, setCreateFolderPath] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>( + new Set(), + ); + + const utils = api.useUtils(); + const { data: directories, isPending: isDirLoading } = + api.patch.readRepoDirectories.useQuery( + { id: id, type, repoPath }, + { enabled: !!repoPath }, + ); + + const { data: patches } = api.patch.byEntityId.useQuery( + { id, type }, + { enabled: !!id }, + ); + + const { mutateAsync: saveAsPatch, isPending: isSavingPatch } = + api.patch.saveFileAsPatch.useMutation(); + + const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } = + api.patch.markFileForDeletion.useMutation(); + + const updatePatch = api.patch.update.useMutation(); + + const { data: fileData, isFetching: isFileLoading } = + api.patch.readRepoFile.useQuery( + { + id, + type, + filePath: selectedFile || "", + }, + { + enabled: !!selectedFile, + }, + ); + + useEffect(() => { + if (fileData !== undefined) { + setFileContent(fileData); + } + }, [fileData]); + + const handleFileSelect = (filePath: string) => { + setSelectedFile(filePath); + }; + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleSave = () => { + if (!selectedFile) return; + saveAsPatch({ + id, + type, + filePath: selectedFile, + content: fileContent, + patchType: "update", + }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to save patch"); + }); + }; + + const handleMarkForDeletion = () => { + if (!selectedFile) return; + markForDeletion({ id, type, filePath: selectedFile }) + .then(() => { + toast.success("File marked for deletion"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to mark file for deletion"); + }); + }; + + const handleCreateFile = useCallback( + (folderPath: string, filename: string, content: string) => { + const filePath = folderPath ? `${folderPath}/${filename}` : filename; + saveAsPatch({ + id, + type, + filePath, + content, + patchType: "create", + }) + .then(() => { + toast.success("File created"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to create file"); + }); + }, + [id, type, saveAsPatch, utils], + ); + + const selectedFilePatch = patches?.find( + (p) => p.filePath === selectedFile && p.type === "delete", + ); + + const handleUnmarkDeletion = () => { + if (!selectedFilePatch) return; + updatePatch + .mutateAsync({ + patchId: selectedFilePatch.patchId, + type: "update", + content: fileData || "", + }) + .then(() => { + toast.success("Deletion unmarked"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to unmark deletion"); + }); + }; + + const hasChanges = fileData !== undefined && fileContent !== fileData; + + const renderTree = useCallback( + (entries: DirectoryEntry[], depth = 0) => { + return entries + .sort((a, b) => { + // Directories first, then alphabetically + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .map((entry) => { + const isExpanded = expandedFolders.has(entry.path); + const isSelected = selectedFile === entry.path; + + if (entry.type === "directory") { + return ( +
+
+ + + handleCreateFile(entry.path, filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? entry.path : null) + } + /> +
+ {isExpanded && entry.children && ( +
{renderTree(entry.children, depth + 1)}
+ )} +
+ ); + } + + const isMarkedForDeletion = patches?.some( + (p) => p.filePath === entry.path && p.type === "delete", + ); + + return ( + + ); + }); + }, + [expandedFolders, selectedFile, patches, handleCreateFile], + ); + + return ( + + +
+ +
+ Edit File + + {selectedFile + ? `Editing: ${selectedFile}` + : "Select a file from the tree to edit"} + +
+
+ {selectedFile && ( +
+ {selectedFilePatch ? ( + + ) : ( + <> + + + + )} +
+ )} +
+ +
+
+ +
+
+ + handleCreateFile("", filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? "" : null) + } + /> + + New file in root + +
+ {isDirLoading ? ( +
+ +
+ ) : directories ? ( + renderTree(directories) + ) : ( +
+ No files found +
+ )} +
+
+
+
+ {isFileLoading ? ( +
+ +
+ ) : selectedFile ? ( + setFileContent(value || "")} + className="h-full w-full" + wrapperClassName="h-full" + lineWrapping + /> + ) : ( +
+ Select a file to edit +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx new file mode 100644 index 000000000..e471b3fc1 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -0,0 +1,225 @@ +import { File, FilePlus2, Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { EditPatchDialog } from "./edit-patch-dialog"; +import { PatchEditor } from "./patch-editor"; + +interface Props { + id: string; + type: "application" | "compose"; +} + +export const ShowPatches = ({ id, type }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [repoPath, setRepoPath] = useState(null); + const [isLoadingRepo, setIsLoadingRepo] = useState(false); + + const utils = api.useUtils(); + + const { data: patches, isPending: isPatchesLoading } = + api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id }); + + const mutationMap = { + application: () => api.patch.delete.useMutation(), + compose: () => api.patch.delete.useMutation(), + }; + + const ensureRepo = api.patch.ensureRepo.useMutation(); + + const togglePatch = api.patch.toggleEnabled.useMutation(); + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.patch.delete.useMutation(); + + const handleCloseEditor = () => { + setSelectedFile(null); + setRepoPath(null); + }; + + if (repoPath) { + return ( + + ); + } + + const handleOpenEditor = async () => { + setIsLoadingRepo(true); + await ensureRepo + .mutateAsync({ id, type }) + .then((result) => { + setRepoPath(result); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }; + + return ( + + +
+ Patches + + Apply code patches to your repository during build. Patches are + applied after cloning the repository and before building. + +
+ {patches && patches?.length > 0 && ( + + )} +
+ + {isPatchesLoading ? ( +
+ +
+ ) : patches?.length === 0 ? ( +
+
+ +
+
+

No patches yet

+

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

+
+ +
+ ) : ( + + + + File Path + Type + Enabled + Actions + + + + {patches?.map((patch) => ( + + +
+ + {patch.filePath} +
+
+ + + {patch.type} + + + + { + togglePatch + .mutateAsync({ + patchId: patch.patchId, + enabled: checked, + }) + .then(() => { + toast.success("Patch updated"); + utils.patch.byEntityId.invalidate({ + id, + type, + }); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }} + /> + + +
+ {(patch.type === "update" || patch.type === "create") && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx index bb9321a51..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,11 +75,11 @@ 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({ @@ -103,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", @@ -301,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 6cf8d8830..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 @@ -43,7 +43,7 @@ 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 } = @@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { { applicationId }, { enabled: !!applicationId, - refetchInterval: (data) => - data?.some((d) => d.previewStatus === "running") ? 2000 : false, + refetchInterval: 2000, }, ); @@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index e85b1b004..36ddb53f1 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, @@ -220,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: "", @@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { } }, [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({ @@ -662,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/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..9d417ee91 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"; @@ -74,7 +74,7 @@ export const DeleteService = ({ id, type }: Props) => { mongo: () => api.mongo.remove.useMutation(), compose: () => api.compose.delete.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.remove.useMutation(); const { push } = useRouter(); @@ -130,7 +130,7 @@ export const DeleteService = ({ id, type }: Props) => { variant="ghost" size="icon" className="group hover:bg-red-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -228,7 +228,7 @@ export const DeleteService = ({ id, type }: Props) => { @@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -331,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( @@ -348,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..4ad4f741c 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,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -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: "", @@ -318,7 +318,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 98c2afa11..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,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, useMemo } from "react"; @@ -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: { @@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -349,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( @@ -366,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,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} @@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services.find((c) => c.containerId === containerId)?.error} +
+ )} { - const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( + const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { appName, appType, @@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({ @@ -161,7 +161,7 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx index 8e996846f..9d953279c 100644 --- a/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx +++ b/apps/dokploy/components/dashboard/mariadb/general/show-general-mariadb.tsx @@ -28,13 +28,13 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => { { enabled: !!mariadbId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mariadb.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mariadb.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mariadb.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx b/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx index 62486e015..d181103b3 100644 --- a/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx +++ b/apps/dokploy/components/dashboard/mariadb/update-mariadb.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -41,8 +41,9 @@ interface Props { } export const UpdateMariadb = ({ mariadbId }: Props) => { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mariadb.update.useMutation(); const { data } = api.mariadb.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => { utils.mariadb.one.invalidate({ mariadbId: mariadbId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating the Mariadb"); @@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => { }; return ( - +
diff --git a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx index 23fbe51d3..47a29e6c1 100644 --- a/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx +++ b/apps/dokploy/components/dashboard/mongo/general/show-general-mongo.tsx @@ -28,13 +28,13 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => { { enabled: !!mongoId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mongo.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mongo.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mongo.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/apps/dokploy/components/dashboard/mongo/update-mongo.tsx b/apps/dokploy/components/dashboard/mongo/update-mongo.tsx index e78abddbd..55bccce67 100644 --- a/apps/dokploy/components/dashboard/mongo/update-mongo.tsx +++ b/apps/dokploy/components/dashboard/mongo/update-mongo.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 UpdateMongo = ({ mongoId }: Props) => { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mongo.update.useMutation(); const { data } = api.mongo.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdateMongo = ({ mongoId }: Props) => { /> diff --git a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx index 045a717b7..1a55c1d1a 100644 --- a/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx +++ b/apps/dokploy/components/dashboard/mysql/general/show-general-mysql.tsx @@ -28,12 +28,12 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => { { enabled: !!mysqlId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.mysql.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.mysql.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.mysql.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/apps/dokploy/components/dashboard/mysql/update-mysql.tsx b/apps/dokploy/components/dashboard/mysql/update-mysql.tsx index 353523aa0..3442d44e3 100644 --- a/apps/dokploy/components/dashboard/mysql/update-mysql.tsx +++ b/apps/dokploy/components/dashboard/mysql/update-mysql.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -41,8 +41,9 @@ interface Props { } export const UpdateMysql = ({ mysqlId }: Props) => { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.mysql.update.useMutation(); const { data } = api.mysql.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => { utils.mysql.one.invalidate({ mysqlId: mysqlId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating MySQL"); @@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => { }; return ( - + diff --git a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx index d9841716e..0921984ac 100644 --- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx +++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-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"; diff --git a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx index 46b3772a0..c38240a3f 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-external-postgres-credentials.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -48,12 +48,12 @@ interface Props { export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { const { data: ip } = api.settings.getIp.useQuery(); const { data, refetch } = api.postgres.one.useQuery({ postgresId }); - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = api.postgres.saveExternalPort.useMutation(); const getIp = data?.server?.ipAddress || ip; const [connectionUrl, setConnectionUrl] = useState(""); - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(DockerProviderSchema), }); @@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; @@ -142,7 +142,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { @@ -162,7 +162,7 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx index de520053d..0e6b87e9e 100644 --- a/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx +++ b/apps/dokploy/components/dashboard/postgres/general/show-general-postgres.tsx @@ -28,13 +28,13 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => { { enabled: !!postgresId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.postgres.reload.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.postgres.stop.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.postgres.start.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/apps/dokploy/components/dashboard/postgres/update-postgres.tsx b/apps/dokploy/components/dashboard/postgres/update-postgres.tsx index d4485862e..c83604b54 100644 --- a/apps/dokploy/components/dashboard/postgres/update-postgres.tsx +++ b/apps/dokploy/components/dashboard/postgres/update-postgres.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBox } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -43,7 +43,7 @@ interface Props { export const UpdatePostgres = ({ postgresId }: Props) => { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.postgres.update.useMutation(); const { data } = api.postgres.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => { /> diff --git a/apps/dokploy/components/dashboard/project/add-compose.tsx b/apps/dokploy/components/dashboard/project/add-compose.tsx index bb911373f..815c58ca8 100644 --- a/apps/dokploy/components/dashboard/project/add-compose.tsx +++ b/apps/dokploy/components/dashboard/project/add-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CircuitBoard, HelpCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -75,7 +75,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { const slug = slugify(projectName); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery(); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.compose.create.useMutation(); // Get environment data to extract projectId @@ -307,7 +307,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => { - diff --git a/apps/dokploy/components/dashboard/project/add-database.tsx b/apps/dokploy/components/dashboard/project/add-database.tsx index 67d00b0d7..e14653880 100644 --- a/apps/dokploy/components/dashboard/project/add-database.tsx +++ b/apps/dokploy/components/dashboard/project/add-database.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Database, HelpCircle } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -52,7 +52,7 @@ import { import { slugify } from "@/lib/slug"; import { api } from "@/utils/api"; -type DbType = typeof mySchema._type.type; +type DbType = z.infer["type"]; const dockerImageDefaultPlaceholder: Record = { mongo: "mongo:7", @@ -196,7 +196,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => { // Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers) const shouldShowServerDropdown = hasServers; - const form = useForm({ + const form = useForm({ defaultValues: { type: "postgres", dockerImage: "", diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 72c42da49..ef9a88e6f 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -116,7 +116,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { ); const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery(); - const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery( + const { data: tags, isPending: isLoadingTags } = api.compose.getTags.useQuery( { baseUrl: customBaseUrl }, { enabled: open, @@ -125,7 +125,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { const utils = api.useUtils(); const [serverId, setServerId] = useState(undefined); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.compose.deployTemplate.useMutation(); const templates = @@ -512,7 +512,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { Cancel { const promise = mutateAsync({ serverId: diff --git a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx index 678928990..3e28a248b 100644 --- a/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx +++ b/apps/dokploy/components/dashboard/project/advanced-environment-selector.tsx @@ -93,7 +93,7 @@ export const AdvancedEnvironmentSelector = ({ await createEnvironment.mutateAsync({ projectId, name: name.trim(), - description: description.trim() || null, + description: description.trim() || undefined, }); toast.success("Environment created successfully"); @@ -115,7 +115,7 @@ export const AdvancedEnvironmentSelector = ({ await updateEnvironment.mutateAsync({ environmentId: selectedEnvironment.environmentId, name: name.trim(), - description: description.trim() || null, + description: description.trim() || undefined, }); toast.success("Environment updated successfully"); @@ -168,7 +168,7 @@ export const AdvancedEnvironmentSelector = ({ const result = await duplicateEnvironment.mutateAsync({ environmentId: environment.environmentId, name: `${environment.name}-copy`, - description: environment.description, + description: environment.description || undefined, }); toast.success("Environment duplicated successfully"); @@ -334,9 +334,9 @@ export const AdvancedEnvironmentSelector = ({
@@ -387,9 +387,9 @@ export const AdvancedEnvironmentSelector = ({
@@ -427,12 +427,12 @@ export const AdvancedEnvironmentSelector = ({ variant="destructive" onClick={handleDeleteEnvironment} disabled={ - deleteEnvironment.isLoading || + deleteEnvironment.isPending || haveServices || !selectedEnvironment } > - {deleteEnvironment.isLoading ? "Deleting..." : "Delete"} + {deleteEnvironment.isPending ? "Deleting..." : "Delete"} diff --git a/apps/dokploy/components/dashboard/project/ai/step-two.tsx b/apps/dokploy/components/dashboard/project/ai/step-two.tsx index 09484bc57..e13ff40ad 100644 --- a/apps/dokploy/components/dashboard/project/ai/step-two.tsx +++ b/apps/dokploy/components/dashboard/project/ai/step-two.tsx @@ -28,7 +28,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => { const suggestions = templateInfo.suggestions || []; const selectedVariant = templateInfo.details; - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.ai.suggest.useMutation(); useEffect(() => { @@ -184,7 +184,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => { ); } - if (isLoading) { + if (isPending) { return (
diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx index 3455f34cf..f84cf35dd 100644 --- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -76,7 +76,7 @@ export const DuplicateProject = ({ selectedServiceIds.includes(service.id), ); - const { mutateAsync: duplicateProject, isLoading } = + const { mutateAsync: duplicateProject, isPending } = api.project.duplicate.useMutation({ onSuccess: async (newProject) => { await utils.project.all.invalidate(); @@ -321,20 +321,20 @@ export const DuplicateProject = ({ diff --git a/apps/dokploy/components/dashboard/projects/handle-project.tsx b/apps/dokploy/components/dashboard/projects/handle-project.tsx index 09fd36f84..d3305e864 100644 --- a/apps/dokploy/components/dashboard/projects/handle-project.tsx +++ b/apps/dokploy/components/dashboard/projects/handle-project.tsx @@ -1,4 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; + import { PlusIcon, SquarePen } from "lucide-react"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -80,7 +81,7 @@ export const HandleProject = ({ projectId }: Props) => { description: "", name: "", }, - resolver: zodResolver(AddProjectSchema), + resolver: standardSchemaResolver(AddProjectSchema), }); useEffect(() => { diff --git a/apps/dokploy/components/dashboard/projects/project-environment.tsx b/apps/dokploy/components/dashboard/projects/project-environment.tsx index cb6245f08..b02f9024a 100644 --- a/apps/dokploy/components/dashboard/projects/project-environment.tsx +++ b/apps/dokploy/components/dashboard/projects/project-environment.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { FileIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -41,7 +41,7 @@ interface Props { export const ProjectEnvironment = ({ projectId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.project.update.useMutation(); const { data } = api.project.one.useQuery( { @@ -84,7 +84,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -94,7 +94,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading, isOpen]); + }, [form, onSubmit, isPending, isOpen]); return ( @@ -155,7 +155,7 @@ PORT=3000 )} /> - diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 8234593e1..c3d4d498b 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -63,7 +63,7 @@ export const ShowProjects = () => { const utils = api.useUtils(); const router = useRouter(); const { data: isCloud } = api.settings.isCloud.useQuery(); - const { data, isLoading } = api.project.all.useQuery(); + const { data, isPending } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); @@ -200,7 +200,7 @@ export const ShowProjects = () => {
- {isLoading ? ( + {isPending ? (
Loading... @@ -430,7 +430,7 @@ export const ShowProjects = () => { ) : null} - +
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
- + {project.description} diff --git a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx index 8edd92389..ebc01200a 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-external-redis-credentials.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -48,11 +48,11 @@ interface Props { export const ShowExternalRedisCredentials = ({ redisId }: Props) => { const { data: ip } = api.settings.getIp.useQuery(); const { data, refetch } = api.redis.one.useQuery({ redisId }); - const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation(); + const { mutateAsync, isPending } = api.redis.saveExternalPort.useMutation(); const [connectionUrl, setConnectionUrl] = useState(""); const getIp = data?.server?.ipAddress || ip; - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(DockerProviderSchema), }); @@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { toast.success("External Port updated"); await refetch(); }) - .catch(() => { - toast.error("Error saving the external port"); + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port"); }); }; @@ -134,7 +134,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { @@ -154,7 +154,7 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx index de70cc558..4300f9af3 100644 --- a/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx +++ b/apps/dokploy/components/dashboard/redis/general/show-general-redis.tsx @@ -28,12 +28,12 @@ export const ShowGeneralRedis = ({ redisId }: Props) => { { enabled: !!redisId }, ); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.redis.reload.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.redis.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.redis.stop.useMutation(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); diff --git a/apps/dokploy/components/dashboard/redis/update-redis.tsx b/apps/dokploy/components/dashboard/redis/update-redis.tsx index 7d17552fa..b20e6b0c4 100644 --- a/apps/dokploy/components/dashboard/redis/update-redis.tsx +++ b/apps/dokploy/components/dashboard/redis/update-redis.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -41,8 +41,9 @@ interface Props { } export const UpdateRedis = ({ redisId }: Props) => { + const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.redis.update.useMutation(); const { data } = api.redis.one.useQuery( { @@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => { utils.redis.one.invalidate({ redisId: redisId, }); + setIsOpen(false); }) .catch(() => { toast.error("Error updating Redis"); @@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => { }; return ( - + + +
+ + New plan +
+ + +
+ + {upgradeTier && ( +
+ + Servers + {upgradeTier === "startup" && + ` (min. ${STARTUP_SERVERS_INCLUDED})`} + +
+ + { + const v = + Number((e.target as HTMLInputElement).value) || + 0; + setUpgradeServerQty( + Math.max( + upgradeTier === "startup" + ? STARTUP_SERVERS_INCLUDED + : 1, + v, + ), + ); + }} + className="w-20 h-8" + /> + +
+

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

+ +

+ Current plan: Legacy +

+

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

+

+ Stripe will prorate the change. +

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

+ Change plan or number of servers +

+

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

+

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

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

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

+ +

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

+

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

+

+ Stripe will prorate the change. +

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

+ Hobby +

+

+ Everything an individual developer needs +

+
+

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

+

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

+ {isAnnual && ( +

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

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

+ Startup +

+

+ Perfect for small to mid-size teams +

+
+

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

+

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

+ {isAnnual && ( +

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

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

+ Enterprise +

+

+ For large organizations who want more control +

+
+

+ Contact Sales +

+
+
    +
  • + + All the features of Startup, plus… +
  • + {[ + "Up to Unlimited Servers", + "Up to Unlimited Organizations", + "Fine-grained RBAC", + "Complete Hosting Flexibility", + "SSO / SAML (Azure, OKTA, etc)", + "Audit Logs", + "MSA/SLA", + "White Labeling", + "Priority Support and Services", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+ +
+
+ ) : ( <> + setIsAnnual(e === "annual")} + > + + Monthly + Annual (20% off) + + {products?.map((product) => { const featured = true; return ( @@ -311,15 +1105,7 @@ export const ShowBilling = () => { -
0 - ? "justify-between" - : "justify-end", - "flex flex-row items-center gap-2 mt-4", - )} - > +
{admin?.user.stripeCustomerId && ( )} - - {data?.subscriptions?.length === 0 && ( -
- -
+ {(data?.subscriptions?.length ?? 0) === 0 && ( + )}
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx index 73cc82efc..b10e09596 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-invoices.tsx @@ -53,11 +53,11 @@ const getStatusBadge = (status: Stripe.Invoice.Status | null) => { }; export const ShowInvoices = () => { - const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery(); + const { data: invoices, isPending } = api.stripe.getInvoices.useQuery(); return (
- {isLoading ? ( + {isPending ? (
Loading invoices... diff --git a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx index f87ca58c7..ca1407d7e 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-welcome-dokploy.tsx @@ -12,7 +12,7 @@ export const ShowWelcomeDokploy = () => { const { data } = api.user.get.useQuery(); const [open, setOpen] = useState(false); - const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); + const { data: isCloud, isPending } = api.settings.isCloud.useQuery(); if (!isCloud || data?.role !== "admin") { return null; @@ -20,14 +20,14 @@ export const ShowWelcomeDokploy = () => { useEffect(() => { if ( - !isLoading && + !isPending && isCloud && !localStorage.getItem("hasSeenCloudWelcomeModal") && data?.role === "owner" ) { setOpen(true); } - }, [isCloud, isLoading]); + }, [isCloud, isPending]); const handleClose = (isOpen: boolean) => { if (data?.role === "owner") { diff --git a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx index 6f7ef6821..bc29a4c95 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/add-certificate.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { HelpCircle, PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -62,7 +62,7 @@ export const AddCertificate = () => { const utils = api.useUtils(); const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync, isError, error, isLoading } = + const { mutateAsync, isError, error, isPending } = api.certificates.create.useMutation(); const { data: servers } = api.server.withSSHKey.useQuery(); const hasServers = servers && servers.length > 0; @@ -247,7 +247,7 @@ export const AddCertificate = () => {
) : (
diff --git a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx new file mode 100644 index 000000000..dcfa0b04f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { Link2, Loader2, Unlink } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; + +const LINKING_CALLBACK_URL = "/dashboard/settings/profile"; + +const TRUSTED_PROVIDERS = ["google", "github"] as const; +type SocialProvider = (typeof TRUSTED_PROVIDERS)[number]; + +type AccountItem = { + providerId: string; + accountId?: string; +}; + +function providerLabel(providerId: string): string { + return providerId.charAt(0).toUpperCase() + providerId.slice(1); +} + +export function LinkingAccount() { + const [accounts, setAccounts] = useState([]); + const [accountsLoading, setAccountsLoading] = useState(true); + const [linkingProvider, setLinkingProvider] = useState( + null, + ); + const [unlinkingProviderId, setUnlinkingProviderId] = useState( + null, + ); + + const fetchAccounts = useCallback(async () => { + setAccountsLoading(true); + try { + const { data } = await authClient.listAccounts(); + const list = Array.isArray(data) + ? data + : ((data && typeof data === "object" && "accounts" in data + ? (data as { accounts?: AccountItem[] }).accounts + : null) ?? []); + setAccounts(Array.isArray(list) ? list : []); + } catch { + setAccounts([]); + } finally { + setAccountsLoading(false); + } + }, []); + + useEffect(() => { + fetchAccounts(); + }, [fetchAccounts]); + + const linkedProviderIds = new Set(accounts.map((a) => a.providerId)); + const socialAccounts = accounts.filter((a) => + TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider), + ); + + const handleLinkSocial = async (provider: SocialProvider) => { + setLinkingProvider(provider); + try { + const { error } = await authClient.linkSocial({ + provider, + callbackURL: LINKING_CALLBACK_URL, + }); + if (error) { + toast.error(error.message ?? "Failed to link account"); + setLinkingProvider(null); + return; + } + } catch (err) { + toast.error( + "Failed to link account", + err instanceof Error ? { description: err.message } : undefined, + ); + setLinkingProvider(null); + } + }; + + const handleUnlink = async (providerId: string, accountId?: string) => { + setUnlinkingProviderId(providerId); + try { + const { error } = await authClient.unlinkAccount({ + providerId, + ...(accountId && { accountId }), + }); + if (error) { + toast.error(error.message ?? "Failed to unlink account"); + return; + } + toast.success("Account unlinked"); + await fetchAccounts(); + } catch (err) { + toast.error( + "Failed to unlink account", + err instanceof Error ? { description: err.message } : undefined, + ); + } finally { + setUnlinkingProviderId(null); + } + }; + + const canUnlink = accounts.length > 1; + + return ( + +
+ +
+
+ + + Linking account + + + Link your Google or GitHub account to sign in with them. + +
+
+
+ + {/* Linked accounts */} +
+

Linked accounts

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

+ No social accounts linked yet. +

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

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

+
+ {!linkedProviderIds.has("google") && ( + + )} + {!linkedProviderIds.has("github") && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index 1d80019ba..a146dfbfd 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Mail, @@ -17,7 +17,9 @@ import { LarkIcon, NtfyIcon, PushoverIcon, + ResendIcon, SlackIcon, + TeamsIcon, TelegramIcon, } from "@/components/icons/notification-icons"; import { Button } from "@/components/ui/button"; @@ -98,6 +100,23 @@ export const notificationSchema = z.discriminatedUnion("type", [ .min(1, { message: "At least one email is required" }), }) .merge(notificationBaseSchema), + z + .object({ + type: z.literal("resend"), + apiKey: z.string().min(1, { message: "API Key is required" }), + fromAddress: z + .string() + .min(1, { message: "From Address is required" }) + .email({ message: "Email is invalid" }), + toAddresses: z + .array( + z.string().min(1, { message: "Email is required" }).email({ + message: "Email is invalid", + }), + ) + .min(1, { message: "At least one email is required" }), + }) + .merge(notificationBaseSchema), z .object({ type: z.literal("gotify"), @@ -155,6 +174,12 @@ export const notificationSchema = z.discriminatedUnion("type", [ webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), }) .merge(notificationBaseSchema), + z + .object({ + type: z.literal("teams"), + webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), + }) + .merge(notificationBaseSchema), ]); export const notificationsMap = { @@ -174,10 +199,18 @@ export const notificationsMap = { icon: , label: "Lark", }, + teams: { + icon: , + label: "Microsoft Teams", + }, email: { icon: , label: "Email", }, + resend: { + icon: , + label: "Resend", + }, gotify: { icon: , label: "Gotify", @@ -219,25 +252,29 @@ export const HandleNotifications = ({ notificationId }: Props) => { enabled: !!notificationId, }, ); - const { mutateAsync: testSlackConnection, isLoading: isLoadingSlack } = + const { mutateAsync: testSlackConnection, isPending: isLoadingSlack } = api.notification.testSlackConnection.useMutation(); - const { mutateAsync: testTelegramConnection, isLoading: isLoadingTelegram } = + const { mutateAsync: testTelegramConnection, isPending: isLoadingTelegram } = api.notification.testTelegramConnection.useMutation(); - const { mutateAsync: testDiscordConnection, isLoading: isLoadingDiscord } = + const { mutateAsync: testDiscordConnection, isPending: isLoadingDiscord } = api.notification.testDiscordConnection.useMutation(); - const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } = + const { mutateAsync: testEmailConnection, isPending: isLoadingEmail } = api.notification.testEmailConnection.useMutation(); - const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } = + const { mutateAsync: testResendConnection, isPending: isLoadingResend } = + api.notification.testResendConnection.useMutation(); + const { mutateAsync: testGotifyConnection, isPending: isLoadingGotify } = api.notification.testGotifyConnection.useMutation(); - const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } = + const { mutateAsync: testNtfyConnection, isPending: isLoadingNtfy } = api.notification.testNtfyConnection.useMutation(); - const { mutateAsync: testMattermostConnection, isLoading: isLoadingMattermost } = + const { mutateAsync: testMattermostConnection, isPending: isLoadingMattermost } = api.notification.testMattermostConnection.useMutation(); - const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } = + const { mutateAsync: testLarkConnection, isPending: isLoadingLark } = api.notification.testLarkConnection.useMutation(); - const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } = + const { mutateAsync: testTeamsConnection, isPending: isLoadingTeams } = + api.notification.testTeamsConnection.useMutation(); + const { mutateAsync: testCustomConnection, isPending: isLoadingCustom } = api.notification.testCustomConnection.useMutation(); - const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } = + const { mutateAsync: testPushoverConnection, isPending: isLoadingPushover } = api.notification.testPushoverConnection.useMutation(); const customMutation = notificationId @@ -255,6 +292,9 @@ export const HandleNotifications = ({ notificationId }: Props) => { const emailMutation = notificationId ? api.notification.updateEmail.useMutation() : api.notification.createEmail.useMutation(); + const resendMutation = notificationId + ? api.notification.updateResend.useMutation() + : api.notification.createResend.useMutation(); const gotifyMutation = notificationId ? api.notification.updateGotify.useMutation() : api.notification.createGotify.useMutation(); @@ -267,11 +307,14 @@ export const HandleNotifications = ({ notificationId }: Props) => { const larkMutation = notificationId ? api.notification.updateLark.useMutation() : api.notification.createLark.useMutation(); + const teamsMutation = notificationId + ? api.notification.updateTeams.useMutation() + : api.notification.createTeams.useMutation(); const pushoverMutation = notificationId ? api.notification.updatePushover.useMutation() : api.notification.createPushover.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { type: "slack", webhookUrl: "", @@ -297,7 +340,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { }); useEffect(() => { - if (type === "email" && fields.length === 0) { + if ((type === "email" || type === "resend") && fields.length === 0) { append(""); } }, [type, append, fields.length]); @@ -342,7 +385,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { volumeBackup: notification.volumeBackup, type: notification.notificationType, webhookUrl: notification.discord?.webhookUrl, - decoration: notification.discord?.decoration || undefined, + decoration: notification.discord?.decoration ?? undefined, name: notification.name, dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, @@ -365,6 +408,21 @@ export const HandleNotifications = ({ notificationId }: Props) => { dockerCleanup: notification.dockerCleanup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "resend") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + apiKey: notification.resend?.apiKey, + toAddresses: notification.resend?.toAddresses, + fromAddress: notification.resend?.fromAddress, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } else if (notification.notificationType === "gotify") { form.reset({ appBuildError: notification.appBuildError, @@ -374,7 +432,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { volumeBackup: notification.volumeBackup, type: notification.notificationType, appToken: notification.gotify?.appToken, - decoration: notification.gotify?.decoration || undefined, + decoration: notification.gotify?.decoration ?? undefined, priority: notification.gotify?.priority, serverUrl: notification.gotify?.serverUrl, name: notification.name, @@ -424,6 +482,19 @@ export const HandleNotifications = ({ notificationId }: Props) => { volumeBackup: notification.volumeBackup, serverThreshold: notification.serverThreshold, }); + } else if (notification.notificationType === "teams") { + form.reset({ + appBuildError: notification.appBuildError, + appDeploy: notification.appDeploy, + dokployRestart: notification.dokployRestart, + databaseBackup: notification.databaseBackup, + volumeBackup: notification.volumeBackup, + type: notification.notificationType, + webhookUrl: notification.teams?.webhookUrl, + name: notification.name, + dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, + }); } else if (notification.notificationType === "custom") { form.reset({ appBuildError: notification.appBuildError, @@ -473,10 +544,12 @@ export const HandleNotifications = ({ notificationId }: Props) => { telegram: telegramMutation, discord: discordMutation, email: emailMutation, + resend: resendMutation, gotify: gotifyMutation, ntfy: ntfyMutation, mattermost: mattermostMutation, lark: larkMutation, + teams: teamsMutation, custom: customMutation, pushover: pushoverMutation, }; @@ -557,6 +630,22 @@ export const HandleNotifications = ({ notificationId }: Props) => { emailId: notification?.emailId || "", serverThreshold: serverThreshold, }); + } else if (data.type === "resend") { + promise = resendMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + resendId: notification?.resendId || "", + serverThreshold: serverThreshold, + }); } else if (data.type === "gotify") { promise = gotifyMutation.mutateAsync({ appBuildError: appBuildError, @@ -619,6 +708,20 @@ export const HandleNotifications = ({ notificationId }: Props) => { larkId: notification?.larkId || "", serverThreshold: serverThreshold, }); + } else if (data.type === "teams") { + promise = teamsMutation.mutateAsync({ + appBuildError: appBuildError, + appDeploy: appDeploy, + dokployRestart: dokployRestart, + databaseBackup: databaseBackup, + volumeBackup: volumeBackup, + webhookUrl: data.webhookUrl, + name: data.name, + dockerCleanup: dockerCleanup, + notificationId: notificationId || "", + teamsId: notification?.teamsId || "", + serverThreshold: serverThreshold, + }); } else if (data.type === "custom") { // Convert headers array to object const headersRecord = @@ -1090,6 +1193,96 @@ export const HandleNotifications = ({ notificationId }: Props) => { )} + {type === "resend" && ( + <> + ( + + API Key + + + + + + )} + /> + + ( + + From Address + + + + + + )} + /> + +
+ To Addresses + + {fields.map((field, index) => ( +
+ ( + + + + + + + + )} + /> + +
+ ))} + {type === "resend" && + "toAddresses" in form.formState.errors && ( +
+ {form.formState?.errors?.toAddresses?.root?.message} +
+ )} +
+ + + + )} + {type === "gotify" && ( <> { )} + {type === "teams" && ( + <> + ( + + Webhook URL + + + + + Incoming Webhook URL from a Teams channel. Add an + Incoming Webhook in your channel settings to get the + URL. + + + + )} + /> + + )} {type === "pushover" && ( <> { isLoadingTelegram || isLoadingDiscord || isLoadingEmail || + isLoadingResend || isLoadingGotify || isLoadingNtfy || isLoadingMattermost || isLoadingLark || + isLoadingTeams || isLoadingCustom || isLoadingPushover } @@ -1774,11 +1994,17 @@ export const HandleNotifications = ({ notificationId }: Props) => { fromAddress: data.fromAddress, toAddresses: data.toAddresses, }); + } else if (data.type === "resend") { + await testResendConnection({ + apiKey: data.apiKey, + fromAddress: data.fromAddress, + toAddresses: data.toAddresses, + }); } else if (data.type === "gotify") { await testGotifyConnection({ serverUrl: data.serverUrl, appToken: data.appToken, - priority: data.priority, + priority: data.priority ?? 0, decoration: data.decoration, }); } else if (data.type === "ntfy") { @@ -1786,7 +2012,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { serverUrl: data.serverUrl, topic: data.topic, accessToken: data.accessToken || "", - priority: data.priority, + priority: data.priority ?? 0, }); } else if (data.type === "mattermost") { await testMattermostConnection({ @@ -1798,6 +2024,10 @@ export const HandleNotifications = ({ notificationId }: Props) => { await testLarkConnection({ webhookUrl: data.webhookUrl, }); + } else if (data.type === "teams") { + await testTeamsConnection({ + webhookUrl: data.webhookUrl, + }); } else if (data.type === "custom") { const headersRecord = data.headers && data.headers.length > 0 @@ -1825,7 +2055,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { await testPushoverConnection({ userKey: data.userKey, apiToken: data.apiToken, - priority: data.priority, + priority: data.priority ?? 0, retry: data.priority === 2 ? data.retry : undefined, expire: data.priority === 2 ? data.expire : undefined, }); diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 06ffd91e4..d8ac31d97 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -5,7 +5,9 @@ import { GotifyIcon, LarkIcon, NtfyIcon, + ResendIcon, SlackIcon, + TeamsIcon, TelegramIcon, } from "@/components/icons/notification-icons"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -21,8 +23,8 @@ import { api } from "@/utils/api"; import { HandleNotifications } from "./handle-notifications"; export const ShowNotifications = () => { - const { data, isLoading, refetch } = api.notification.all.useQuery(); - const { mutateAsync, isLoading: isRemoving } = + const { data, isPending, refetch } = api.notification.all.useQuery(); + const { mutateAsync, isPending: isRemoving } = api.notification.remove.useMutation(); return ( @@ -36,11 +38,11 @@ export const ShowNotifications = () => { Add your providers to receive notifications, like Discord, Slack, - Telegram, Email, Lark. + Telegram, Teams, Email, Resend, Lark. - {isLoading ? ( + {isPending ? (
Loading... @@ -86,6 +88,11 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "resend" && ( +
+ +
+ )} {notification.notificationType === "gotify" && (
@@ -106,6 +113,11 @@ export const ShowNotifications = () => {
)} + {notification.notificationType === "teams" && ( +
+ +
+ )} {notification.name} diff --git a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx index 17220cd11..84a170434 100644 --- a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; import { CopyIcon, @@ -356,7 +356,7 @@ export const Configure2FA = () => {
{backupCodes.map((code, index) => ( {code} diff --git a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx index 656b27401..ff11d0322 100644 --- a/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/enable-2fa.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react"; import QRCode from "qrcode"; @@ -401,7 +401,7 @@ export const Enable2FA = () => {
{backupCodes.map((code, index) => ( {code} diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 461a4c17c..ceb90a560 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -1,6 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Loader2, Palette, User } from "lucide-react"; -import { useTranslation } from "next-i18next"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -64,16 +63,15 @@ const randomImages = [ ]; export const ProfileForm = () => { - const { data, refetch, isLoading } = api.user.get.useQuery(); + const { data, refetch, isPending } = api.user.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); const { mutateAsync, - isLoading: isUpdating, + isPending: isUpdating, isError, error, } = api.user.update.useMutation(); - const { t } = useTranslation("settings"); const [gravatarHash, setGravatarHash] = useState(null); const colorInputRef = useRef(null); @@ -84,7 +82,7 @@ export const ProfileForm = () => { ]); }, [gravatarHash]); - const form = useForm({ + const form = useForm({ defaultValues: { email: data?.user?.email || "", password: "", @@ -157,10 +155,10 @@ export const ProfileForm = () => {
- {t("settings.profile.title")} + Account - {t("settings.profile.description")} + Change the details of your profile here.
@@ -169,7 +167,7 @@ export const ProfileForm = () => { {isError && {error?.message}} - {isLoading ? ( + {isPending ? (
Loading... @@ -213,12 +211,9 @@ export const ProfileForm = () => { name="email" render={({ field }) => ( - {t("settings.profile.email")} + Email - + @@ -233,7 +228,7 @@ export const ProfileForm = () => { @@ -247,13 +242,11 @@ export const ProfileForm = () => { name="password" render={({ field }) => ( - - {t("settings.profile.password")} - + Password @@ -268,9 +261,7 @@ export const ProfileForm = () => { name="image" render={({ field }) => ( - - {t("settings.profile.avatar")} - + Avatar { @@ -454,7 +445,7 @@ export const ProfileForm = () => {
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx index 2bafe7e64..7d63de210 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip"; import { Button } from "@/components/ui/button"; @@ -17,24 +16,23 @@ import { TerminalModal } from "../../web-server/terminal-modal"; import { GPUSupportModal } from "../gpu-support-modal"; export const ShowDokployActions = () => { - const { t } = useTranslation("settings"); - const { mutateAsync: reloadServer, isLoading } = + const { mutateAsync: reloadServer, isPending } = api.settings.reloadServer.useMutation(); const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation(); const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation(); + const { mutateAsync: cleanAllDeploymentQueue } = + api.settings.cleanAllDeploymentQueue.useMutation(); return ( - - - - {t("settings.server.webServer.actions")} - + Actions { }} className="cursor-pointer" > - {t("settings.server.webServer.reload")} + Reload - {t("settings.common.enterTerminal")} + Terminal e.preventDefault()} > - {t("settings.server.webServer.watchLogs")} + View Logs @@ -68,7 +66,7 @@ export const ShowDokployActions = () => { className="cursor-pointer" onSelect={(e) => e.preventDefault()} > - {t("settings.server.webServer.updateServerIp")} + Update Server IP @@ -87,6 +85,21 @@ export const ShowDokployActions = () => { Clean Redis + { + await cleanAllDeploymentQueue() + .then(() => { + toast.success("Deployment queue cleaned"); + }) + .catch(() => { + toast.error("Error cleaning deployment queue"); + }); + }} + > + Clean all deployment queue + + { diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx index c80648142..2e69dfd23 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-storage-actions.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -16,61 +15,63 @@ interface Props { serverId?: string; } export const ShowStorageActions = ({ serverId }: Props) => { - const { t } = useTranslation("settings"); - const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } = + const { mutateAsync: cleanAll, isPending: cleanAllIsLoading } = api.settings.cleanAll.useMutation(); const { mutateAsync: cleanDockerBuilder, - isLoading: cleanDockerBuilderIsLoading, + isPending: cleanDockerBuilderIsPending, } = api.settings.cleanDockerBuilder.useMutation(); const { mutateAsync: cleanMonitoring } = api.settings.cleanMonitoring.useMutation(); const { mutateAsync: cleanUnusedImages, - isLoading: cleanUnusedImagesIsLoading, + isPending: cleanUnusedImagesIsPending, } = api.settings.cleanUnusedImages.useMutation(); const { mutateAsync: cleanUnusedVolumes, - isLoading: cleanUnusedVolumesIsLoading, + isPending: cleanUnusedVolumesIsPending, } = api.settings.cleanUnusedVolumes.useMutation(); const { mutateAsync: cleanStoppedContainers, - isLoading: cleanStoppedContainersIsLoading, + isPending: cleanStoppedContainersIsPending, } = api.settings.cleanStoppedContainers.useMutation(); + const { mutateAsync: cleanPatchRepos, isPending: cleanPatchReposIsLoading } = + api.patch.cleanPatchRepos.useMutation(); + return ( - - {t("settings.server.webServer.actions")} - + Actions { }); }} > - - {t("settings.server.webServer.storage.cleanUnusedImages")} - + Clean unused images { }); }} > - - {t("settings.server.webServer.storage.cleanUnusedVolumes")} - + Clean unused volumes { }); }} > - - {t("settings.server.webServer.storage.cleanStoppedContainers")} - + Clean stopped containers + + + { + await cleanPatchRepos({ + serverId: serverId, + }) + .then(async () => { + toast.success("Cleaned Patch Caches"); + }) + .catch(() => { + toast.error("Error cleaning Patch Caches"); + }); + }} + > + Clean Patch Caches { }); }} > - - {t("settings.server.webServer.storage.cleanDockerBuilder")} - + Clean Docker Builder & System {!serverId && ( { }); }} > - - {t("settings.server.webServer.storage.cleanMonitoring")} - + Clean Monitoring )} @@ -180,7 +188,7 @@ export const ShowStorageActions = ({ serverId }: Props) => { }); }} > - {t("settings.server.webServer.storage.cleanAll")} + Clean all diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index aebba8877..65957a881 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -1,4 +1,3 @@ -import { useTranslation } from "next-i18next"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; import { DialogAction } from "@/components/shared/dialog-action"; @@ -12,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; @@ -21,11 +21,10 @@ interface Props { serverId?: string; } export const ShowTraefikActions = ({ serverId }: Props) => { - const { t } = useTranslation("settings"); - const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } = + const { mutateAsync: reloadTraefik, isPending: reloadTraefikIsLoading } = api.settings.reloadTraefik.useMutation(); - const { mutateAsync: toggleDashboard, isLoading: toggleDashboardIsLoading } = + const { mutateAsync: toggleDashboard, isPending: toggleDashboardIsLoading } = api.settings.toggleDashboard.useMutation(); const { data: haveTraefikDashboardPortEnabled, refetch: refetchDashboard } = @@ -33,38 +32,71 @@ export const ShowTraefikActions = ({ serverId }: Props) => { serverId, }); + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik dashboard updated successfully", + onSuccess: () => { + refetchDashboard(); + }, + }); + + const { + execute: executeReloadWithHealthCheck, + isExecuting: isReloadHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + pollInterval: 4000, + successMessage: "Traefik Reloaded", + }); + return ( - - {t("settings.server.webServer.actions")} - + Actions { - await reloadTraefik({ - serverId: serverId, - }) - .then(async () => { - toast.success("Traefik Reloaded"); - }) - .catch(() => {}); + try { + await executeReloadWithHealthCheck(() => + reloadTraefik({ serverId }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to reload Traefik. Please try again."; + toast.error(errorMessage); + } }} className="cursor-pointer" + disabled={isReloadHealthCheckExecuting} > - {t("settings.server.webServer.reload")} + Reload { onSelect={(e) => e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.watchLogs")} + View Logs @@ -83,7 +115,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => { onSelect={(e) => e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.traefik.modifyEnv")} + Modify Environment @@ -108,24 +140,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
} onClick={async () => { - await toggleDashboard({ - enableDashboard: !haveTraefikDashboardPortEnabled, - serverId: serverId, - }) - .then(async () => { - toast.success( - `${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`, - ); - refetchDashboard(); - }) - .catch((error) => { - const errorMessage = - error?.message || - "Failed to toggle dashboard. Please check if port 8080 is available."; - toast.error(errorMessage); - }); + try { + await executeWithHealthCheck(() => + toggleDashboard({ + enableDashboard: !haveTraefikDashboardPortEnabled, + serverId: serverId, + }), + ); + } catch (error) { + const errorMessage = + (error as Error)?.message || + "Failed to toggle dashboard. Please check if port 8080 is available."; + toast.error(errorMessage); + } }} - disabled={toggleDashboardIsLoading} + disabled={toggleDashboardIsLoading || isHealthCheckExecuting} type="default" > { onSelect={(e) => e.preventDefault()} className="cursor-pointer" > - {t("settings.server.webServer.traefik.managePorts")} + Additional Port Mappings diff --git a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx index a21f76cb3..92a2294ee 100644 --- a/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/edit-script.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { FileTerminal } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -49,7 +49,7 @@ export const EditScript = ({ serverId }: Props) => { }, ); - const { mutateAsync, isLoading } = api.server.update.useMutation(); + const { mutateAsync, isPending } = api.server.update.useMutation(); const { data: defaultCommand } = api.server.getDefaultCommand.useQuery( { @@ -155,7 +155,7 @@ echo "Hello world" Reset )}
- diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx index 0a29f2d8f..6fd62e462 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx @@ -14,8 +14,8 @@ import { api } from "@/utils/api"; import { HandleSSHKeys } from "./handle-ssh-keys"; export const ShowDestinations = () => { - const { data, isLoading, refetch } = api.sshKey.all.useQuery(); - const { mutateAsync, isLoading: isRemoving } = + const { data, isPending, refetch } = api.sshKey.all.useQuery(); + const { mutateAsync, isPending: isRemoving } = api.sshKey.remove.useMutation(); return ( @@ -33,7 +33,7 @@ export const ShowDestinations = () => { - {isLoading ? ( + {isPending ? (
Loading... diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index e778f2e96..d9d9117ae 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PlusIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index 7c6ef8b84..d3f8af31c 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.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"; @@ -184,10 +184,10 @@ export const AddUserPermissions = ({ userId }: Props) => { }, ); - const { mutateAsync, isError, error, isLoading } = + const { mutateAsync, isError, error, isPending } = api.user.assignPermissions.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { accessedProjects: [], accessedEnvironments: [], @@ -839,7 +839,7 @@ export const AddUserPermissions = ({ userId }: Props) => { />
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index c9cb7985b..d9df975e7 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -1,5 +1,4 @@ import { ServerIcon } from "lucide-react"; -import { useTranslation } from "next-i18next"; import { Card, CardContent, @@ -15,7 +14,6 @@ import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup"; import { UpdateServer } from "./web-server/update-server"; export const WebServer = () => { - const { t } = useTranslation("settings"); const { data: webServerSettings } = api.settings.getWebServerSettings.useQuery(); @@ -29,18 +27,16 @@ export const WebServer = () => { - {t("settings.server.webServer.title")} + Web Server - - {t("settings.server.webServer.description")} - + Reload or clean the web server. {/* - {t("settings.server.webServer.title")} + Web Server - {t("settings.server.webServer.description")} + Reload or clean the web server. */} diff --git a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx index 30471bcba..599dba57b 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx @@ -48,7 +48,7 @@ export const DockerTerminalModal = ({ serverId, appType, }: Props) => { - const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( + const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { appName, appType, @@ -101,7 +101,7 @@ export const DockerTerminalModal = ({ { name="username" render={({ field }) => ( - {t("settings.terminal.username")} + Username @@ -142,7 +137,7 @@ const LocalServerConfig = ({ onSave }: Props) => { className="ml-auto" disabled={!form.formState.isDirty} > - {t("settings.common.save")} + Save diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx index 3ce95aa1f..6f42c804b 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -1,6 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { ArrowRightLeft, Plus, Trash2 } from "lucide-react"; -import { useTranslation } from "next-i18next"; import type React from "react"; import { useEffect, useState } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -35,6 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation"; import { api } from "@/utils/api"; interface Props { @@ -55,7 +55,6 @@ const TraefikPortsSchema = z.object({ type TraefikPortsForm = z.infer; export const ManageTraefikPorts = ({ children, serverId }: Props) => { - const { t } = useTranslation("settings"); const [open, setOpen] = useState(false); const form = useForm({ @@ -75,12 +74,20 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { serverId, }); - const { mutateAsync: updatePorts, isLoading } = - api.settings.updateTraefikPorts.useMutation({ - onSuccess: () => { - refetchPorts(); - }, - }); + const { mutateAsync: updatePorts, isPending } = + api.settings.updateTraefikPorts.useMutation(); + + const { + execute: executeWithHealthCheck, + isExecuting: isHealthCheckExecuting, + } = useHealthCheckAfterMutation({ + initialDelay: 5000, + successMessage: "Ports updated successfully", + onSuccess: () => { + refetchPorts(); + setOpen(false); + }, + }); useEffect(() => { if (currentPorts) { @@ -99,11 +106,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { const onSubmit = async (data: TraefikPortsForm) => { try { - await updatePorts({ - serverId, - additionalPorts: data.ports, - }); - toast.success(t("settings.server.webServer.traefik.portsUpdated")); + await executeWithHealthCheck(() => + updatePorts({ + serverId, + additionalPorts: data.ports, + }), + ); setOpen(false); } catch (error) { toast.error((error as Error).message || "Error updating Traefik ports"); @@ -119,14 +127,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { - {t("settings.server.webServer.traefik.managePorts")} + Additional Port Mappings
- {t( - "settings.server.webServer.traefik.managePortsDescription", - )} + Add or remove additional ports for Traefik {fields.length} port mapping{fields.length !== 1 ? "s" : ""}{" "} configured @@ -169,9 +175,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => { render={({ field }) => ( - {t( - "settings.server.webServer.traefik.targetPort", - )} + Target Port { render={({ field }) => ( - {t( - "settings.server.webServer.traefik.publishedPort", - )} + Published Port { type="submit" variant="default" className="text-sm" - isLoading={isLoading} + isLoading={isPending || isHealthCheckExecuting} > Save diff --git a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx index 9aac25820..9b9d6fb7f 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx @@ -47,7 +47,7 @@ export const ShowModalLogs = ({ serverId, type = "swarm", }: Props) => { - const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery( + const { data, isPending } = api.docker.getContainersByAppLabel.useQuery( { appName, serverId, @@ -76,7 +76,7 @@ export const ShowModalLogs = ({ - - - - - {Object.values(Languages).map((language) => ( - - {language.name} - - ))} - - -
-
+ { + await authClient.signOut().then(() => { + router.push("/"); + }); + // await mutateAsync().then(() => { + // router.push("/"); + // }); + }} + > + Log out + ); diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx new file mode 100644 index 000000000..22b0aef81 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +export function SignInWithGithub() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "github", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with GitHub", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx new file mode 100644 index 000000000..e40d8d9b5 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +export function SignInWithGoogle() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + const { error } = await authClient.signIn.social({ + provider: "google", + }); + if (error) { + toast.error(error.message); + return; + } + } catch (err) { + toast.error("An error occurred while signing in with Google", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx new file mode 100644 index 000000000..f4094c8d9 --- /dev/null +++ b/apps/dokploy/components/proprietary/enterprise-feature-gate.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Loader2, Lock } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; + +interface EnterpriseFeatureLockedProps { + /** Optional title override */ + title?: string; + /** Optional description override */ + description?: string; + /** Optional custom CTA label */ + ctaLabel?: string; + /** Optional CTA href (default: /dashboard/settings/license) */ + ctaHref?: string; + /** Compact variant (less padding, smaller icon) */ + compact?: boolean; +} + +/** + * Displays a locked state for enterprise features when the user has no valid license. + * Use standalone or via EnterpriseFeatureGate. + */ +export function EnterpriseFeatureLocked({ + title = "Enterprise feature", + description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.", + ctaLabel = "Go to License", + ctaHref = "/dashboard/settings/license", + compact = false, +}: EnterpriseFeatureLockedProps) { + return ( + + +
+
+ +
+
+ {title} + + {description} + +
+
+
+ +
+ +
+
+
+ ); +} + +interface EnterpriseFeatureGateProps { + children: React.ReactNode; + /** Props for the locked state when license is invalid */ + lockedProps?: Omit; + /** Show loading spinner while checking license */ + fallback?: React.ReactNode; +} + +/** + * Renders children only when the instance has a valid enterprise license. + * Otherwise shows EnterpriseFeatureLocked. + */ +export function EnterpriseFeatureGate({ + children, + lockedProps, + fallback, +}: EnterpriseFeatureGateProps) { + const { data: haveValidLicense, isPending } = + api.licenseKey.haveValidLicenseKey.useQuery(); + + if (isPending) { + if (fallback) return <>{fallback}; + return ( +
+ + + Checking license... + +
+ ); + } + + if (!haveValidLicense) { + return ; + } + + return <>{children}; +} diff --git a/apps/dokploy/components/proprietary/license-keys/license-key.tsx b/apps/dokploy/components/proprietary/license-keys/license-key.tsx new file mode 100644 index 000000000..26bb3fa20 --- /dev/null +++ b/apps/dokploy/components/proprietary/license-keys/license-key.tsx @@ -0,0 +1,237 @@ +import { Key, Loader2, ShieldCheck } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +export function LicenseKeySettings() { + const utils = api.useUtils(); + const { data, isPending } = api.licenseKey.getEnterpriseSettings.useQuery(); + const { mutateAsync: updateEnterpriseSettings, isPending: isSaving } = + api.licenseKey.updateEnterpriseSettings.useMutation(); + const { mutateAsync: activateLicenseKey, isPending: isActivating } = + api.licenseKey.activate.useMutation(); + const { mutateAsync: validateLicenseKey, isPending: isValidating } = + api.licenseKey.validate.useMutation(); + const { mutateAsync: deactivateLicenseKey, isPending: isDeactivating } = + api.licenseKey.deactivate.useMutation(); + const { data: haveValidLicenseKey, isPending: isCheckingLicenseKey } = + api.licenseKey.haveValidLicenseKey.useQuery(); + const [licenseKey, setLicenseKey] = useState(""); + + useEffect(() => { + if (data?.licenseKey) { + setLicenseKey(data.licenseKey); + } + }, [data?.licenseKey]); + + const enabled = !!data?.enableEnterpriseFeatures; + + return ( +
+ {isCheckingLicenseKey ? ( +
+ + + Checking license key... + +
+ ) : ( + <> +
+
+
+ + License Key +
+ + {enabled && ( +
+ + {enabled ? "Enabled" : "Disabled"} + + { + try { + await updateEnterpriseSettings({ + enableEnterpriseFeatures: next, + }); + await utils.licenseKey.getEnterpriseSettings.invalidate(); + toast.success("Enterprise features updated"); + } catch (error) { + console.error(error); + toast.error("Failed to update enterprise features"); + } + }} + /> +
+ )} +
+ +

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

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

Enterprise Features

+

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

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

+ Callback URL (configure in your IdP) +

+

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

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

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

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

{msg}

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

+ Callback URL (configure in your IdP) +

+

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

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

{msg}

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