diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..77f39858f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "worktree": { + "baseRef": "fresh" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\"" + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\"" + } + ] + } + ] + } +} diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md new file mode 100644 index 000000000..226083304 --- /dev/null +++ b/.claude/skills/fix-issue/SKILL.md @@ -0,0 +1,55 @@ +--- +name: fix-issue +description: Implement a GitHub issue with reproduction and verification +allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__* +--- + +The issue number is passed as $1. + +## Instance + +No instance is running yet — start your own, isolated to this worktree: + +1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already). +2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the + background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT` + until it answers (usually ~10-15s). +3. Use `http://localhost:$PORT` as the base URL for Playwright navigation. + +Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from +`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port +discovered mid-session. If those tools are unavailable or point at the wrong +instance, fall back to `curl`/`gh api` for API-level checks, or ask the user +to relaunch with `DOKPLOY_BASE_URL` exported first. + +## Tools + +- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up + state (create a project, an app, an env var) and to verify backend behavior. + Search for the tool you need; they are not all loaded upfront. +- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything + a user would see or click. + +Pick by where the bug lives, not by convenience: + +- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright. + The API returning correct data proves nothing here. +- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP. + A green screenshot proves nothing here. +- Unclear → do both. + +Use the MCP to reach the state you need quickly, then verify in the UI. Do not +click through ten screens to create a project the API can create in one call. + +## Steps + +1. Run `gh issue view $1` and read the full issue, including comments. +2. Reproduce the bug with the appropriate tool. If you cannot reproduce it, + comment on the issue explaining what you tried and STOP. + Do not implement anything. +3. Implement the fix. Keep the change minimal and scoped to the issue. +4. Run `pnpm test`, then re-run the same reproduction from step 2. +5. Only if both pass: commit and run `gh pr create`. The PR description must + include the before/after reproduction steps and reference the issue. + +Never skip step 2. A fix you cannot reproduce and then verify is not a fix. \ No newline at end of file diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 000000000..e3d34d2fb --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,2 @@ +.env +.env.local \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..654dd27db --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +## Code style +- Don't write comments that restate what the code already says. +- Comment only the "why" when something isn't obvious: workarounds, + counterintuitive decisions, constraints from an external API. +- No section-divider comments like `// --- Helpers ---`. +- Don't leave comments describing the change you just made. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fa0dd358..daf538b1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,12 +120,20 @@ pnpm run docker:push ## Password Reset -In the case you lost your password, you can reset it using the following command +In the case you lost your password, you can reset the owner's password using the following command ```bash pnpm run reset-password ``` +To reset the password of a specific user instead, pass their email as an argument + +```bash +pnpm run reset-password -- user@example.com +``` + +Both commands print the new randomly generated password to the console. + If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/) ```bash diff --git a/apps/dokploy/__test__/compose/compose-project-directory.test.ts b/apps/dokploy/__test__/compose/compose-project-directory.test.ts new file mode 100644 index 000000000..6c409cc77 --- /dev/null +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -0,0 +1,123 @@ +import { createCommand } from "@dokploy/server/utils/builders/compose"; +import { describe, expect, it } from "vitest"; + +const base = { + composeType: "docker-compose" as const, + appName: "compose-app", + sourceType: "github" as const, + command: "", +}; + +describe("compose createCommand --project-directory", () => { + it("pins --project-directory to the code dir when composePath is nested", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f ./deploy/docker-compose.yml"); + }); + + it("omits --project-directory when no projectPath is passed", () => { + const cmd = createCommand({ + ...base, + composePath: "./deploy/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + }); + + it("does not add --project-directory to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd.startsWith("stack deploy")).toBe(true); + }); + + it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => { + const cmd = createCommand( + { ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + expect(cmd).toContain("-f docker-compose.yml"); + }); + + it("resolves build.context against the compose file's own directory when there are no mounts", () => { + const cmd = createCommand({ + ...base, + composePath: "./backend/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd).toContain("-f ./backend/docker-compose.yml"); + }); +}); + +describe("compose createCommand --env-file", () => { + it("points --env-file at the generated .env next to a nested compose file", () => { + const cmd = createCommand( + { + ...base, + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file deploy/.env"); + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + }); + + it("omits --env-file when createEnvFile is disabled", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); + + it("uses the code-root .env for raw sourceType", () => { + const cmd = createCommand( + { + ...base, + sourceType: "raw", + composePath: "docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file .env"); + }); + + it("does not add --env-file to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); +}); diff --git a/apps/dokploy/__test__/compose/env-file-literals.test.ts b/apps/dokploy/__test__/compose/env-file-literals.test.ts index 2dbb7fa4e..b7223ca4a 100644 --- a/apps/dokploy/__test__/compose/env-file-literals.test.ts +++ b/apps/dokploy/__test__/compose/env-file-literals.test.ts @@ -32,6 +32,9 @@ const cases: Record = { APOSTROPHE: "it's a test", UNICODE: "héllo wörld 日本語 🚀", MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----", + APP_URL: "https://example.com", + ASSET_URL: "https://example.com", + DB_HOST: "localhost", }; // How each value must be typed in the UI so the (unchanged) dotenv input @@ -46,6 +49,9 @@ const inputEncoding: Record = { APOSTROPHE: "it's a test", UNICODE: "héllo wörld 日本語 🚀", MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"', + APP_URL: "https://example.com", + ASSET_URL: '"${APP_URL}"', + DB_HOST: '"${UNDEFINED_HOST:-localhost}"', }; describe("getCreateEnvFileCommand", () => { diff --git a/apps/dokploy/__test__/deploy/railpack.command.test.ts b/apps/dokploy/__test__/deploy/railpack.command.test.ts index 94c95c950..13345ac0f 100644 --- a/apps/dokploy/__test__/deploy/railpack.command.test.ts +++ b/apps/dokploy/__test__/deploy/railpack.command.test.ts @@ -50,6 +50,15 @@ describe("getRailpackCommand", () => { expect(command).toContain("--build-arg cache-key="); }); + it("installs Railpack through sudo for non-root users", () => { + const command = getRailpackCommand(createApplication()); + + expect(command).toContain( + '$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"', + ); + expect(command).toContain("sudo -n true 2>/dev/null"); + }); + it("changes secrets-hash when an environment value changes", () => { const firstCommand = getRailpackCommand( createApplication({ diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts index d9a1c0244..8b0da4428 100644 --- a/apps/dokploy/__test__/deploy/should-deploy.test.ts +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -16,6 +16,30 @@ describe("shouldDeploy", () => { expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false); }); + it("should apply multiple negated watch paths as one pattern set", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false); + expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false); + expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe( + false, + ); + }); + + it("should deploy when a changed file remains after exclusions", () => { + const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"]; + + expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true); + }); + + it("should combine positive and negated watch paths", () => { + const watchPaths = ["src/**", "!src/**/*.test.ts"]; + + expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true); + expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false); + expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false); + }); + it("should not throw when modified files contain non-string values", () => { expect(() => shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any), diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index a9e18526c..ea57b6800 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -20,6 +20,7 @@ import { import { azureClient } from "@dokploy/server/utils/vault/azure"; import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { phaseClient } from "@dokploy/server/utils/vault/phase"; import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; const mockFetch = vi.fn(); @@ -616,3 +617,129 @@ describe("scaleway client", () => { expect(result).toBe("DB_PASSWORD=s3cret"); }); }); + +describe("phase client", () => { + const config = { + providerType: "phase" as const, + token: "phase-rest-token", + appId: "app-123", + env: "Production", + path: "/", + apiUrl: "https://api.phase.dev", + }; + + it("fetches secrets by key and sends ServiceAccount auth", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "postgres://real", path: "/" }, + { key: "API_KEY", value: "key-123", path: "/" }, + ]), + ); + + const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]); + + expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + expect((init.headers as Record).Authorization).toBe( + "Bearer ServiceAccount phase-rest-token", + ); + }); + + it("throws when a requested secret is missing", async () => { + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]), + ); + + await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow( + 'secret "MISSING" not found in environment "Production"', + ); + }); + + it("reports authentication failures with the status code", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ error: "unauthorized" }, false, 401), + ); + + await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow( + "authentication failed (status 401: unauthorized)", + ); + }); + + it("rejects apps without SSE enabled during testConnection", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: false, + }), + ); + + await expect(phaseClient.testConnection(config)).rejects.toThrow( + "enable Server-side Encryption (SSE)", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + }); + + it("tests connection against the app then secrets listing", async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: true, + }), + ) + .mockResolvedValueOnce(jsonResponse([])); + + await phaseClient.testConnection(config); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + expect(mockFetch.mock.calls[1]?.[0]).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + }); + + it("lists secret names from the configured path", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "x", path: "/" }, + { key: "API_KEY", value: "y", path: "/" }, + ]), + ); + + const names = await phaseClient.listSecretNames?.(config); + + expect(names).toEqual(["DB_URL", "API_KEY"]); + }); + + it("resolves env refs end to end through a phase provider", async () => { + findMany.mockResolvedValue([ + { + name: "phase-prod", + providerType: "phase", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]), + ); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index b9f19d984..ee0e61d7a 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow(); }); + it("member is denied server.terminal", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + }); + it("member is denied registry.create", async () => { memberToReturn = mockMemberData("member"); await expect( diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index c85bde189..b78627ea9 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => { const perms = await resolvePermissions(ctx); expect(perms.server.read).toBe(false); + expect(perms.server.terminal).toBe(false); expect(perms.registry.read).toBe(false); expect(perms.certificate.read).toBe(false); expect(perms.destination.read).toBe(false); diff --git a/apps/dokploy/__test__/permissions/server-terminal.test.ts b/apps/dokploy/__test__/permissions/server-terminal.test.ts new file mode 100644 index 000000000..01545f97e --- /dev/null +++ b/apps/dokploy/__test__/permissions/server-terminal.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = (role: string) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + accessedServers: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn = mockMemberData("deployer"); +let rolesToReturn: { permission: string }[] = []; + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve(rolesToReturn)), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(true)), +})); + +const { checkPermission, resolvePermissions } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +const withPermissions = (permissions: Record) => { + rolesToReturn = [{ permission: JSON.stringify(permissions) }]; +}; + +beforeEach(() => { + vi.clearAllMocks(); + memberToReturn = mockMemberData("deployer"); + rolesToReturn = []; +}); + +describe("server.terminal on custom roles", () => { + it("a role with server.read alone cannot open a terminal", async () => { + withPermissions({ server: ["read"] }); + + await expect( + checkPermission(ctx, { server: ["read"] }), + ).resolves.toBeUndefined(); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).rejects.toThrow(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.read).toBe(true); + expect(perms.server.terminal).toBe(false); + }); + + it("a role with server.terminal can open a terminal", async () => { + withPermissions({ server: ["read", "terminal"] }); + + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + }); + + it("owner and admin keep terminal access", async () => { + for (const role of ["owner", "admin"]) { + memberToReturn = mockMemberData(role); + await expect( + checkPermission(ctx, { server: ["terminal"] }), + ).resolves.toBeUndefined(); + + const perms = await resolvePermissions(ctx); + expect(perms.server.terminal).toBe(true); + } + }); +}); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts new file mode 100644 index 000000000..89b8b390d --- /dev/null +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -0,0 +1,252 @@ +import { execSync } from "node:child_process"; +import { docker } from "@dokploy/server/constants"; +import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const REAL_TEST_TIMEOUT = 120000; +const SERVICE_NAME = "dokploy-monitoring"; +const TEST_IMAGE = "busybox:latest"; + +// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to +// the local daemon, so the legacy-container cleanup is exercised for real. +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn().mockResolvedValue({ + serverId: "test-server", + serverType: "deploy", + sshKeyId: null, // -> getRemoteDocker returns the local docker instance + metricsConfig: { + server: { + type: "Remote", + port: 4500, + token: "test-token", + urlCallback: "http://localhost/callback", + cronJob: "0 0 * * *", + retentionDays: 2, + refreshRate: 60, + thresholds: { cpu: 0, memory: 0 }, + }, + containers: { refreshRate: 60, services: { include: [], exclude: [] } }, + }, + }), +})); + +vi.mock("@dokploy/server/services/settings", () => ({ + getDokployImageTag: vi.fn(() => "latest"), +})); + +vi.mock("@dokploy/server/utils/docker/utils", () => ({ + pullImage: vi.fn().mockResolvedValue(undefined), + pullRemoteImage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), + execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }), +})); + +const containerExists = async (name: string) => { + try { + await docker.getContainer(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +const serviceExists = async (name: string) => { + try { + await docker.getService(name).inspect(); + return true; + } catch (error: any) { + if (error.statusCode === 404) return false; + throw error; + } +}; + +// Swarm keeps converging a service for a bit after it's created (scheduling +// tasks, resolving endpoints), which bumps Version.Index on its own. Calling +// setupMonitoring again before that settles races that internal bump, so wait +// for two consecutive reads to agree before treating the service as stable. +const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let lastIndex: string | null = null; + while (Date.now() < deadline) { + const inspect = await docker.getService(name).inspect(); + if (inspect.Version.Index === lastIndex) return; + lastIndex = inspect.Version.Index; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; + +const swarmTaskNames = async () => { + const list = await docker.listContainers({ all: true }); + return list + .flatMap((c) => c.Names.map((n) => n.replace(/^\//, ""))) + .filter((n) => n.startsWith(`${SERVICE_NAME}.`)); +}; + +const cleanup = async () => { + try { + await docker.getService(SERVICE_NAME).remove(); + } catch {} + try { + await docker.getContainer(SERVICE_NAME).remove({ force: true }); + } catch {} + for (const name of await swarmTaskNames()) { + try { + await docker.getContainer(name).remove({ force: true }); + } catch {} + } +}; + +// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop. +const createLegacyZombie = async () => { + const container = await docker.createContainer({ + name: SERVICE_NAME, + Image: TEST_IMAGE, + Cmd: [ + "sh", + "-c", + "echo 'Error starting metrics cleanup system: empty spec string'; exit 1", + ], + HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" }, + }); + await container.start(); +}; + +const pullTestImage = async () => { + try { + await docker.getImage(TEST_IMAGE).inspect(); + return; + } catch {} + await new Promise((resolve, reject) => + docker.pull(TEST_IMAGE, (err: any, stream: any) => + err + ? reject(err) + : docker.modem.followProgress(stream, (e: any) => + e ? reject(e) : resolve(), + ), + ), + ); +}; + +// The code under test hardcodes the production name, so this suite cannot +// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host. +const hasRealMonitoring = () => { + const query = (cmd: string) => { + try { + return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim(); + } catch { + return ""; + } + }; + + return ( + query( + `docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`, + ) !== "" || + query( + `docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`, + ) !== "" + ); +}; + +describe.skipIf(hasRealMonitoring())( + "setupMonitoring - legacy container cleanup (real docker)", + () => { + beforeEach(async () => { + await pullTestImage(); + await cleanup(); + }, REAL_TEST_TIMEOUT); + + afterAll(async () => { + await cleanup(); + }, REAL_TEST_TIMEOUT); + + it( + "removes the legacy standalone container left behind by the swarm migration", + async () => { + await createLegacyZombie(); + expect(await containerExists(SERVICE_NAME)).toBe(true); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "removes the legacy container without touching swarm tasks, which are named dokploy-monitoring..", + async () => { + const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`; + const task = await docker.createContainer({ + name: taskName, + Image: TEST_IMAGE, + Cmd: ["sleep", "3600"], + }); + await task.start(); + await createLegacyZombie(); + + await setupMonitoring("test-server"); + + expect(await containerExists(SERVICE_NAME)).toBe(false); + expect(await containerExists(taskName)).toBe(true); + const inspect = await docker.getContainer(taskName).inspect(); + expect(inspect.State.Running).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "is idempotent when no legacy container exists", + async () => { + expect(await containerExists(SERVICE_NAME)).toBe(false); + + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + await waitForServiceConvergence(SERVICE_NAME); + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + + expect(await serviceExists(SERVICE_NAME)).toBe(true); + }, + REAL_TEST_TIMEOUT, + ); + + it( + "deploys the service even when removing the legacy container fails", + async () => { + const failingDocker = { + getContainer: () => ({ + remove: async () => { + const error: any = new Error("device or resource busy"); + error.statusCode = 500; + throw error; + }, + }), + getService: docker.getService.bind(docker), + createService: docker.createService.bind(docker), + }; + + const remoteDocker = await import( + "@dokploy/server/utils/servers/remote-docker" + ); + const spy = vi + .spyOn(remoteDocker, "getRemoteDocker") + .mockResolvedValue(failingDocker as any); + + try { + await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting + expect(await serviceExists(SERVICE_NAME)).toBe(true); + } finally { + spy.mockRestore(); + } + }, + REAL_TEST_TIMEOUT, + ); + }, +); diff --git a/apps/dokploy/__test__/traefik/reconnect-services.test.ts b/apps/dokploy/__test__/traefik/reconnect-services.test.ts new file mode 100644 index 000000000..a60b0504b --- /dev/null +++ b/apps/dokploy/__test__/traefik/reconnect-services.test.ts @@ -0,0 +1,71 @@ +import { reconnectServicesToTraefik } from "@dokploy/server/services/settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + compose: { + findMany: mocks.findMany, + }, + }, + }, +})); + +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); + +describe("reconnectServicesToTraefik", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findMany.mockResolvedValue([]); + }); + + it("does not execute an empty local command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); + + it("does not execute an empty remote command when no isolated deployments exist", async () => { + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments to the local Traefik network", async () => { + mocks.findMany.mockResolvedValue([ + { appName: "first-compose" }, + { appName: "second-compose" }, + ]); + + await reconnectServicesToTraefik(); + + expect(mocks.execAsync).toHaveBeenCalledOnce(); + expect(mocks.execAsync).toHaveBeenCalledWith( + 'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' + + 'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); + + it("reconnects isolated deployments on a remote server", async () => { + mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]); + + await reconnectServicesToTraefik("server-id"); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + expect(mocks.execAsyncRemote).toHaveBeenCalledWith( + "server-id", + 'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n', + ); + expect(mocks.execAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts new file mode 100644 index 000000000..90c1ba60a --- /dev/null +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execAsyncRemote: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >(); + return { + ...actual, + execAsyncRemote: mocks.execAsyncRemote, + }; +}); + +describe("writeAppTraefikConfig", () => { + let cwd: string; + let dynamicPath: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-")); + dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic"); + fs.mkdirSync(dynamicPath, { recursive: true }); + vi.spyOn(process, "cwd").mockReturnValue(cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + // Regression test for #5189: Traefik's file provider rejects a standalone + // `routers: {}` / `services: {}` map and aborts its watcher for every + // dynamic config once it hits one, so an app with no domains must never + // get an on-disk config file at all. + it("removes the file instead of writing empty routers/services", async () => { + const appName = "no-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + fs.writeFileSync(configPath, "stale content", "utf8"); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("writes the file when routers/services are present", async () => { + const appName = "with-domain-app"; + const configPath = path.join(dynamicPath, `${appName}.yml`); + + await writeAppTraefikConfig( + { + http: { + routers: { + [`${appName}-router-1`]: { + rule: "Host(`x`)", + service: `${appName}-service`, + }, + }, + services: {}, + }, + }, + appName, + ); + + expect(fs.existsSync(configPath)).toBe(true); + }); + + it("removes the remote file instead of writing empty routers/services", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { http: { routers: {}, services: {} } }, + "no-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; + expect(command).toMatch(/^rm -f /); + expect(command).toContain("no-domain-app.yml"); + }); + + it("writes the remote file when routers/services are present", async () => { + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + + await writeAppTraefikConfig( + { + http: { + routers: { + "with-domain-app-router-1": { + rule: "Host(`x`)", + service: "with-domain-app-service", + }, + }, + services: {}, + }, + }, + "with-domain-app", + "server-id", + ); + + expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); + const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; + expect(command).toMatch(/^echo /); + }); +}); diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 1b93b9f87..63fe1b9ac 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => { }); it("gates a remote server terminal on server access", async () => { + mockHasPermission.mockResolvedValue(true); mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); - // role lookup must not be needed for the remote path + // the remote path must never fall through to the owner/admin local branch expect(mockFindMember).not.toHaveBeenCalled(); }); + + it("denies a remote server terminal without the server.terminal permission", async () => { + // Reaching a server (to deploy on it) must not imply a root shell on it. + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(false); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false); + expect(mockHasPermission).toHaveBeenCalledWith( + { user: { id: USER.id }, session: { activeOrganizationId: "org-1" } }, + { server: ["terminal"] }, + ); + }); + + it("allows a remote server terminal with the server.terminal permission", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true); + }); + + it("does not check permissions for a server the caller cannot access", async () => { + mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"])); + mockHasPermission.mockResolvedValue(true); + expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false); + expect(mockHasPermission).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts index 29d3152eb..fdb4263e1 100644 --- a/apps/dokploy/__test__/wss/readValidDirectory.test.ts +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => { ), ).toBe(true); }); + + it("returns true for SvelteKit routes with + prefix and @ symbols", () => { + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+page.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+layout.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+server.ts`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/src/routes/+error.svelte`, + ), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`, + ), + ).toBe(true); + }); }); diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1a531e24c..b3be371ca 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -342,7 +342,7 @@ export const ShowDeployments = ({ )} {/* Hash (from description) - shown in compact form */} {deployment.description?.trim() && ( - + {deployment.description} )} diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 6a6ad62ae..f4206ee95 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => { try { const result = await validateDomain({ domain: host, - serverIp: - application?.server?.ipAddress?.toString() || ip?.toString() || "", + serverId: application?.serverId ?? undefined, }); setValidationStates((prev) => ({ diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 372640906..36aa59a9f 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => { -
+
diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index acf296f7e..553d46acb 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -55,6 +55,7 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "swarm", + refetchInterval: 5000, }, ); @@ -67,6 +68,7 @@ export const ShowDockerLogsStack = ({ }, { enabled: !!appName && option === "native", + refetchInterval: 5000, }, ); diff --git a/apps/dokploy/components/dashboard/compose/logs/show.tsx b/apps/dokploy/components/dashboard/compose/logs/show.tsx index d7c3c0226..0bd0f988e 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show.tsx @@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { badgeStateColor } from "@/components/dashboard/application/logs/show"; +import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils"; import { Badge } from "@/components/ui/badge"; import { Card, @@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({ }, { enabled: !!appName, + refetchInterval: 5000, }, ); const [containerId, setContainerId] = useState(); useEffect(() => { - if (data && data?.length > 0) { - setContainerId(data[0]?.containerId); - } + setContainerId((currentContainerId) => + resolveContainerSelection(currentContainerId, data), + ); }, [data]); return ( diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 20570b07a..da4c854d0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -352,11 +352,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
+
+ ))} +
+ + )} + {!!data?.missing.length && ( <> diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index d6aab6ebb..ff0b18a30 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) { }, { enabled: !!organizationId, + refetchOnWindowFocus: false, }, ); const { mutateAsync, isPending } = organizationId diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 802b60e46..1e496a68b 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -17,11 +17,13 @@ import { Download, Globe, InfoIcon, + Loader2, Server, TrendingUpIcon, } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { pageSize: 10, }); - const { data: statsLogs } = api.settings.readStatsLogs.useQuery( + const { + data: statsLogs, + isLoading, + isError, + error, + } = api.settings.readStatsLogs.useQuery( { sort: sorting[0], page: pagination, @@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { colSpan={columns.length} className="h-24 text-center" > - {statsLogs?.data.length === 0 && ( + {isLoading ? ( +
+ + Loading requests... +
+ ) : isError ? ( +
+ + {error?.message} + +
+ ) : (
No results. diff --git a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx index ddcef338e..d4abdc63b 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx @@ -446,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { serverUrl: notification.gotify?.serverUrl, name: notification.name, dockerCleanup: notification.dockerCleanup, + serverThreshold: notification.serverThreshold, }); } else if (notification.notificationType === "ntfy") { form.reset({ @@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { name: data.name, dockerCleanup: dockerCleanup, decoration: data.decoration, + serverThreshold: serverThreshold, notificationId: notificationId || "", gotifyId: notification?.gotifyId || "", }); @@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => { priority: data.priority, name: data.name, dockerCleanup: dockerCleanup, + serverThreshold: serverThreshold, notificationId: notificationId || "", ntfyId: notification?.ntfyId || "", }); diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 93f4d977e..112f4a18e 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -250,29 +250,30 @@ export const ShowServers = () => {
- {server.sshKeyId && ( - - -
- - - -
-
- -

Terminal

-
-
- )} + + +
+ + +

Terminal

+
+ + )} diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 126981bc4..6e6743fd4 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isPending, refetch } = api.user.all.useQuery(); - const { mutateAsync } = api.user.remove.useMutation(); + const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation(); const { data: permissions } = api.user.getPermissions.useQuery(); const { data: hasValidLicense } = api.licenseKey.haveValidLicenseKey.useQuery(); @@ -237,6 +237,7 @@ export const ShowUsers = () => { title="Delete User" description="Are you sure you want to delete this user?" type="destructive" + disabled={isRemoving} onClick={async () => { await mutateAsync({ userId: member.user.id, @@ -269,6 +270,7 @@ export const ShowUsers = () => { title="Unlink User" description="Are you sure you want to unlink this user?" type="destructive" + disabled={isRemoving} onClick={async () => { if (!isCloud) { const orgCount = diff --git a/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx index bb438d6d3..e68b1de4e 100644 --- a/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/vault/handle-vault-provider.tsx @@ -43,6 +43,7 @@ const providerLabels = { doppler: "Doppler", azure: "Azure Key Vault", scaleway: "Scaleway Secret Manager", + phase: "Phase", } as const; type ProviderType = keyof typeof providerLabels; @@ -63,6 +64,7 @@ const VaultProviderSchema = z "doppler", "azure", "scaleway", + "phase", ]), url: z.string(), token: z.string(), @@ -89,6 +91,11 @@ const VaultProviderSchema = z scalewayProjectId: z.string(), scalewaySecretKey: z.string(), scalewayApiUrl: z.string(), + phaseToken: z.string(), + phaseAppId: z.string(), + phaseEnv: z.string(), + phasePath: z.string(), + phaseApiUrl: z.string(), assignments: z.array( z.object({ projectId: z.string(), @@ -161,6 +168,17 @@ const VaultProviderSchema = z path: ["siteUrl"], }); } + if ( + data.providerType === "phase" && + data.phaseApiUrl && + !isValidUrl(data.phaseApiUrl) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter a valid URL (e.g. https://api.phase.dev)", + path: ["phaseApiUrl"], + }); + } const required: Partial< Record @@ -194,6 +212,11 @@ const VaultProviderSchema = z ["scalewayProjectId", "Project ID is required"], ["scalewaySecretKey", "Secret Key is required"], ], + phase: [ + ["phaseToken", "Service Account REST API token is required"], + ["phaseAppId", "App ID is required"], + ["phaseEnv", "Environment is required"], + ], }; for (const [field, message] of required[data.providerType] ?? []) { @@ -254,6 +277,11 @@ const defaultValues: VaultProviderForm = { scalewayProjectId: "", scalewaySecretKey: "", scalewayApiUrl: "https://api.scaleway.com", + phaseToken: "", + phaseAppId: "", + phaseEnv: "", + phasePath: "/", + phaseApiUrl: "https://api.phase.dev", assignments: [], }; @@ -308,6 +336,15 @@ const buildConfig = (data: VaultProviderForm) => { secretKey: data.scalewaySecretKey, apiUrl: data.scalewayApiUrl || "https://api.scaleway.com", }; + case "phase": + return { + providerType: "phase" as const, + token: data.phaseToken, + appId: data.phaseAppId, + env: data.phaseEnv, + path: data.phasePath || "/", + apiUrl: data.phaseApiUrl || "https://api.phase.dev", + }; } }; @@ -429,6 +466,13 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { scalewaySecretKey: provider.config.secretKey, scalewayApiUrl: provider.config.apiUrl, }), + ...(provider.config.providerType === "phase" && { + phaseToken: provider.config.token, + phaseAppId: provider.config.appId, + phaseEnv: provider.config.env, + phasePath: provider.config.path, + phaseApiUrl: provider.config.apiUrl, + }), }); } else if (!vaultProviderId) { form.reset(defaultValues); @@ -996,6 +1040,94 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => { )} + {providerType === "phase" && ( + <> + ( + + Service Account REST API Token + + + + + Use the REST API token from a Service Account — not the + CLI/SDK pss_* token. The Phase App must + have Server-side Encryption (SSE) enabled. + + + + )} + /> +
+ ( + + App ID + + + + + + )} + /> + ( + + Environment + + + + + + )} + /> +
+ ( + + Secret Path + + + + + + )} + /> + ( + + API URL + + + + + Self-hosted Phase defaults to{" "} + {"${HTTP_PROTOCOL}${HOST}/service/public"} + + + + )} + /> + + Reference format:{" "} + {"${{vault..SECRET_KEY}}"} + + + )} +
Access diff --git a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx index f61327882..4995b4d7a 100644 --- a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx @@ -21,6 +21,7 @@ const providerLabels: Record = { doppler: "Doppler", azure: "Azure Key Vault", scaleway: "Scaleway Secret Manager", + phase: "Phase", }; export const ShowVaultProviders = () => { diff --git a/apps/dokploy/components/icons/vault-provider-icons.tsx b/apps/dokploy/components/icons/vault-provider-icons.tsx index 8a4685299..0e81fa9c5 100644 --- a/apps/dokploy/components/icons/vault-provider-icons.tsx +++ b/apps/dokploy/components/icons/vault-provider-icons.tsx @@ -582,6 +582,28 @@ export const ScalewayIcon = ({ className }: Props) => ( ); +export const PhaseIcon = ({ className }: Props) => ( + + + + +); + export const vaultProviderIcons = { hashicorp: HashicorpVaultIcon, infisical: InfisicalIcon, @@ -589,4 +611,5 @@ export const vaultProviderIcons = { doppler: DopplerIcon, azure: AzureIcon, scaleway: ScalewayIcon, + phase: PhaseIcon, } as const; diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 222f69b9e..77a63765b 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -1,4 +1,6 @@ +import Head from "next/head"; import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar"; import { HubSpotWidget } from "../shared/HubSpotWidget"; import Page from "./side"; @@ -8,9 +10,11 @@ interface Props { metaName?: string; } -export const DashboardLayout = ({ children }: Props) => { +export const DashboardLayout = ({ children, metaName }: Props) => { const { data: haveRootAccess } = api.user.haveRootAccess.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); + const { config: whitelabeling } = useWhitelabeling(); + const appName = whitelabeling?.appName || "Dokploy"; const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, { enabled: isCloud === true, refetchOnWindowFocus: false, @@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => { return ( <> + {metaName && ( + + + {metaName} | {appName} + + + )} {children} {isChatEnabled && ( <> diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index f56babdb0..797bc8d20 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -173,7 +173,7 @@ const RESOURCE_META: Record = { vaultProvider: { label: "Secrets Providers", description: - "Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced", + "Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced", }, dnsProvider: { label: "DNS Providers", @@ -328,6 +328,10 @@ const ACTION_META: Record< label: "Delete", description: "Remove servers from the organization", }, + terminal: { + label: "Terminal", + description: "Open an SSH root shell on remote servers", + }, }, registry: { read: { label: "Read", description: "View configured Docker registries" }, @@ -569,7 +573,7 @@ const ROLE_PRESETS: { envVars: ["read", "write"], projectEnvVars: ["read", "write"], environmentEnvVars: ["read", "write"], - server: ["read", "create", "delete"], + server: ["read", "create", "delete", "terminal"], registry: ["read", "create", "delete"], certificate: ["read", "create", "delete"], backup: ["read", "create", "delete", "restore"], diff --git a/apps/dokploy/components/shared/analytics.tsx b/apps/dokploy/components/shared/analytics.tsx index b54fc96bf..ab1acb283 100644 --- a/apps/dokploy/components/shared/analytics.tsx +++ b/apps/dokploy/components/shared/analytics.tsx @@ -1,3 +1,4 @@ +import { OpenPanelComponent } from "@openpanel/nextjs"; import { useRouter } from "next/router"; import Script from "next/script"; import { useEffect } from "react"; @@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics"; import { api } from "@/utils/api"; /** - * Loads Google Tag Manager and the HubSpot marketing tag on the cloud - * version only, and translates tracking query params appended by the - * auth/billing flows (?signup=..., ?subscription=new) into dataLayer events. + * Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel + * product analytics on the cloud version only, and translates tracking + * query params appended by the auth/billing flows (?signup=..., + * ?subscription=new) into dataLayer events. */ export const Analytics = () => { const router = useRouter(); @@ -40,6 +42,14 @@ export const Analytics = () => { return ( <> +