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/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" < + 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..6c409cc77 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -0,0 +1,123 @@ +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"); + }); + + it("resolves build.context against the compose file's own directory when there are no mounts", () => { + const cmd = createCommand({ + ...base, + composePath: "./backend/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd).toContain("-f ./backend/docker-compose.yml"); + }); +}); + +describe("compose createCommand --env-file", () => { + it("points --env-file at the generated .env next to a nested compose file", () => { + const cmd = createCommand( + { + ...base, + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file deploy/.env"); + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + }); + + it("omits --env-file when createEnvFile is disabled", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); + + it("uses the code-root .env for raw sourceType", () => { + const cmd = createCommand( + { + ...base, + sourceType: "raw", + composePath: "docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file .env"); + }); + + it("does not add --env-file to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); +}); 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__/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__/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/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__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts new file mode 100644 index 000000000..89b8b390d --- /dev/null +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -0,0 +1,252 @@ +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; + } +}; + +// Swarm keeps converging a service for a bit after it's created (scheduling +// tasks, resolving endpoints), which bumps Version.Index on its own. Calling +// setupMonitoring again before that settles races that internal bump, so wait +// for two consecutive reads to agree before treating the service as stable. +const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let lastIndex: string | null = null; + while (Date.now() < deadline) { + const inspect = await docker.getService(name).inspect(); + if (inspect.Version.Index === lastIndex) return; + lastIndex = inspect.Version.Index; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; + +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 waitForServiceConvergence(SERVICE_NAME); + 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/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/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/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/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 6a6ad62ae..f4206ee95 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -191,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) => ({ 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/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index a7b4d3014..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,6 +55,7 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "swarm", + refetchInterval: 5000, }, ); @@ -66,22 +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 = @@ -106,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/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 20570b07a..da4c854d0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -352,11 +352,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
+
+ ))} +
+ + )} + {!!data?.missing.length && ( <> diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index d6aab6ebb..ff0b18a30 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) { }, { enabled: !!organizationId, + refetchOnWindowFocus: false, }, ); const { mutateAsync, isPending } = organizationId diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 802b60e46..1e496a68b 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -17,11 +17,13 @@ import { Download, Globe, InfoIcon, + Loader2, Server, TrendingUpIcon, } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { pageSize: 10, }); - const { data: statsLogs } = api.settings.readStatsLogs.useQuery( + const { + data: statsLogs, + isLoading, + isError, + error, + } = api.settings.readStatsLogs.useQuery( { sort: sorting[0], page: pagination, @@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { colSpan={columns.length} className="h-24 text-center" > - {statsLogs?.data.length === 0 && ( + {isLoading ? ( +
+ + Loading requests... +
+ ) : isError ? ( +
+ + {error?.message} + +
+ ) : (
No results. diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index ddcef338e..d4abdc63b 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -446,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { serverUrl: notification.gotify?.serverUrl, name: notification.name, dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, }); } else if (notification.notificationType === "ntfy") { form.reset({ @@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { name: data.name, dockerCleanup: dockerCleanup, decoration: data.decoration, + serverThreshold: serverThreshold, notificationId: notificationId || "", gotifyId: notification?.gotifyId || "", }); @@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { priority: data.priority, name: data.name, dockerCleanup: dockerCleanup, + serverThreshold: serverThreshold, notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", }); diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 93f4d977e..112f4a18e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -250,29 +250,30 @@ export const ShowServers = () => {
- {server.sshKeyId && ( - - -
- - - -
-
- -

Terminal

-
-
- )} + + +
+ + +

Terminal

+
+ + )} 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 { 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/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 222f69b9e..77a63765b 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -1,4 +1,6 @@ +import Head from "next/head"; import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; import { HubSpotWidget } from "../shared/HubSpotWidget"; import Page from "./side"; @@ -8,9 +10,11 @@ interface Props { metaName?: string; } -export const DashboardLayout = ({ children }: Props) => { +export const DashboardLayout = ({ children, metaName }: Props) => { const { data: haveRootAccess } = api.user.haveRootAccess.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); + const { config: whitelabeling } = useWhitelabeling(); + const appName = whitelabeling?.appName || "Dokploy"; const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, { enabled: isCloud === true, refetchOnWindowFocus: false, @@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => { return ( <> + {metaName && ( + + + {metaName} | {appName} + + + )} {children} {isChatEnabled && ( <> diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index b1be15bfd..797bc8d20 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -328,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" }, @@ -569,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"], diff --git a/apps/dokploy/components/shared/analytics.tsx b/apps/dokploy/components/shared/analytics.tsx index b54fc96bf..ab1acb283 100644 --- a/apps/dokploy/components/shared/analytics.tsx +++ b/apps/dokploy/components/shared/analytics.tsx @@ -1,3 +1,4 @@ +import { OpenPanelComponent } from "@openpanel/nextjs"; import { useRouter } from "next/router"; import Script from "next/script"; import { useEffect } from "react"; @@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics"; import { api } from "@/utils/api"; /** - * Loads Google Tag Manager and the HubSpot marketing tag on the cloud - * version only, and translates tracking query params appended by the - * auth/billing flows (?signup=..., ?subscription=new) into dataLayer events. + * Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel + * product analytics on the cloud version only, and translates tracking + * query params appended by the auth/billing flows (?signup=..., + * ?subscription=new) into dataLayer events. */ export const Analytics = () => { const router = useRouter(); @@ -40,6 +42,14 @@ export const Analytics = () => { return ( <> +