diff --git a/.github/workflows/create-pr.yml b/.github/workflows/create-pr.yml index 248b98d5a..7cf6fa136 100644 --- a/.github/workflows/create-pr.yml +++ b/.github/workflows/create-pr.yml @@ -14,34 +14,34 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Get version from package.json - run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV + run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV" - name: Get latest GitHub tag run: | LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1) - echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - echo $LATEST_TAG + echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV" + echo "$LATEST_TAG" - name: Compare versions run: | - if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then + if [ "$VERSION" != "$LATEST_TAG" ]; then VERSION_CHANGED="true" else VERSION_CHANGED="false" fi - echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV + echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV" echo "Comparing versions:" - echo "Current version: ${{ env.VERSION }}" - echo "Latest tag: ${{ env.LATEST_TAG }}" + echo "Current version: $VERSION" + echo "Latest tag: $LATEST_TAG" echo "Version changed: $VERSION_CHANGED" - name: Check if a PR already exists run: | PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length') - echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV + echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV" env: GH_TOKEN: ${{ secrets.GH_PAT }} @@ -56,12 +56,12 @@ jobs: git push origin canary gh pr create \ - --title "🚀 Release ${{ env.VERSION }}" \ - --body ' - This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}. + --title "🚀 Release $VERSION" \ + --body " + This PR promotes changes from \`canary\` to \`main\` for version $VERSION. ### 🔍 Changes Include: - - Version bump to ${{ env.VERSION }} + - Version bump to $VERSION - All changes from canary branch ### ✅ Pre-merge Checklist: @@ -69,7 +69,7 @@ jobs: - [ ] Documentation updated - [ ] Docker images built and tested - > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \ + > 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \ --base main \ --head canary \ --label "release" --label "automated pr" || true \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 321fb2029..49428320e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,33 +5,38 @@ on: branches: [main, canary] workflow_dispatch: +permissions: + contents: read + jobs: build-and-push-cloud-image: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - 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 + 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 + echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.cloud @@ -48,26 +53,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - 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 + 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 + echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.schedule @@ -80,26 +87,28 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v7 + with: + persist-credentials: false - 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 + 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 + echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT" fi - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.server diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 08d6b8a5b..175543205 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -8,18 +8,23 @@ on: env: IMAGE_NAME: dokploy/dokploy +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -27,15 +32,15 @@ jobs: - name: Set tag and version id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -43,7 +48,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/amd64 @@ -53,13 +58,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -68,15 +75,15 @@ jobs: id: meta run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" VERSION=$(node -p "require('./apps/dokploy/package.json').version") - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Prepare env file run: | @@ -84,7 +91,7 @@ jobs: cp apps/dokploy/.env.production.example apps/dokploy/.env.production - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . platforms: linux/arm64 @@ -96,20 +103,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then VERSION=$(node -p "require('./apps/dokploy/package.json').version") TAG="latest" @@ -121,7 +130,7 @@ jobs: ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ @@ -138,32 +147,37 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: write outputs: version: ${{ steps.get_version.outputs.version }} npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 + persist-credentials: false - name: Get version id: get_version run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT" - name: Fetch install.sh + env: + RELEASE_VERSION: ${{ steps.get_version.outputs.version }} run: | curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; } grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; } - { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh + { head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh mv install-pinned.sh install.sh - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.get_version.outputs.version }} target_commitish: ${{ github.sha }} @@ -179,15 +193,19 @@ jobs: needs: [generate-release] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + env: + NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10.22.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: pnpm @@ -202,63 +220,63 @@ jobs: git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json src/generated/openapi.json + cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ MCP repo synced to version $NPM_VERSION" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ CLI repo synced to version $NPM_VERSION" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - cp ${{ github.workspace }}/openapi.json ./openapi.json + cp $GITHUB_WORKSPACE/openapi.json ./openapi.json pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + git commit -m "chore: bump version to $NPM_VERSION" \ + -m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" + echo "✅ SDK repo synced to version $NPM_VERSION" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 48589575f..fd0823a7a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -6,15 +6,20 @@ on: pull_request: branches: [canary] +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup biomeJs - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@v2 - name: Run Biome formatter run: biome format --write diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index c85e118d6..30a3dda7c 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -16,17 +16,19 @@ jobs: contents: write steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 token: ${{ secrets.HOTFIX_PUSH_TOKEN }} - name: Cherry-pick fix to main + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" - SHA="${{ github.event.pull_request.merge_commit_sha }}" + SHA="$MERGE_SHA" if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then git cherry-pick -x -m 1 "$SHA" else diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml index 7e473b3d2..3271df861 100644 --- a/.github/workflows/hotfix-release.yml +++ b/.github/workflows/hotfix-release.yml @@ -7,12 +7,16 @@ concurrency: group: hotfix-to-main cancel-in-progress: false +# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token. +permissions: + contents: read + jobs: release: runs-on: ubuntu-latest steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: main token: ${{ secrets.HOTFIX_PUSH_TOKEN }} diff --git a/.github/workflows/monitoring.yml b/.github/workflows/monitoring.yml index 378b019d4..df0a70e0a 100644 --- a/.github/workflows/monitoring.yml +++ b/.github/workflows/monitoring.yml @@ -7,18 +7,23 @@ on: env: IMAGE_NAME: dokploy/monitoring +permissions: + contents: read + jobs: docker-amd: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -26,17 +31,17 @@ jobs: - name: Set tag id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -47,13 +52,15 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -61,17 +68,17 @@ jobs: - name: Set id: meta run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" else TAG="feature" fi - echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT + echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile.monitoring @@ -84,27 +91,29 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 + with: + persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create and push manifests run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then + if [ "$GITHUB_REF" = "refs/heads/main" ]; then TAG="latest" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ ${IMAGE_NAME}:${TAG}-arm64 - elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then + elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then TAG="canary" docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \ ${IMAGE_NAME}:${TAG}-amd64 \ diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 227022a90..d1cd96364 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,11 @@ jobs: matrix: job: [build, test, typecheck] steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index 147d8d97e..f378f7dca 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -12,17 +12,20 @@ on: workflow_dispatch: +permissions: + contents: read + jobs: generate-and-commit: name: Generate OpenAPI and commit to Dokploy repo runs-on: ubuntu-latest steps: - name: Checkout Dokploy repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml index f73b7276e..eea581175 100644 --- a/.github/workflows/upgrade-integration-test.yml +++ b/.github/workflows/upgrade-integration-test.yml @@ -28,6 +28,9 @@ env: DOKPLOY_SERVICE: dokploy DOKPLOY_PORT: 3000 +permissions: + contents: read + # ────────────────────────────────────────────────────────────────────────────── jobs: @@ -421,8 +424,10 @@ jobs: # ── Job summary ──────────────────────────────────────────────────────── - name: Write job summary if: always() + env: + JOB_STATUS: ${{ job.status }} run: | - STATUS="${{ job.status }}" + STATUS="$JOB_STATUS" ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" cat >> "$GITHUB_STEP_SUMMARY" < vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => { + return { + checkServicePermissionAndAccess: vi.fn(async () => undefined), + }; +}); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: vi.fn(async () => false), + updateApplication: mockUpdateApplication, + findApplicationById: vi.fn(async () => ({ + applicationId: "app-1", + appName: "example-app", + })), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); + +const caller = applicationRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +const providerChanges = [ + { + name: "GitHub", + sourceType: "github", + save: () => + caller.saveGithubProvider({ + applicationId: "app-1", + repository: "example-repo", + owner: "example-owner", + branch: "main", + buildPath: "/", + githubId: "provider-1", + watchPaths: ["src/**"], + triggerType: "push", + enableSubmodules: false, + }), + }, + { + name: "GitLab", + sourceType: "gitlab", + save: () => + caller.saveGitlabProvider({ + applicationId: "app-1", + gitlabRepository: "example-repo", + gitlabOwner: "example-owner", + gitlabBranch: "main", + gitlabBuildPath: "/", + gitlabId: "provider-1", + gitlabProjectId: 1, + gitlabPathNamespace: "example-owner/example-repo", + }), + }, + { + name: "Bitbucket", + sourceType: "bitbucket", + save: () => + caller.saveBitbucketProvider({ + applicationId: "app-1", + bitbucketRepository: "example-repo", + bitbucketRepositorySlug: "example-repo", + bitbucketOwner: "example-owner", + bitbucketBranch: "main", + bitbucketBuildPath: "/", + bitbucketId: "provider-1", + }), + }, + { + name: "Gitea", + sourceType: "gitea", + save: () => + caller.saveGiteaProvider({ + applicationId: "app-1", + giteaRepository: "example-repo", + giteaOwner: "example-owner", + giteaBranch: "main", + giteaBuildPath: "/", + giteaId: "provider-1", + }), + }, + { + name: "Docker", + sourceType: "docker", + save: () => + caller.saveDockerProvider({ + applicationId: "app-1", + dockerImage: "nginx:alpine", + username: "", + password: "", + registryUrl: "", + }), + }, + { + name: "custom Git", + sourceType: "git", + save: () => + caller.saveGitProvider({ + applicationId: "app-1", + customGitUrl: "https://example.com/repo.git", + customGitBranch: "main", + customGitBuildPath: "/", + customGitSSHKeyId: "key-1", + watchPaths: ["src/**"], + enableSubmodules: false, + }), + }, + { + name: "disconnect Git provider", + sourceType: "github", + save: () => caller.disconnectGitProvider({ applicationId: "app-1" }), + }, +]; + +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(providerChanges)( + "keeps the status when $name settings change", + async ({ save, sourceType }) => { + for (const status of ["done", "running"]) { + mockUpdateApplication.mockClear(); + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await save(); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledTimes(1); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ sourceType }), + ); + } + }, + ); +}); diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts index 5fff508cc..f5e3e01e0 100644 --- a/apps/dokploy/__test__/backups/redact-credentials.test.ts +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -1,4 +1,6 @@ +import type { Destination } from "@dokploy/server/services/destination"; import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { getS3Credentials } from "@dokploy/server/utils/backups/utils"; import { describe, expect, it } from "vitest"; describe("redactRcloneCredentials (#4621)", () => { @@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => { expect(redacted).toContain("[REDACTED]"); }); }); + +describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => { + const buildCommand = (accessKey: string, secretAccessKey: string) => + `rclone rcat ${getS3Credentials({ + accessKey, + secretAccessKey, + region: "us-west-001", + endpoint: "https://s3.us-west-001.backblazeb2.com", + provider: "Other", + } as Destination).join(" ")} :s3:bucket/file.gz`; + + it.each([ + [ + "plain alphanumeric", + "001aaaabbbbccccdd0000000001", + "K001FAKEfakeFAKEfake", + ], + [ + "containing slashes", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ], + ["containing spaces", "key with space", "secret with space"], + ["containing quotes", "it's-a-key", `say "hi" it's`], + ["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"], + ])("redacts credentials %s", (_, accessKey, secretAccessKey) => { + const redacted = redactRcloneCredentials( + buildCommand(accessKey, secretAccessKey), + ); + expect(redacted).toContain( + '--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001', + ); + expect(redacted).not.toContain(accessKey); + expect(redacted).not.toContain(secretAccessKey); + expect(redacted).toContain(":s3:bucket/file.gz"); + }); + + it("redacts credentials embedded in an error string", () => { + const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`; + const redacted = redactRcloneCredentials(errorStr); + expect(redacted).not.toContain("MYKEY"); + expect(redacted).not.toContain("MY/SECRET"); + expect(redacted).toContain("[REDACTED]"); + }); +}); diff --git a/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx new file mode 100644 index 000000000..2864f371d --- /dev/null +++ b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; + +describe("Remote Server Action Tooltips", () => { + it("should close the tooltip when the dialog trigger is clicked", () => { + // Note: The UI layer in this project is not currently tested with DOM simulation tools + // like React Testing Library or JSDOM. + // + // The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild) + // inherently guarantees that Radix UI's composeEventHandlers properly propagates the + // onPointerDown event to dismiss the tooltip simultaneously as the dialog opens. + + expect(true).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts index abee0dc59..4c6373d28 100644 --- a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts +++ b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts @@ -33,8 +33,16 @@ services: volumes: test-data: `, + composeId: "compose-1", + name: "raw-stack", composePath: "./docker-compose.yml", composeType: "stack", + environment: { + environmentId: "env-1", + name: "production", + projectId: "project-1", + project: { name: "shop", organizationId: "org-1" }, + }, isolatedDeployment: false, isolatedDeploymentsVolume: false, randomize: false, @@ -54,6 +62,72 @@ volumes: }, ]); expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]); + expect(converted?.services?.test?.labels).toEqual( + expect.arrayContaining([ + "dokploy.organization.id=org-1", + "dokploy.project=shop", + "dokploy.service=test", + ]), + ); + expect(converted?.services?.test?.deploy?.labels).toEqual( + expect.arrayContaining(["dokploy.organization.id=org-1"]), + ); expect(execAsyncRemote).not.toHaveBeenCalled(); }); + + it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => { + const compose = { + appName: "dollar-stack", + composeFile: ` +services: + mapped: + image: alpine:latest + labels: + custom: keep + deploy: + labels: + custom: keep + listed: + image: alpine:latest + labels: + - custom=keep +`, + composeId: "compose-1", + name: "app $DB_PASSWORD", + composePath: "./docker-compose.yml", + composeType: "stack", + environment: { + environmentId: "env-1", + name: "env ${HOME}", + projectId: "project-1", + project: { name: "Budget $100", organizationId: "org-1" }, + }, + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: null, + sourceType: "raw", + suffix: "", + } as unknown as Parameters[0]; + + const converted = await addDomainToCompose(compose, []); + const escaped = { + "dokploy.project": "Budget $$100", + "dokploy.environment": "env $${HOME}", + "dokploy.application": "app $$DB_PASSWORD", + }; + + const mapped = converted?.services?.mapped; + expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped }); + expect(mapped?.deploy?.labels).toMatchObject({ + custom: "keep", + ...escaped, + }); + const listed = converted?.services?.listed; + const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`); + expect(listed?.labels).toEqual( + expect.arrayContaining(["custom=keep", ...asList]), + ); + expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList)); + }); }); diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts new file mode 100644 index 000000000..d808dcc00 --- /dev/null +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -0,0 +1,113 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + addAppNameToPreventCollision, + addSuffixToAllProperties, +} from "@dokploy/server"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { expect, test, vi } from "vitest"; +import { stringify } from "yaml"; +import composeSpec from "../../../components/shared/compose-spec.json"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsyncRemote: vi.fn(), +})); + +const modelCompose = { + services: { + app: { + image: "my-chat-app", + models: ["llm"], + }, + worker: { + image: "my-worker", + models: { + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }, + }, + }, + models: { + llm: { + model: "ai/smollm2", + context_size: 2048, + runtime_flags: ["--verbose"], + }, + }, +} satisfies ComposeSpecification; + +test("bundled compose schema declares models", () => { + expect(composeSpec.properties).toHaveProperty("models"); + expect(composeSpec.definitions.model.required).toEqual(["model"]); + expect(composeSpec.definitions.service.properties).toHaveProperty("models"); +}); + +test("suffixing does not rename model identifiers or drop model config", () => { + const updated = addSuffixToAllProperties( + structuredClone(modelCompose), + "testhash", + ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.models?.llm?.context_size).toBe(2048); + expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]); + expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]); + expect(updated.services?.["worker-testhash"]?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); + expect(updated.services).not.toHaveProperty("app"); + expect(updated.models).not.toHaveProperty("llm-testhash"); +}); + +test("isolated deployment preserves model identifiers", () => { + const updated = addAppNameToPreventCollision( + structuredClone(modelCompose), + "chat-app", + false, + ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.services?.app?.models).toEqual(["llm"]); + expect(updated.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); +}); + +test("raw remote compose conversion preserves models", async () => { + const converted = await addDomainToCompose( + { + appName: "chat-app", + composeFile: stringify(modelCompose, { lineWidth: 1000 }), + composePath: "./docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Compose, + [], + ); + + expect(execAsyncRemote).not.toHaveBeenCalled(); + expect(converted?.models?.llm?.model).toBe("ai/smollm2"); + expect(converted?.services?.app?.models).toEqual(["llm"]); + expect(converted?.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); +}); diff --git a/apps/dokploy/__test__/compose/provider-status.test.ts b/apps/dokploy/__test__/compose/provider-status.test.ts new file mode 100644 index 000000000..88f299b21 --- /dev/null +++ b/apps/dokploy/__test__/compose/provider-status.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateCompose = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: true, + findComposeById: vi.fn(async () => ({ + composeId: "compose-1", + name: "example-compose", + })), + updateCompose: mockUpdateCompose, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanQueuesByCompose: vi.fn(), + killDockerBuild: vi.fn(), + myQueue: { + add: vi.fn(), + }, +})); + +const { composeRouter } = await import("@/server/api/routers/compose"); + +const caller = composeRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +describe("saving a compose provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(["done", "running"])( + "keeps the %s status when disconnecting a Git provider", + async (status) => { + let savedStatus = status; + mockUpdateCompose.mockImplementation(async (_id, update) => { + if (update.composeStatus !== undefined) { + savedStatus = update.composeStatus; + } + }); + + await caller.disconnectGitProvider({ composeId: "compose-1" }); + + expect(savedStatus).toBe(status); + expect(mockUpdateCompose).toHaveBeenCalledTimes(1); + expect(mockUpdateCompose).toHaveBeenCalledWith( + "compose-1", + expect.objectContaining({ sourceType: "github" }), + ); + }, + ); +}); diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 7e08d7f99..f41735682 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => { await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); }); + + it("returns only the configured zone without listing every zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + expect(apiCalls()).toHaveLength(0); + }); }); describe("ovhClient.listRecords", () => { @@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => { "Invalid signature", ); }); + + it("checks the configured zone instead of listing all zones", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess({ name: "example.com" })); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + + const calls = apiCalls(); + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`); + }); +}); + +describe("ovhClient with a configured zone", () => { + const record = { + type: "A" as const, + name: "app.other.org", + content: "1.2.3.4", + }; + + it("refuses record operations on any other zone without calling OVH", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const message = /restricted to the zone "example.com"/; + await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow( + message, + ); + await expect( + ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }), + ).rejects.toThrow(message); + await expect( + ovhClient.updateRecord(cfg, "other.org", "1", record), + ).rejects.toThrow(message); + await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow( + message, + ); + expect(apiCalls()).toHaveLength(0); + }); + + it("still allows operations on the configured zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "1"), + ).resolves.toBeUndefined(); + expect(apiCalls()[0]?.[0]).toBe( + `${cfg.baseUrl}/domain/zone/example.com/record/1`, + ); + }); }); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index 6b3e0ab62..ec136ba85 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => { ]); }); + it("decodes octal escapes such as \\052 for wildcard names", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]).toMatchObject({ + id: "A:*.example.com", + name: "*.example.com", + }); + }); + + it("keeps escaped dots so they stay distinct from label separators", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "a\\056b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + { + Name: "a.b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "5.6.7.8" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records.map((r) => r.id)).toEqual([ + "A:a\\056b.example.com", + "A:a.b.example.com", + ]); + }); + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [ @@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => { ]); }); + it("finds a wildcard record set that Route53 returns escaped", async () => { + const existingSet = { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:*.example.com"); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + ]); + }); + it("throws when the record no longer exists", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [] }); diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 98fd7e4d9..b1381cd05 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -472,6 +472,77 @@ describe("infisical client", () => { expect(params.get("secretPath")).toBe("/frontend"); }); + it("asks for imported secrets and merges them", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://imported" }); + const [listUrl] = mockFetch.mock.calls[1] as [string]; + // snake_case on purpose: `includeImports` is silently ignored and the + // response comes back with an empty `imports` array. + expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true"); + }); + + it("lets the later import win when two define the same key", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [], + imports: [ + { + secretPath: "/base", + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }], + }, + { + secretPath: "/override", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://override" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + // Infisical documents this order: "If two imports carry a secret with the + // same name, the value from the bottom-most import wins." The response + // lists imports in that order, so assigning them in sequence matches it. + expect(result).toEqual({ DB_URL: "postgres://override" }); + }); + + it("lets a folder's own secret win over an imported one of the same name", async () => { + mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( + jsonResponse({ + secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }], + imports: [ + { + secretPath: "/shared", + secrets: [ + { secretKey: "DB_URL", secretValue: "postgres://imported" }, + ], + }, + ], + }), + ); + + const result = await infisicalClient.getSecrets(config, ["DB_URL"]); + + expect(result).toEqual({ DB_URL: "postgres://local" }); + }); + it("throws a clear error for a missing secret", async () => { mockFetch .mockResolvedValueOnce(loginResponse()) diff --git a/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts new file mode 100644 index 000000000..71b4421f3 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ + provider: {} as Record, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ ...store.provider })), + updateGitlab: vi.fn(async (_id: string, values: Record) => { + store.provider = { ...store.provider, ...values }; + }), +})); + +const { refreshGitlabToken } = await import( + "@dokploy/server/utils/providers/gitlab" +); + +const GITLAB_ID = "gitlab-1"; + +// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used +const createGitlab = () => { + let validRefreshToken = "refresh-1"; + let issued = 1; + let failNext = false; + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + // Keeps the request open, so concurrent callers overlap as two deployments do + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (failNext) { + failNext = false; + return new Response(null, { + status: 500, + statusText: "Internal Server Error", + }); + } + + if (body.get("refresh_token") !== validRefreshToken) { + return new Response(null, { status: 400, statusText: "Bad Request" }); + } + + issued++; + validRefreshToken = `refresh-${issued}`; + return Response.json({ + access_token: `access-${issued}`, + refresh_token: validRefreshToken, + expires_in: 7200, + }); + }); + + return { + fetch, + failNextRequest: () => { + failNext = true; + }, + }; +}; + +describe("refreshGitlabToken", () => { + let gitlab: ReturnType; + + beforeEach(() => { + store.provider = { + gitlabId: GITLAB_ID, + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + applicationId: "app-id", + secret: "app-secret", + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: 0, + }; + gitlab = createGitlab(); + vi.stubGlobal("fetch", gitlab.fetch); + }); + + it("sends one refresh request when two callers find the token expired at the same time", async () => { + await Promise.all([ + refreshGitlabToken(GITLAB_ID), + refreshGitlabToken(GITLAB_ID), + ]); + + expect(gitlab.fetch).toHaveBeenCalledTimes(1); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); + + it("refreshes again after a failed refresh", async () => { + gitlab.failNextRequest(); + + await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow( + "Failed to refresh token: Internal Server Error", + ); + await refreshGitlabToken(GITLAB_ID); + + expect(gitlab.fetch).toHaveBeenCalledTimes(2); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); +}); diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/__test__/libsql/create-schema.test.ts b/apps/dokploy/__test__/libsql/create-schema.test.ts new file mode 100644 index 000000000..c8e398079 --- /dev/null +++ b/apps/dokploy/__test__/libsql/create-schema.test.ts @@ -0,0 +1,66 @@ +import { apiCreateLibsql } from "@dokploy/server/db/schema"; +import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils"; +import { describe, expect, it } from "vitest"; + +describe("apiCreateLibsql appName", () => { + const validBase = { + name: "My libSQL", + dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32", + environmentId: "env-1", + description: null, + databaseUser: "admin", + databasePassword: "secretPassword1", + sqldNode: "primary" as const, + sqldPrimaryUrl: null, + enableNamespaces: false, + serverId: null, + }; + + it.each([ + ["a space", "my libsql"], + ["an at sign", "repro@lib"], + ["a slash", "team/libsql"], + ["a colon", "libsql:1"], + ])("refuses an appName with %s", (_, appName) => { + const result = apiCreateLibsql.safeParse({ ...validBase, appName }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.message)).toEqual([ + APP_NAME_MESSAGE, + ]); + } + }); + + it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "my-libsql.db_1", + }); + expect(result.success).toBe(true); + }); + + it("refuses an appName longer than 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(64), + }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]); + } + }); + + it("accepts an appName of 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(63), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts new file mode 100644 index 000000000..2feaa3438 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findOwnedOrganizations: vi.fn(), + transaction: vi.fn(), + removeVectorAgents: vi.fn(), + calls: [] as string[], +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { organization: { findMany: mocks.findOwnedOrganizations } }, + transaction: mocks.transaction, + }, +})); +vi.mock("@dokploy/server/services/organization", () => ({ + removeVectorAgents: mocks.removeVectorAgents, +})); + +const { deleteUserAccountData } = await import( + "@dokploy/server/services/account-deletion" +); + +beforeEach(() => { + mocks.calls.length = 0; + mocks.findOwnedOrganizations.mockReset(); + mocks.removeVectorAgents + .mockReset() + .mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`)); + mocks.transaction.mockReset().mockImplementation(async () => { + mocks.calls.push("delete"); + return { email: "a@b.c", organizationsDeleted: 2 }; + }); +}); + +describe("deleteUserAccountData", () => { + it("removes the Vector agents of every owned organization before deleting the user", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([ + { id: "org-a" }, + { id: "org-b" }, + ]); + + await deleteUserAccountData("user-1"); + + expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]); + }); + + it("deletes the user without touching agents when it owns no organization", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([]); + + await deleteUserAccountData("user-1"); + + expect(mocks.removeVectorAgents).not.toHaveBeenCalled(); + expect(mocks.calls).toEqual(["delete"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts new file mode 100644 index 000000000..76ba498ce --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts @@ -0,0 +1,542 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const aws = vi.hoisted(() => ({ + describeLogGroups: vi.fn(), + putMetricData: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: async () => [{ address: "93.184.216.34" }], +})); +vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({ + CloudWatchLogsClient: class { + send = aws.describeLogGroups; + }, + DescribeLogGroupsCommand: class { + constructor(public input: unknown) {} + }, +})); +vi.mock("@aws-sdk/client-cloudwatch", () => ({ + CloudWatchClient: class { + send = aws.putMetricData; + }, + PutMetricDataCommand: class { + constructor(public input: unknown) {} + }, +})); + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["metrics"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +const originalFetch = global.fetch; +const fetchMock = vi.fn(); + +const respond = (status: number, body: unknown = "") => + new Response( + status === 204 + ? null + : typeof body === "string" + ? body + : JSON.stringify(body), + { status }, + ); + +beforeEach(() => { + fetchMock.mockReset(); + aws.describeLogGroups.mockReset(); + aws.putMetricData.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0])); + +describe("registry", () => { + it("resolves the nine provider types, each with a non-empty valid signal list", () => { + const types = [ + "loki", + "prometheus_remote_write", + "new_relic", + "influxdb", + "datadog", + "aws_cloudwatch", + "splunk_hec", + "elasticsearch", + "betterstack", + ] as const; + expect(Object.keys(telemetryProviderAdapters).sort()).toEqual( + [...types].sort(), + ); + for (const type of types) { + const adapter = getTelemetryProviderAdapter(type); + expect(adapter.type).toBe(type); + expect(adapter.signals.length).toBeGreaterThan(0); + expect( + adapter.signals.every((s) => s === "logs" || s === "metrics"), + ).toBe(true); + } + expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]); + expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([ + "metrics", + ]); + expect(telemetryProviderAdapters.datadog.signals).toEqual([ + "logs", + "metrics", + ]); + }); +}); + +describe("prometheusRemoteWriteAdapter", () => { + const adapter = telemetryProviderAdapters.prometheus_remote_write; + const endpoint = "http://prom.example.com:9090/api/v1/write"; + + it("builds a sink without auth, with the healthcheck disabled", () => { + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint }, + "sink_1", + "dokploy_metrics_scope", + "metrics", + ); + expect(sink).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + endpoint, + healthcheck: { enabled: false }, + }); + expect(sink.auth).toBeUndefined(); + expect(sink.tenant_id).toBeUndefined(); + }); + + it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => { + const basic = adapter.toVectorSink( + { + ...baseConfig, + endpoint, + apiKey: "pw", + extraConfig: { username: "12345", tenantId: "team-a" }, + }, + "sink_1", + "scope", + "metrics", + ); + expect(basic.auth).toEqual({ + strategy: "basic", + user: "12345", + password: "pw", + }); + expect(basic.tenant_id).toBe("team-a"); + + const bearer = adapter.toVectorSink( + { ...baseConfig, endpoint, apiKey: "tok" }, + "sink_1", + "scope", + "metrics", + ); + expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" }); + }); + + it("rejects an endpoint that is not an http(s) URL", () => { + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }), + ).toThrow(/valid http\(s\) URL/); + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }), + ).toThrow(/valid http\(s\) URL/); + }); + + it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(400)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }), + ).rejects.toThrow(/rejected the credentials \(status 401\)/); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).rejects.toThrow(/not found at this URL \(status 404\)/); + }); + + it("sends the auth header and the tenant header on the test request", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await adapter.testConnection?.({ + ...baseConfig, + endpoint, + apiKey: "tok", + extraConfig: { tenantId: "team-a" }, + }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Scope-OrgID": "team-a", + }); + }); +}); + +describe("newRelicAdapter", () => { + const adapter = telemetryProviderAdapters.new_relic; + const config = { + ...baseConfig, + apiKey: "license", + extraConfig: { accountId: "123" }, + }; + + it("defaults to the us region and accepts eu", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "new_relic", + api: "metrics", + license_key: "license", + account_id: "123", + region: "us", + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { accountId: "123", region: "EU" } }, + "s", + "scope", + "metrics", + ).region, + ).toBe("eu"); + }); + + it("rejects an unknown region", () => { + expect(() => + adapter.validateConfig?.({ + ...config, + extraConfig: { accountId: "123", region: "jp" }, + }), + ).toThrow(/"us" or "eu"/); + }); + + it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => { + fetchMock.mockResolvedValueOnce(respond(202)); + await expect( + adapter.testConnection?.({ + ...config, + extraConfig: { accountId: "123", region: "eu" }, + }), + ).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "https://metric-api.eu.newrelic.com/metric/v1", + ); + + fetchMock.mockResolvedValueOnce(respond(403)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the license key/, + ); + expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1"); + }); +}); + +describe("influxdbAdapter", () => { + const adapter = telemetryProviderAdapters.influxdb; + const config = { + ...baseConfig, + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }; + + it("builds an influxdb_metrics v2 sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({ + type: "influxdb_metrics", + inputs: ["scope"], + endpoint: "http://influx.example.com:8086", + org: "dokploy", + bucket: "metrics", + token: "token", + default_namespace: "dokploy", + version: "2", + buffer: { type: "disk", max_size: 268_435_488, when_full: "block" }, + }); + }); + + it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s", + ); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the token/, + ); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /organization or bucket not found/, + ); + }); +}); + +describe("datadogAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.datadog; + const config = { + ...baseConfig, + signals: ["logs", "metrics"] as const, + apiKey: "dd", + }; + + it("ships metrics through datadog_metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"), + ).toBeNull(); + expect( + adapter.toVectorSink(config as never, "s", "scope", "metrics"), + ).toMatchObject({ + type: "datadog_metrics", + inputs: ["scope"], + default_api_key: "dd", + site: "datadoghq.com", + }); + }); + + it("keeps the logs sink and ddtags transform unchanged", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "logs"), + ).toMatchObject({ type: "remap", inputs: ["scope"] }); + expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe( + "datadog_logs", + ); + }); +}); + +describe("betterStackAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.betterstack; + const config = { + ...baseConfig, + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }; + + it("posts metrics to /metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "http", + uri: "https://in.logs.betterstack.com/metrics", + auth: { strategy: "bearer", token: "token" }, + }, + ); + expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe( + "https://in.logs.betterstack.com/", + ); + }); + + it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => { + fetchMock.mockResolvedValue(respond(202)); + await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] }); + expect(calledUrls()).toEqual([ + "https://in.logs.betterstack.com/", + "https://in.logs.betterstack.com/metrics", + ]); + + fetchMock.mockClear(); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]); + }); +}); + +describe("awsCloudwatchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const config = { + ...baseConfig, + apiKey: "AKIA", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }; + + it("scopes the log fields to logs and the namespace to metrics", () => { + const bySignal = Object.fromEntries( + adapter.credentialFields.map((f) => [f.key, f.signal]), + ); + expect(bySignal).toMatchObject({ + logGroup: "logs", + logStream: "logs", + metricsNamespace: "metrics", + }); + expect(bySignal.region).toBeUndefined(); + }); + + it("rejects a namespace starting with AWS/ only when metrics are selected", () => { + const bad = { + ...config, + extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" }, + }; + expect(() => adapter.validateConfig?.(bad)).toThrow( + /cannot start with AWS\//, + ); + expect(() => + adapter.validateConfig?.({ ...bad, signals: ["logs"] }), + ).not.toThrow(); + }); + + it("filters metrics to the allowlist and sets the namespace in the transform", () => { + const transform = adapter.toVectorTransform?.( + config, + "t", + "scope", + "metrics", + ); + expect(transform).toMatchObject({ + type: "remap", + inputs: ["scope"], + drop_on_abort: true, + }); + expect(transform?.source).toContain( + 'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }', + ); + expect(transform?.source).toContain('.namespace = "Dokploy"'); + expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({ + type: "aws_cloudwatch_metrics", + inputs: ["t"], + default_namespace: "Dokploy", + region: "us-east-1", + auth: { access_key_id: "AKIA", secret_access_key: "secret" }, + }); + }); + + it("tests metrics with PutMetricData only, and translates AccessDenied", async () => { + aws.putMetricData.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["metrics"] }); + expect(aws.putMetricData).toHaveBeenCalledTimes(1); + expect(aws.describeLogGroups).not.toHaveBeenCalled(); + expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({ + input: { Namespace: "Dokploy" }, + }); + + aws.putMetricData.mockRejectedValue( + Object.assign(new Error("denied"), { name: "AccessDenied" }), + ); + await expect( + adapter.testConnection?.({ ...config, signals: ["metrics"] }), + ).rejects.toThrow(/needs cloudwatch:PutMetricData/); + }); + + it("tests logs with DescribeLogGroups only", async () => { + aws.describeLogGroups.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(aws.describeLogGroups).toHaveBeenCalledTimes(1); + expect(aws.putMetricData).not.toHaveBeenCalled(); + }); +}); + +describe("splunkAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const config = { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { metricsIndex: "dokploy_metrics" }, + }; + + it("requires the metrics index only for metrics", () => { + const field = adapter.credentialFields.find( + (f) => f.key === "metricsIndex", + ); + expect(field).toMatchObject({ required: true, signal: "metrics" }); + expect( + adapter.credentialFields.find((f) => f.key === "index")?.signal, + ).toBe("logs"); + }); + + it("builds a splunk_hec_metrics sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "splunk_hec_metrics", + endpoint: "https://splunk.example.com:8088", + default_token: "hec", + index: "dokploy_metrics", + source: "dokploy", + sourcetype: "dokploy:metrics", + }, + ); + }); + + it("writes a test metric and translates HEC code 7", async () => { + fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 })); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector", + ]); + + fetchMock.mockResolvedValueOnce( + respond(400, { text: "Incorrect index", code: 7 }), + ); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /can't write to the metrics index "dokploy_metrics"/, + ); + }); + + it("only checks the health endpoint for logs", async () => { + fetchMock.mockResolvedValueOnce(respond(200)); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector/health", + ]); + }); +}); + +describe("elasticsearchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const config = { ...baseConfig, endpoint: "https://es.example.com:9200" }; + + it("uses the daily default metrics index when none is set, and the custom one otherwise", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "elasticsearch", + api_version: "v8", + bulk: { index: "dokploy-metrics-%Y.%m.%d" }, + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { metricsIndex: "metrics-custom" } }, + "s", + "scope", + "metrics", + ).bulk, + ).toEqual({ index: "metrics-custom" }); + }); + + it("keeps the logs sink without a bulk index unless one is set", () => { + expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts new file mode 100644 index 000000000..78bb638be --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findServersByOrganizationForVectorAgent: vi.fn(), + getWebServerSettings: vi.fn(), + releaseWebServerAgent: vi.fn(), + removeVectorAgent: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false })); +vi.mock("@dokploy/server/db", () => ({ + db: { delete: () => ({ where: mocks.deleteWhere }) }, +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, + releaseWebServerAgent: mocks.releaseWebServerAgent, +})); +vi.mock("@dokploy/server/setup/vector-setup", () => ({ + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: (_: string | undefined, fn: () => Promise) => + fn(), +})); + +const { deleteOrganization } = await import( + "@dokploy/server/services/organization" +); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.deleteWhere.mockResolvedValue({ count: 1 }); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("deleteOrganization", () => { + it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => { + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { serverId: "server-1", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-2", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-3", telemetryProviderIds: [] }, + ]); + mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-1") throw new Error("ssh down"); + }); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); + + it("leaves the local agent alone when another organization owns it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-b", + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled(); + }); + + it("releases the local agent even when removing it fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + }); + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith(); + expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts new file mode 100644 index 000000000..a571ca7f4 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts @@ -0,0 +1,220 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + lookup: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + lookup: mocks.lookup, +})); + +const loadModule = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + return await import( + "@dokploy/server/services/logs-and-metrics/provider-fetch" + ); +}; + +const loadFetch = async (isCloud: boolean) => + (await loadModule(isCloud)).providerFetch; + +const resolvesTo = (...addresses: string[]) => + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback( + null, + addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })), + ), + ); + +describe("providerFetch — blocked endpoint guard", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + mocks.lookup.mockReset(); + global.fetch = vi.fn().mockResolvedValue(new Response("ok")); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://169.254.169.254/latest/meta-data/"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => { + const providerFetch = await loadFetch(false); + for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) { + await expect( + providerFetch(`http://${address}/latest/meta-data/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects the AWS ECS metadata IPv6 address", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://[fd00:ec2::254]/v2/credentials"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => { + const providerFetch = await loadFetch(false); + for (const address of [ + "[fe80::1]", + "[fe90::1]", + "[fea0::1]", + "[febf::1]", + ]) { + await expect( + providerFetch(`http://${address}/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("does not block a hostname just because it starts like a link-local prefix", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://feature-logs.example.com/ready"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("rejects the GCP metadata hostname without needing DNS resolution", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://metadata.google.internal/computeMetadata/v1/"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects loopback and private-network endpoints in cloud", async () => { + const providerFetch = await loadFetch(true); + + for (const url of [ + "http://10.0.0.5:3100/ready", + "http://127.0.0.1:3100/ready", + "http://localhost:3100/ready", + "http://[::1]:3100/ready", + "http://0.0.0.0:3100/ready", + "http://100.100.100.200/latest/meta-data/", + "http://[::]:3100/ready", + ]) { + await expect( + providerFetch(url), + `expected ${url} to be rejected`, + ).rejects.toThrow(/blocked/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("does not follow redirects", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://logs.example.com/ready"); + expect(global.fetch).toHaveBeenCalledWith( + "https://logs.example.com/ready", + expect.objectContaining({ redirect: "error" }), + ); + }); +}); + +describe("providerFetch — address checked at connect time", () => { + let server: Server; + let port: number; + let hostHeader: string | undefined; + + beforeEach(async () => { + mocks.lookup.mockReset(); + hostHeader = undefined; + server = createServer((req, res) => { + hostHeader = req.headers.host; + res.end("ok"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("rejects a hostname whose lookup returns a metadata address", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("203.0.113.10", "169.254.169.254"); + + await expect( + providerFetch("http://attacker-controlled.example.com/"), + ).rejects.toThrow(/metadata/i); + expect(mocks.lookup).toHaveBeenCalledWith( + "attacker-controlled.example.com", + expect.anything(), + expect.any(Function), + ); + }); + + it("rejects a hostname resolving to a private address in cloud", async () => { + const providerFetch = await loadFetch(true); + resolvesTo("127.0.0.1"); + + await expect( + providerFetch(`http://loki.internal.example.com:${port}/ready`), + ).rejects.toThrow(/blocked/i); + expect(hostHeader).toBeUndefined(); + }); + + it("rejects when DNS resolution fails", async () => { + const providerFetch = await loadFetch(false); + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })), + ); + + await expect( + providerFetch("http://does-not-exist.invalid/"), + ).rejects.toThrow(); + expect(hostHeader).toBeUndefined(); + }); + + it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("127.0.0.1"); + + const response = await providerFetch( + `http://loki.internal.example.com:${port}/ready`, + ); + expect(await response.text()).toBe("ok"); + expect(hostHeader).toBe(`loki.internal.example.com:${port}`); + }); + + it("passes a public address through in cloud", async () => { + const { guardedLookup } = await loadModule(true); + resolvesTo("203.0.113.10"); + + const result = await new Promise((resolve, reject) => + guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) => + error ? reject(error) : resolve(addresses), + ), + ); + expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts new file mode 100644 index 000000000..30d84a6f8 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers"; + +describe("providerLabel", () => { + it("names what the provider sends next to its name", () => { + expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe( + "Loki prod (logs)", + ); + expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe( + "Splunk (metrics)", + ); + expect( + providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }), + ).toBe("Datadog (logs and metrics)"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts new file mode 100644 index 000000000..ad6cd1da4 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts @@ -0,0 +1,101 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + deleted: [] as unknown[], + updates: [] as Array<{ table: unknown; values: Record }>, + updatedServers: [] as Array<{ serverId: string }>, + updatedLocal: [] as Array<{ id: string }>, + queried: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const tx = { + delete: () => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.deleted), + }), + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: () => ({ + returning: () => { + mocks.updates.push({ table, values }); + return Promise.resolve( + mocks.updates.length === 1 + ? mocks.updatedServers + : mocks.updatedLocal, + ); + }, + }), + }), + }), + }; + return { + db: { + transaction: (fn: (tx: unknown) => unknown) => fn(tx), + query: { + server: { findMany: mocks.queried }, + webServerSettings: { findFirst: mocks.queried }, + }, + }, + }; +}); + +const { server, webServerSettings } = await import("@dokploy/server/db/schema"); +const { removeTelemetryProvider } = await import( + "@dokploy/server/services/logs-and-metrics/service" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +describe("removeTelemetryProvider", () => { + beforeEach(() => { + mocks.updates = []; + mocks.updatedServers = [{ serverId: "server-1" }]; + mocks.updatedLocal = [{ id: "ws-1" }]; + mocks.queried.mockReset(); + }); + + it("drops the provider id from the selection of every server and of the local host", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + + const { provider, targets } = await removeTelemetryProvider("lp-1"); + expect(provider).toEqual({ telemetryProviderId: "lp-1" }); + expect(targets).toEqual(["server-1", null]); + + expect(mocks.updates.map((u) => u.table)).toEqual([ + server, + webServerSettings, + ]); + expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe( + 'array_remove("server"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe( + 'array_remove("webServerSettings"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe( + 'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END', + ); + }); + + it("reconciles exactly the hosts whose selection the transaction updated", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }]; + mocks.updatedLocal = []; + + const { targets } = await removeTelemetryProvider("lp-1"); + expect(targets).toEqual(["server-1", "server-2"]); + expect(mocks.queried).not.toHaveBeenCalled(); + }); + + it("does not touch assignments when the provider does not exist", async () => { + mocks.deleted = []; + + await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect(mocks.updates).toEqual([]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts new file mode 100644 index 000000000..31b01a09b --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts @@ -0,0 +1,392 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { describe, expect, it } from "vitest"; + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["logs"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +describe("getTelemetryProviderAdapter", () => { + it("returns the registered adapter for each known type", () => { + expect(getTelemetryProviderAdapter("loki").type).toBe("loki"); + expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog"); + expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack"); + expect(getTelemetryProviderAdapter("elasticsearch").type).toBe( + "elasticsearch", + ); + expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec"); + expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe( + "aws_cloudwatch", + ); + }); + + it("throws an explicit error for an unregistered type", () => { + // @ts-expect-error: intentionally an unregistered type + expect(() => getTelemetryProviderAdapter("unknown")).toThrow( + /No TelemetryProviderAdapter registered/, + ); + }); +}); + +describe("lokiAdapter.toVectorSink", () => { + it("maps endpoint + labels, without tenant_id if not set", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://loki.example.com" }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("loki"); + expect(sink.inputs).toEqual(["dokploy_scope"]); + expect(sink.endpoint).toBe("https://loki.example.com"); + expect(sink.labels).toMatchObject({ + dokploy_project: "{{ dokploy_project }}", + dokploy_environment: "{{ dokploy_environment }}", + dokploy_application: "{{ dokploy_application }}", + dokploy_organization: "{{ dokploy_organization }}", + dokploy_server: "{{ dokploy_server }}", + }); + expect(sink.tenant_id).toBeUndefined(); + expect(sink.buffer).toEqual({ + type: "disk", + max_size: 268_435_488, + when_full: "block", + }); + }); + + it("includes tenant_id when present in extraConfig", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://loki.example.com", + extraConfig: { tenantId: "tenant-a" }, + }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.tenant_id).toBe("tenant-a"); + }); + + it("has no toVectorTransform — goes directly scope -> sink", () => { + expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined(); + }); +}); + +describe("datadogAdapter.toVectorSink", () => { + it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { ...baseConfig, apiKey: "dd-key" }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.type).toBe("datadog_logs"); + expect(sink.default_api_key).toBe("dd-key"); + expect(sink.site).toBe("datadoghq.com"); + expect(sink.tags).toBeUndefined(); + expect(sink.inputs).toEqual(["transform_2"]); + }); + + it("uses extraConfig.site when present", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "datadoghq.eu" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "https://datadoghq.eu/" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => { + const adapter = telemetryProviderAdapters.datadog; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain(".ddtags"); + expect(transform?.source).toContain( + '"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")', + ); + }); + + it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => { + const adapter = telemetryProviderAdapters.datadog; + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.source).toContain( + 'replace(to_string!(.dokploy_project), ",", "_")', + ); + }); +}); + +describe("betterStackAdapter", () => { + it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => { + const adapter = telemetryProviderAdapters.betterstack; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ); + expect(transform).toEqual({ + type: "remap", + inputs: ["dokploy_scope"], + source: ".dt = del(.timestamp)", + }); + }); + + it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => { + const adapter = telemetryProviderAdapters.betterstack; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://in.logs.betterstack.com", + apiKey: "source-token", + }, + "sink_3", + "transform_1", + "logs", + ); + expect(sink.type).toBe("http"); + expect(sink.inputs).toEqual(["transform_1"]); + expect(sink.uri).toBe("https://in.logs.betterstack.com/"); + expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" }); + expect(sink.compression).toBe("gzip"); + }); +}); + +describe("elasticsearchAdapter", () => { + it("has a toVectorTransform that flattens .label to a JSON string", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_4", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain("encode_json(.label)"); + }); + + it("uses basic auth when a username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "secret", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("elasticsearch"); + expect(sink.endpoints).toEqual(["https://es.example.com:9200"]); + expect(sink.auth).toEqual({ + strategy: "basic", + user: "elastic", + password: "secret", + }); + expect(sink.request).toBeUndefined(); + }); + + it("falls back to an ApiKey header when no username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "key-123", + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.auth).toBeUndefined(); + expect(sink.request).toEqual({ + headers: { Authorization: "ApiKey key-123" }, + }); + }); + + it("rejects a username set without a password instead of silently shipping unauthenticated", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(() => + adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ), + ).toThrow(/Password.*required/i); + }); + + it("includes bulk.index only when extraConfig.index is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const withoutIndex = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://es.example.com:9200" }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withoutIndex.bulk).toBeUndefined(); + const withIndex = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { index: "dokploy-%Y.%m.%d" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" }); + }); +}); + +describe("splunkAdapter", () => { + it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.splunk_hec.toVectorTransform, + ).toBeUndefined(); + }); + + it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("splunk_hec_logs"); + expect(sink.endpoint).toBe("https://splunk.example.com:8088"); + expect(sink.default_token).toBe("hec-token"); + expect(sink.encoding).toEqual({ codec: "json" }); + expect(sink.index).toBeUndefined(); + expect(sink.sourcetype).toBeUndefined(); + + const withExtras = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + extraConfig: { index: "dokploy", sourcetype: "docker" }, + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(withExtras.index).toBe("dokploy"); + expect(withExtras.sourcetype).toBe("docker"); + }); +}); + +describe("awsCloudwatchAdapter", () => { + it("has no logs transform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ), + ).toBeNull(); + }); + + it("toVectorSink maps access key/secret, region and log group/stream", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("aws_cloudwatch_logs"); + expect(sink.group_name).toBe("/dokploy/logs"); + expect(sink.stream_name).toBe("{{ container_name }}"); + expect(sink.region).toBe("us-east-1"); + expect(sink.auth).toEqual({ + access_key_id: "AKIA...", + secret_access_key: "shh", + }); + }); + + it("uses extraConfig.logStream as the stream template when set", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + logStream: "{{ dokploy_application }}", + }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.stream_name).toBe("{{ dokploy_application }}"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts new file mode 100644 index 000000000..e30efb520 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts @@ -0,0 +1,661 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + insertValues: vi.fn(), + updateSet: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + serverFindFirst: vi.fn(), + serverFindMany: vi.fn(), + webServerFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + insert: () => ({ + values: (v: any) => ({ + returning: () => Promise.resolve(mocks.insertValues(v)), + }), + }), + update: () => ({ + set: (v: any) => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.updateSet(v)), + }), + }), + }), + query: { + telemetryProvider: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + server: { + findFirst: mocks.serverFindFirst, + findMany: mocks.serverFindMany, + }, + webServerSettings: { findFirst: mocks.webServerFindFirst }, + }, + }, +})); + +const { + assertProvidersBelongToOrg, + assertSignalsSupported, + createTelemetryProvider, + testTelemetryProviderConnection, + updateTelemetryProvider, +} = await import("@dokploy/server/services/logs-and-metrics/service"); +const { telemetryProviderAdapters } = await import( + "@dokploy/server/services/logs-and-metrics/providers/registry" +); + +beforeEach(() => { + mocks.serverFindMany.mockResolvedValue([]); + mocks.webServerFindFirst.mockResolvedValue(undefined); +}); + +describe("updateTelemetryProvider — targets to re-apply", () => { + const existing = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old", + apiSecret: null, + extraConfig: null, + enabled: true, + }; + + beforeEach(() => { + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]); + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + }); + + it("returns the servers and the local host that ship with it when a credential changes", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + apiKey: "new", + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns the same targets when the provider is disabled", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + enabled: false, + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns no targets when only the name changes or a value is re-sent unchanged", async () => { + expect( + (await updateTelemetryProvider("lp-1", { name: "renamed" })).targets, + ).toEqual([]); + expect( + ( + await updateTelemetryProvider("lp-1", { + endpoint: "https://loki.example.com", + }) + ).targets, + ).toEqual([]); + }); +}); + +describe("createTelemetryProvider — required credential fields per adapter", () => { + it("rejects a loki provider without endpoint", async () => { + await expect( + createTelemetryProvider( + { name: "loki-prod", providerType: "loki", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(/endpoint/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider without apiKey", async () => { + await expect( + createTelemetryProvider( + { name: "dd", providerType: "datadog", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a betterstack provider missing either field", async () => { + await expect( + createTelemetryProvider( + { + name: "bs", + providerType: "betterstack", + signals: ["logs"], + apiKey: "token-only", + } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a splunk_hec provider without a HEC token", async () => { + await expect( + createTelemetryProvider( + { + name: "splunk", + providerType: "splunk_hec", + signals: ["logs"], + endpoint: "https://splunk.example.com:8088", + } as any, + "org-1", + ), + ).rejects.toThrow(/token/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + } as any, + "org-1", + ), + ).rejects.toThrow(/region|log group/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => { + await expect( + createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["logs"], + extraConfig: { apiKey: "sneaky-value" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/api key/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => { + await expect( + createTelemetryProvider( + { + name: "es", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/password.*required/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => { + mocks.insertValues.mockReturnValue([ + { + telemetryProviderId: "lp-2", + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + }, + ]); + const created = await createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-2"); + }); + + it("accepts a loki provider with endpoint", async () => { + mocks.insertValues.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" }, + ]); + const created = await createTelemetryProvider( + { + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-1"); + expect(mocks.insertValues).toHaveBeenCalled(); + }); +}); + +describe("updateTelemetryProvider — merges partial update with existing row before validating", () => { + it("allows updating just the name without re-sending existing credentials", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "old-name", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "new-name" }, + ]); + + const { provider: updated, targets } = await updateTelemetryProvider( + "lp-1", + { name: "new-name" }, + ); + expect(updated.name).toBe("new-name"); + expect(targets).toEqual([]); + }); + + it("rejects clearing the only required field via update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { endpoint: null }), + ).rejects.toThrow(/endpoint/i); + }); + + it("clears the old provider's credentials when providerType changes, unless new values are given", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: { site: "datadoghq.com" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }), + ); + }); + + it("merges a partial extraConfig update instead of replacing it wholesale", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "secret", + apiSecret: null, + extraConfig: { username: "elastic", index: "custom-index" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + extraConfig: { username: "new-user" }, + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + extraConfig: { username: "new-user", index: "custom-index" }, + }), + ); + }); + + it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: null, + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { extraConfig: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ extraConfig: null }), + ); + }); + + it("clears an optional credential when it is explicitly set to null", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old-token", + apiSecret: null, + extraConfig: { username: "user" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { apiKey: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: null }), + ); + }); + + it("rejects clearing the elasticsearch password while a username stays saved", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "old-password", + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + + await expect( + updateTelemetryProvider("lp-1", { apiKey: null }), + ).rejects.toThrow(/Password/); + }); + + it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { providerType: "loki" }), + ).rejects.toThrow(/endpoint/i); + }); +}); + +describe("assertSignalsSupported", () => { + it("rejects a repeated signal", () => { + expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow( + /only be selected once/, + ); + }); + + it("rejects a signal the type cannot send", () => { + expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow( + /Grafana Loki cannot send metrics/, + ); + }); + + it("accepts a supported subset", () => { + expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow(); + }); +}); + +describe("createTelemetryProvider — signals", () => { + it("rejects a provider whose type cannot send the selected signal", async () => { + mocks.insertValues.mockClear(); + await expect( + createTelemetryProvider( + { + name: "loki", + providerType: "loki", + signals: ["metrics"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ), + ).rejects.toThrow(/cannot send metrics/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("assertProvidersBelongToOrg", () => { + beforeEach(() => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: true, + signals: ["logs"], + }, + ]); + }); + + it("accepts a provider of the organization regardless of what it sends", async () => { + await expect( + assertProvidersBelongToOrg(["lp-1"], "org-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects an id that is not in the organization", async () => { + await expect( + assertProvidersBelongToOrg(["lp-9"], "org-1"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("rejects a selection where every provider is disabled", async () => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: false, + signals: ["logs"], + }, + ]); + await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow( + /at least one enabled provider/, + ); + }); +}); + +describe("required fields scoped to a signal", () => { + beforeEach(() => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]); + }); + + it("does not require the log group when only metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("does not require the metrics namespace when only logs are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("requires the metrics namespace when metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/Metrics namespace/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("signals are stored in a fixed order", () => { + it("normalizes the order on create", async () => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["metrics", "logs"], + apiKey: "dd-key", + } as any, + "org-1", + ); + + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); + + it("normalizes the order on update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "dd-1", + name: "dd", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); +}); + +describe("testTelemetryProviderConnection by id", () => { + it("tests the saved provider with the signals override from the form", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "bs-1", + name: "bs", + providerType: "betterstack", + signals: ["logs"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + apiSecret: null, + extraConfig: null, + }); + const spy = vi + .spyOn(telemetryProviderAdapters.betterstack, "testConnection") + .mockResolvedValue(undefined); + + await testTelemetryProviderConnection({ + telemetryProviderId: "bs-1", + signals: ["logs", "metrics"], + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "token", + signals: ["logs", "metrics"], + }), + ); + spy.mockRestore(); + }); +}); + +describe("updateTelemetryProvider — signals", () => { + const existing = { + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }; + + beforeEach(() => { + mocks.updateSet.mockReset(); + mocks.serverFindFirst.mockReset(); + mocks.webServerFindFirst.mockReset(); + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]); + }); + + it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => { + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + + await expect( + updateTelemetryProvider("dd-1", { signals: ["metrics"] }), + ).resolves.toMatchObject({ + provider: { signals: ["metrics"] }, + targets: ["server-1", null], + }); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["metrics"] }), + ); + }); + + it("rejects a type change that cannot send the saved signals", async () => { + await expect( + updateTelemetryProvider("dd-1", { + providerType: "loki", + endpoint: "https://loki.example.com", + }), + ).rejects.toThrow(/Grafana Loki cannot send metrics/); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts new file mode 100644 index 000000000..6cc14b1c5 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + setWebServerProviderIds: vi.fn(), + setServerProviderIds: vi.fn(), + getAccessibleServerIds: vi.fn(), + reconcileVectorAgent: vi.fn(), + removeVectorAgent: vi.fn(), + findServerById: vi.fn(), + assertProvidersBelongToOrg: vi.fn(), +})); + +const loadAgent = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + vi.doMock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + setServerProviderIds: mocks.setServerProviderIds, + getAccessibleServerIds: mocks.getAccessibleServerIds, + })); + vi.doMock("@dokploy/server/services/web-server-settings", () => ({ + setWebServerProviderIds: mocks.setWebServerProviderIds, + })); + const locks = new Map>(); + vi.doMock("@dokploy/server/setup/vector-setup", () => ({ + LOCAL_SERVER_NAME: "Dokploy Server (local)", + reconcileVectorAgent: mocks.reconcileVectorAgent, + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: ( + serverId: string | undefined, + fn: () => Promise, + ) => { + const key = serverId ?? "local"; + const current = (locks.get(key) ?? Promise.resolve()) + .catch(() => {}) + .then(fn); + locks.set(key, current); + return current; + }, + })); + vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({ + assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg, + })); + return await import("@dokploy/server/services/logs-and-metrics/vector-agent"); +}; + +const sessionA = { userId: "user-a", activeOrganizationId: "org-a" }; +const sessionB = { userId: "user-b", activeOrganizationId: "org-b" }; + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"])); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + name: `name-of-${serverId}`, + organizationId: "org-a", + sshKeyId: "key-1", + })); + mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" }); + mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" }); +}); + +describe("applyVectorAgentSelection on the local host", () => { + it("rejects the local host in cloud without touching the database", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + await expect( + applyVectorAgentSelection(sessionA, undefined, ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => { + mocks.setWebServerProviderIds.mockResolvedValue(null); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionB, undefined, ["mp-1"]), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [ + "mp-1", + ]); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the selection and then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]); + expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith( + ["lp-1"], + "org-a", + ); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [ + "lp-1", + ]); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + expect( + mocks.setWebServerProviderIds.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0); + }); + + it("removing the agent saves an empty selection without checking providers", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, []); + expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled(); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + }); +}); + +describe("applyVectorAgentSelection on a remote server", () => { + it("still manages remote servers in cloud", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + const result = await applyVectorAgentSelection(sessionA, "server-1", []); + expect(result).toEqual({ serverName: "name-of-server-1" }); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("rejects a server of another organization", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-b", + sshKeyId: "key-1", + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + }); + + it("rejects a server without an SSH key before saving anything", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + sshKeyId: null, + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects a server the member cannot access", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the array and only then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "mp-1", + ]); + expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("keeps the saved selection when the reconcile fails", async () => { + mocks.reconcileVectorAgent.mockRejectedValue( + new Error("validation failed"), + ); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ).rejects.toThrow("validation failed"); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "lp-2", + ]); + }); + + it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => { + const saved: string[][] = []; + const seen: string[][] = []; + mocks.setServerProviderIds.mockImplementation( + async (_serverId: string, ids: string[]) => { + saved.push(ids); + return { serverId: "server-1" }; + }, + ); + mocks.reconcileVectorAgent.mockImplementation(async () => { + seen.push(saved.at(-1) ?? []); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await Promise.all([ + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ]); + expect(seen).toEqual([["lp-1"], ["lp-2"]]); + }); +}); + +describe("removeServerVectorAgent", () => { + it("removes the agent on server deletion when providers are assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("skips servers without an SSH key, warning when providers were assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toMatch(/no SSH key/); + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + }); + + it("returns the failure as a message instead of throwing", async () => { + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "k", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBe("docker down"); + }); +}); + +describe("reconcileVectorTargets", () => { + it("re-applies every target under its lock and reports failures as a warning", async () => { + mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-2") throw new Error("ssh down"); + }); + const { reconcileVectorTargets } = await loadAgent(false); + + const warning = await reconcileVectorTargets([ + "server-1", + "server-2", + null, + ]); + expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([ + "server-1", + "server-2", + undefined, + ]); + expect(warning).toBe( + "The Vector agent could not be re-applied on server-2: ssh down", + ); + }); + + it("returns no warning when every target reconciles, and skips the local host in cloud", async () => { + const { reconcileVectorTargets } = await loadAgent(true); + + await expect( + reconcileVectorTargets(["server-1", null]), + ).resolves.toBeUndefined(); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts new file mode 100644 index 000000000..98c4d70d3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts @@ -0,0 +1,375 @@ +import { vrlString } from "@dokploy/server/services/logs-and-metrics/types"; +import { + buildMetricsScopeTransformSource, + buildVectorConfigYaml, + collectSecretValues, + redactSecrets, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + signals: ["logs"], + ...overrides, + }) as any; + +const loki = provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", +}); + +const prom = provider({ + telemetryProviderId: "prom-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", +}); + +const datadog = provider({ + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", +}); + +const build = ( + overrides: Partial[0]>, +) => + parse( + buildVectorConfigYaml({ + logProviders: [], + metricsProviders: [], + serverName: "my server", + ...overrides, + }), + ) as any; + +describe("buildVectorConfigYaml — logs", () => { + it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => { + const config = build({ logProviders: [loki] }); + + expect(config.data_dir).toBe("/var/lib/vector"); + expect(config.sources.docker_logs_source.type).toBe("docker_logs"); + expect(config.transforms.dokploy_scope.type).toBe("remap"); + expect(config.transforms.dokploy_scope.inputs).toEqual([ + "docker_logs_source", + ]); + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + expect(config.sinks["sink_loki-1"].type).toBe("loki"); + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + + expect(config.sources.host_metrics_source).toBeUndefined(); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(config.transforms.dokploy_metrics_scope).toBeUndefined(); + }); + + it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => { + const config = build({ + logProviders: [ + loki, + provider({ + telemetryProviderId: "bs-1", + name: "betterstack", + providerType: "betterstack", + endpoint: "https://in.logs.betterstack.com", + apiKey: "token", + }), + datadog, + ], + }); + + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + expect(config.transforms["transform_bs-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]); + expect(config.transforms["transform_dd-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]); + }); + + it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => { + const config = build({ logProviders: [loki], serverName: 'srv "quoted"' }); + + expect(config.transforms.dokploy_scope.source).toBe( + [ + '.dokploy_server = "srv \\"quoted\\""', + '.dokploy_organization = .label."dokploy.organization.id" || ""', + '.dokploy_project = .label."dokploy.project" || ""', + '.dokploy_project_id = .label."dokploy.project.id" || ""', + '.dokploy_environment = .label."dokploy.environment" || ""', + '.dokploy_environment_id = .label."dokploy.environment.id" || ""', + '.dokploy_application = .label."dokploy.application" || ""', + '.dokploy_application_id = .label."dokploy.application.id" || ""', + '.dokploy_service = .label."dokploy.service" || ""', + ].join("\n"), + ); + }); + + it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => { + const config = build({ + logProviders: [ + provider({ + telemetryProviderId: "broken-es", + name: "es-broken", + providerType: "elasticsearch", + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }), + loki, + ], + }); + + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + }); + + it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => { + const config = build({ logProviders: [loki], organizationId: "org-a" }); + + expect(config.transforms.dokploy_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_scope"], + condition: '.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_loki-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + }); + + it("does not add the organization filter for the per-server (non-local) path", () => { + const config = build({ logProviders: [loki] }); + + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + }); +}); + +describe("buildVectorConfigYaml — metrics", () => { + it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => { + const config = build({ metricsProviders: [prom] }); + + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.transforms.dokploy_scope).toBeUndefined(); + expect(config.sources.host_metrics_source).toMatchObject({ + type: "host_metrics", + scrape_interval_secs: 30, + collectors: [ + "cpu", + "memory", + "disk", + "filesystem", + "network", + "load", + "host", + ], + }); + expect( + config.sources.host_metrics_source.filesystem.filesystems.excludes, + ).toEqual( + expect.arrayContaining([ + "overlay", + "tmpfs", + "squashfs", + "devtmpfs", + "nsfs", + "proc", + "sysfs", + "devpts", + "cgroup2", + ]), + ); + expect(config.sources.host_metrics_source.network.devices.excludes).toEqual( + ["veth*", "br-*", "docker*"], + ); + expect(config.sources.cadvisor_source).toEqual({ + type: "prometheus_scrape", + endpoints: ["http://127.0.0.1:4510/metrics"], + scrape_interval_secs: 30, + scrape_timeout_secs: 10, + }); + expect(config.transforms.dokploy_metrics_scope).toMatchObject({ + type: "remap", + inputs: ["host_metrics_source", "cadvisor_source"], + drop_on_abort: true, + }); + expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]); + expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + }); + expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined(); + }); + + it("on the local host it lets host series through and keeps only the owning organization's containers", () => { + const config = build({ metricsProviders: [prom], organizationId: "org-a" }); + + expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_metrics_scope"], + condition: + '.namespace == "host" || .tags.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([ + "dokploy_metrics_scope_local_only", + ]); + }); + + it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => { + const config = build({ + logProviders: [datadog], + metricsProviders: [datadog], + organizationId: "org-a", + }); + + expect(config.sinks["sink_dd-1"]).toMatchObject({ + type: "datadog_logs", + inputs: ["transform_dd-1"], + }); + expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({ + type: "datadog_metrics", + inputs: ["dokploy_metrics_scope_local_only"], + }); + expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined(); + }); + + it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => { + const source = buildMetricsScopeTransformSource('srv "quoted"'); + + expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""'); + expect(source).toContain( + 'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }', + ); + expect(source).toContain( + 'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }', + ); + expect(source.trim().endsWith("del(.tags.id)")).toBe(true); + expect( + build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope + .source, + ).toBe(buildMetricsScopeTransformSource("my server")); + }); +}); + +describe("buildVectorConfigYaml — dollar signs", () => { + it("keeps every $ literal because the agent runs without env var interpolation", () => { + const yamlStr = buildVectorConfigYaml({ + logProviders: [ + provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), + ], + metricsProviders: [prom], + serverName: "srv $ONE", + }); + + expect(yamlStr).not.toContain("$$"); + expect(yamlStr).toContain("srv $ONE"); + expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe( + "Pa$5w0rd$HOME", + ); + }); +}); + +describe("collectSecretValues / redactSecrets", () => { + it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => { + const providers = [ + { + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "loki-key", + apiSecret: null, + extraConfig: { tenantId: "tenant-a", username: "12345" }, + }, + { + providerType: "datadog", + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: { site: "datadoghq.eu" }, + }, + { + providerType: "aws_cloudwatch", + endpoint: null, + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + }, + ] as any; + + const values = collectSecretValues(providers); + expect(values).toEqual( + expect.arrayContaining([ + "https://loki.example.com", + "loki-key", + "dd-key", + "AKIA", + "shh", + ]), + ); + for (const plain of [ + "tenant-a", + "12345", + "datadoghq.eu", + "us-east-1", + "Dokploy", + ]) { + expect(values).not.toContain(plain); + } + }); + + it("ignores null/empty credentials", () => { + const providers = [ + { + providerType: "elasticsearch", + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: { limit: 10, enabled: true, empty: "" }, + }, + ] as any; + expect(collectSecretValues(providers)).toEqual([]); + }); + + it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => { + const providers = [ + { + providerType: "loki", + endpoint: null, + apiKey: "hunter2-long-suffix", + apiSecret: "hunter2", + extraConfig: null, + }, + ] as any; + const text = + "sink validation error near apiKey=hunter2-long-suffix and hunter2"; + + const redacted = redactSecrets(text, collectSecretValues(providers)); + + expect(redacted).not.toContain("hunter2"); + expect(redacted).toBe( + "sink validation error near apiKey=[redacted] and [redacted]", + ); + }); +}); + +describe("vrlString", () => { + it("quotes and escapes like JSON but drops the control characters VRL rejects", () => { + expect(vrlString('my "server"')).toBe('"my \\"server\\""'); + expect(vrlString("back\\slash")).toBe('"back\\\\slash"'); + expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"'); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts new file mode 100644 index 000000000..4d3775553 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts @@ -0,0 +1,526 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + getWebServerSettings: vi.fn(), + findEnabledTelemetryProvidersByOrganization: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + inspectService: vi.fn(), + updateService: vi.fn(), + removeService: vi.fn(), + createService: vi.fn(), + getService: vi.fn(), + listContainers: vi.fn(), + killContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: (isServer: boolean) => ({ + VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector", + }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findEnabledTelemetryProvidersByOrganization: + mocks.findEnabledTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + getService: mocks.getService, + createService: mocks.createService, + listContainers: mocks.listContainers, + getContainer: () => ({ kill: mocks.killContainer }), + }), +})); + +const { reconcileVectorAgent, removeVectorAgent } = await import( + "@dokploy/server/setup/vector-setup" +); + +const lokiProvider = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "sekret-token", + apiSecret: null, + extraConfig: null, + enabled: true, + organizationId: "org-a", +}; + +const promProvider = { + ...lokiProvider, + telemetryProviderId: "mp-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: null, +}; + +const notFound = () => + Object.assign(new Error("not found"), { statusCode: 404 }); + +const writtenYaml = (command: string) => { + const match = command.match(/echo "([^"]+)" \| base64 -d/); + return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8")); +}; + +const commandsOf = (mock: typeof mocks.execAsync) => + mock.mock.calls.map((call) => String(call[call.length - 1])); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getService.mockImplementation(() => ({ + inspect: mocks.inspectService, + update: mocks.updateService, + remove: mocks.removeService, + })); + mocks.inspectService.mockRejectedValue(notFound()); + mocks.removeService.mockRejectedValue(notFound()); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]); + mocks.killContainer.mockResolvedValue(undefined); +}); + +describe("reconcileVectorAgent", () => { + it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const commands = commandsOf(mocks.execAsync); + expect(commands).toHaveLength(3); + expect(commands[0]).toContain( + "/local/dokploy/vector/vector.yaml.candidate.", + ); + expect(commands[1]).toMatch( + /timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/, + ); + expect(commands[2]).toMatch( + /^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/, + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + + const config = writtenYaml(commands[0] ?? ""); + expect(config.sinks["sink_lp-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + expect(config.sources.host_metrics_source).toBeUndefined(); + + expect(mocks.createService).toHaveBeenCalledTimes(1); + expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({ + Name: "dokploy-vector", + }); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => { + const datadogProvider = { + ...lokiProvider, + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + }; + const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" }; + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1", "dd-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + promProvider, + datadogProvider, + unselected, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + const sinkIds = Object.keys(config.sinks); + expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true); + expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true); + expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2); + expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false); + expect(config.sources.docker_logs_source).toBeDefined(); + expect(config.sources.cadvisor_source).toBeDefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + }); + + it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + ]); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("filters by the organization only on the local host and runs on the server over SSH", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent("server-1"); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect( + mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"), + ).toBe(true); + const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? ""); + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]); + }); + + it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.includes("validate")) { + throw new Error("bad sink: password sekret-token rejected"); + } + return { stdout: "", stderr: "" }; + }); + + await expect(reconcileVectorAgent()).rejects.toThrow( + /failed validation, not applying it: bad sink: password \[redacted\] rejected/, + ); + + const commands = commandsOf(mocks.execAsync); + expect(commands.some((c) => c.startsWith("mv "))).toBe(false); + expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when no enabled provider is left in the selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["deleted-or-disabled"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent(); + + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector"); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when the selection is empty, without an owner", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: [], + }); + + await reconcileVectorAgent(); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).not.toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + }); + + it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + promProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.sources.cadvisor_source.endpoints).toEqual([ + "http://127.0.0.1:4510/metrics", + ]); + + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + const cadvisor = mocks.createService.mock.calls[1]?.[0]; + expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch( + /^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/, + ); + expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual( + expect.arrayContaining([ + "--listen_ip=127.0.0.1", + "--port=4510", + "--docker_only=true", + "--store_container_labels=false", + ]), + ); + expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({ + Test: ["NONE"], + }); + expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]); + expect(mocks.removeService).not.toHaveBeenCalled(); + }); + + it("removes the agent on a remote server when the selection is empty", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: "key-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: [], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent("server-1"); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(mocks.createService).not.toHaveBeenCalled(); + expect(commandsOf(mocks.execAsyncRemote)).toEqual([ + "rm -rf /etc/dokploy/vector", + ]); + }); + + it("redacts the config and secrets when writing or applying the candidate fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + const echoCommand = async (command: string) => { + if (command.includes("base64 -d") || command.startsWith("mv ")) { + throw new Error(`Command failed: ${command} (token sekret-token)`); + } + return { stdout: "", stderr: "" }; + }; + mocks.execAsync.mockImplementation(echoCommand); + + const writeError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(writeError).toMatch(/^Failed to write the Vector config: /); + expect(writeError).not.toContain("sekret-token"); + expect(writeError).not.toContain( + Buffer.from("data_dir").toString("base64").slice(0, 8), + ); + expect(writeError).toContain("[redacted]"); + + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.startsWith("mv ")) return echoCommand(command); + return { stdout: "", stderr: "" }; + }); + const applyError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(applyError).toMatch(/^Failed to apply the Vector config: /); + expect(applyError).not.toContain("sekret-token"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates a rejected service update instead of pretending the create succeeded", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + mocks.updateService.mockRejectedValue( + new Error("rpc error: invalid mount"), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates an inspect failure that is not a 404", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("daemon unreachable"), { statusCode: 500 }), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("does not restart Vector on a config-only redeploy", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockImplementation(async () => ({ + Version: { Index: "7" }, + Spec: { + TaskTemplate: { + ForceUpdate: 2, + ContainerSpec: mocks.createService.mock.calls[0]?.[0] + ? undefined + : undefined, + }, + }, + })); + + await reconcileVectorAgent(); + expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" }); + expect(mocks.listContainers).toHaveBeenCalledWith({ + filters: JSON.stringify({ + label: ["com.docker.swarm.service.name=dokploy-vector"], + }), + }); + const firstSpec = mocks.updateService.mock.calls[0]?.[0]; + mocks.inspectService.mockResolvedValue({ + Version: { Index: "8" }, + Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } }, + }); + mocks.updateService.mockClear(); + + await reconcileVectorAgent(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 8, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); + + it("updates an existing Vector service instead of creating it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + + await reconcileVectorAgent(); + + expect(mocks.createService).not.toHaveBeenCalled(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 7, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); +}); + +describe("removeVectorAgent", () => { + it("removes cAdvisor, then Vector, then the config directory", async () => { + await removeVectorAgent(); + + expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([ + "dokploy-cadvisor", + "dokploy-vector", + ]); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("keeps going when cAdvisor cannot be removed", async () => { + mocks.removeService + .mockRejectedValueOnce(new Error("docker hiccup")) + .mockRejectedValueOnce(notFound()); + + await removeVectorAgent(); + + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: null, + }); + + await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/); + expect(mocks.getService).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts new file mode 100644 index 000000000..12131a7e7 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listTasks: vi.fn(), + inspectService: vi.fn(), + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), + getWebServerSettings: vi.fn(), + findTelemetryProvidersByOrganization: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, + getAccessibleServerIds: mocks.getAccessibleServerIds, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findTelemetryProvidersByOrganization: + mocks.findTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + listTasks: mocks.listTasks, + getService: () => ({ inspect: mocks.inspectService }), + }), +})); + +const { getVectorAgentTargets, withVectorTargetLock } = await import( + "@dokploy/server/setup/vector-setup" +); + +const session = { userId: "user-1", activeOrganizationId: "org-1" }; + +describe("getVectorAgentTargets", () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.inspectService.mockResolvedValue({}); + mocks.getAccessibleServerIds.mockResolvedValue(new Set()); + mocks.findTelemetryProvidersByOrganization.mockResolvedValue([ + { telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] }, + { telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] }, + { telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] }, + ]); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1"], + }); + }); + + it("requires a running cAdvisor task when the target ships metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockImplementation( + async ({ filters }: { filters: string }) => + JSON.parse(filters).service[0] === "dokploy-vector" + ? [{ Status: { State: "running" } }] + : [], + ); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(2); + }); + + it("reports running when both Vector and cAdvisor have a running task", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + }); + + it("does not look at cAdvisor for a logs-only target", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + await getVectorAgentTargets(session); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("does not look at cAdvisor when the only metrics provider is disabled", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1", "mp-off"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("reports running only when a task of the service is running", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledWith({ + filters: JSON.stringify({ + service: ["dokploy-vector"], + "desired-state": ["running"], + }), + }); + }); + + it("reports not-running when the service has no running task", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + }); + + it("reports stopped when the service does not exist", async () => { + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("not found"), { statusCode: 404 }), + ); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("stopped"); + expect(mocks.listTasks).not.toHaveBeenCalled(); + }); + + it("reports unknown on any other docker error", async () => { + mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED")); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("unknown"); + }); + + it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"], + }); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local).toMatchObject({ + serverId: null, + telemetryProviderIds: [], + }); + }); + + it("hides servers the member cannot access", async () => { + mocks.listTasks.mockResolvedValue([]); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { + serverId: "server-1", + name: "one", + ipAddress: "1.1.1.1", + telemetryProviderIds: [], + }, + { + serverId: "server-2", + name: "two", + ipAddress: "2.2.2.2", + telemetryProviderIds: [], + }, + ]); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"])); + + const targets = await getVectorAgentTargets(session); + expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]); + }); +}); + +describe("withVectorTargetLock", () => { + it("runs operations on the same target one after another", async () => { + const events: string[] = []; + let releaseFirst = () => {}; + const first = withVectorTargetLock("server-1", async () => { + events.push("first:start"); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + events.push("first:end"); + throw new Error("first failed"); + }); + const second = withVectorTargetLock("server-1", async () => { + events.push("second"); + }); + const other = withVectorTargetLock("server-2", async () => { + events.push("other"); + }); + + await other; + expect(events).toEqual(["first:start", "other"]); + releaseFirst(); + await expect(first).rejects.toThrow("first failed"); + await second; + expect(events).toEqual(["first:start", "other", "first:end", "second"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts new file mode 100644 index 000000000..612b782d3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts @@ -0,0 +1,197 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildVectorConfigYaml, + VECTOR_IMAGE, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; + +const dockerAvailable = (() => { + try { + execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 }); + return true; + } catch { + return false; + } +})(); + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + enabled: true, + ...overrides, + }) as any; + +const logsOnly = [ + provider({ + telemetryProviderId: "loki1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "http://loki.example.com:3100", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), +]; + +const metricsOnly = [ + provider({ + telemetryProviderId: "prom1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: "token", + extraConfig: { tenantId: "team-a" }, + }), + provider({ + telemetryProviderId: "nr1", + name: "new relic", + providerType: "new_relic", + signals: ["metrics"], + apiKey: "license", + extraConfig: { accountId: "123", region: "eu" }, + }), + provider({ + telemetryProviderId: "influx1", + name: "influx", + providerType: "influxdb", + signals: ["metrics"], + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }), +]; + +const both = [ + provider({ + telemetryProviderId: "dd1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", + }), + provider({ + telemetryProviderId: "cw1", + name: "cloudwatch", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIAFAKE", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }), + provider({ + telemetryProviderId: "splunk1", + name: "splunk", + providerType: "splunk_hec", + signals: ["logs", "metrics"], + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { index: "main", metricsIndex: "dokploy_metrics" }, + }), + provider({ + telemetryProviderId: "es1", + name: "elastic", + providerType: "elasticsearch", + signals: ["logs", "metrics"], + endpoint: "http://es.example.com:9200", + apiKey: "pw", + extraConfig: { username: "elastic" }, + }), + provider({ + telemetryProviderId: "bs1", + name: "better stack", + providerType: "betterstack", + signals: ["logs", "metrics"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }), +]; + +const cases: Array<[string, Parameters[0]]> = [ + [ + "local logs only", + { + logProviders: logsOnly, + metricsProviders: [], + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local metrics only", + { + logProviders: [], + metricsProviders: metricsOnly, + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + organizationId: "org_a", + serverName: 'Dokploy "Server" $HOSTNAME (local)', + }, + ], + [ + "remote metrics only", + { logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" }, + ], + [ + "remote every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + serverName: "edge-1", + }, + ], +]; + +describe.skipIf(!dockerAvailable)( + "vector validate on generated configs", + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-")); + + it.each(cases)( + "%s", + (name, input) => { + const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`); + writeFileSync(file, buildVectorConfigYaml(input)); + const output = execFileSync( + "docker", + [ + "run", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock:ro", + "-v", + `${file}:/etc/vector/vector.yaml:ro`, + VECTOR_IMAGE, + "validate", + "--skip-healthchecks", + "/etc/vector/vector.yaml", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }, + ); + expect(output).toContain("Validated"); + }, + 150_000, + ); + }, +); diff --git a/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts new file mode 100644 index 000000000..eaf7a2cec --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts @@ -0,0 +1,86 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + updates: [] as Array<{ values: Record; where: unknown }>, + returning: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { webServerSettings: { findFirst: mocks.findFirst } }, + update: () => ({ + set: (values: Record) => ({ + where: (where: unknown) => { + mocks.updates.push({ values, where }); + return { returning: () => Promise.resolve(mocks.returning()) }; + }, + }), + }), + }, +})); + +const { releaseWebServerAgent, setWebServerProviderIds } = await import( + "@dokploy/server/services/web-server-settings" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +beforeEach(() => { + mocks.updates = []; + mocks.findFirst.mockResolvedValue({ + id: "ws-1", + vectorAgentOrganizationId: "org-a", + }); + mocks.returning.mockReturnValue([{ id: "ws-1" }]); +}); + +describe("setWebServerProviderIds", () => { + it("claims the local agent for the organization when assigning providers", async () => { + const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]); + + expect(row).toEqual({ id: "ws-1" }); + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: ["lp-1", "mp-1"], + vectorAgentOrganizationId: "org-a", + }); + expect(render(update?.where)).toBe( + '("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))', + ); + }); + + it("releases the owner when the selection is cleared", async () => { + await setWebServerProviderIds("org-a", []); + + expect(mocks.updates[0]?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + }); + + it("returns null when another organization owns the local agent", async () => { + mocks.returning.mockReturnValue([]); + + await expect( + setWebServerProviderIds("org-b", ["lp-9"]), + ).resolves.toBeNull(); + }); +}); + +describe("releaseWebServerAgent", () => { + it("clears the selection and the owner only for the owning organization", async () => { + await releaseWebServerAgent("org-a"); + + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + expect(render(update?.where)).toBe( + '"webServerSettings"."vectorAgentOrganizationId" = $1', + ); + }); +}); diff --git a/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts new file mode 100644 index 000000000..be3fd9e0a --- /dev/null +++ b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts @@ -0,0 +1,104 @@ +import { + getHostSystemStats, + isPhysicalDisk, +} from "@dokploy/server/monitoring/utils"; +import { describe, expect, it, vi } from "vitest"; + +const GiB = 1024 ** 3; +const diskStat = (device: string, readGiB: number, writeGiB: number) => ({ + device, + readBytes: { toBytes: () => readGiB * GiB }, + writeBytes: { toBytes: () => writeGiB * GiB }, +}); + +vi.mock("node-os-utils", () => ({ + OSUtils: class { + cpu = { usage: async () => ({ success: false }) }; + memory = { info: async () => ({ success: false }) }; + network = { overview: async () => ({ success: false }) }; + disk = { + stats: async () => ({ + success: true, + data: [ + diskStat("nvme0n1", 50, 100), + diskStat("nvme0n1p1", 50, 100), + diskStat("nvme1n1", 50, 100), + diskStat("nvme1n1p1", 50, 100), + diskStat("md0", 50, 100), + diskStat("md0p1", 50, 100), + diskStat("dm-0", 50, 100), + diskStat("zram0", 10, 20), + ], + }), + }; + }, +})); + +describe("isPhysicalDisk (#5513, #5385)", () => { + it.each([ + "sda", + "sdab", + "hda", + "vda", + "xvda", + "nvme0n1", + "nvme1n1", + "mmcblk0", + ])("counts whole disk %s", (device) => { + expect(isPhysicalDisk(device)).toBe(true); + }); + + it.each([ + "sda1", + "sdab12", + "hda1", + "vda1", + "xvda1", + "nvme0n1p1", + "nvme1n1p4", + "mmcblk0p1", + ])("skips partition %s", (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }); + + it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])( + "skips stacked device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])( + "skips virtual device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it("counts each write once on an mdadm RAID1 host", () => { + const devices = [ + "nvme0n1", + "nvme0n1p1", + "nvme0n1p2", + "nvme0n1p3", + "nvme0n1p4", + "nvme1n1", + "nvme1n1p1", + "nvme1n1p2", + "nvme1n1p3", + "nvme1n1p4", + "md0", + "md1", + "md2", + "md3", + ]; + expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]); + }); +}); + +describe("getHostSystemStats Block I/O (#5513, #5385)", () => { + it("sums only whole physical disks", async () => { + const stats = await getHostSystemStats(); + expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB"); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index ee0e61d7a..08594e6c9 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => { ).resolves.toBeUndefined(); }); + it("owner bypasses telemetryProvider.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses telemetryProvider.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).resolves.toBeUndefined(); + }); + it("owner bypasses multiple enterprise permissions at once", async () => { memberToReturn = mockMemberData("owner"); await expect( @@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati checkPermission(ctx, { registry: ["create"] }), ).rejects.toThrow(); }); + + it("member is denied telemetryProvider.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).rejects.toThrow(); + }); + + it("member is denied telemetryProvider.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).rejects.toThrow(); + }); }); describe("static roles validate free-tier resources", () => { diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index 970c24d07..4849a0ba7 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [ "gitProviders", "traefikFiles", "api", + "telemetryProvider", ]; const ENTERPRISE_RESOURCES = [ @@ -41,6 +42,7 @@ const ENTERPRISE_RESOURCES = [ "auditLog", "vaultProvider", "dnsProvider", + "ai", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index b78627ea9..6f3f66618 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => { expect(perms.destination.read).toBe(false); expect(perms.notification.read).toBe(false); expect(perms.auditLog.read).toBe(false); + expect(perms.telemetryProvider.read).toBe(false); }); }); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index d1a03fedc..2372df11f 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, + telemetryProviderIds: [], + vectorAgentOrganizationId: null, buildsConcurrency: 1, logCleanupCron: null, metricsConfig: { @@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = { errorPageDescription: null, ogImageUrl: null, footerText: null, + logoLightUrl: null, + loginLogoLightUrl: null, + faviconLightUrl: null, + dualModeBranding: false, }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, diff --git a/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts new file mode 100644 index 000000000..c9c2167b4 --- /dev/null +++ b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts @@ -0,0 +1,119 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// apiReadTraefikConfig validates against the real local paths(), which outside +// production live under the working directory. +const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`); + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + readDirectory: vi.fn(), + readConfigInPath: vi.fn(), + writeTraefikConfigInPath: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }), + ...mocks, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +const { settingsRouter } = await import("@/server/api/routers/settings"); + +const caller = settingsRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "member" }, +} as Parameters[0]); + +const filePath = path.join(traefikPath, "dynamic/app.yml"); + +describe("traefik file endpoints scope remote servers to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + organizationId: serverId === "server-a" ? "org-a" : "org-b", + })); + mocks.readDirectory.mockResolvedValue([]); + mocks.readConfigInPath.mockResolvedValue("http: {}"); + }); + + it("rejects listing a server from another organization", async () => { + await expect( + caller.readDirectories({ serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readDirectory).not.toHaveBeenCalled(); + }); + + it("rejects writing to a server from another organization", async () => { + await expect( + caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-b", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled(); + }); + + it("rejects reading from a server from another organization", async () => { + await expect( + caller.readTraefikFile({ path: filePath, serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readConfigInPath).not.toHaveBeenCalled(); + }); + + it("allows servers of the active organization", async () => { + await caller.readDirectories({ serverId: "server-a" }); + await caller.readTraefikFile({ path: filePath, serverId: "server-a" }); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-a", + }); + + expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a"); + expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a"); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + "server-a", + ); + }); + + it("does not look up a server when none is given", async () => { + await caller.readDirectories({}); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + }); + + expect(mocks.findServerById).not.toHaveBeenCalled(); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + undefined, + ); + }); +}); diff --git a/apps/dokploy/__test__/utils/account-deletion.test.ts b/apps/dokploy/__test__/utils/account-deletion.test.ts new file mode 100644 index 000000000..b83ad0b7f --- /dev/null +++ b/apps/dokploy/__test__/utils/account-deletion.test.ts @@ -0,0 +1,182 @@ +import { + checkDeletionCode, + generateDeletionCode, + serializeDeletionCode, +} from "@dokploy/server/services/account-deletion"; +import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot"; +import Stripe from "stripe"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { cancelStripeSubscriptions } from "@/server/utils/billing"; + +const makeStripe = ( + subscriptions: Array<{ id: string; status: string }>, + listError?: Error, +) => { + const stripe = { + subscriptions: { + list: vi.fn(() => + listError + ? Promise.reject(listError) + : Promise.resolve({ data: subscriptions }), + ), + cancel: vi.fn(() => Promise.resolve({})), + }, + }; + return stripe as unknown as Stripe & typeof stripe; +}; + +describe("cancelStripeSubscriptions", () => { + test("cancels every non-terminal subscription without invoicing", async () => { + const stripe = makeStripe([ + { id: "sub_active", status: "active" }, + { id: "sub_trial", status: "trialing" }, + { id: "sub_past_due", status: "past_due" }, + { id: "sub_canceled", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ]); + + const result = await cancelStripeSubscriptions("cus_1", stripe); + + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ + customer: "cus_1", + status: "all", + limit: 100, + }); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", { + invoice_now: false, + prorate: false, + }); + expect(result).toEqual({ + cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"], + }); + }); + + test("treats a missing customer as having nothing to cancel", async () => { + const missing = new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such customer", + }); + const stripe = makeStripe([], missing); + + const result = await cancelStripeSubscriptions("cus_missing", stripe); + + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelledSubscriptions: [] }); + }); + + test("propagates unexpected Stripe errors", async () => { + const failure = new Stripe.errors.StripeAPIError({ + type: "api_error", + message: "boom", + }); + const stripe = makeStripe([], failure); + + await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe( + failure, + ); + }); +}); + +describe("deleteHubSpotContactByEmail", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("is skipped when no access token is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe( + "skipped", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the GDPR delete endpoint with the email as id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "deleted", + ); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer token", + ); + expect(JSON.parse(init.body as string)).toEqual({ + idProperty: "email", + objectId: "a@b.com", + }); + }); + + test("maps 404 to not_found and other errors to failed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "not_found", + ); + + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "failed", + ); + }); +}); + +describe("account deletion codes", () => { + test("generates six-digit numeric codes", () => { + for (let i = 0; i < 50; i++) { + expect(generateDeletionCode()).toMatch(/^\d{6}$/); + } + }); + + test("accepts the right code, ignoring surrounding whitespace", () => { + const value = serializeDeletionCode("482913"); + + expect(checkDeletionCode(value, "482913")).toEqual({ + valid: true, + attempts: 0, + }); + expect(checkDeletionCode(value, " 482913 ").valid).toBe(true); + }); + + test("counts failed attempts without revealing the hash", () => { + const value = serializeDeletionCode("482913"); + + const first = checkDeletionCode(value, "000000"); + expect(first.valid).toBe(false); + if (first.valid) throw new Error("unreachable"); + expect(first.attempts).toBe(1); + expect(JSON.parse(first.nextValue)).toEqual({ + ...JSON.parse(value), + attempts: 1, + }); + + const second = checkDeletionCode(first.nextValue, "999999"); + expect(second.valid).toBe(false); + if (second.valid) throw new Error("unreachable"); + expect(second.attempts).toBe(2); + + expect(checkDeletionCode(second.nextValue, "482913")).toEqual({ + valid: true, + attempts: 2, + }); + expect(value).not.toContain("482913"); + }); +}); diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts index 6f7a3b358..e428de9f3 100644 --- a/apps/dokploy/__test__/utils/remote-stream.test.ts +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, }); expect(bytes).toBe(11); - }); + }, 30_000); it("pipes data larger than the pipe buffer unchanged", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); @@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "echo boom >&2; exit 3" }, }), ).rejects.toThrow("target exited with code 3: boom"); - }); + }, 30_000); it("reports a source failure", async () => { await expect( @@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "cat > /dev/null" }, }), ).rejects.toThrow("source exited with code 2"); - }); + }, 30_000); }); diff --git a/apps/dokploy/__test__/wss/listen-deployment.test.ts b/apps/dokploy/__test__/wss/listen-deployment.test.ts new file mode 100644 index 000000000..947006da2 --- /dev/null +++ b/apps/dokploy/__test__/wss/listen-deployment.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from "node:events"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import WebSocket from "ws"; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + signals: string[] = []; + // Mirrors ChildProcess: `killed` flips as soon as a signal is delivered, + // even if the process ignores it and keeps running. + ignoreSigterm = false; + + kill(signal: NodeJS.Signals = "SIGTERM") { + this.signals.push(signal); + this.killed = true; + if (signal === "SIGTERM" && this.ignoreSigterm) return true; + this.signalCode = signal; + this.emit("close", null, signal); + return true; + } +} + +const children = vi.hoisted(() => [] as FakeChild[]); +const nextChild = vi.hoisted(() => ({ ignoreSigterm: false })); +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(() => { + const child = new FakeChild(); + child.ignoreSigterm = nextChild.ignoreSigterm; + children.push(child); + return child; + }), +})); + +const sshClients = vi.hoisted( + () => [] as { connect: ReturnType }[], +); +vi.mock("ssh2", () => ({ + Client: vi.fn(function (this: Record) { + this.on = vi.fn(() => this); + this.connect = vi.fn(() => this); + this.end = vi.fn(); + sshClients.push(this as never); + }), +})); + +const mockValidateRequest = vi.hoisted(() => vi.fn()); +const mockFindServerById = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + validateRequest: mockValidateRequest, + findServerById: mockFindServerById, +})); +vi.mock("@dokploy/server/wss/utils", () => ({ + readValidDirectory: () => true, +})); + +import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment"; + +const AUTH = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +const flush = () => new Promise((r) => setTimeout(r, 50)); + +let server: http.Server; +let url: string; + +beforeEach(async () => { + children.length = 0; + sshClients.length = 0; + nextChild.ignoreSigterm = false; + vi.clearAllMocks(); + server = http.createServer(); + setupDeploymentLogsWebSocketServer(server); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const { port } = server.address() as AddressInfo; + url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`; +}); + +afterEach(async () => { + vi.useRealTimers(); + await new Promise((r) => server.close(r)); +}); + +const connect = async (query = "") => { + const ws = new WebSocket(`${url}${query}`); + await new Promise((r) => ws.once("open", r)); + return ws; +}; + +const closeClient = async (ws: WebSocket) => { + ws.close(); + await new Promise((r) => ws.once("close", r)); + await flush(); +}; + +describe("deployment logs websocket (#5518)", () => { + it("kills tail when the client disconnects", async () => { + mockValidateRequest.mockResolvedValue(AUTH); + const ws = await connect(); + await flush(); + expect(children).toHaveLength(1); + + await closeClient(ws); + expect(children[0]!.signals).toContain("SIGTERM"); + }); + + it("does not spawn tail when the client disconnects during auth", async () => { + const auth = deferred(); + mockValidateRequest.mockReturnValue(auth.promise); + const ws = await connect(); + + await closeClient(ws); + auth.resolve(AUTH); + await flush(); + + const leaked = children.filter( + (c) => c.exitCode === null && c.signalCode === null, + ); + expect(leaked).toHaveLength(0); + }); + + it("does not open an SSH session when the client disconnects while loading the server", async () => { + mockValidateRequest.mockResolvedValue(AUTH); + const serverLookup = deferred(); + mockFindServerById.mockReturnValue(serverLookup.promise); + const ws = await connect("&serverId=srv-1"); + + await closeClient(ws); + serverLookup.resolve({ + organizationId: "org-1", + sshKeyId: "key-1", + sshKey: { privateKey: "key" }, + ipAddress: "10.0.0.1", + port: 22, + username: "root", + }); + await flush(); + + const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0); + expect(connected).toHaveLength(0); + }); + + it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => { + nextChild.ignoreSigterm = true; + mockValidateRequest.mockResolvedValue(AUTH); + const ws = await connect(); + await flush(); + + await closeClient(ws); + await new Promise((r) => setTimeout(r, 1100)); + expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index ac2531f94..e55db5524 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -31,6 +31,45 @@ import { api } from "@/utils/api"; // Railpack versions from https://github.com/railwayapp/railpack/releases export const RAILPACK_VERSIONS = [ + "0.39.0", + "0.38.0", + "0.37.1", + "0.37.0", + "0.36.4", + "0.36.3", + "0.36.2", + "0.36.1", + "0.36.0", + "0.35.0", + "0.34.0", + "0.33.0", + "0.32.0", + "0.31.2", + "0.31.1", + "0.31.0", + "0.30.1", + "0.30.0", + "0.29.0", + "0.28.0", + "0.27.2", + "0.27.1", + "0.27.0", + "0.26.1", + "0.26.0", + "0.25.0", + "0.24.0", + "0.23.0", + "0.22.2", + "0.22.1", + "0.22.0", + "0.21.0", + "0.20.0", + "0.19.0", + "0.18.0", + "0.17.2", + "0.17.1", + "0.17.0", + "0.16.0", "0.15.4", "0.15.3", "0.15.2", @@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [ "0.9.1", "0.9.0", "0.8.0", + "0.7.2", + "0.7.1", "0.7.0", + "0.6.1", "0.6.0", + "0.5.1", "0.5.0", "0.4.0", "0.3.0", + "0.2.3", "0.2.2", + "0.2.1", + "0.2.0", + "0.1.2", + "0.1.1", + "0.1.0", ] as const; export enum BuildType { diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx index e471b3fc1..3b794c1ed 100644 --- a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => { return ( - -
- Patches + +
+ Patches Apply code patches to your repository during build. Patches are applied after cloning the repository and before building. diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3c76fb969..f6fb952ce 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { }; return ( - - -
-
- - Scheduled Tasks - - - Schedule tasks to run automatically at specified intervals. - -
- {schedules && schedules.length > 0 && ( - - )} + + +
+ Scheduled Tasks + + Schedule tasks to run automatically at specified intervals. +
+ {schedules && schedules.length > 0 && ( + + )}
- + {isLoadingSchedules ? (
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 1a72b4640..29848e7bd 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 @@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({ }; return ( - - -
-
- - Volume Backups - - - Schedule volume backups to run automatically at specified - intervals - -
-
- {volumeBackups && volumeBackups.length > 0 && ( - <> - -
- -
- - )} -
+ + +
+ Volume Backups + + Schedule volume backups to run automatically at specified intervals + +
+
+ {volumeBackups && volumeBackups.length > 0 && ( + <> + +
+ +
+ + )}
- + {isLoadingVolumeBackups ? (
diff --git a/apps/dokploy/components/dashboard/billing/trial-banner.tsx b/apps/dokploy/components/dashboard/billing/trial-banner.tsx index 0c5bfdffb..caf3634f1 100644 --- a/apps/dokploy/components/dashboard/billing/trial-banner.tsx +++ b/apps/dokploy/components/dashboard/billing/trial-banner.tsx @@ -6,28 +6,41 @@ import { api } from "@/utils/api"; export const TrialBanner = () => { const router = useRouter(); const { data: billingStatus } = api.stripe.getBillingStatus.useQuery(); + const { mutateAsync: createCustomerPortalSession, isPending } = + api.stripe.createCustomerPortalSession.useMutation(); - if (!billingStatus?.isOnTrial) { + const handleAddPaymentMethod = async () => { + const session = await createCustomerPortalSession().catch(() => null); + if (session?.url) { + window.open(session.url); + return; + } + router.push("/dashboard/settings/billing"); + }; + + if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) { return null; } const daysRemaining = billingStatus.trialDaysRemaining ?? 0; + const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; return (
{daysRemaining > 0 - ? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.` - : "Your free trial ends today."} + ? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.` + : "Your free trial ends today. Add a payment method to keep your servers."}
); diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 5d95aafce..0dd1fb211 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, composeId, sourceType: "bitbucket", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) 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 a454ff610..2a22bdb36 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 @@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { composeId, sourceType: "git", composePath: values.composePath, - composeStatus: "idle", watchPaths: values.watchPaths || [], enableSubmodules: values.enableSubmodules, }) 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 24ed16117..219819b35 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 @@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { giteaId: data.giteaId, composeId, sourceType: "gitea", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, } as any) 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 2266e10dc..0f08be5fb 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 @@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, githubId: data.githubId, sourceType: "github", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, triggerType: data.triggerType, 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 c101d091b..baa22f6d5 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 @@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabProjectId: data.repository.id, gitlabPathNamespace: data.repository.gitlabPathNamespace, sourceType: "gitlab", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index d90ef92aa..4c9c3da41 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -554,7 +554,10 @@ export const RestoreBackup = ({ value={field.value} onValueChange={(value: DatabaseType) => { field.onChange(value); - form.setValue("metadata", {}); + form.setValue("metadata", { + serviceName: + form.getValues("metadata.serviceName") ?? "", + }); }} > @@ -581,7 +584,7 @@ export const RestoreBackup = ({
setGlobalFilter(e.target.value)} - className="max-w-xs" - /> - - -
-
- {isLoading ? ( -
- - Loading deployments... -
- ) : ( - <> -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - +
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + ))} + )) + ) : ( + + +
+ +

No deployments found

+

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

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries +
-
-
- - Rows per page - - - - Showing{" "} - {filteredData.length === 0 - ? 0 - : pagination.pageIndex * pagination.pageSize + 1}{" "} - to{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )}{" "} - of {filteredData.length} entries - -
-
- - -
+
+ +
- - )} -
+
+ + )}
); } diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index e2a78a696..f5766f7c4 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
-
+
+ const content = ( +
{label} @@ -63,6 +69,16 @@ function StatCard({
); + + if (href) { + return ( + + {content} + + ); + } + + return content; } function StatusListCard({ @@ -182,6 +198,7 @@ export const ShowHome = () => { label="Projects" value={String(totals.projects)} delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`} + href="/dashboard/projects" /> { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [record, setRecord] = useState(null); + const { mutateAsync, isPending } = + api.user.deleteAccountByEmail.useMutation(); + + const canConfirm = + email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD; + + const reset = () => { + setEmail(""); + setConfirmation(""); + setRecord(null); + }; + + const handleDelete = async () => { + try { + const result = await mutateAsync({ email: email.trim() }); + setRecord(JSON.stringify(result, null, 2)); + toast.success(`Account ${result.email} deleted`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deleting account", + ); + } + }; + + return ( + { + setOpen(value); + if (!value) reset(); + }} + > + + + + + + Delete an account by email + + Cancels every Stripe subscription and deletes the user with all the + organizations they own. Invoices stay in Stripe for accounting. Use + it to fulfil data deletion requests. + + + {record ? ( +
+ + Deletion completed. Keep this record as evidence of the request + being fulfilled. + +
+							{record}
+						
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setConfirmation(e.target.value)} + /> +
+
+ )} + + {record ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index c6c72fae6..8a66b5270 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; +import { DeleteAccountByEmail } from "./delete-account-by-email"; type User = typeof authClient.$Infer.Session.user; @@ -78,12 +79,12 @@ export const ImpersonationBar = () => { }); const filteredUsers = response.data?.users.filter( - // @ts-ignore + // @ts-expect-error (user) => user.allowImpersonation && data?.user?.email !== user.email, ); if (!response.error) { - // @ts-ignore + // @ts-expect-error setUsers(filteredUsers || []); } } catch (error) { @@ -185,6 +186,7 @@ export const ImpersonationBar = () => { {!isImpersonating ? (
@@ -282,6 +284,7 @@ export const ImpersonationBar = () => { Impersonate +
) : (
diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx index da29cae7c..95780bf63 100644 --- a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => { Skip onboarding? If this is your first time using Dokploy, we recommend going - through these steps — it only takes a couple of minutes and gives - you a feel for how projects, servers and deployments fit together. + through these steps – it only takes a couple of minutes and gives + you a feel for how projects, servers, and deployments fit + together. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 9fe8a4947..c32a0dac2 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -11,17 +11,54 @@ import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; import { displayFont } from "../font"; -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, -); +const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + ? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + : null; + +type Tier = "hobby" | "startup"; + +const PLANS: { + tier: Tier; + name: string; + description: string; + price: number; + features: string[]; + recommended?: boolean; +}[] = [ + { + tier: "hobby", + name: "Hobby", + description: "For individual developers", + price: calculatePriceHobby(1, false), + features: [ + "Setup 1 server", + "Unlimited apps & databases", + "2 environments", + "Community support", + ], + }, + { + tier: "startup", + name: "Startup", + description: "For small to mid-size teams", + price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false), + recommended: true, + features: [ + `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ], + }, +]; interface Props { onNext: () => void; } export const PlanStep = ({ onNext }: Props) => { - const [loadingTier, setLoadingTier] = useState< - "hobby" | "startup" | "trial" | null + const [loading, setLoading] = useState< + `${"trial" | "checkout"}:${Tier}` | null >(null); const { data } = api.stripe.getProducts.useQuery(); const { mutateAsync: createCheckoutSession } = @@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => { api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); - const handleCheckout = async (tier: "hobby" | "startup") => { + const handleCheckout = async (tier: Tier) => { if (!data) return; const productId = tier === "hobby" ? data.hobbyProductId : data.startupProductId; if (!productId) return; - setLoadingTier(tier); + setLoading(`checkout:${tier}`); try { const stripe = await stripePromise; const session = await createCheckoutSession({ @@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => { await stripe?.redirectToCheckout({ sessionId: session.sessionId }); } catch { toast.error("Error starting checkout"); - setLoadingTier(null); + setLoading(null); } }; - const handleTrial = async () => { - setLoadingTier("trial"); + const handleTrial = async (tier: Tier) => { + setLoading(`trial:${tier}`); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await utils.project.onboardingStatus.invalidate(); toast.success("Your 7-day trial has started"); onNext(); @@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => { error instanceof Error ? error.message : "Error starting trial", ); } finally { - setLoadingTier(null); + setLoading(null); } }; @@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => { Start free, upgrade when ready.

- No credit card for the trial — add one only if you decide to stay. + Try any plan free for 7 days. No credit card required — add one only + if you decide to stay.

-
-
-
- - Recommended - -

- 7-day free trial -

-

- No card required — cancel anytime. -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((f) => ( -
  • - - {f} -
  • - ))} -
-
- -
-
-
-
-
-

Hobby

-

- For individual developers -

-

- ${calculatePriceHobby(1, false).toFixed(2)} - - {" "} - /mo - -

-
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "2 environments", - "Community support", - ].map((f) => ( -
  • { + const productId = + plan.tier === "hobby" + ? data?.hobbyProductId + : data?.startupProductId; + return ( +
    +
    + {plan.recommended && ( + + Recommended + + )} +

    {plan.name}

    +

    + {plan.description} +

    +

    + ${plan.price.toFixed(2)} + + {" "} + /mo + +

    +
      + {plan.features.map((f) => ( +
    • + + {f} +
    • + ))} +
    +
    +
    +
  • - ))} -
-
- -
- -
-
-

Startup

-

- For small to mid-size teams -

-

- $ - {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( - 2, - )} - - {" "} - /mo - -

-
    - {[ - `Setup up to ${STARTUP_SERVERS_INCLUDED} servers`, - "Unlimited users & environments", - "Basic RBAC + 2FA", - "Email & chat support", - ].map((f) => ( -
  • + +
  • - ))} -
-
- -
+ Subscribe now + +
+
+ ); + })}
); diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx index 24c37d9d9..ccc43b030 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {

Create your first project.

- Projects group your apps, databases and environments together. + Projects group your apps, databases, and environments together.

diff --git a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx index 08efb12c3..7af9b6ee3 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/server-step.tsx @@ -1,6 +1,12 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react"; +import { + CheckCircle2, + CopyIcon, + InfoIcon, + Loader2, + XCircle, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {

Connect a server.

- Dokploy deploys to servers you own — buy one from any VPS provider + Dokploy deploys to servers you own. Buy one from any VPS provider (Hetzner, DigitalOcean, Hostinger...) and paste its IP below.

+
+ +
+ Requirements + + To ensure a smooth experience with Dokploy, your server should + have at least 1GB of RAM and 30GB of disk space. + +
+
{!createdServerId ? ( @@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => { {isSettingUp ? (
- Setting up your server — installing Docker and dependencies... + Setting up your server – installing Docker and dependencies…
) : isValidating && !validation ? (
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{!isReady && !isSettingUp && ( - Setup usually takes a minute after the server boots — this checks + Setup runs after the server boots and this page checks for it automatically. You can retry setup, or use "Skip for now" above to continue and come back to this later. diff --git a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx index 967075452..6f5ef9a1e 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/welcome-step.tsx @@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => { Let's get your first app live.

- A few steps — pick a plan, connect a server, ship something. You'll - have a working URL by the end. + It just takes a few steps: pick a plan, connect a server, ship + something. You'll have a working URL by the end.

diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index be12da353..71e5b36ce 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -1,5 +1,6 @@ import { Rocket } from "lucide-react"; import { useRouter } from "next/router"; +import { useState } from "react"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; import { @@ -8,6 +9,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const SUBTAB_VALUES = ["deployments", "queue"] as const; @@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => { ? router.query.subtab : DEFAULT_SUBTAB; + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const setSubtab = (value: string) => { if (!isValidSubtab(value)) return; const { subtab: _current, ...query } = router.query; @@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - - Deployments - Queue - +
+ + Deployments + Queue + + {subtab === "deployments" && ( +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+ )} +
- + diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 44512d93a..07fe775dc 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { {viewMode === "detailed" && template?.tags?.length > 0 && (
- {template?.tags?.map((tag) => ( + {template.tags.slice(0, 6).map((tag) => ( { {tag} ))} + {template.tags.length > 6 && ( + + +{template.tags.length - 6} + + )}
)}
diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 0a020ec68..1e496a68b 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { <>
-
+
{ const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery(); const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { data: currentUser } = api.user.get.useQuery(); + const isOrgAdmin = currentUser?.role === "owner" || currentUser?.role === "admin"; return (
- +
@@ -36,7 +38,9 @@ export const AiForm = () => {
{isOrgAdmin && } - {aiConfigs && aiConfigs?.length > 0 && } + {permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && ( + + )}
@@ -53,7 +57,7 @@ export const AiForm = () => { You don't have any AI configurations - + {permissions?.ai.create && }
) : (
@@ -62,7 +66,7 @@ export const AiForm = () => { key={config.aiId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{config.name} @@ -70,33 +74,37 @@ export const AiForm = () => { {config.model}
- - { - await mutateAsync({ - aiId: config.aiId, - }) - .then(() => { - toast.success("AI deleted successfully"); - refetch(); + {permissions?.ai.update && ( + + )} + {permissions?.ai.delete && ( + { + await mutateAsync({ + aiId: config.aiId, }) - .catch(() => { - toast.error("Error deleting AI"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx index 38abf4107..2e90a9bb6 100644 --- a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx @@ -22,7 +22,7 @@ export const ShowApiKeys = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx index 43cfac1dd..67c15ee63 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx @@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index a85b3242e..ee8e47e04 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, -); +const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + ? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + : null; /** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */ export const calculatePrice = (count: number, isAnnual = false) => { @@ -105,13 +105,53 @@ export const ShowBilling = () => { api.stripe.upgradeSubscription.useMutation(); const { mutateAsync: updateInvoiceNotifications } = api.stripe.updateInvoiceNotifications.useMutation(); - const { mutateAsync: startFreeTrial, isPending: isStartingTrial } = + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); const utils = api.useUtils(); + const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); + const [switchingTrial, setSwitchingTrial] = useState(false); - const handleStartTrial = async () => { + const handleSwitchTrialPlan = async ( + tier: "hobby" | "startup", + isAnnualPlan: boolean, + label?: string, + ) => { + const planName = + label ?? + `${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`; + + const switchPlan = async () => { + await upgradeSubscription({ + tier, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: isAnnualPlan, + }); + await new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.all([ + utils.stripe.getBillingStatus.invalidate(), + utils.stripe.getProducts.invalidate(), + utils.user.get.invalidate(), + utils.server.count.invalidate(), + ]); + }; + + setSwitchingTrial(true); + await toast + .promise(switchPlan(), { + loading: `Switching to ${planName}...`, + success: `Your trial is now on ${planName}`, + error: (error) => + error instanceof Error ? error.message : "Error switching plan", + }) + .unwrap() + .catch(() => undefined); + setSwitchingTrial(false); + }; + + const handleStartTrial = async (tier: "hobby" | "startup") => { + setTrialTier(tier); try { - await startFreeTrial(); + await startFreeTrial({ tier }); await Promise.all([ utils.stripe.getBillingStatus.invalidate(), utils.stripe.getProducts.invalidate(), @@ -122,6 +162,8 @@ export const ShowBilling = () => { toast.error( error instanceof Error ? error.message : "Error starting trial", ); + } finally { + setTrialTier(null); } }; @@ -170,12 +212,23 @@ export const ShowBilling = () => { const useNewPricing = data?.hobbyProductId && data?.startupProductId; const products = data?.products.filter((product) => { - // @ts-ignore + // @ts-expect-error const interval = product?.default_price?.recurring?.interval; return isAnnual ? interval === "year" : interval === "month"; }); const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false; + const canStartTrial = + !isEnterpriseCloud && + !!billingStatus && + !billingStatus.plan && + !billingStatus.isOnTrial && + !billingStatus.hasUsedTrial; + /** Checkout would create a second subscription alongside a running trial. */ + const canSubscribe = + !isEnterpriseCloud && + (data?.subscriptions?.length ?? 0) === 0 && + !billingStatus?.isOnTrial; const maxServers = admin?.user.serversQuantity ?? 1; const percentage = ((servers ?? 0) / maxServers) * 100; const safePercentage = Math.min(percentage, 100); @@ -289,6 +342,11 @@ export const ShowBilling = () => { {billingStatus.plan ?? "Trial"} + {billingStatus.plan && ( + + {billingStatus.isAnnual ? "Annual" : "Monthly"} + + )}
{billingStatus.isOnTrial @@ -296,67 +354,94 @@ export const ShowBilling = () => { billingStatus.trialEndsAt ? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}` : "" + }${ + billingStatus.hasPaymentMethod + ? " · billing starts automatically" + : " · add a card to keep your servers" }` : "You're subscribed and billed automatically."}
- {billingStatus.isOnTrial && - admin?.user.stripeCustomerId && ( + {admin?.user.stripeCustomerId && ( +
+ {billingStatus.isOnTrial && + useNewPricing && + billingStatus.plan && + billingStatus.plan !== "legacy" && ( + <> + + + + )} - )} -
- )} - {!isEnterpriseCloud && - billingStatus && - !billingStatus.plan && - !billingStatus.isOnTrial && - !billingStatus.hasUsedTrial && ( -
-
- -
- - 7-day free trial - - - No credit card required — cancel anytime. - -
    - {[ - "Setup 1 server", - "Unlimited apps & databases", - "Community support", - ].map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- + )}
)} + {canStartTrial && ( +
+ +
+ + 7-day free trial on any plan + + + No credit card required — pick Hobby or Startup below. + +
+
+ )} {(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (

Servers Plan

@@ -933,68 +1018,71 @@ export const ShowBilling = () => { ))}
-
- - Servers: - - - - setHobbyServerQuantity( - Math.max( - 1, - Number( - (e.target as HTMLInputElement).value, - ) || 1, - ), - ) - } - className="text-center" - /> - -
-
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( +
+ + Servers: + + + setHobbyServerQuantity( + Math.max( + 1, + Number( + (e.target as HTMLInputElement).value, + ) || 1, + ), + ) + } + className="text-center" + /> + +
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
@@ -1067,85 +1155,83 @@ export const ShowBilling = () => { ))}
-
- - Servers (min. {STARTUP_SERVERS_INCLUDED} included) - -
- - - setStartupServerQuantity( - Math.max( - STARTUP_SERVERS_INCLUDED, - Number( - (e.target as HTMLInputElement).value, - ) || STARTUP_SERVERS_INCLUDED, - ), - ) - } - className="h-8 text-center" - /> - -
-
-
- {admin?.user.stripeCustomerId && ( - - )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( + {canSubscribe && ( +
+ + Servers (min. {STARTUP_SERVERS_INCLUDED} included) + +
- )} + + setStartupServerQuantity( + Math.max( + STARTUP_SERVERS_INCLUDED, + Number( + (e.target as HTMLInputElement).value, + ) || STARTUP_SERVERS_INCLUDED, + ), + ) + } + className="h-8 text-center" + /> + +
+
+ )} +
+ {canStartTrial && ( + + )} + {canSubscribe && ( + + )}
@@ -1337,31 +1423,17 @@ export const ShowBilling = () => {
- {admin?.user.stripeCustomerId && ( + {canSubscribe && ( )} - {!isEnterpriseCloud && - (data?.subscriptions?.length ?? 0) === 0 && ( - - )}
diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index 6f732657f..c69451c89 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -38,7 +38,7 @@ export const ShowCertificates = () => { return (
- +
@@ -111,7 +111,7 @@ export const ShowCertificates = () => { key={certificate.certificateId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx index 25eb01b09..86deb38a7 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx @@ -20,7 +20,7 @@ export const ShowRegistry = () => { return (
- +
@@ -55,7 +55,7 @@ export const ShowRegistry = () => { key={registry.registryId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx index 6ce5789f4..39741b932 100644 --- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx @@ -19,7 +19,7 @@ export const ShowDestinations = () => { const { data: permissions } = api.user.getPermissions.useQuery(); return (
- +
@@ -56,7 +56,7 @@ export const ShowDestinations = () => { key={destination.destinationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{index + 1}. {destination.name} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index d2d8c0d3f..66fdddc69 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({ applicationKey: z.string(), applicationSecret: z.string(), consumerKey: z.string(), + zone: z.string(), }); type DnsProviderForm = z.infer; @@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = { applicationKey: "", applicationSecret: "", consumerKey: "", + zone: "", }; const buildConfig = (data: DnsProviderForm) => { @@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => { applicationKey: data.applicationKey, applicationSecret: data.applicationSecret, consumerKey: data.consumerKey, + zone: data.zone.trim() || undefined, }; } }; @@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { applicationKey: provider.config.applicationKey, applicationSecret: provider.config.applicationSecret, consumerKey: provider.config.consumerKey, + zone: provider.config.zone ?? "", }), }); } else if (!dnsProviderId) { @@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { )} /> + ( + + Zone (optional) + + + + + Restrict this provider to a single DNS zone: Dokploy + will only list and change records in it. Leave empty to + use every zone the credentials can access. When set, the{" "} + GET /domain/zone right is not needed. This + is enforced by Dokploy only, so also limit the OVH token + to that zone if you need a hard guarantee. + + + + )} + /> )} diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx index d29e93495..d1980e66e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -35,7 +35,7 @@ export const ShowDnsProviders = () => { const { data: permissions } = api.user.getPermissions.useQuery(); return ( -
+
diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx index 9a3dbce71..f395018b2 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx @@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => { api.dnsProvider.listZones.useQuery({ dnsProviderId }); return ( -
+
diff --git a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx index df6d21d76..f4f000637 100644 --- a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx @@ -69,7 +69,7 @@ export const ShowGitProviders = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index b7cf186e7..0cd73d82a 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => { { apiUrl: apiUrl ?? "", apiKey: apiKey ?? "", + ...(aiId ? { aiId } : {}), }, { enabled: !!apiUrl && (isLocalOllama || !!apiKey), @@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => { apiUrl={apiUrl} apiKey={apiKey} model={form.watch("model")} + aiId={aiId} /> + ) : ( + + )} + + + + + {telemetryProviderId ? "Edit provider" : "Add a provider"} + + + Vector ships container logs and host/container metrics from every + server you deploy it on to this provider. + + + {(isError || testIsError || testByIdIsError) && ( + + {testError?.message || testByIdError?.message || error?.message} + + )} + +
+ + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {selectedType && ( + ( + + Sends + + + + )} + /> + )} + + {visibleFields.map((credentialField) => ( + ( + + + {credentialField.label} + {!credentialField.required && " (Optional)"} + + {credentialField.helpText && ( + + {credentialField.helpText} + + )} +
+ + + + {telemetryProviderId && + TOP_LEVEL_KEYS.has(credentialField.key) && + !credentialField.required && ( + + )} +
+ +
+ )} + /> + ))} + + ( + + + + + Enabled + + )} + /> + + + + + {isPartiallyTouched && ( + + You changed something that needs a fresh credential to test — fill + in every credential field to test with the new values, or revert + it to test with what's already saved. + + )} +
+ + +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx new file mode 100644 index 000000000..bc375a3d2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx @@ -0,0 +1,361 @@ +import { Copy, Loader2, Server } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +type Status = "running" | "not-running" | "stopped" | "unknown"; + +const statusBadge = (status: Status) => { + if (status === "running") { + return Running; + } + if (status === "not-running") { + return Not running; + } + if (status === "stopped") { + return Not deployed; + } + return Unreachable; +}; + +export const providerLabel = (provider: { name: string; signals: string[] }) => + `${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`; + +export const ShowServers = () => { + const utils = api.useUtils(); + const [selection, setSelection] = useState>({}); + const [pendingTarget, setPendingTarget] = useState(null); + const [errors, setErrors] = useState< + Record + >({}); + + const { data: providers } = api.telemetryProvider.all.useQuery(); + const { data: targets, isPending } = + api.telemetryProvider.serverStatus.useQuery(undefined, { + refetchOnWindowFocus: false, + staleTime: 30_000, + trpc: { context: { skipBatch: true } }, + }); + + const { mutateAsync: deployOnServer } = + api.telemetryProvider.deployOnServer.useMutation(); + const { mutateAsync: removeOnServer } = + api.telemetryProvider.removeOnServer.useMutation(); + + const keyOf = (serverId: string | null) => serverId ?? "local"; + + // The selection can still hold providers deleted since it was made. + const idsFor = (serverId: string | null, saved: string[]) => { + const ids = selection[keyOf(serverId)] ?? saved; + return providers + ? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id)) + : ids; + }; + + const toggleProvider = ( + serverId: string | null, + saved: string[], + telemetryProviderId: string, + ) => { + const key = keyOf(serverId); + const current = idsFor(serverId, saved); + setSelection({ + ...selection, + [key]: current.includes(telemetryProviderId) + ? current.filter((id) => id !== telemetryProviderId) + : [...current, telemetryProviderId], + }); + }; + + const setError = ( + serverId: string | null, + error?: { title: string; message: string }, + ) => + setErrors((prev) => { + const next = { ...prev }; + if (error) { + next[keyOf(serverId)] = error; + } else { + delete next[keyOf(serverId)]; + } + return next; + }); + + // The task is still starting right after a deploy, so look again once it had time to come up. + const refreshStatus = () => { + utils.telemetryProvider.serverStatus.invalidate(); + setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000); + }; + + const handleDeploy = async (serverId: string | null, ids: string[]) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await deployOnServer({ serverId, providerIds: ids }) + .then(() => { + toast.success("Vector agent deployed"); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Deploy failed", + message: + error instanceof Error + ? error.message + : "Failed to deploy the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const handleRemove = async (serverId: string | null) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await removeOnServer({ serverId }) + .then(() => { + toast.success("Vector agent removed"); + setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] })); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Remove failed", + message: + error instanceof Error + ? error.message + : "Failed to remove the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const hasProviders = (providers?.length ?? 0) > 0; + const visibleTargets = hasProviders + ? targets + : targets?.filter( + (target) => + target.telemetryProviderIds.length > 0 || + target.status === "running" || + target.status === "not-running", + ); + + return ( +
+ +
+ + + + Servers + + + Pick which providers each server ships to, then deploy its Vector + agent; a provider receives every signal it sends, and cAdvisor + runs next to the agent on 127.0.0.1:4510 when metrics are shipped. + Services deployed before Logs & Metrics need a redeploy to get the + labels it uses; until then their logs and container metrics are + not shipped from the Dokploy server. + + + + {isPending ? ( +
+ Checking servers... + +
+ ) : !hasProviders && !visibleTargets?.length ? ( +
+ + + Add a provider first — there's nowhere to ship to yet + +
+ ) : ( +
+ {visibleTargets?.map((target) => { + const key = keyOf(target.serverId); + const saved = target.telemetryProviderIds; + const ids = idsFor(target.serverId, saved); + const isBusy = pendingTarget === key; + const agentDeployed = target.status !== "stopped"; + const hasEnabledSelected = ids.some( + (id) => + providers?.find((p) => p.telemetryProviderId === id) + ?.enabled, + ); + + return ( +
+
+
+ + {target.name} + + {target.ipAddress && ( + + {target.ipAddress} + + )} +
+
+ {statusBadge(target.status as Status)} + {agentDeployed && ( + handleRemove(target.serverId)} + > + + + )} +
+
+ +
+ {hasProviders && ( + Providers + )} +
+ {providers?.map((provider) => { + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + return ( +
+ + toggleProvider( + target.serverId, + saved, + provider.telemetryProviderId, + ) + } + /> + {ProviderIcon && ( + + )} + + {!provider.enabled && ( + Disabled + )} +
+ ); + })} +
+
+ + {errors[key] && ( + +
+ + {errors[key].title} + +
+															{errors[key].message}
+														
+ +
+
+ )} + + {hasProviders && ( +
+ {ids.length > 0 && !hasEnabledSelected && ( + + Every selected provider is disabled — nothing + would ship. + + )} + +
+ )} +
+ ); + })} + + {visibleTargets?.length === 0 && ( +
+ + + You don't have any server yet + +
+ )} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx new file mode 100644 index 000000000..afe48a79f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx @@ -0,0 +1,196 @@ +import { Loader2, ScrollText, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { HandleTelemetryProvider } from "./handle-telemetry-provider"; + +interface TelemetryProviderRowProps { + provider: { + telemetryProviderId: string; + name: string; + enabled: boolean; + providerType: string; + signals: string[]; + }; + index: number; + typeLabel: string; + canEdit: boolean; + canDelete: boolean; + onDeleted: () => void; +} + +const TelemetryProviderRow = ({ + provider, + index, + typeLabel, + canEdit, + canDelete, + onDeleted, +}: TelemetryProviderRowProps) => { + const { mutateAsync, isPending: isRemoving } = + api.telemetryProvider.remove.useMutation(); + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + + return ( +
+
+
+ {ProviderIcon && } +
+ + {index + 1}. {provider.name} + + {provider.enabled ? "Enabled" : "Disabled"} + + {provider.signals.includes("logs") && ( + Logs + )} + {provider.signals.includes("metrics") && ( + Metrics + )} + +
{typeLabel}
+
+
+ +
+ {canEdit && ( + + )} + + {canDelete && ( + { + await mutateAsync({ + telemetryProviderId: provider.telemetryProviderId, + }) + .then((result) => { + if (result.warning) { + toast.warning("Provider deleted", { + description: result.warning, + }); + } else { + toast.success( + "Provider deleted — the agents that used it were re-applied", + ); + } + onDeleted(); + }) + .catch(() => { + toast.error("Error deleting provider"); + }); + }} + > + + + )} +
+
+
+ ); +}; + +export const ShowTelemetryProviders = () => { + const utils = api.useUtils(); + const { data, isPending, refetch } = api.telemetryProvider.all.useQuery(); + const { data: availableTypes } = + api.telemetryProvider.availableTypes.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const providerListLabel = availableTypes?.length + ? ` (${availableTypes.map((t) => t.label).join(", ")})` + : ""; + + return ( +
+ +
+ + + + Logs & Metrics + + + {`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`} + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any provider configured + + {permissions?.telemetryProvider.create && ( + + )} +
+ ) : ( +
+
+ {data?.map((provider, index) => ( + t.type === provider.providerType, + )?.label ?? provider.providerType + } + canEdit={!!permissions?.telemetryProvider.create} + canDelete={!!permissions?.telemetryProvider.delete} + onDeleted={() => { + refetch(); + utils.telemetryProvider.serverStatus.invalidate(); + }} + /> + ))} +
+ + {permissions?.telemetryProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 4411d22c7..ccb28ee4c 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -31,7 +31,7 @@ export const ShowNotifications = () => { return (
- +
@@ -70,7 +70,7 @@ export const ShowNotifications = () => { key={notification.notificationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{notification.notificationType === "slack" && (
diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx new file mode 100644 index 000000000..18a9f1bd2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -0,0 +1,214 @@ +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + +export const DeleteAccount = () => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"request" | "confirm">("request"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [expiresInMinutes, setExpiresInMinutes] = useState(10); + const { data } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: hasPassword } = api.user.hasPassword.useQuery(); + const requestCode = api.user.requestAccountDeletionCode.useMutation(); + const deleteAccount = api.user.deleteAccount.useMutation(); + + const email = data?.user?.email ?? ""; + const isBusy = requestCode.isPending || deleteAccount.isPending; + + if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) { + return null; + } + + const reset = () => { + setStep("request"); + setPassword(""); + setCode(""); + }; + + const handleRequestCode = async () => { + try { + const result = await requestCode.mutateAsync( + hasPassword ? { password } : {}, + ); + setExpiresInMinutes(result.expiresInMinutes); + setCode(""); + setStep("confirm"); + toast.success(`We sent a confirmation code to ${email}`); + } catch (error) { + toast.error(errorMessage(error, "Error sending the confirmation code")); + } + }; + + const handleDelete = async () => { + try { + await deleteAccount.mutateAsync({ code }); + toast.success("Your account has been deleted"); + await authClient.signOut().catch(() => undefined); + router.push("/"); + } catch (error) { + toast.error(errorMessage(error, "Error deleting account")); + } + }; + + return ( + + + + + Delete account + + + Permanently remove your account and all the data associated with it. + + + +
    +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • +
  • + Every organization you own is deleted, including its projects, + services, environments, domains and server connections. +
  • +
  • + In organizations you belong to but do not own, only your membership, + API keys and the Git providers you connected are removed. + Applications using those providers keep running but must be + reconnected to redeploy. +
  • +
  • Containers already running on your servers are not touched.
  • +
  • + We will email a confirmation code to{" "} + {email} before + anything is deleted. +
  • +
  • This action cannot be undone.
  • +
+
+ { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Delete your account? + + {step === "request" + ? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.` + : `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`} + + +
+ {step === "request" && hasPassword && ( + <> + + setPassword(e.target.value)} + /> + + )} + {step === "confirm" && ( + <> + + + setCode(e.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+ + Cancel + {step === "request" ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 82398be4d..ceb6d3cd1 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -150,7 +150,7 @@ export const ProfileForm = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx index 334d25b20..b1e12b440 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx @@ -1,6 +1,4 @@ -import { Activity } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const ShowServerActions = ({ serverId, asButton = false }: Props) => { +export const ShowServerActions = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - {asButton ? ( - - - + {children ? ( + {children} ) : ( - { - e.preventDefault(); - setIsOpen(true); - }} - > - View Actions - + + { + e.preventDefault(); + }} + > + View Actions + + )} - + e.preventDefault()} + >
Web server settings Reload or clean the web server. diff --git a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx index 6f6e72b11..eb5b1b711 100644 --- a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx @@ -109,8 +109,13 @@ export const DeleteServerModal = ({ const handleDeleteServer = async () => { try { - await deleteServer({ serverId }); + const result = await deleteServer({ serverId }); toast.success(`Server ${serverName} deleted successfully`); + if (result?.vectorAgentRemovalWarning) { + toast.error( + `Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`, + ); + } setOpen(false); utils.server.all.invalidate(); } catch (error: any) { @@ -121,7 +126,10 @@ export const DeleteServerModal = ({ return ( {children} - + e.preventDefault()} + > Delete Server diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx index d2c158f91..10518fc56 100644 --- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { Pencil, PlusIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -61,10 +61,10 @@ type Schema = z.infer; interface Props { serverId?: string; - asButton?: boolean; + children?: React.ReactNode; } -export const HandleServers = ({ serverId, asButton = false }: Props) => { +export const HandleServers = ({ serverId, children }: Props) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { data: canCreateMoreServers, refetch } = @@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => { return ( - {serverId ? ( - asButton ? ( - - - - ) : ( + {children ? ( + {children} + ) : serverId ? ( + { e.preventDefault(); - setIsOpen(true); }} > Edit Server - ) + ) : ( )} - + e.preventDefault()} + > {serverId ? "Edit" : "Create"} Server diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 71761536b..576e8eb44 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const SetupServer = ({ serverId, asButton = false }: Props) => { +export const SetupServer = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); const { data: server } = api.server.one.useQuery( { @@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => { return ( - {asButton ? ( + {children ? ( + {children} + ) : ( - - ) : ( - )} - + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx index a318d0b40..bc7df5b0f 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx @@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni interface Props { url: string; token: string; + children?: React.ReactNode; } -export const ShowMonitoringModal = ({ url, token }: Props) => { +export const ShowMonitoringModal = ({ url, token, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - - - - + {children ? ( + {children} + ) : ( + + + + )} + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 4eb3ae959..6931319bd 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -1,11 +1,15 @@ import { format } from "date-fns"; import { + Activity, + BarChartHorizontalBigIcon, Clock, Key, KeyIcon, Loader2, Network, + Pencil, ServerIcon, + Settings, Terminal, Trash2, User, @@ -49,7 +53,7 @@ export const ShowServers = () => { return (
{query?.success && isCloud && } - +
@@ -224,11 +228,19 @@ export const ShowServers = () => {
- - - + + + + + { {server.sshKeyId && permissions?.server.terminal && ( - -
- + + - -
-
+ + + +

Terminal

@@ -274,14 +284,19 @@ export const ShowServers = () => { )} - -
- -
-
+ + + + +

Edit Server

@@ -289,14 +304,19 @@ export const ShowServers = () => { {server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Web Server Actions

@@ -307,17 +327,23 @@ export const ShowServers = () => { server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Monitoring

@@ -328,22 +354,20 @@ export const ShowServers = () => { {permissions?.server.delete && ( - -
- + + - -
-
+ + + +

Delete Server

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 413af8bb7..86ea0a2ea 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx @@ -21,7 +21,7 @@ export const ShowDestinations = () => { return (
- +
@@ -57,7 +57,7 @@ export const ShowDestinations = () => { key={sshKey.sshKeyId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx index 232260cba..07eb9a7f3 100644 --- a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx @@ -22,7 +22,7 @@ export const TagManager = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 1c8952d43..7dbe5ac66 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -124,8 +124,11 @@ export const AddInvitation = () => { const mode = form.watch("mode"); useEffect(() => { - form.reset(); - }, [form, form.formState.isSubmitSuccessful, form.reset]); + if (!open) { + form.reset(); + setError(null); + } + }, [open, form, form.reset]); useEffect(() => { if (isCloud && form.getValues("mode") === "credentials") { @@ -225,7 +228,7 @@ export const AddInvitation = () => { Invite Method diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index 10543a626..098faf5f1 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
{isCustomRole && (
diff --git a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx index cca6fc7cc..b6e95cb75 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx @@ -40,7 +40,7 @@ export const ShowInvitations = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 347aa04a1..6e6743fd4 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -52,7 +52,7 @@ export const ShowUsers = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx index 14147594b..8c9b6e098 100644 --- a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx @@ -33,7 +33,7 @@ export const ShowVaultProviders = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index 4b92454ec..187bbb452 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -123,7 +123,7 @@ export const WebDomain = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index 8238e585b..a383fbf7d 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -24,7 +24,7 @@ export const WebServer = () => { return (
{/* */} - +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index afb1d4451..4a4189d11 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -45,9 +45,8 @@ export const Terminal: React.FC = ({ id, serverId }) => { term.loadAddon(clipboardAddon); fixMacOsAltKeys(term); - // @ts-ignore + // @ts-expect-error term.open(termRef.current); - // @ts-ignore term.loadAddon(addonFit); addonFit.fit(); @@ -89,9 +88,9 @@ export const Terminal: React.FC = ({ id, serverId }) => { }, [id, serverId]); return ( -
+
-
+
); diff --git a/apps/dokploy/components/icons/telemetry-provider-icons.tsx b/apps/dokploy/components/icons/telemetry-provider-icons.tsx new file mode 100644 index 000000000..e8cfe5cd5 --- /dev/null +++ b/apps/dokploy/components/icons/telemetry-provider-icons.tsx @@ -0,0 +1,105 @@ +import { AwsIcon } from "@/components/icons/vault-provider-icons"; + +interface Props { + className?: string; +} + +export const GrafanaLokiIcon = ({ className }: Props) => ( + + + +); + +export const DatadogIcon = ({ className }: Props) => ( + + + +); + +export const BetterStackIcon = ({ className }: Props) => ( + + + +); + +export const ElasticsearchIcon = ({ className }: Props) => ( + + + +); + +export const SplunkIcon = ({ className }: Props) => ( + + + +); + +export const PrometheusIcon = ({ className }: Props) => ( + + + +); + +export const NewRelicIcon = ({ className }: Props) => ( + + + +); + +export const InfluxDbIcon = ({ className }: Props) => ( + + + +); + +export const telemetryProviderIcons = { + loki: GrafanaLokiIcon, + prometheus_remote_write: PrometheusIcon, + new_relic: NewRelicIcon, + influxdb: InfluxDbIcon, + datadog: DatadogIcon, + aws_cloudwatch: AwsIcon, + splunk_hec: SplunkIcon, + elasticsearch: ElasticsearchIcon, + betterstack: BetterStackIcon, +} as const; diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 816e7bc6e..6762f127a 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; -import { HubSpotWidget } from "../shared/HubSpotWidget"; +import { useHubSpotChat } from "../shared/analytics"; import Page from "./side"; interface Props { @@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => { }); const isChatEnabled = isCloud === true && currentPlan === "startup"; + useHubSpotChat(isChatEnabled); const { data: onboardingStatus } = api.project.onboardingStatus.useQuery(); const shouldRedirectToOnboarding = @@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => { )} {children} - {isChatEnabled && ( - <> - - - )} - {haveRootAccess === true && } ); diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index ed46c1930..3776c2db5 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { "\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D"; const logoUrl = whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined; + const logoLightUrl = + whitelabeling?.loginLogoLightUrl || + whitelabeling?.logoLightUrl || + undefined; return (
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { href="/" className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary" > - + {appName}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 1e20a0fbe..9cbc187b6 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -29,6 +29,7 @@ import { type LucideIcon, Package, Palette, + ScrollText, Server, ShieldCheck, Smartphone, @@ -350,7 +351,13 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ permissions }) => !!permissions?.organization.update, + isEnabled: ({ permissions }) => + !!( + permissions?.ai.read || + permissions?.ai.create || + permissions?.ai.update || + permissions?.ai.delete + ), }, { isSingle: true, @@ -374,6 +381,13 @@ const MENU: Menu = { icon: Package, isEnabled: ({ permissions }) => !!permissions?.registry.read, }, + { + isSingle: true, + title: "Logs & Metrics", + url: "/dashboard/settings/logs-and-metrics", + icon: ScrollText, + isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read, + }, { isSingle: true, title: "Secrets", diff --git a/apps/dokploy/components/proprietary/audit-logs/columns.tsx b/apps/dokploy/components/proprietary/audit-logs/columns.tsx index 517335e29..ce36dbae8 100644 --- a/apps/dokploy/components/proprietary/audit-logs/columns.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/columns.tsx @@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record = { settings: "Settings", session: "Session", network: "Network", + telemetryProvider: "Logs & Metrics", }; function MetadataCell({ metadata }: { metadata: string | null }) { diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index 73409dc1a..22f1dd507 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -180,6 +180,16 @@ const RESOURCE_META: Record = { description: "Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records", }, + telemetryProvider: { + label: "Logs & Metrics", + description: + "Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to", + }, + ai: { + label: "AI Settings", + description: + "Manage AI providers, models, and use AI features (log analysis, suggestions)", + }, }; /** Descriptions for each action within a resource */ @@ -475,6 +485,40 @@ const ACTION_META: Record< description: "Remove DNS providers and delete their records", }, }, + telemetryProvider: { + read: { + label: "Read", + description: "View configured logs and metrics providers", + }, + create: { + label: "Create", + description: + "Add new providers, test their connection and deploy the Vector agent", + }, + delete: { + label: "Delete", + description: "Remove logs and metrics providers", + }, + }, + ai: { + read: { + label: "Read", + description: + "View AI providers and use AI features (log analysis, suggestions)", + }, + create: { + label: "Create", + description: "Add new AI provider configurations", + }, + update: { + label: "Update", + description: "Edit AI provider settings and custom providers", + }, + delete: { + label: "Delete", + description: "Remove AI provider configurations", + }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -586,6 +630,7 @@ const ROLE_PRESETS: { logs: ["read"], monitoring: ["read"], auditLog: ["read"], + ai: ["read", "create", "update", "delete"], }, }, ]; @@ -605,7 +650,7 @@ type CreateRoleSchema = z.infer; export const ManageCustomRoles = () => { return ( - +
diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx index d53af90a2..5d1beea37 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTheme } from "next-themes"; import { Card, CardContent, @@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps { config: { appName?: string; logoUrl?: string; + logoLightUrl?: string; + dualModeBranding?: boolean; footerText?: string; }; } export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) { const appName = config.appName || "Dokploy"; + const { resolvedTheme } = useTheme(); + const effectiveLogoUrl = + config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl + ? config.logoLightUrl + : config.logoUrl; return ( @@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
{/* Simulated sidebar header */}
- {config.logoUrl ? ( + {effectiveLogoUrl ? ( Preview Logo diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx index 3ba9e80bd..ee0e54e78 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx @@ -26,6 +26,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; import { WhitelabelingPreview } from "./whitelabeling-preview"; @@ -36,20 +37,42 @@ const safeUrlField = z message: "Only http:// and https:// URLs are allowed", }); -const formSchema = z.object({ - appName: z.string(), - appDescription: z.string(), - logoUrl: safeUrlField, - faviconUrl: safeUrlField, - customCss: z.string(), - loginLogoUrl: safeUrlField, - supportUrl: safeUrlField, - docsUrl: safeUrlField, - errorPageTitle: z.string(), - errorPageDescription: z.string(), - footerText: z.string(), - ogImageUrl: safeUrlField, -}); +const formSchema = z + .object({ + appName: z.string(), + appDescription: z.string(), + logoUrl: safeUrlField, + faviconUrl: safeUrlField, + customCss: z.string(), + loginLogoUrl: safeUrlField, + supportUrl: safeUrlField, + docsUrl: safeUrlField, + errorPageTitle: z.string(), + errorPageDescription: z.string(), + footerText: z.string(), + ogImageUrl: safeUrlField, + logoLightUrl: z.string(), + loginLogoLightUrl: z.string(), + faviconLightUrl: z.string(), + dualModeBranding: z.boolean(), + }) + .superRefine((data, ctx) => { + if (!data.dualModeBranding) return; + for (const field of [ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ] as const) { + const val = data[field]; + if (val !== "" && !/^https?:\/\//i.test(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Only http:// and https:// URLs are allowed", + path: [field], + }); + } + } + }); type FormSchema = z.infer; @@ -195,6 +218,10 @@ export function WhitelabelingSettings() { errorPageDescription: "", footerText: "", ogImageUrl: "", + logoLightUrl: "", + loginLogoLightUrl: "", + faviconLightUrl: "", + dualModeBranding: false, }, resolver: zodResolver(formSchema), }); @@ -214,6 +241,10 @@ export function WhitelabelingSettings() { errorPageDescription: data.errorPageDescription ?? "", footerText: data.footerText ?? "", ogImageUrl: data.ogImageUrl ?? "", + logoLightUrl: data.logoLightUrl ?? "", + loginLogoLightUrl: data.loginLogoLightUrl ?? "", + faviconLightUrl: data.faviconLightUrl ?? "", + dualModeBranding: data.dualModeBranding ?? false, }); } }, [data, form]); @@ -244,6 +275,10 @@ export function WhitelabelingSettings() { errorPageDescription: values.errorPageDescription || null, footerText: values.footerText || null, ogImageUrl: values.ogImageUrl || null, + logoLightUrl: values.logoLightUrl || null, + loginLogoLightUrl: values.loginLogoLightUrl || null, + faviconLightUrl: values.faviconLightUrl || null, + dualModeBranding: values.dualModeBranding, }, }) .then(async () => { @@ -412,6 +447,111 @@ export function WhitelabelingSettings() { + {/* Light Mode Logos Section */} + + +
+
+ Light Mode Logos + + Use separate logos for light and dark themes. When enabled, + the logos in Branding are used for dark mode. + +
+ ( + + + { + field.onChange(checked); + if (!checked) { + form.clearErrors([ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ]); + } + }} + /> + + + )} + /> +
+
+
+
+ + ( + + Logo URL + + + + + + )} + /> + + ( + + Login Page Logo URL + + + + + + )} + /> + + ( + + Favicon URL + + + + + Follows the browser/OS theme, not the website theme. + + + + )} + /> + +
+
+
+ {/* Appearance Section */} diff --git a/apps/dokploy/components/shared/HubSpotWidget.tsx b/apps/dokploy/components/shared/HubSpotWidget.tsx deleted file mode 100644 index ff3013459..000000000 --- a/apps/dokploy/components/shared/HubSpotWidget.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Script from "next/script"; - -export const HubSpotWidget = () => { - return ( -