diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 4ffcfaed7..d1bfb6661 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # Dockerfile for DevContainer -FROM node:24.4.0-bullseye-slim +FROM node:24.22.1-bookworm-slim # Install essential packages RUN apt-get update && apt-get install -y \ diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 175543205..2aaea6bc4 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -207,7 +207,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24.4.0 + node-version: 24.22.1 cache: pnpm - name: Generate OpenAPI specification diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d1cd96364..c0aba9946 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -20,7 +20,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: 24.4.0 + node-version: 24.22.1 cache: "pnpm" - name: Install Nixpacks diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index f378f7dca..b2e4f0392 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -27,7 +27,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: - node-version: 24.4.0 + node-version: 24.22.1 cache: "pnpm" - name: Install dependencies diff --git a/.nvmrc b/.nvmrc index 84e5de6ef..aec62a6cc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.4.0 \ No newline at end of file +24.22.1 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 011d3e2d6..6a6bbe37a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ feat: add new feature Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. -We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory. +We use Node v24.22.1 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.22.1 && nvm use` in the root directory. ```bash git clone https://github.com/dokploy/dokploy.git diff --git a/Dockerfile b/Dockerfile index 7d0150416..87f43b95c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.4.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/Dockerfile.cloud b/Dockerfile.cloud index 05e7cde49..60fe9ccb3 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.4.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/Dockerfile.schedule b/Dockerfile.schedule index 81b13fd64..fa8c1e653 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.4.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/Dockerfile.server b/Dockerfile.server index 8990ece4d..79682b404 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:24.4.0-slim AS base +FROM node:24.22.1-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable diff --git a/README.md b/README.md index 6a72f10d9..4021b6c33 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier. - **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click. - **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing. - **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource. +- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application. - **Docker Management**: Easily deploy and manage Docker containers. - **CLI/API**: Manage your applications and databases using the command line or through the API. - **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.). diff --git a/apps/api/package.json b/apps/api/package.json index 119c2b3f1..030acc264 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,12 +10,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "inngest": "3.40.1", + "inngest": "3.54.0", "@dokploy/server": "workspace:*", - "@hono/node-server": "^1.14.3", + "@hono/node-server": "^1.19.15", "@hono/zod-validator": "0.7.6", "dotenv": "^16.4.5", - "hono": "^4.11.7", + "hono": "^4.13.5", "pino": "9.4.0", "pino-pretty": "11.2.2", "react": "19.2.7", @@ -33,7 +33,7 @@ }, "packageManager": "pnpm@10.22.0", "engines": { - "node": "^24.4.0", + "node": "^24.22.1", "pnpm": ">=10.22.0" } } diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts new file mode 100644 index 000000000..427b6e6dd --- /dev/null +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateApplication = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => { + return { + checkServicePermissionAndAccess: vi.fn(async () => undefined), + }; +}); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: vi.fn(async () => false), + updateApplication: mockUpdateApplication, + findApplicationById: vi.fn(async () => ({ + applicationId: "app-1", + appName: "example-app", + })), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); + +const caller = applicationRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +const providerChanges = [ + { + name: "GitHub", + sourceType: "github", + save: () => + caller.saveGithubProvider({ + applicationId: "app-1", + repository: "example-repo", + owner: "example-owner", + branch: "main", + buildPath: "/", + githubId: "provider-1", + watchPaths: ["src/**"], + triggerType: "push", + enableSubmodules: false, + }), + }, + { + name: "GitLab", + sourceType: "gitlab", + save: () => + caller.saveGitlabProvider({ + applicationId: "app-1", + gitlabRepository: "example-repo", + gitlabOwner: "example-owner", + gitlabBranch: "main", + gitlabBuildPath: "/", + gitlabId: "provider-1", + gitlabProjectId: 1, + gitlabPathNamespace: "example-owner/example-repo", + }), + }, + { + name: "Bitbucket", + sourceType: "bitbucket", + save: () => + caller.saveBitbucketProvider({ + applicationId: "app-1", + bitbucketRepository: "example-repo", + bitbucketRepositorySlug: "example-repo", + bitbucketOwner: "example-owner", + bitbucketBranch: "main", + bitbucketBuildPath: "/", + bitbucketId: "provider-1", + }), + }, + { + name: "Gitea", + sourceType: "gitea", + save: () => + caller.saveGiteaProvider({ + applicationId: "app-1", + giteaRepository: "example-repo", + giteaOwner: "example-owner", + giteaBranch: "main", + giteaBuildPath: "/", + giteaId: "provider-1", + }), + }, + { + name: "Docker", + sourceType: "docker", + save: () => + caller.saveDockerProvider({ + applicationId: "app-1", + dockerImage: "nginx:alpine", + username: "", + password: "", + registryUrl: "", + }), + }, + { + name: "custom Git", + sourceType: "git", + save: () => + caller.saveGitProvider({ + applicationId: "app-1", + customGitUrl: "https://example.com/repo.git", + customGitBranch: "main", + customGitBuildPath: "/", + customGitSSHKeyId: "key-1", + watchPaths: ["src/**"], + enableSubmodules: false, + }), + }, + { + name: "disconnect Git provider", + sourceType: "github", + save: () => caller.disconnectGitProvider({ applicationId: "app-1" }), + }, +]; + +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(providerChanges)( + "keeps the status when $name settings change", + async ({ save, sourceType }) => { + for (const status of ["done", "running"]) { + mockUpdateApplication.mockClear(); + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await save(); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledTimes(1); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ sourceType }), + ); + } + }, + ); +}); diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts index 5fff508cc..f5e3e01e0 100644 --- a/apps/dokploy/__test__/backups/redact-credentials.test.ts +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -1,4 +1,6 @@ +import type { Destination } from "@dokploy/server/services/destination"; import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { getS3Credentials } from "@dokploy/server/utils/backups/utils"; import { describe, expect, it } from "vitest"; describe("redactRcloneCredentials (#4621)", () => { @@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => { expect(redacted).toContain("[REDACTED]"); }); }); + +describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => { + const buildCommand = (accessKey: string, secretAccessKey: string) => + `rclone rcat ${getS3Credentials({ + accessKey, + secretAccessKey, + region: "us-west-001", + endpoint: "https://s3.us-west-001.backblazeb2.com", + provider: "Other", + } as Destination).join(" ")} :s3:bucket/file.gz`; + + it.each([ + [ + "plain alphanumeric", + "001aaaabbbbccccdd0000000001", + "K001FAKEfakeFAKEfake", + ], + [ + "containing slashes", + "AKIAIOSFODNN7EXAMPLE", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ], + ["containing spaces", "key with space", "secret with space"], + ["containing quotes", "it's-a-key", `say "hi" it's`], + ["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"], + ])("redacts credentials %s", (_, accessKey, secretAccessKey) => { + const redacted = redactRcloneCredentials( + buildCommand(accessKey, secretAccessKey), + ); + expect(redacted).toContain( + '--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001', + ); + expect(redacted).not.toContain(accessKey); + expect(redacted).not.toContain(secretAccessKey); + expect(redacted).toContain(":s3:bucket/file.gz"); + }); + + it("redacts credentials embedded in an error string", () => { + const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`; + const redacted = redactRcloneCredentials(errorStr); + expect(redacted).not.toContain("MYKEY"); + expect(redacted).not.toContain("MY/SECRET"); + expect(redacted).toContain("[REDACTED]"); + }); +}); diff --git a/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx new file mode 100644 index 000000000..2864f371d --- /dev/null +++ b/apps/dokploy/__test__/components/server-actions-tooltip.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; + +describe("Remote Server Action Tooltips", () => { + it("should close the tooltip when the dialog trigger is clicked", () => { + // Note: The UI layer in this project is not currently tested with DOM simulation tools + // like React Testing Library or JSDOM. + // + // The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild) + // inherently guarantees that Radix UI's composeEventHandlers properly propagates the + // onPointerDown event to dismiss the tooltip simultaneously as the dialog opens. + + expect(true).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts index abee0dc59..4c6373d28 100644 --- a/apps/dokploy/__test__/compose/domain/raw-compose.test.ts +++ b/apps/dokploy/__test__/compose/domain/raw-compose.test.ts @@ -33,8 +33,16 @@ services: volumes: test-data: `, + composeId: "compose-1", + name: "raw-stack", composePath: "./docker-compose.yml", composeType: "stack", + environment: { + environmentId: "env-1", + name: "production", + projectId: "project-1", + project: { name: "shop", organizationId: "org-1" }, + }, isolatedDeployment: false, isolatedDeploymentsVolume: false, randomize: false, @@ -54,6 +62,72 @@ volumes: }, ]); expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]); + expect(converted?.services?.test?.labels).toEqual( + expect.arrayContaining([ + "dokploy.organization.id=org-1", + "dokploy.project=shop", + "dokploy.service=test", + ]), + ); + expect(converted?.services?.test?.deploy?.labels).toEqual( + expect.arrayContaining(["dokploy.organization.id=org-1"]), + ); expect(execAsyncRemote).not.toHaveBeenCalled(); }); + + it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => { + const compose = { + appName: "dollar-stack", + composeFile: ` +services: + mapped: + image: alpine:latest + labels: + custom: keep + deploy: + labels: + custom: keep + listed: + image: alpine:latest + labels: + - custom=keep +`, + composeId: "compose-1", + name: "app $DB_PASSWORD", + composePath: "./docker-compose.yml", + composeType: "stack", + environment: { + environmentId: "env-1", + name: "env ${HOME}", + projectId: "project-1", + project: { name: "Budget $100", organizationId: "org-1" }, + }, + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: null, + sourceType: "raw", + suffix: "", + } as unknown as Parameters[0]; + + const converted = await addDomainToCompose(compose, []); + const escaped = { + "dokploy.project": "Budget $$100", + "dokploy.environment": "env $${HOME}", + "dokploy.application": "app $$DB_PASSWORD", + }; + + const mapped = converted?.services?.mapped; + expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped }); + expect(mapped?.deploy?.labels).toMatchObject({ + custom: "keep", + ...escaped, + }); + const listed = converted?.services?.listed; + const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`); + expect(listed?.labels).toEqual( + expect.arrayContaining(["custom=keep", ...asList]), + ); + expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList)); + }); }); diff --git a/apps/dokploy/__test__/compose/provider-status.test.ts b/apps/dokploy/__test__/compose/provider-status.test.ts new file mode 100644 index 000000000..88f299b21 --- /dev/null +++ b/apps/dokploy/__test__/compose/provider-status.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateCompose = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkServicePermissionAndAccess: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: true, + findComposeById: vi.fn(async () => ({ + composeId: "compose-1", + name: "example-compose", + })), + updateCompose: mockUpdateCompose, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanQueuesByCompose: vi.fn(), + killDockerBuild: vi.fn(), + myQueue: { + add: vi.fn(), + }, +})); + +const { composeRouter } = await import("@/server/api/routers/compose"); + +const caller = composeRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +describe("saving a compose provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(["done", "running"])( + "keeps the %s status when disconnecting a Git provider", + async (status) => { + let savedStatus = status; + mockUpdateCompose.mockImplementation(async (_id, update) => { + if (update.composeStatus !== undefined) { + savedStatus = update.composeStatus; + } + }); + + await caller.disconnectGitProvider({ composeId: "compose-1" }); + + expect(savedStatus).toBe(status); + expect(mockUpdateCompose).toHaveBeenCalledTimes(1); + expect(mockUpdateCompose).toHaveBeenCalledWith( + "compose-1", + expect.objectContaining({ sourceType: "github" }), + ); + }, + ); +}); diff --git a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts index 6b61c97d7..d552cd76f 100644 --- a/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts +++ b/apps/dokploy/__test__/deploy/github-webhook-handler.test.ts @@ -323,6 +323,43 @@ describe("GitHub app webhook auto-deploy", () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" }); }); + + it("passes serverId to queued jobs for remote servers", async () => { + mocks.applicationsFindMany.mockImplementation(({ where }) => { + const matches = + getConditionValue(where, "application.sourceType") === "github" && + getConditionValue(where, "application.autoDeploy") === true && + getConditionValue(where, "application.triggerType") === "push" && + getConditionValue(where, "application.branch") === "main"; + + return Promise.resolve( + matches + ? [ + { + applicationId: "remote-app-id", + serverId: "remote-server-id", + watchPaths: null, + }, + ] + : [], + ); + }); + const res = createResponse(); + + await handler(createPushRequest("main"), res); + + expect(mocks.queueAdd).toHaveBeenCalledWith( + "deployments", + expect.objectContaining({ + applicationId: "remote-app-id", + applicationType: "application", + server: true, + serverId: "remote-server-id", + }), + expect.anything(), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); }); describe("GitHub app webhook preview deployments", () => { diff --git a/apps/dokploy/__test__/dns/ovh.test.ts b/apps/dokploy/__test__/dns/ovh.test.ts index 7e08d7f99..f41735682 100644 --- a/apps/dokploy/__test__/dns/ovh.test.ts +++ b/apps/dokploy/__test__/dns/ovh.test.ts @@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => { await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature"); }); + + it("returns only the configured zone without listing every zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const zones = await ovhClient.listZones(cfg); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + expect(apiCalls()).toHaveLength(0); + }); }); describe("ovhClient.listRecords", () => { @@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => { "Invalid signature", ); }); + + it("checks the configured zone instead of listing all zones", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess({ name: "example.com" })); + + await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined(); + + const calls = apiCalls(); + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`); + }); +}); + +describe("ovhClient with a configured zone", () => { + const record = { + type: "A" as const, + name: "app.other.org", + content: "1.2.3.4", + }; + + it("refuses record operations on any other zone without calling OVH", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(); + + const message = /restricted to the zone "example.com"/; + await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow( + message, + ); + await expect( + ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }), + ).rejects.toThrow(message); + await expect( + ovhClient.updateRecord(cfg, "other.org", "1", record), + ).rejects.toThrow(message); + await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow( + message, + ); + expect(apiCalls()).toHaveLength(0); + }); + + it("still allows operations on the configured zone", async () => { + const cfg = { ...freshConfig(), zone: "example.com" }; + mockApi(ovhSuccess(null), ovhSuccess(null)); + + await expect( + ovhClient.deleteRecord(cfg, "example.com", "1"), + ).resolves.toBeUndefined(); + expect(apiCalls()[0]?.[0]).toBe( + `${cfg.baseUrl}/domain/zone/example.com/record/1`, + ); + }); }); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index 6b3e0ab62..ec136ba85 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => { ]); }); + it("decodes octal escapes such as \\052 for wildcard names", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records[0]).toMatchObject({ + id: "A:*.example.com", + name: "*.example.com", + }); + }); + + it("keeps escaped dots so they stay distinct from label separators", async () => { + send.mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "a\\056b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }, + { + Name: "a.b.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "5.6.7.8" }], + }, + ], + IsTruncated: false, + }); + + const records = await route53Client.listRecords(config, "Z123"); + + expect(records.map((r) => r.id)).toEqual([ + "A:a\\056b.example.com", + "A:a.b.example.com", + ]); + }); + it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [ @@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => { ]); }); + it("finds a wildcard record set that Route53 returns escaped", async () => { + const existingSet = { + Name: "\\052.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.2.3.4" }], + }; + send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }); + send.mockResolvedValueOnce({}); + + await route53Client.deleteRecord(config, "Z123", "A:*.example.com"); + + const changeCommand = send.mock.calls[1]?.[0] as HasInput; + expect(changeCommand.input.ChangeBatch.Changes).toEqual([ + { Action: "DELETE", ResourceRecordSet: existingSet }, + ]); + }); + it("throws when the record no longer exists", async () => { send.mockResolvedValueOnce({ ResourceRecordSets: [] }); diff --git a/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts new file mode 100644 index 000000000..71b4421f3 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitlab-refresh-token.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const store = vi.hoisted(() => ({ + provider: {} as Record, +})); + +vi.mock("@dokploy/server/services/gitlab", () => ({ + findGitlabById: vi.fn(async () => ({ ...store.provider })), + updateGitlab: vi.fn(async (_id: string, values: Record) => { + store.provider = { ...store.provider, ...values }; + }), +})); + +const { refreshGitlabToken } = await import( + "@dokploy/server/utils/providers/gitlab" +); + +const GITLAB_ID = "gitlab-1"; + +// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used +const createGitlab = () => { + let validRefreshToken = "refresh-1"; + let issued = 1; + let failNext = false; + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = new URLSearchParams(init.body as string); + // Keeps the request open, so concurrent callers overlap as two deployments do + await new Promise((resolve) => setTimeout(resolve, 20)); + + if (failNext) { + failNext = false; + return new Response(null, { + status: 500, + statusText: "Internal Server Error", + }); + } + + if (body.get("refresh_token") !== validRefreshToken) { + return new Response(null, { status: 400, statusText: "Bad Request" }); + } + + issued++; + validRefreshToken = `refresh-${issued}`; + return Response.json({ + access_token: `access-${issued}`, + refresh_token: validRefreshToken, + expires_in: 7200, + }); + }); + + return { + fetch, + failNextRequest: () => { + failNext = true; + }, + }; +}; + +describe("refreshGitlabToken", () => { + let gitlab: ReturnType; + + beforeEach(() => { + store.provider = { + gitlabId: GITLAB_ID, + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: null, + applicationId: "app-id", + secret: "app-secret", + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: 0, + }; + gitlab = createGitlab(); + vi.stubGlobal("fetch", gitlab.fetch); + }); + + it("sends one refresh request when two callers find the token expired at the same time", async () => { + await Promise.all([ + refreshGitlabToken(GITLAB_ID), + refreshGitlabToken(GITLAB_ID), + ]); + + expect(gitlab.fetch).toHaveBeenCalledTimes(1); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); + + it("refreshes again after a failed refresh", async () => { + gitlab.failNextRequest(); + + await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow( + "Failed to refresh token: Internal Server Error", + ); + await refreshGitlabToken(GITLAB_ID); + + expect(gitlab.fetch).toHaveBeenCalledTimes(2); + expect(store.provider.refreshToken).toBe("refresh-2"); + }); +}); diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/__test__/libsql/create-schema.test.ts b/apps/dokploy/__test__/libsql/create-schema.test.ts new file mode 100644 index 000000000..c8e398079 --- /dev/null +++ b/apps/dokploy/__test__/libsql/create-schema.test.ts @@ -0,0 +1,66 @@ +import { apiCreateLibsql } from "@dokploy/server/db/schema"; +import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils"; +import { describe, expect, it } from "vitest"; + +describe("apiCreateLibsql appName", () => { + const validBase = { + name: "My libSQL", + dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32", + environmentId: "env-1", + description: null, + databaseUser: "admin", + databasePassword: "secretPassword1", + sqldNode: "primary" as const, + sqldPrimaryUrl: null, + enableNamespaces: false, + serverId: null, + }; + + it.each([ + ["a space", "my libsql"], + ["an at sign", "repro@lib"], + ["a slash", "team/libsql"], + ["a colon", "libsql:1"], + ])("refuses an appName with %s", (_, appName) => { + const result = apiCreateLibsql.safeParse({ ...validBase, appName }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.message)).toEqual([ + APP_NAME_MESSAGE, + ]); + } + }); + + it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "my-libsql.db_1", + }); + expect(result.success).toBe(true); + }); + + it("refuses an appName longer than 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(64), + }); + expect(result.success).toBe(false); + if (!result.success) { + const appNameIssues = result.error.issues.filter( + (issue) => issue.path[0] === "appName", + ); + expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]); + } + }); + + it("accepts an appName of 63 characters", () => { + const result = apiCreateLibsql.safeParse({ + ...validBase, + appName: "a".repeat(63), + }); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts new file mode 100644 index 000000000..2feaa3438 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/account-deletion-vector.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findOwnedOrganizations: vi.fn(), + transaction: vi.fn(), + removeVectorAgents: vi.fn(), + calls: [] as string[], +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { organization: { findMany: mocks.findOwnedOrganizations } }, + transaction: mocks.transaction, + }, +})); +vi.mock("@dokploy/server/services/organization", () => ({ + removeVectorAgents: mocks.removeVectorAgents, +})); + +const { deleteUserAccountData } = await import( + "@dokploy/server/services/account-deletion" +); + +beforeEach(() => { + mocks.calls.length = 0; + mocks.findOwnedOrganizations.mockReset(); + mocks.removeVectorAgents + .mockReset() + .mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`)); + mocks.transaction.mockReset().mockImplementation(async () => { + mocks.calls.push("delete"); + return { email: "a@b.c", organizationsDeleted: 2 }; + }); +}); + +describe("deleteUserAccountData", () => { + it("removes the Vector agents of every owned organization before deleting the user", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([ + { id: "org-a" }, + { id: "org-b" }, + ]); + + await deleteUserAccountData("user-1"); + + expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]); + }); + + it("deletes the user without touching agents when it owns no organization", async () => { + mocks.findOwnedOrganizations.mockResolvedValue([]); + + await deleteUserAccountData("user-1"); + + expect(mocks.removeVectorAgents).not.toHaveBeenCalled(); + expect(mocks.calls).toEqual(["delete"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts new file mode 100644 index 000000000..76ba498ce --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/metrics-adapters.test.ts @@ -0,0 +1,542 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const aws = vi.hoisted(() => ({ + describeLogGroups: vi.fn(), + putMetricData: vi.fn(), +})); + +vi.mock("node:dns/promises", () => ({ + lookup: async () => [{ address: "93.184.216.34" }], +})); +vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({ + CloudWatchLogsClient: class { + send = aws.describeLogGroups; + }, + DescribeLogGroupsCommand: class { + constructor(public input: unknown) {} + }, +})); +vi.mock("@aws-sdk/client-cloudwatch", () => ({ + CloudWatchClient: class { + send = aws.putMetricData; + }, + PutMetricDataCommand: class { + constructor(public input: unknown) {} + }, +})); + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["metrics"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +const originalFetch = global.fetch; +const fetchMock = vi.fn(); + +const respond = (status: number, body: unknown = "") => + new Response( + status === 204 + ? null + : typeof body === "string" + ? body + : JSON.stringify(body), + { status }, + ); + +beforeEach(() => { + fetchMock.mockReset(); + aws.describeLogGroups.mockReset(); + aws.putMetricData.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0])); + +describe("registry", () => { + it("resolves the nine provider types, each with a non-empty valid signal list", () => { + const types = [ + "loki", + "prometheus_remote_write", + "new_relic", + "influxdb", + "datadog", + "aws_cloudwatch", + "splunk_hec", + "elasticsearch", + "betterstack", + ] as const; + expect(Object.keys(telemetryProviderAdapters).sort()).toEqual( + [...types].sort(), + ); + for (const type of types) { + const adapter = getTelemetryProviderAdapter(type); + expect(adapter.type).toBe(type); + expect(adapter.signals.length).toBeGreaterThan(0); + expect( + adapter.signals.every((s) => s === "logs" || s === "metrics"), + ).toBe(true); + } + expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]); + expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([ + "metrics", + ]); + expect(telemetryProviderAdapters.datadog.signals).toEqual([ + "logs", + "metrics", + ]); + }); +}); + +describe("prometheusRemoteWriteAdapter", () => { + const adapter = telemetryProviderAdapters.prometheus_remote_write; + const endpoint = "http://prom.example.com:9090/api/v1/write"; + + it("builds a sink without auth, with the healthcheck disabled", () => { + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint }, + "sink_1", + "dokploy_metrics_scope", + "metrics", + ); + expect(sink).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + endpoint, + healthcheck: { enabled: false }, + }); + expect(sink.auth).toBeUndefined(); + expect(sink.tenant_id).toBeUndefined(); + }); + + it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => { + const basic = adapter.toVectorSink( + { + ...baseConfig, + endpoint, + apiKey: "pw", + extraConfig: { username: "12345", tenantId: "team-a" }, + }, + "sink_1", + "scope", + "metrics", + ); + expect(basic.auth).toEqual({ + strategy: "basic", + user: "12345", + password: "pw", + }); + expect(basic.tenant_id).toBe("team-a"); + + const bearer = adapter.toVectorSink( + { ...baseConfig, endpoint, apiKey: "tok" }, + "sink_1", + "scope", + "metrics", + ); + expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" }); + }); + + it("rejects an endpoint that is not an http(s) URL", () => { + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }), + ).toThrow(/valid http\(s\) URL/); + expect(() => + adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }), + ).toThrow(/valid http\(s\) URL/); + }); + + it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(400)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }), + ).rejects.toThrow(/rejected the credentials \(status 401\)/); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect( + adapter.testConnection?.({ ...baseConfig, endpoint }), + ).rejects.toThrow(/not found at this URL \(status 404\)/); + }); + + it("sends the auth header and the tenant header on the test request", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await adapter.testConnection?.({ + ...baseConfig, + endpoint, + apiKey: "tok", + extraConfig: { tenantId: "team-a" }, + }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.headers).toMatchObject({ + Authorization: "Bearer tok", + "X-Scope-OrgID": "team-a", + }); + }); +}); + +describe("newRelicAdapter", () => { + const adapter = telemetryProviderAdapters.new_relic; + const config = { + ...baseConfig, + apiKey: "license", + extraConfig: { accountId: "123" }, + }; + + it("defaults to the us region and accepts eu", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "new_relic", + api: "metrics", + license_key: "license", + account_id: "123", + region: "us", + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { accountId: "123", region: "EU" } }, + "s", + "scope", + "metrics", + ).region, + ).toBe("eu"); + }); + + it("rejects an unknown region", () => { + expect(() => + adapter.validateConfig?.({ + ...config, + extraConfig: { accountId: "123", region: "jp" }, + }), + ).toThrow(/"us" or "eu"/); + }); + + it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => { + fetchMock.mockResolvedValueOnce(respond(202)); + await expect( + adapter.testConnection?.({ + ...config, + extraConfig: { accountId: "123", region: "eu" }, + }), + ).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "https://metric-api.eu.newrelic.com/metric/v1", + ); + + fetchMock.mockResolvedValueOnce(respond(403)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the license key/, + ); + expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1"); + }); +}); + +describe("influxdbAdapter", () => { + const adapter = telemetryProviderAdapters.influxdb; + const config = { + ...baseConfig, + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }; + + it("builds an influxdb_metrics v2 sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({ + type: "influxdb_metrics", + inputs: ["scope"], + endpoint: "http://influx.example.com:8086", + org: "dokploy", + bucket: "metrics", + token: "token", + default_namespace: "dokploy", + version: "2", + buffer: { type: "disk", max_size: 268_435_488, when_full: "block" }, + }); + }); + + it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => { + fetchMock.mockResolvedValueOnce(respond(204)); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()[0]).toBe( + "http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s", + ); + + fetchMock.mockResolvedValueOnce(respond(401)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /rejected the token/, + ); + + fetchMock.mockResolvedValueOnce(respond(404)); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /organization or bucket not found/, + ); + }); +}); + +describe("datadogAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.datadog; + const config = { + ...baseConfig, + signals: ["logs", "metrics"] as const, + apiKey: "dd", + }; + + it("ships metrics through datadog_metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"), + ).toBeNull(); + expect( + adapter.toVectorSink(config as never, "s", "scope", "metrics"), + ).toMatchObject({ + type: "datadog_metrics", + inputs: ["scope"], + default_api_key: "dd", + site: "datadoghq.com", + }); + }); + + it("keeps the logs sink and ddtags transform unchanged", () => { + expect( + adapter.toVectorTransform?.(config as never, "t", "scope", "logs"), + ).toMatchObject({ type: "remap", inputs: ["scope"] }); + expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe( + "datadog_logs", + ); + }); +}); + +describe("betterStackAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.betterstack; + const config = { + ...baseConfig, + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }; + + it("posts metrics to /metrics without a transform", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "http", + uri: "https://in.logs.betterstack.com/metrics", + auth: { strategy: "bearer", token: "token" }, + }, + ); + expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe( + "https://in.logs.betterstack.com/", + ); + }); + + it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => { + fetchMock.mockResolvedValue(respond(202)); + await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] }); + expect(calledUrls()).toEqual([ + "https://in.logs.betterstack.com/", + "https://in.logs.betterstack.com/metrics", + ]); + + fetchMock.mockClear(); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]); + }); +}); + +describe("awsCloudwatchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const config = { + ...baseConfig, + apiKey: "AKIA", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }; + + it("scopes the log fields to logs and the namespace to metrics", () => { + const bySignal = Object.fromEntries( + adapter.credentialFields.map((f) => [f.key, f.signal]), + ); + expect(bySignal).toMatchObject({ + logGroup: "logs", + logStream: "logs", + metricsNamespace: "metrics", + }); + expect(bySignal.region).toBeUndefined(); + }); + + it("rejects a namespace starting with AWS/ only when metrics are selected", () => { + const bad = { + ...config, + extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" }, + }; + expect(() => adapter.validateConfig?.(bad)).toThrow( + /cannot start with AWS\//, + ); + expect(() => + adapter.validateConfig?.({ ...bad, signals: ["logs"] }), + ).not.toThrow(); + }); + + it("filters metrics to the allowlist and sets the namespace in the transform", () => { + const transform = adapter.toVectorTransform?.( + config, + "t", + "scope", + "metrics", + ); + expect(transform).toMatchObject({ + type: "remap", + inputs: ["scope"], + drop_on_abort: true, + }); + expect(transform?.source).toContain( + 'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }', + ); + expect(transform?.source).toContain('.namespace = "Dokploy"'); + expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({ + type: "aws_cloudwatch_metrics", + inputs: ["t"], + default_namespace: "Dokploy", + region: "us-east-1", + auth: { access_key_id: "AKIA", secret_access_key: "secret" }, + }); + }); + + it("tests metrics with PutMetricData only, and translates AccessDenied", async () => { + aws.putMetricData.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["metrics"] }); + expect(aws.putMetricData).toHaveBeenCalledTimes(1); + expect(aws.describeLogGroups).not.toHaveBeenCalled(); + expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({ + input: { Namespace: "Dokploy" }, + }); + + aws.putMetricData.mockRejectedValue( + Object.assign(new Error("denied"), { name: "AccessDenied" }), + ); + await expect( + adapter.testConnection?.({ ...config, signals: ["metrics"] }), + ).rejects.toThrow(/needs cloudwatch:PutMetricData/); + }); + + it("tests logs with DescribeLogGroups only", async () => { + aws.describeLogGroups.mockResolvedValue({}); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(aws.describeLogGroups).toHaveBeenCalledTimes(1); + expect(aws.putMetricData).not.toHaveBeenCalled(); + }); +}); + +describe("splunkAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const config = { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { metricsIndex: "dokploy_metrics" }, + }; + + it("requires the metrics index only for metrics", () => { + const field = adapter.credentialFields.find( + (f) => f.key === "metricsIndex", + ); + expect(field).toMatchObject({ required: true, signal: "metrics" }); + expect( + adapter.credentialFields.find((f) => f.key === "index")?.signal, + ).toBe("logs"); + }); + + it("builds a splunk_hec_metrics sink", () => { + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "splunk_hec_metrics", + endpoint: "https://splunk.example.com:8088", + default_token: "hec", + index: "dokploy_metrics", + source: "dokploy", + sourcetype: "dokploy:metrics", + }, + ); + }); + + it("writes a test metric and translates HEC code 7", async () => { + fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 })); + await expect(adapter.testConnection?.(config)).resolves.toBeUndefined(); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector", + ]); + + fetchMock.mockResolvedValueOnce( + respond(400, { text: "Incorrect index", code: 7 }), + ); + await expect(adapter.testConnection?.(config)).rejects.toThrow( + /can't write to the metrics index "dokploy_metrics"/, + ); + }); + + it("only checks the health endpoint for logs", async () => { + fetchMock.mockResolvedValueOnce(respond(200)); + await adapter.testConnection?.({ ...config, signals: ["logs"] }); + expect(calledUrls()).toEqual([ + "https://splunk.example.com:8088/services/collector/health", + ]); + }); +}); + +describe("elasticsearchAdapter with metrics", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const config = { ...baseConfig, endpoint: "https://es.example.com:9200" }; + + it("uses the daily default metrics index when none is set, and the custom one otherwise", () => { + expect( + adapter.toVectorTransform?.(config, "t", "scope", "metrics"), + ).toBeNull(); + expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject( + { + type: "elasticsearch", + api_version: "v8", + bulk: { index: "dokploy-metrics-%Y.%m.%d" }, + }, + ); + expect( + adapter.toVectorSink( + { ...config, extraConfig: { metricsIndex: "metrics-custom" } }, + "s", + "scope", + "metrics", + ).bulk, + ).toEqual({ index: "metrics-custom" }); + }); + + it("keeps the logs sink without a bulk index unless one is set", () => { + expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts new file mode 100644 index 000000000..78bb638be --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/organization-delete.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findServersByOrganizationForVectorAgent: vi.fn(), + getWebServerSettings: vi.fn(), + releaseWebServerAgent: vi.fn(), + removeVectorAgent: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false })); +vi.mock("@dokploy/server/db", () => ({ + db: { delete: () => ({ where: mocks.deleteWhere }) }, +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, + releaseWebServerAgent: mocks.releaseWebServerAgent, +})); +vi.mock("@dokploy/server/setup/vector-setup", () => ({ + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: (_: string | undefined, fn: () => Promise) => + fn(), +})); + +const { deleteOrganization } = await import( + "@dokploy/server/services/organization" +); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.deleteWhere.mockResolvedValue({ count: 1 }); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("deleteOrganization", () => { + it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => { + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { serverId: "server-1", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-2", telemetryProviderIds: ["lp-1"] }, + { serverId: "server-3", telemetryProviderIds: [] }, + ]); + mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-1") throw new Error("ssh down"); + }); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2"); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); + + it("leaves the local agent alone when another organization owns it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-b", + }); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled(); + }); + + it("releases the local agent even when removing it fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + }); + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + + await deleteOrganization("org-a"); + + expect(mocks.removeVectorAgent).toHaveBeenCalledWith(); + expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a"); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts new file mode 100644 index 000000000..a571ca7f4 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-fetch.test.ts @@ -0,0 +1,220 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + lookup: vi.fn(), +})); + +vi.mock("node:dns", () => ({ + lookup: mocks.lookup, +})); + +const loadModule = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + return await import( + "@dokploy/server/services/logs-and-metrics/provider-fetch" + ); +}; + +const loadFetch = async (isCloud: boolean) => + (await loadModule(isCloud)).providerFetch; + +const resolvesTo = (...addresses: string[]) => + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback( + null, + addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })), + ), + ); + +describe("providerFetch — blocked endpoint guard", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + mocks.lookup.mockReset(); + global.fetch = vi.fn().mockResolvedValue(new Response("ok")); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://169.254.169.254/latest/meta-data/"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => { + const providerFetch = await loadFetch(false); + for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) { + await expect( + providerFetch(`http://${address}/latest/meta-data/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects the AWS ECS metadata IPv6 address", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://[fd00:ec2::254]/v2/credentials"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => { + const providerFetch = await loadFetch(false); + for (const address of [ + "[fe80::1]", + "[fe90::1]", + "[fea0::1]", + "[febf::1]", + ]) { + await expect( + providerFetch(`http://${address}/`), + `expected ${address} to be rejected`, + ).rejects.toThrow(/metadata/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("does not block a hostname just because it starts like a link-local prefix", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://feature-logs.example.com/ready"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("rejects the GCP metadata hostname without needing DNS resolution", async () => { + const providerFetch = await loadFetch(false); + await expect( + providerFetch("http://metadata.google.internal/computeMetadata/v1/"), + ).rejects.toThrow(/metadata/i); + expect(global.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }); + + it("rejects loopback and private-network endpoints in cloud", async () => { + const providerFetch = await loadFetch(true); + + for (const url of [ + "http://10.0.0.5:3100/ready", + "http://127.0.0.1:3100/ready", + "http://localhost:3100/ready", + "http://[::1]:3100/ready", + "http://0.0.0.0:3100/ready", + "http://100.100.100.200/latest/meta-data/", + "http://[::]:3100/ready", + ]) { + await expect( + providerFetch(url), + `expected ${url} to be rejected`, + ).rejects.toThrow(/blocked/i); + } + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("does not follow redirects", async () => { + const providerFetch = await loadFetch(false); + + await providerFetch("https://logs.example.com/ready"); + expect(global.fetch).toHaveBeenCalledWith( + "https://logs.example.com/ready", + expect.objectContaining({ redirect: "error" }), + ); + }); +}); + +describe("providerFetch — address checked at connect time", () => { + let server: Server; + let port: number; + let hostHeader: string | undefined; + + beforeEach(async () => { + mocks.lookup.mockReset(); + hostHeader = undefined; + server = createServer((req, res) => { + hostHeader = req.headers.host; + res.end("ok"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + port = (server.address() as AddressInfo).port; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it("rejects a hostname whose lookup returns a metadata address", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("203.0.113.10", "169.254.169.254"); + + await expect( + providerFetch("http://attacker-controlled.example.com/"), + ).rejects.toThrow(/metadata/i); + expect(mocks.lookup).toHaveBeenCalledWith( + "attacker-controlled.example.com", + expect.anything(), + expect.any(Function), + ); + }); + + it("rejects a hostname resolving to a private address in cloud", async () => { + const providerFetch = await loadFetch(true); + resolvesTo("127.0.0.1"); + + await expect( + providerFetch(`http://loki.internal.example.com:${port}/ready`), + ).rejects.toThrow(/blocked/i); + expect(hostHeader).toBeUndefined(); + }); + + it("rejects when DNS resolution fails", async () => { + const providerFetch = await loadFetch(false); + mocks.lookup.mockImplementation( + (_host: string, _options: unknown, callback: Function) => + callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })), + ); + + await expect( + providerFetch("http://does-not-exist.invalid/"), + ).rejects.toThrow(); + expect(hostHeader).toBeUndefined(); + }); + + it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => { + const providerFetch = await loadFetch(false); + resolvesTo("127.0.0.1"); + + const response = await providerFetch( + `http://loki.internal.example.com:${port}/ready`, + ); + expect(await response.text()).toBe("ok"); + expect(hostHeader).toBe(`loki.internal.example.com:${port}`); + }); + + it("passes a public address through in cloud", async () => { + const { guardedLookup } = await loadModule(true); + resolvesTo("203.0.113.10"); + + const result = await new Promise((resolve, reject) => + guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) => + error ? reject(error) : resolve(addresses), + ), + ); + expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts new file mode 100644 index 000000000..30d84a6f8 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/provider-label.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers"; + +describe("providerLabel", () => { + it("names what the provider sends next to its name", () => { + expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe( + "Loki prod (logs)", + ); + expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe( + "Splunk (metrics)", + ); + expect( + providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }), + ).toBe("Datadog (logs and metrics)"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts new file mode 100644 index 000000000..ad6cd1da4 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/remove-telemetry-provider.test.ts @@ -0,0 +1,101 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + deleted: [] as unknown[], + updates: [] as Array<{ table: unknown; values: Record }>, + updatedServers: [] as Array<{ serverId: string }>, + updatedLocal: [] as Array<{ id: string }>, + queried: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => { + const tx = { + delete: () => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.deleted), + }), + }), + update: (table: unknown) => ({ + set: (values: Record) => ({ + where: () => ({ + returning: () => { + mocks.updates.push({ table, values }); + return Promise.resolve( + mocks.updates.length === 1 + ? mocks.updatedServers + : mocks.updatedLocal, + ); + }, + }), + }), + }), + }; + return { + db: { + transaction: (fn: (tx: unknown) => unknown) => fn(tx), + query: { + server: { findMany: mocks.queried }, + webServerSettings: { findFirst: mocks.queried }, + }, + }, + }; +}); + +const { server, webServerSettings } = await import("@dokploy/server/db/schema"); +const { removeTelemetryProvider } = await import( + "@dokploy/server/services/logs-and-metrics/service" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +describe("removeTelemetryProvider", () => { + beforeEach(() => { + mocks.updates = []; + mocks.updatedServers = [{ serverId: "server-1" }]; + mocks.updatedLocal = [{ id: "ws-1" }]; + mocks.queried.mockReset(); + }); + + it("drops the provider id from the selection of every server and of the local host", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + + const { provider, targets } = await removeTelemetryProvider("lp-1"); + expect(provider).toEqual({ telemetryProviderId: "lp-1" }); + expect(targets).toEqual(["server-1", null]); + + expect(mocks.updates.map((u) => u.table)).toEqual([ + server, + webServerSettings, + ]); + expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe( + 'array_remove("server"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe( + 'array_remove("webServerSettings"."telemetryProviderIds", $1)', + ); + expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe( + 'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END', + ); + }); + + it("reconciles exactly the hosts whose selection the transaction updated", async () => { + mocks.deleted = [{ telemetryProviderId: "lp-1" }]; + mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }]; + mocks.updatedLocal = []; + + const { targets } = await removeTelemetryProvider("lp-1"); + expect(targets).toEqual(["server-1", "server-2"]); + expect(mocks.queried).not.toHaveBeenCalled(); + }); + + it("does not touch assignments when the provider does not exist", async () => { + mocks.deleted = []; + + await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect(mocks.updates).toEqual([]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts new file mode 100644 index 000000000..31b01a09b --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-adapters.test.ts @@ -0,0 +1,392 @@ +import { + getTelemetryProviderAdapter, + telemetryProviderAdapters, +} from "@dokploy/server/services/logs-and-metrics/providers/registry"; +import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types"; +import { describe, expect, it } from "vitest"; + +const baseConfig: TelemetryProviderRuntimeConfig = { + telemetryProviderId: "provider-1", + name: "test", + signals: ["logs"], + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: null, +}; + +describe("getTelemetryProviderAdapter", () => { + it("returns the registered adapter for each known type", () => { + expect(getTelemetryProviderAdapter("loki").type).toBe("loki"); + expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog"); + expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack"); + expect(getTelemetryProviderAdapter("elasticsearch").type).toBe( + "elasticsearch", + ); + expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec"); + expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe( + "aws_cloudwatch", + ); + }); + + it("throws an explicit error for an unregistered type", () => { + // @ts-expect-error: intentionally an unregistered type + expect(() => getTelemetryProviderAdapter("unknown")).toThrow( + /No TelemetryProviderAdapter registered/, + ); + }); +}); + +describe("lokiAdapter.toVectorSink", () => { + it("maps endpoint + labels, without tenant_id if not set", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://loki.example.com" }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("loki"); + expect(sink.inputs).toEqual(["dokploy_scope"]); + expect(sink.endpoint).toBe("https://loki.example.com"); + expect(sink.labels).toMatchObject({ + dokploy_project: "{{ dokploy_project }}", + dokploy_environment: "{{ dokploy_environment }}", + dokploy_application: "{{ dokploy_application }}", + dokploy_organization: "{{ dokploy_organization }}", + dokploy_server: "{{ dokploy_server }}", + }); + expect(sink.tenant_id).toBeUndefined(); + expect(sink.buffer).toEqual({ + type: "disk", + max_size: 268_435_488, + when_full: "block", + }); + }); + + it("includes tenant_id when present in extraConfig", () => { + const adapter = telemetryProviderAdapters.loki; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://loki.example.com", + extraConfig: { tenantId: "tenant-a" }, + }, + "sink_1", + "dokploy_scope", + "logs", + ); + expect(sink.tenant_id).toBe("tenant-a"); + }); + + it("has no toVectorTransform — goes directly scope -> sink", () => { + expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined(); + }); +}); + +describe("datadogAdapter.toVectorSink", () => { + it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { ...baseConfig, apiKey: "dd-key" }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.type).toBe("datadog_logs"); + expect(sink.default_api_key).toBe("dd-key"); + expect(sink.site).toBe("datadoghq.com"); + expect(sink.tags).toBeUndefined(); + expect(sink.inputs).toEqual(["transform_2"]); + }); + + it("uses extraConfig.site when present", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "datadoghq.eu" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => { + const adapter = telemetryProviderAdapters.datadog; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "dd-key", + extraConfig: { site: "https://datadoghq.eu/" }, + }, + "sink_2", + "transform_2", + "logs", + ); + expect(sink.site).toBe("datadoghq.eu"); + }); + + it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => { + const adapter = telemetryProviderAdapters.datadog; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain(".ddtags"); + expect(transform?.source).toContain( + '"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")', + ); + }); + + it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => { + const adapter = telemetryProviderAdapters.datadog; + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_2", + "dokploy_scope", + "logs", + ); + expect(transform?.source).toContain( + 'replace(to_string!(.dokploy_project), ",", "_")', + ); + }); +}); + +describe("betterStackAdapter", () => { + it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => { + const adapter = telemetryProviderAdapters.betterstack; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ); + expect(transform).toEqual({ + type: "remap", + inputs: ["dokploy_scope"], + source: ".dt = del(.timestamp)", + }); + }); + + it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => { + const adapter = telemetryProviderAdapters.betterstack; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://in.logs.betterstack.com", + apiKey: "source-token", + }, + "sink_3", + "transform_1", + "logs", + ); + expect(sink.type).toBe("http"); + expect(sink.inputs).toEqual(["transform_1"]); + expect(sink.uri).toBe("https://in.logs.betterstack.com/"); + expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" }); + expect(sink.compression).toBe("gzip"); + }); +}); + +describe("elasticsearchAdapter", () => { + it("has a toVectorTransform that flattens .label to a JSON string", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(adapter.toVectorTransform).toBeDefined(); + const transform = adapter.toVectorTransform?.( + baseConfig, + "transform_4", + "dokploy_scope", + "logs", + ); + expect(transform?.type).toBe("remap"); + expect(transform?.inputs).toEqual(["dokploy_scope"]); + expect(transform?.source).toContain("encode_json(.label)"); + }); + + it("uses basic auth when a username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "secret", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("elasticsearch"); + expect(sink.endpoints).toEqual(["https://es.example.com:9200"]); + expect(sink.auth).toEqual({ + strategy: "basic", + user: "elastic", + password: "secret", + }); + expect(sink.request).toBeUndefined(); + }); + + it("falls back to an ApiKey header when no username is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + apiKey: "key-123", + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(sink.auth).toBeUndefined(); + expect(sink.request).toEqual({ + headers: { Authorization: "ApiKey key-123" }, + }); + }); + + it("rejects a username set without a password instead of silently shipping unauthenticated", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + expect(() => + adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ), + ).toThrow(/Password.*required/i); + }); + + it("includes bulk.index only when extraConfig.index is set", () => { + const adapter = telemetryProviderAdapters.elasticsearch; + const withoutIndex = adapter.toVectorSink( + { ...baseConfig, endpoint: "https://es.example.com:9200" }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withoutIndex.bulk).toBeUndefined(); + const withIndex = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://es.example.com:9200", + extraConfig: { index: "dokploy-%Y.%m.%d" }, + }, + "sink_4", + "dokploy_scope", + "logs", + ); + expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" }); + }); +}); + +describe("splunkAdapter", () => { + it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.splunk_hec.toVectorTransform, + ).toBeUndefined(); + }); + + it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => { + const adapter = telemetryProviderAdapters.splunk_hec; + const sink = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("splunk_hec_logs"); + expect(sink.endpoint).toBe("https://splunk.example.com:8088"); + expect(sink.default_token).toBe("hec-token"); + expect(sink.encoding).toEqual({ codec: "json" }); + expect(sink.index).toBeUndefined(); + expect(sink.sourcetype).toBeUndefined(); + + const withExtras = adapter.toVectorSink( + { + ...baseConfig, + endpoint: "https://splunk.example.com:8088", + apiKey: "hec-token", + extraConfig: { index: "dokploy", sourcetype: "docker" }, + }, + "sink_5", + "dokploy_scope", + "logs", + ); + expect(withExtras.index).toBe("dokploy"); + expect(withExtras.sourcetype).toBe("docker"); + }); +}); + +describe("awsCloudwatchAdapter", () => { + it("has no logs transform — scoping fields ride along as plain JSON fields", () => { + expect( + telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.( + baseConfig, + "transform_1", + "dokploy_scope", + "logs", + ), + ).toBeNull(); + }); + + it("toVectorSink maps access key/secret, region and log group/stream", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.type).toBe("aws_cloudwatch_logs"); + expect(sink.group_name).toBe("/dokploy/logs"); + expect(sink.stream_name).toBe("{{ container_name }}"); + expect(sink.region).toBe("us-east-1"); + expect(sink.auth).toEqual({ + access_key_id: "AKIA...", + secret_access_key: "shh", + }); + }); + + it("uses extraConfig.logStream as the stream template when set", () => { + const adapter = telemetryProviderAdapters.aws_cloudwatch; + const sink = adapter.toVectorSink( + { + ...baseConfig, + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + logStream: "{{ dokploy_application }}", + }, + }, + "sink_6", + "dokploy_scope", + "logs", + ); + expect(sink.stream_name).toBe("{{ dokploy_application }}"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts new file mode 100644 index 000000000..e30efb520 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/telemetry-provider-service.test.ts @@ -0,0 +1,661 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + insertValues: vi.fn(), + updateSet: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + serverFindFirst: vi.fn(), + serverFindMany: vi.fn(), + webServerFindFirst: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + insert: () => ({ + values: (v: any) => ({ + returning: () => Promise.resolve(mocks.insertValues(v)), + }), + }), + update: () => ({ + set: (v: any) => ({ + where: () => ({ + returning: () => Promise.resolve(mocks.updateSet(v)), + }), + }), + }), + query: { + telemetryProvider: { + findFirst: mocks.findFirst, + findMany: mocks.findMany, + }, + server: { + findFirst: mocks.serverFindFirst, + findMany: mocks.serverFindMany, + }, + webServerSettings: { findFirst: mocks.webServerFindFirst }, + }, + }, +})); + +const { + assertProvidersBelongToOrg, + assertSignalsSupported, + createTelemetryProvider, + testTelemetryProviderConnection, + updateTelemetryProvider, +} = await import("@dokploy/server/services/logs-and-metrics/service"); +const { telemetryProviderAdapters } = await import( + "@dokploy/server/services/logs-and-metrics/providers/registry" +); + +beforeEach(() => { + mocks.serverFindMany.mockResolvedValue([]); + mocks.webServerFindFirst.mockResolvedValue(undefined); +}); + +describe("updateTelemetryProvider — targets to re-apply", () => { + const existing = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old", + apiSecret: null, + extraConfig: null, + enabled: true, + }; + + beforeEach(() => { + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]); + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + }); + + it("returns the servers and the local host that ship with it when a credential changes", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + apiKey: "new", + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns the same targets when the provider is disabled", async () => { + const { targets } = await updateTelemetryProvider("lp-1", { + enabled: false, + }); + expect(targets).toEqual(["server-1", null]); + }); + + it("returns no targets when only the name changes or a value is re-sent unchanged", async () => { + expect( + (await updateTelemetryProvider("lp-1", { name: "renamed" })).targets, + ).toEqual([]); + expect( + ( + await updateTelemetryProvider("lp-1", { + endpoint: "https://loki.example.com", + }) + ).targets, + ).toEqual([]); + }); +}); + +describe("createTelemetryProvider — required credential fields per adapter", () => { + it("rejects a loki provider without endpoint", async () => { + await expect( + createTelemetryProvider( + { name: "loki-prod", providerType: "loki", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(/endpoint/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider without apiKey", async () => { + await expect( + createTelemetryProvider( + { name: "dd", providerType: "datadog", signals: ["logs"] } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a betterstack provider missing either field", async () => { + await expect( + createTelemetryProvider( + { + name: "bs", + providerType: "betterstack", + signals: ["logs"], + apiKey: "token-only", + } as any, + "org-1", + ), + ).rejects.toThrow(); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a splunk_hec provider without a HEC token", async () => { + await expect( + createTelemetryProvider( + { + name: "splunk", + providerType: "splunk_hec", + signals: ["logs"], + endpoint: "https://splunk.example.com:8088", + } as any, + "org-1", + ), + ).rejects.toThrow(/token/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + } as any, + "org-1", + ), + ).rejects.toThrow(/region|log group/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => { + await expect( + createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["logs"], + extraConfig: { apiKey: "sneaky-value" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/api key/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => { + await expect( + createTelemetryProvider( + { + name: "es", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/password.*required/i); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); + + it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => { + mocks.insertValues.mockReturnValue([ + { + telemetryProviderId: "lp-2", + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + }, + ]); + const created = await createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA...", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-2"); + }); + + it("accepts a loki provider with endpoint", async () => { + mocks.insertValues.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" }, + ]); + const created = await createTelemetryProvider( + { + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ); + expect(created.telemetryProviderId).toBe("lp-1"); + expect(mocks.insertValues).toHaveBeenCalled(); + }); +}); + +describe("updateTelemetryProvider — merges partial update with existing row before validating", () => { + it("allows updating just the name without re-sending existing credentials", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "old-name", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([ + { telemetryProviderId: "lp-1", name: "new-name" }, + ]); + + const { provider: updated, targets } = await updateTelemetryProvider( + "lp-1", + { name: "new-name" }, + ); + expect(updated.name).toBe("new-name"); + expect(targets).toEqual([]); + }); + + it("rejects clearing the only required field via update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { endpoint: null }), + ).rejects.toThrow(/endpoint/i); + }); + + it("clears the old provider's credentials when providerType changes, unless new values are given", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: { site: "datadoghq.com" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: null, + apiSecret: null, + extraConfig: null, + }), + ); + }); + + it("merges a partial extraConfig update instead of replacing it wholesale", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "secret", + apiSecret: null, + extraConfig: { username: "elastic", index: "custom-index" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { + extraConfig: { username: "new-user" }, + }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + extraConfig: { username: "new-user", index: "custom-index" }, + }), + ); + }); + + it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: null, + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { extraConfig: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ extraConfig: null }), + ); + }); + + it("clears an optional credential when it is explicitly set to null", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "loki-prod", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "old-token", + apiSecret: null, + extraConfig: { username: "user" }, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]); + + await updateTelemetryProvider("lp-1", { apiKey: null }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: null }), + ); + }); + + it("rejects clearing the elasticsearch password while a username stays saved", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "es-prod", + providerType: "elasticsearch", + signals: ["logs"], + endpoint: "https://es.example.com:9200", + apiKey: "old-password", + apiSecret: null, + extraConfig: { username: "elastic" }, + }); + + await expect( + updateTelemetryProvider("lp-1", { apiKey: null }), + ).rejects.toThrow(/Password/); + }); + + it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "lp-1", + name: "dd-prod", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "old-datadog-key", + apiSecret: null, + extraConfig: null, + }); + + await expect( + updateTelemetryProvider("lp-1", { providerType: "loki" }), + ).rejects.toThrow(/endpoint/i); + }); +}); + +describe("assertSignalsSupported", () => { + it("rejects a repeated signal", () => { + expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow( + /only be selected once/, + ); + }); + + it("rejects a signal the type cannot send", () => { + expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow( + /Grafana Loki cannot send metrics/, + ); + }); + + it("accepts a supported subset", () => { + expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow(); + }); +}); + +describe("createTelemetryProvider — signals", () => { + it("rejects a provider whose type cannot send the selected signal", async () => { + mocks.insertValues.mockClear(); + await expect( + createTelemetryProvider( + { + name: "loki", + providerType: "loki", + signals: ["metrics"], + endpoint: "https://loki.example.com", + } as any, + "org-1", + ), + ).rejects.toThrow(/cannot send metrics/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("assertProvidersBelongToOrg", () => { + beforeEach(() => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: true, + signals: ["logs"], + }, + ]); + }); + + it("accepts a provider of the organization regardless of what it sends", async () => { + await expect( + assertProvidersBelongToOrg(["lp-1"], "org-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects an id that is not in the organization", async () => { + await expect( + assertProvidersBelongToOrg(["lp-9"], "org-1"), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + it("rejects a selection where every provider is disabled", async () => { + mocks.findMany.mockResolvedValue([ + { + telemetryProviderId: "lp-1", + name: "loki", + enabled: false, + signals: ["logs"], + }, + ]); + await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow( + /at least one enabled provider/, + ); + }); +}); + +describe("required fields scoped to a signal", () => { + beforeEach(() => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]); + }); + + it("does not require the log group when only metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("does not require the metrics namespace when only logs are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).resolves.toMatchObject({ telemetryProviderId: "cw-1" }); + }); + + it("requires the metrics namespace when metrics are selected", async () => { + await expect( + createTelemetryProvider( + { + name: "cw", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" }, + } as any, + "org-1", + ), + ).rejects.toThrow(/Metrics namespace/); + expect(mocks.insertValues).not.toHaveBeenCalled(); + }); +}); + +describe("signals are stored in a fixed order", () => { + it("normalizes the order on create", async () => { + mocks.insertValues.mockReset(); + mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await createTelemetryProvider( + { + name: "dd", + providerType: "datadog", + signals: ["metrics", "logs"], + apiKey: "dd-key", + } as any, + "org-1", + ); + + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); + + it("normalizes the order on update", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "dd-1", + name: "dd", + providerType: "datadog", + signals: ["logs"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }); + mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]); + + await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] }); + + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["logs", "metrics"] }), + ); + }); +}); + +describe("testTelemetryProviderConnection by id", () => { + it("tests the saved provider with the signals override from the form", async () => { + mocks.findFirst.mockResolvedValue({ + telemetryProviderId: "bs-1", + name: "bs", + providerType: "betterstack", + signals: ["logs"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + apiSecret: null, + extraConfig: null, + }); + const spy = vi + .spyOn(telemetryProviderAdapters.betterstack, "testConnection") + .mockResolvedValue(undefined); + + await testTelemetryProviderConnection({ + telemetryProviderId: "bs-1", + signals: ["logs", "metrics"], + }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "token", + signals: ["logs", "metrics"], + }), + ); + spy.mockRestore(); + }); +}); + +describe("updateTelemetryProvider — signals", () => { + const existing = { + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: null, + }; + + beforeEach(() => { + mocks.updateSet.mockReset(); + mocks.serverFindFirst.mockReset(); + mocks.webServerFindFirst.mockReset(); + mocks.findFirst.mockResolvedValue(existing); + mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]); + }); + + it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => { + mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]); + mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" }); + + await expect( + updateTelemetryProvider("dd-1", { signals: ["metrics"] }), + ).resolves.toMatchObject({ + provider: { signals: ["metrics"] }, + targets: ["server-1", null], + }); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ signals: ["metrics"] }), + ); + }); + + it("rejects a type change that cannot send the saved signals", async () => { + await expect( + updateTelemetryProvider("dd-1", { + providerType: "loki", + endpoint: "https://loki.example.com", + }), + ).rejects.toThrow(/Grafana Loki cannot send metrics/); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts new file mode 100644 index 000000000..6cc14b1c5 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-agent.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + setWebServerProviderIds: vi.fn(), + setServerProviderIds: vi.fn(), + getAccessibleServerIds: vi.fn(), + reconcileVectorAgent: vi.fn(), + removeVectorAgent: vi.fn(), + findServerById: vi.fn(), + assertProvidersBelongToOrg: vi.fn(), +})); + +const loadAgent = async (isCloud: boolean) => { + vi.resetModules(); + vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud })); + vi.doMock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + setServerProviderIds: mocks.setServerProviderIds, + getAccessibleServerIds: mocks.getAccessibleServerIds, + })); + vi.doMock("@dokploy/server/services/web-server-settings", () => ({ + setWebServerProviderIds: mocks.setWebServerProviderIds, + })); + const locks = new Map>(); + vi.doMock("@dokploy/server/setup/vector-setup", () => ({ + LOCAL_SERVER_NAME: "Dokploy Server (local)", + reconcileVectorAgent: mocks.reconcileVectorAgent, + removeVectorAgent: mocks.removeVectorAgent, + withVectorTargetLock: ( + serverId: string | undefined, + fn: () => Promise, + ) => { + const key = serverId ?? "local"; + const current = (locks.get(key) ?? Promise.resolve()) + .catch(() => {}) + .then(fn); + locks.set(key, current); + return current; + }, + })); + vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({ + assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg, + })); + return await import("@dokploy/server/services/logs-and-metrics/vector-agent"); +}; + +const sessionA = { userId: "user-a", activeOrganizationId: "org-a" }; +const sessionB = { userId: "user-b", activeOrganizationId: "org-b" }; + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"])); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + name: `name-of-${serverId}`, + organizationId: "org-a", + sshKeyId: "key-1", + })); + mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" }); + mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" }); +}); + +describe("applyVectorAgentSelection on the local host", () => { + it("rejects the local host in cloud without touching the database", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + await expect( + applyVectorAgentSelection(sessionA, undefined, ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => { + mocks.setWebServerProviderIds.mockResolvedValue(null); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionB, undefined, ["mp-1"]), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [ + "mp-1", + ]); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the selection and then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]); + expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith( + ["lp-1"], + "org-a", + ); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [ + "lp-1", + ]); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + expect( + mocks.setWebServerProviderIds.mock.invocationCallOrder[0], + ).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0); + }); + + it("removing the agent saves an empty selection without checking providers", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, undefined, []); + expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled(); + expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined); + }); +}); + +describe("applyVectorAgentSelection on a remote server", () => { + it("still manages remote servers in cloud", async () => { + const { applyVectorAgentSelection } = await loadAgent(true); + + const result = await applyVectorAgentSelection(sessionA, "server-1", []); + expect(result).toEqual({ serverName: "name-of-server-1" }); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("rejects a server of another organization", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-b", + sshKeyId: "key-1", + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + }); + + it("rejects a server without an SSH key before saving anything", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + sshKeyId: null, + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("rejects a server the member cannot access", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA); + expect(mocks.setServerProviderIds).not.toHaveBeenCalled(); + expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled(); + }); + + it("saves the array and only then reconciles", async () => { + const { applyVectorAgentSelection } = await loadAgent(false); + + await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "mp-1", + ]); + expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("keeps the saved selection when the reconcile fails", async () => { + mocks.reconcileVectorAgent.mockRejectedValue( + new Error("validation failed"), + ); + const { applyVectorAgentSelection } = await loadAgent(false); + + await expect( + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ).rejects.toThrow("validation failed"); + expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [ + "lp-2", + ]); + }); + + it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => { + const saved: string[][] = []; + const seen: string[][] = []; + mocks.setServerProviderIds.mockImplementation( + async (_serverId: string, ids: string[]) => { + saved.push(ids); + return { serverId: "server-1" }; + }, + ); + mocks.reconcileVectorAgent.mockImplementation(async () => { + seen.push(saved.at(-1) ?? []); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const { applyVectorAgentSelection } = await loadAgent(false); + + await Promise.all([ + applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]), + applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]), + ]); + expect(seen).toEqual([["lp-1"], ["lp-2"]]); + }); +}); + +describe("removeServerVectorAgent", () => { + it("removes the agent on server deletion when providers are assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "key-1", + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1"); + }); + + it("skips servers without an SSH key, warning when providers were assigned", async () => { + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: [], + } as never), + ).resolves.toBeUndefined(); + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: null, + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toMatch(/no SSH key/); + expect(mocks.removeVectorAgent).not.toHaveBeenCalled(); + }); + + it("returns the failure as a message instead of throwing", async () => { + mocks.removeVectorAgent.mockRejectedValue(new Error("docker down")); + const { removeServerVectorAgent } = await loadAgent(false); + + await expect( + removeServerVectorAgent({ + serverId: "server-1", + sshKeyId: "k", + telemetryProviderIds: ["lp-1"], + } as never), + ).resolves.toBe("docker down"); + }); +}); + +describe("reconcileVectorTargets", () => { + it("re-applies every target under its lock and reports failures as a warning", async () => { + mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => { + if (serverId === "server-2") throw new Error("ssh down"); + }); + const { reconcileVectorTargets } = await loadAgent(false); + + const warning = await reconcileVectorTargets([ + "server-1", + "server-2", + null, + ]); + expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([ + "server-1", + "server-2", + undefined, + ]); + expect(warning).toBe( + "The Vector agent could not be re-applied on server-2: ssh down", + ); + }); + + it("returns no warning when every target reconciles, and skips the local host in cloud", async () => { + const { reconcileVectorTargets } = await loadAgent(true); + + await expect( + reconcileVectorTargets(["server-1", null]), + ).resolves.toBeUndefined(); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1); + expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1"); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts new file mode 100644 index 000000000..98c4d70d3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-config.test.ts @@ -0,0 +1,375 @@ +import { vrlString } from "@dokploy/server/services/logs-and-metrics/types"; +import { + buildMetricsScopeTransformSource, + buildVectorConfigYaml, + collectSecretValues, + redactSecrets, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + signals: ["logs"], + ...overrides, + }) as any; + +const loki = provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", +}); + +const prom = provider({ + telemetryProviderId: "prom-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", +}); + +const datadog = provider({ + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", +}); + +const build = ( + overrides: Partial[0]>, +) => + parse( + buildVectorConfigYaml({ + logProviders: [], + metricsProviders: [], + serverName: "my server", + ...overrides, + }), + ) as any; + +describe("buildVectorConfigYaml — logs", () => { + it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => { + const config = build({ logProviders: [loki] }); + + expect(config.data_dir).toBe("/var/lib/vector"); + expect(config.sources.docker_logs_source.type).toBe("docker_logs"); + expect(config.transforms.dokploy_scope.type).toBe("remap"); + expect(config.transforms.dokploy_scope.inputs).toEqual([ + "docker_logs_source", + ]); + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + expect(config.sinks["sink_loki-1"].type).toBe("loki"); + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + + expect(config.sources.host_metrics_source).toBeUndefined(); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(config.transforms.dokploy_metrics_scope).toBeUndefined(); + }); + + it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => { + const config = build({ + logProviders: [ + loki, + provider({ + telemetryProviderId: "bs-1", + name: "betterstack", + providerType: "betterstack", + endpoint: "https://in.logs.betterstack.com", + apiKey: "token", + }), + datadog, + ], + }); + + expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]); + expect(config.transforms["transform_bs-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]); + expect(config.transforms["transform_dd-1"].inputs).toEqual([ + "dokploy_scope", + ]); + expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]); + }); + + it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => { + const config = build({ logProviders: [loki], serverName: 'srv "quoted"' }); + + expect(config.transforms.dokploy_scope.source).toBe( + [ + '.dokploy_server = "srv \\"quoted\\""', + '.dokploy_organization = .label."dokploy.organization.id" || ""', + '.dokploy_project = .label."dokploy.project" || ""', + '.dokploy_project_id = .label."dokploy.project.id" || ""', + '.dokploy_environment = .label."dokploy.environment" || ""', + '.dokploy_environment_id = .label."dokploy.environment.id" || ""', + '.dokploy_application = .label."dokploy.application" || ""', + '.dokploy_application_id = .label."dokploy.application.id" || ""', + '.dokploy_service = .label."dokploy.service" || ""', + ].join("\n"), + ); + }); + + it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => { + const config = build({ + logProviders: [ + provider({ + telemetryProviderId: "broken-es", + name: "es-broken", + providerType: "elasticsearch", + endpoint: "https://es.example.com:9200", + extraConfig: { username: "elastic" }, + }), + loki, + ], + }); + + expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]); + }); + + it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => { + const config = build({ logProviders: [loki], organizationId: "org-a" }); + + expect(config.transforms.dokploy_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_scope"], + condition: '.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_loki-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + }); + + it("does not add the organization filter for the per-server (non-local) path", () => { + const config = build({ logProviders: [loki] }); + + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + }); +}); + +describe("buildVectorConfigYaml — metrics", () => { + it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => { + const config = build({ metricsProviders: [prom] }); + + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.transforms.dokploy_scope).toBeUndefined(); + expect(config.sources.host_metrics_source).toMatchObject({ + type: "host_metrics", + scrape_interval_secs: 30, + collectors: [ + "cpu", + "memory", + "disk", + "filesystem", + "network", + "load", + "host", + ], + }); + expect( + config.sources.host_metrics_source.filesystem.filesystems.excludes, + ).toEqual( + expect.arrayContaining([ + "overlay", + "tmpfs", + "squashfs", + "devtmpfs", + "nsfs", + "proc", + "sysfs", + "devpts", + "cgroup2", + ]), + ); + expect(config.sources.host_metrics_source.network.devices.excludes).toEqual( + ["veth*", "br-*", "docker*"], + ); + expect(config.sources.cadvisor_source).toEqual({ + type: "prometheus_scrape", + endpoints: ["http://127.0.0.1:4510/metrics"], + scrape_interval_secs: 30, + scrape_timeout_secs: 10, + }); + expect(config.transforms.dokploy_metrics_scope).toMatchObject({ + type: "remap", + inputs: ["host_metrics_source", "cadvisor_source"], + drop_on_abort: true, + }); + expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]); + expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({ + type: "prometheus_remote_write", + inputs: ["dokploy_metrics_scope"], + }); + expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined(); + }); + + it("on the local host it lets host series through and keeps only the owning organization's containers", () => { + const config = build({ metricsProviders: [prom], organizationId: "org-a" }); + + expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({ + type: "filter", + inputs: ["dokploy_metrics_scope"], + condition: + '.namespace == "host" || .tags.dokploy_organization == "org-a"', + }); + expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([ + "dokploy_metrics_scope_local_only", + ]); + }); + + it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => { + const config = build({ + logProviders: [datadog], + metricsProviders: [datadog], + organizationId: "org-a", + }); + + expect(config.sinks["sink_dd-1"]).toMatchObject({ + type: "datadog_logs", + inputs: ["transform_dd-1"], + }); + expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({ + type: "datadog_metrics", + inputs: ["dokploy_metrics_scope_local_only"], + }); + expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined(); + }); + + it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => { + const source = buildMetricsScopeTransformSource('srv "quoted"'); + + expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""'); + expect(source).toContain( + 'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }', + ); + expect(source).toContain( + 'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }', + ); + expect(source.trim().endsWith("del(.tags.id)")).toBe(true); + expect( + build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope + .source, + ).toBe(buildMetricsScopeTransformSource("my server")); + }); +}); + +describe("buildVectorConfigYaml — dollar signs", () => { + it("keeps every $ literal because the agent runs without env var interpolation", () => { + const yamlStr = buildVectorConfigYaml({ + logProviders: [ + provider({ + telemetryProviderId: "loki-1", + name: "loki", + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), + ], + metricsProviders: [prom], + serverName: "srv $ONE", + }); + + expect(yamlStr).not.toContain("$$"); + expect(yamlStr).toContain("srv $ONE"); + expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe( + "Pa$5w0rd$HOME", + ); + }); +}); + +describe("collectSecretValues / redactSecrets", () => { + it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => { + const providers = [ + { + providerType: "loki", + endpoint: "https://loki.example.com", + apiKey: "loki-key", + apiSecret: null, + extraConfig: { tenantId: "tenant-a", username: "12345" }, + }, + { + providerType: "datadog", + endpoint: null, + apiKey: "dd-key", + apiSecret: null, + extraConfig: { site: "datadoghq.eu" }, + }, + { + providerType: "aws_cloudwatch", + endpoint: null, + apiKey: "AKIA", + apiSecret: "shh", + extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" }, + }, + ] as any; + + const values = collectSecretValues(providers); + expect(values).toEqual( + expect.arrayContaining([ + "https://loki.example.com", + "loki-key", + "dd-key", + "AKIA", + "shh", + ]), + ); + for (const plain of [ + "tenant-a", + "12345", + "datadoghq.eu", + "us-east-1", + "Dokploy", + ]) { + expect(values).not.toContain(plain); + } + }); + + it("ignores null/empty credentials", () => { + const providers = [ + { + providerType: "elasticsearch", + endpoint: null, + apiKey: null, + apiSecret: null, + extraConfig: { limit: 10, enabled: true, empty: "" }, + }, + ] as any; + expect(collectSecretValues(providers)).toEqual([]); + }); + + it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => { + const providers = [ + { + providerType: "loki", + endpoint: null, + apiKey: "hunter2-long-suffix", + apiSecret: "hunter2", + extraConfig: null, + }, + ] as any; + const text = + "sink validation error near apiKey=hunter2-long-suffix and hunter2"; + + const redacted = redactSecrets(text, collectSecretValues(providers)); + + expect(redacted).not.toContain("hunter2"); + expect(redacted).toBe( + "sink validation error near apiKey=[redacted] and [redacted]", + ); + }); +}); + +describe("vrlString", () => { + it("quotes and escapes like JSON but drops the control characters VRL rejects", () => { + expect(vrlString('my "server"')).toBe('"my \\"server\\""'); + expect(vrlString("back\\slash")).toBe('"back\\\\slash"'); + expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"'); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts new file mode 100644 index 000000000..4d3775553 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-reconcile.test.ts @@ -0,0 +1,526 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + getWebServerSettings: vi.fn(), + findEnabledTelemetryProvidersByOrganization: vi.fn(), + execAsync: vi.fn(), + execAsyncRemote: vi.fn(), + inspectService: vi.fn(), + updateService: vi.fn(), + removeService: vi.fn(), + createService: vi.fn(), + getService: vi.fn(), + listContainers: vi.fn(), + killContainer: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: (isServer: boolean) => ({ + VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector", + }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: mocks.findServerById, + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findEnabledTelemetryProvidersByOrganization: + mocks.findEnabledTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mocks.execAsync, + execAsyncRemote: mocks.execAsyncRemote, +})); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + getService: mocks.getService, + createService: mocks.createService, + listContainers: mocks.listContainers, + getContainer: () => ({ kill: mocks.killContainer }), + }), +})); + +const { reconcileVectorAgent, removeVectorAgent } = await import( + "@dokploy/server/setup/vector-setup" +); + +const lokiProvider = { + telemetryProviderId: "lp-1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "https://loki.example.com", + apiKey: "sekret-token", + apiSecret: null, + extraConfig: null, + enabled: true, + organizationId: "org-a", +}; + +const promProvider = { + ...lokiProvider, + telemetryProviderId: "mp-1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: null, +}; + +const notFound = () => + Object.assign(new Error("not found"), { statusCode: 404 }); + +const writtenYaml = (command: string) => { + const match = command.match(/echo "([^"]+)" \| base64 -d/); + return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8")); +}; + +const commandsOf = (mock: typeof mocks.execAsync) => + mock.mock.calls.map((call) => String(call[call.length - 1])); + +beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.getService.mockImplementation(() => ({ + inspect: mocks.inspectService, + update: mocks.updateService, + remove: mocks.removeService, + })); + mocks.inspectService.mockRejectedValue(notFound()); + mocks.removeService.mockRejectedValue(notFound()); + mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]); + mocks.killContainer.mockResolvedValue(undefined); +}); + +describe("reconcileVectorAgent", () => { + it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const commands = commandsOf(mocks.execAsync); + expect(commands).toHaveLength(3); + expect(commands[0]).toContain( + "/local/dokploy/vector/vector.yaml.candidate.", + ); + expect(commands[1]).toMatch( + /timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/, + ); + expect(commands[2]).toMatch( + /^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/, + ); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + + const config = writtenYaml(commands[0] ?? ""); + expect(config.sinks["sink_lp-1"].inputs).toEqual([ + "dokploy_scope_local_only", + ]); + expect(config.sources.host_metrics_source).toBeUndefined(); + + expect(mocks.createService).toHaveBeenCalledTimes(1); + expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({ + Name: "dokploy-vector", + }); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => { + const datadogProvider = { + ...lokiProvider, + telemetryProviderId: "dd-1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + endpoint: null, + apiKey: "dd-key", + }; + const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" }; + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1", "dd-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + promProvider, + datadogProvider, + unselected, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + const sinkIds = Object.keys(config.sinks); + expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true); + expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true); + expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2); + expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false); + expect(config.sources.docker_logs_source).toBeDefined(); + expect(config.sources.cadvisor_source).toBeDefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + }); + + it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1", "mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.cadvisor_source).toBeUndefined(); + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + ]); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.removeService).toHaveBeenCalledTimes(1); + }); + + it("filters by the organization only on the local host and runs on the server over SSH", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + + await reconcileVectorAgent("server-1"); + + expect(mocks.execAsync).not.toHaveBeenCalled(); + expect( + mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"), + ).toBe(true); + const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? ""); + expect(config.transforms.dokploy_scope_local_only).toBeUndefined(); + expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]); + }); + + it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.includes("validate")) { + throw new Error("bad sink: password sekret-token rejected"); + } + return { stdout: "", stderr: "" }; + }); + + await expect(reconcileVectorAgent()).rejects.toThrow( + /failed validation, not applying it: bad sink: password \[redacted\] rejected/, + ); + + const commands = commandsOf(mocks.execAsync); + expect(commands.some((c) => c.startsWith("mv "))).toBe(false); + expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when no enabled provider is left in the selection", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["deleted-or-disabled"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent(); + + expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor"); + expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector"); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("removes the agent when the selection is empty, without an owner", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: [], + }); + + await reconcileVectorAgent(); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).not.toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + }); + + it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["mp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + promProvider, + ]); + + await reconcileVectorAgent(); + + const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? ""); + expect(config.sources.docker_logs_source).toBeUndefined(); + expect(config.sources.cadvisor_source.endpoints).toEqual([ + "http://127.0.0.1:4510/metrics", + ]); + + expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([ + "dokploy-vector", + "dokploy-cadvisor", + ]); + const cadvisor = mocks.createService.mock.calls[1]?.[0]; + expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch( + /^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/, + ); + expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual( + expect.arrayContaining([ + "--listen_ip=127.0.0.1", + "--port=4510", + "--docker_only=true", + "--store_container_labels=false", + ]), + ); + expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({ + Test: ["NONE"], + }); + expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]); + expect(mocks.removeService).not.toHaveBeenCalled(); + }); + + it("removes the agent on a remote server when the selection is empty", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: "key-1", + organizationId: "org-a", + name: "edge-1", + telemetryProviderIds: [], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]); + + await reconcileVectorAgent("server-1"); + + expect( + mocks.findEnabledTelemetryProvidersByOrganization, + ).toHaveBeenCalled(); + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(mocks.createService).not.toHaveBeenCalled(); + expect(commandsOf(mocks.execAsyncRemote)).toEqual([ + "rm -rf /etc/dokploy/vector", + ]); + }); + + it("redacts the config and secrets when writing or applying the candidate fails", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + const echoCommand = async (command: string) => { + if (command.includes("base64 -d") || command.startsWith("mv ")) { + throw new Error(`Command failed: ${command} (token sekret-token)`); + } + return { stdout: "", stderr: "" }; + }; + mocks.execAsync.mockImplementation(echoCommand); + + const writeError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(writeError).toMatch(/^Failed to write the Vector config: /); + expect(writeError).not.toContain("sekret-token"); + expect(writeError).not.toContain( + Buffer.from("data_dir").toString("base64").slice(0, 8), + ); + expect(writeError).toContain("[redacted]"); + + mocks.execAsync.mockImplementation(async (command: string) => { + if (command.startsWith("mv ")) return echoCommand(command); + return { stdout: "", stderr: "" }; + }); + const applyError = await reconcileVectorAgent().catch( + (e: Error) => e.message, + ); + expect(applyError).toMatch(/^Failed to apply the Vector config: /); + expect(applyError).not.toContain("sekret-token"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates a rejected service update instead of pretending the create succeeded", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + mocks.updateService.mockRejectedValue( + new Error("rpc error: invalid mount"), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("propagates an inspect failure that is not a 404", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("daemon unreachable"), { statusCode: 500 }), + ); + + await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable"); + expect(mocks.createService).not.toHaveBeenCalled(); + }); + + it("does not restart Vector on a config-only redeploy", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockImplementation(async () => ({ + Version: { Index: "7" }, + Spec: { + TaskTemplate: { + ForceUpdate: 2, + ContainerSpec: mocks.createService.mock.calls[0]?.[0] + ? undefined + : undefined, + }, + }, + })); + + await reconcileVectorAgent(); + expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" }); + expect(mocks.listContainers).toHaveBeenCalledWith({ + filters: JSON.stringify({ + label: ["com.docker.swarm.service.name=dokploy-vector"], + }), + }); + const firstSpec = mocks.updateService.mock.calls[0]?.[0]; + mocks.inspectService.mockResolvedValue({ + Version: { Index: "8" }, + Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } }, + }); + mocks.updateService.mockClear(); + + await reconcileVectorAgent(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 8, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); + + it("updates an existing Vector service instead of creating it", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-a", + telemetryProviderIds: ["lp-1"], + }); + mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([ + lokiProvider, + ]); + mocks.inspectService.mockResolvedValue({ + Version: { Index: "7" }, + Spec: { TaskTemplate: { ForceUpdate: 2 } }, + }); + + await reconcileVectorAgent(); + + expect(mocks.createService).not.toHaveBeenCalled(); + expect(mocks.updateService).toHaveBeenCalledWith( + expect.objectContaining({ + version: 7, + TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }), + }), + ); + }); +}); + +describe("removeVectorAgent", () => { + it("removes cAdvisor, then Vector, then the config directory", async () => { + await removeVectorAgent(); + + expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([ + "dokploy-cadvisor", + "dokploy-vector", + ]); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("keeps going when cAdvisor cannot be removed", async () => { + mocks.removeService + .mockRejectedValueOnce(new Error("docker hiccup")) + .mockRejectedValueOnce(notFound()); + + await removeVectorAgent(); + + expect(mocks.removeService).toHaveBeenCalledTimes(2); + expect(commandsOf(mocks.execAsync)).toEqual([ + "rm -rf /local/dokploy/vector", + ]); + }); + + it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => { + mocks.findServerById.mockResolvedValue({ + serverId: "server-1", + sshKeyId: null, + }); + + await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/); + expect(mocks.getService).not.toHaveBeenCalled(); + expect(mocks.execAsyncRemote).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts new file mode 100644 index 000000000..12131a7e7 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-status.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listTasks: vi.fn(), + inspectService: vi.fn(), + findServersByOrganizationForVectorAgent: vi.fn(), + getAccessibleServerIds: vi.fn(), + getWebServerSettings: vi.fn(), + findTelemetryProvidersByOrganization: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ db: {} })); +vi.mock("@dokploy/server/constants", () => ({ + IS_CLOUD: false, + paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }), +})); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(), + findServersByOrganizationForVectorAgent: + mocks.findServersByOrganizationForVectorAgent, + getAccessibleServerIds: mocks.getAccessibleServerIds, +})); +vi.mock("@dokploy/server/services/web-server-settings", () => ({ + getWebServerSettings: mocks.getWebServerSettings, +})); +vi.mock( + "@dokploy/server/services/logs-and-metrics/service", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/services/logs-and-metrics/service") + >()), + findTelemetryProvidersByOrganization: + mocks.findTelemetryProvidersByOrganization, + }), +); +vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({ + getRemoteDocker: async () => ({ + listTasks: mocks.listTasks, + getService: () => ({ inspect: mocks.inspectService }), + }), +})); + +const { getVectorAgentTargets, withVectorTargetLock } = await import( + "@dokploy/server/setup/vector-setup" +); + +const session = { userId: "user-1", activeOrganizationId: "org-1" }; + +describe("getVectorAgentTargets", () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]); + mocks.inspectService.mockResolvedValue({}); + mocks.getAccessibleServerIds.mockResolvedValue(new Set()); + mocks.findTelemetryProvidersByOrganization.mockResolvedValue([ + { telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] }, + { telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] }, + { telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] }, + ]); + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1"], + }); + }); + + it("requires a running cAdvisor task when the target ships metrics", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockImplementation( + async ({ filters }: { filters: string }) => + JSON.parse(filters).service[0] === "dokploy-vector" + ? [{ Status: { State: "running" } }] + : [], + ); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(2); + }); + + it("reports running when both Vector and cAdvisor have a running task", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["mp-1"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + }); + + it("does not look at cAdvisor for a logs-only target", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + await getVectorAgentTargets(session); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("does not look at cAdvisor when the only metrics provider is disabled", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: "org-1", + telemetryProviderIds: ["lp-1", "mp-off"], + }); + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledTimes(1); + }); + + it("reports running only when a task of the service is running", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("running"); + expect(mocks.listTasks).toHaveBeenCalledWith({ + filters: JSON.stringify({ + service: ["dokploy-vector"], + "desired-state": ["running"], + }), + }); + }); + + it("reports not-running when the service has no running task", async () => { + mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("not-running"); + }); + + it("reports stopped when the service does not exist", async () => { + mocks.inspectService.mockRejectedValue( + Object.assign(new Error("not found"), { statusCode: 404 }), + ); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("stopped"); + expect(mocks.listTasks).not.toHaveBeenCalled(); + }); + + it("reports unknown on any other docker error", async () => { + mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED")); + + const [local] = await getVectorAgentTargets(session); + expect(local?.status).toBe("unknown"); + }); + + it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => { + mocks.getWebServerSettings.mockResolvedValue({ + vectorAgentOrganizationId: null, + telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"], + }); + mocks.listTasks.mockResolvedValue([]); + + const [local] = await getVectorAgentTargets(session); + expect(local).toMatchObject({ + serverId: null, + telemetryProviderIds: [], + }); + }); + + it("hides servers the member cannot access", async () => { + mocks.listTasks.mockResolvedValue([]); + mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([ + { + serverId: "server-1", + name: "one", + ipAddress: "1.1.1.1", + telemetryProviderIds: [], + }, + { + serverId: "server-2", + name: "two", + ipAddress: "2.2.2.2", + telemetryProviderIds: [], + }, + ]); + mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"])); + + const targets = await getVectorAgentTargets(session); + expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]); + }); +}); + +describe("withVectorTargetLock", () => { + it("runs operations on the same target one after another", async () => { + const events: string[] = []; + let releaseFirst = () => {}; + const first = withVectorTargetLock("server-1", async () => { + events.push("first:start"); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + events.push("first:end"); + throw new Error("first failed"); + }); + const second = withVectorTargetLock("server-1", async () => { + events.push("second"); + }); + const other = withVectorTargetLock("server-2", async () => { + events.push("other"); + }); + + await other; + expect(events).toEqual(["first:start", "other"]); + releaseFirst(); + await expect(first).rejects.toThrow("first failed"); + await second; + expect(events).toEqual(["first:start", "other", "first:end", "second"]); + }); +}); diff --git a/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts new file mode 100644 index 000000000..612b782d3 --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/vector-validate.test.ts @@ -0,0 +1,197 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildVectorConfigYaml, + VECTOR_IMAGE, +} from "@dokploy/server/setup/vector-setup"; +import { describe, expect, it } from "vitest"; + +const dockerAvailable = (() => { + try { + execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 }); + return true; + } catch { + return false; + } +})(); + +const provider = (overrides: Record) => + ({ + apiKey: null, + apiSecret: null, + endpoint: null, + extraConfig: null, + enabled: true, + ...overrides, + }) as any; + +const logsOnly = [ + provider({ + telemetryProviderId: "loki1", + name: "loki", + providerType: "loki", + signals: ["logs"], + endpoint: "http://loki.example.com:3100", + apiKey: "Pa$5w0rd$HOME", + extraConfig: { username: "12345" }, + }), +]; + +const metricsOnly = [ + provider({ + telemetryProviderId: "prom1", + name: "prom", + providerType: "prometheus_remote_write", + signals: ["metrics"], + endpoint: "http://prom.example.com:9090/api/v1/write", + apiKey: "token", + extraConfig: { tenantId: "team-a" }, + }), + provider({ + telemetryProviderId: "nr1", + name: "new relic", + providerType: "new_relic", + signals: ["metrics"], + apiKey: "license", + extraConfig: { accountId: "123", region: "eu" }, + }), + provider({ + telemetryProviderId: "influx1", + name: "influx", + providerType: "influxdb", + signals: ["metrics"], + endpoint: "http://influx.example.com:8086", + apiKey: "token", + extraConfig: { org: "dokploy", bucket: "metrics" }, + }), +]; + +const both = [ + provider({ + telemetryProviderId: "dd1", + name: "datadog", + providerType: "datadog", + signals: ["logs", "metrics"], + apiKey: "dd-key", + }), + provider({ + telemetryProviderId: "cw1", + name: "cloudwatch", + providerType: "aws_cloudwatch", + signals: ["logs", "metrics"], + apiKey: "AKIAFAKE", + apiSecret: "secret", + extraConfig: { + region: "us-east-1", + logGroup: "/dokploy/logs", + metricsNamespace: "Dokploy", + }, + }), + provider({ + telemetryProviderId: "splunk1", + name: "splunk", + providerType: "splunk_hec", + signals: ["logs", "metrics"], + endpoint: "https://splunk.example.com:8088", + apiKey: "hec", + extraConfig: { index: "main", metricsIndex: "dokploy_metrics" }, + }), + provider({ + telemetryProviderId: "es1", + name: "elastic", + providerType: "elasticsearch", + signals: ["logs", "metrics"], + endpoint: "http://es.example.com:9200", + apiKey: "pw", + extraConfig: { username: "elastic" }, + }), + provider({ + telemetryProviderId: "bs1", + name: "better stack", + providerType: "betterstack", + signals: ["logs", "metrics"], + endpoint: "in.logs.betterstack.com", + apiKey: "token", + }), +]; + +const cases: Array<[string, Parameters[0]]> = [ + [ + "local logs only", + { + logProviders: logsOnly, + metricsProviders: [], + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local metrics only", + { + logProviders: [], + metricsProviders: metricsOnly, + organizationId: "org_a", + serverName: "Dokploy Server (local)", + }, + ], + [ + "local every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + organizationId: "org_a", + serverName: 'Dokploy "Server" $HOSTNAME (local)', + }, + ], + [ + "remote metrics only", + { logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" }, + ], + [ + "remote every provider on both signals", + { + logProviders: [...logsOnly, ...both], + metricsProviders: [...metricsOnly, ...both], + serverName: "edge-1", + }, + ], +]; + +describe.skipIf(!dockerAvailable)( + "vector validate on generated configs", + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-")); + + it.each(cases)( + "%s", + (name, input) => { + const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`); + writeFileSync(file, buildVectorConfigYaml(input)); + const output = execFileSync( + "docker", + [ + "run", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock:ro", + "-v", + `${file}:/etc/vector/vector.yaml:ro`, + VECTOR_IMAGE, + "validate", + "--skip-healthchecks", + "/etc/vector/vector.yaml", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 120_000, + }, + ); + expect(output).toContain("Validated"); + }, + 150_000, + ); + }, +); diff --git a/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts new file mode 100644 index 000000000..eaf7a2cec --- /dev/null +++ b/apps/dokploy/__test__/logs-and-metrics/web-server-provider-ids.test.ts @@ -0,0 +1,86 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findFirst: vi.fn(), + updates: [] as Array<{ values: Record; where: unknown }>, + returning: vi.fn(), +})); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { webServerSettings: { findFirst: mocks.findFirst } }, + update: () => ({ + set: (values: Record) => ({ + where: (where: unknown) => { + mocks.updates.push({ values, where }); + return { returning: () => Promise.resolve(mocks.returning()) }; + }, + }), + }), + }, +})); + +const { releaseWebServerAgent, setWebServerProviderIds } = await import( + "@dokploy/server/services/web-server-settings" +); + +const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql; + +beforeEach(() => { + mocks.updates = []; + mocks.findFirst.mockResolvedValue({ + id: "ws-1", + vectorAgentOrganizationId: "org-a", + }); + mocks.returning.mockReturnValue([{ id: "ws-1" }]); +}); + +describe("setWebServerProviderIds", () => { + it("claims the local agent for the organization when assigning providers", async () => { + const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]); + + expect(row).toEqual({ id: "ws-1" }); + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: ["lp-1", "mp-1"], + vectorAgentOrganizationId: "org-a", + }); + expect(render(update?.where)).toBe( + '("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))', + ); + }); + + it("releases the owner when the selection is cleared", async () => { + await setWebServerProviderIds("org-a", []); + + expect(mocks.updates[0]?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + }); + + it("returns null when another organization owns the local agent", async () => { + mocks.returning.mockReturnValue([]); + + await expect( + setWebServerProviderIds("org-b", ["lp-9"]), + ).resolves.toBeNull(); + }); +}); + +describe("releaseWebServerAgent", () => { + it("clears the selection and the owner only for the owning organization", async () => { + await releaseWebServerAgent("org-a"); + + const update = mocks.updates[0]; + expect(update?.values).toMatchObject({ + telemetryProviderIds: [], + vectorAgentOrganizationId: null, + }); + expect(render(update?.where)).toBe( + '"webServerSettings"."vectorAgentOrganizationId" = $1', + ); + }); +}); diff --git a/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts new file mode 100644 index 000000000..be3fd9e0a --- /dev/null +++ b/apps/dokploy/__test__/monitoring/is-physical-disk.test.ts @@ -0,0 +1,104 @@ +import { + getHostSystemStats, + isPhysicalDisk, +} from "@dokploy/server/monitoring/utils"; +import { describe, expect, it, vi } from "vitest"; + +const GiB = 1024 ** 3; +const diskStat = (device: string, readGiB: number, writeGiB: number) => ({ + device, + readBytes: { toBytes: () => readGiB * GiB }, + writeBytes: { toBytes: () => writeGiB * GiB }, +}); + +vi.mock("node-os-utils", () => ({ + OSUtils: class { + cpu = { usage: async () => ({ success: false }) }; + memory = { info: async () => ({ success: false }) }; + network = { overview: async () => ({ success: false }) }; + disk = { + stats: async () => ({ + success: true, + data: [ + diskStat("nvme0n1", 50, 100), + diskStat("nvme0n1p1", 50, 100), + diskStat("nvme1n1", 50, 100), + diskStat("nvme1n1p1", 50, 100), + diskStat("md0", 50, 100), + diskStat("md0p1", 50, 100), + diskStat("dm-0", 50, 100), + diskStat("zram0", 10, 20), + ], + }), + }; + }, +})); + +describe("isPhysicalDisk (#5513, #5385)", () => { + it.each([ + "sda", + "sdab", + "hda", + "vda", + "xvda", + "nvme0n1", + "nvme1n1", + "mmcblk0", + ])("counts whole disk %s", (device) => { + expect(isPhysicalDisk(device)).toBe(true); + }); + + it.each([ + "sda1", + "sdab12", + "hda1", + "vda1", + "xvda1", + "nvme0n1p1", + "nvme1n1p4", + "mmcblk0p1", + ])("skips partition %s", (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }); + + it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])( + "skips stacked device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])( + "skips virtual device %s", + (device) => { + expect(isPhysicalDisk(device)).toBe(false); + }, + ); + + it("counts each write once on an mdadm RAID1 host", () => { + const devices = [ + "nvme0n1", + "nvme0n1p1", + "nvme0n1p2", + "nvme0n1p3", + "nvme0n1p4", + "nvme1n1", + "nvme1n1p1", + "nvme1n1p2", + "nvme1n1p3", + "nvme1n1p4", + "md0", + "md1", + "md2", + "md3", + ]; + expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]); + }); +}); + +describe("getHostSystemStats Block I/O (#5513, #5385)", () => { + it("sums only whole physical disks", async () => { + const stats = await getHostSystemStats(); + expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB"); + }); +}); diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts index ee0e61d7a..08594e6c9 100644 --- a/apps/dokploy/__test__/permissions/check-permission.test.ts +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => { ).resolves.toBeUndefined(); }); + it("owner bypasses telemetryProvider.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses telemetryProvider.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).resolves.toBeUndefined(); + }); + it("owner bypasses multiple enterprise permissions at once", async () => { memberToReturn = mockMemberData("owner"); await expect( @@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati checkPermission(ctx, { registry: ["create"] }), ).rejects.toThrow(); }); + + it("member is denied telemetryProvider.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["read"] }), + ).rejects.toThrow(); + }); + + it("member is denied telemetryProvider.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { telemetryProvider: ["create"] }), + ).rejects.toThrow(); + }); }); describe("static roles validate free-tier resources", () => { diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index 970c24d07..4849a0ba7 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [ "gitProviders", "traefikFiles", "api", + "telemetryProvider", ]; const ENTERPRISE_RESOURCES = [ @@ -41,6 +42,7 @@ const ENTERPRISE_RESOURCES = [ "auditLog", "vaultProvider", "dnsProvider", + "ai", ]; describe("enterpriseOnlyResources set", () => { diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts index b78627ea9..6f3f66618 100644 --- a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => { expect(perms.destination.read).toBe(false); expect(perms.notification.read).toBe(false); expect(perms.auditLog.read).toBe(false); + expect(perms.telemetryProvider.read).toBe(false); }); }); diff --git a/apps/dokploy/__test__/process/exec-error-secrets.test.ts b/apps/dokploy/__test__/process/exec-error-secrets.test.ts new file mode 100644 index 000000000..64058ecaf --- /dev/null +++ b/apps/dokploy/__test__/process/exec-error-secrets.test.ts @@ -0,0 +1,17 @@ +import { inspect } from "node:util"; +import { execAsync, ExecError } from "@dokploy/server/utils/process/execAsync"; +import { describe, expect, it } from "vitest"; + +describe("execAsync failure", () => { + it("does not leak secrets embedded in the command", async () => { + const secret = "s3cr3t-value"; + const error = await execAsync(`printf %s '${secret}' >/dev/null; exit 3`) + .then(() => null) + .catch((e) => e); + + expect(error).toBeInstanceOf(ExecError); + expect(error.exitCode).toBe(3); + expect(error.message).not.toContain(secret); + expect(inspect(error)).not.toContain(secret); + }); +}); diff --git a/apps/dokploy/__test__/registry/docker-login.test.ts b/apps/dokploy/__test__/registry/docker-login.test.ts new file mode 100644 index 000000000..7ca9c46ec --- /dev/null +++ b/apps/dokploy/__test__/registry/docker-login.test.ts @@ -0,0 +1,205 @@ +import { EventEmitter } from "node:events"; +import { Duplex, PassThrough, Writable } from "node:stream"; +import { inspect } from "node:util"; +import { dockerLogin } from "@dokploy/server/services/registry"; +import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { parse } from "shell-quote"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + execFile: vi.fn(), + remoteExec: vi.fn(), + end: vi.fn(), +})); + +vi.mock("node:child_process", async () => { + const { promisify } = await import("node:util"); + return { + exec: Object.assign(mocks.exec, { + [promisify.custom]: (command: string) => + new Promise((resolve, reject) => { + mocks.exec( + command, + (error: Error | null, stdout: string, stderr: string) => { + if (error) reject(Object.assign(error, { stdout, stderr })); + else resolve({ stdout, stderr }); + }, + ); + }), + }), + execFile: mocks.execFile, + }; +}); +vi.mock("@dokploy/server/services/server", () => ({ + findServerById: vi.fn(async () => ({ sshKeyId: "test-key" })), +})); +vi.mock("ssh2", async () => { + const { EventEmitter } = await import("node:events"); + return { + Client: class extends EventEmitter { + exec = mocks.remoteExec; + end = mocks.end; + connect() { + queueMicrotask(() => this.emit("ready")); + return this; + } + }, + }; +}); + +const password = "fixture'\";$(false)\nsecret"; +const registryUrl = "registry.example.com; false"; +const username = "user'$(false)"; +let localInput: string; +let remoteInput: string; +let localStdin: Writable; +let remoteStream: Duplex & { stderr: PassThrough }; +let exitCode: number; +let stdout: string; +let stderr: string; + +beforeEach(() => { + vi.clearAllMocks(); + localInput = ""; + remoteInput = ""; + exitCode = 0; + stdout = "Login Succeeded"; + stderr = ""; + + mocks.exec.mockImplementation((command, ...args) => { + const callback = args.at(-1); + queueMicrotask(() => { + callback( + exitCode === 0 + ? null + : Object.assign(new Error(`Command failed: ${command}`), { + code: exitCode, + }), + stdout, + stderr, + ); + }); + }); + mocks.execFile.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new Writable({ + write(chunk, _encoding, callback) { + localInput += chunk.toString(); + callback(); + }, + }), + }); + localStdin = child.stdin; + queueMicrotask(() => { + child.stdout.write(stdout); + child.stderr.write(stderr); + child.emit("close", exitCode); + }); + return child; + }); + mocks.remoteExec.mockImplementation((_command, callback) => { + remoteStream = Object.assign( + new Duplex({ + read() {}, + write(chunk, _encoding, done) { + remoteInput += chunk.toString(); + done(); + }, + }), + { stderr: new PassThrough() }, + ); + callback(null, remoteStream); + queueMicrotask(() => { + remoteStream.emit("data", stdout); + remoteStream.stderr.write(stderr); + remoteStream.emit("close", exitCode); + }); + }); +}); + +describe("docker login credentials", () => { + it("passes local credentials only on stdin, without a shell", async () => { + await dockerLogin(registryUrl, username, password); + + expect(mocks.exec).not.toHaveBeenCalled(); + expect(mocks.execFile).toHaveBeenCalledExactlyOnceWith("docker", [ + "login", + registryUrl, + "--username", + username, + "--password-stdin", + ]); + expect(localInput).toBe(password); + expect(localStdin.writableEnded).toBe(true); + }); + + it("escapes remote arguments and sends the password only on stream stdin", async () => { + await dockerLogin(registryUrl, username, password, "test-server"); + + const command = mocks.remoteExec.mock.calls[0]?.[0]; + expect(command).not.toContain(password); + expect(parse(command)).toEqual([ + "docker", + "login", + registryUrl, + "--username", + username, + "--password-stdin", + ]); + expect(remoteInput).toBe(password); + expect(remoteStream.writableEnded).toBe(true); + expect(mocks.end).toHaveBeenCalledOnce(); + }); + + it("ends local stdin even for an empty password", async () => { + await dockerLogin("", "user", ""); + + expect(localInput).toBe(""); + expect(localStdin.writableEnded).toBe(true); + }); + + it("keeps remote execution without input unchanged", async () => { + const onData = vi.fn(); + stderr = "warning"; + + await expect( + execAsyncRemote("test-server", "echo hello", onData), + ).resolves.toEqual({ + stdout, + stderr, + }); + expect(remoteStream.writableEnded).toBe(false); + expect(onData.mock.calls).toEqual([[stdout], [stderr]]); + }); + + it.each([undefined, "test-server"])( + "does not retain passwords echoed by a failed login on %s", + async (serverId) => { + exitCode = 1; + stdout = `stdout: ${password}`; + stderr = `denied: ${password}`; + + const error = await dockerLogin( + registryUrl, + username, + password, + serverId, + ).catch((error: unknown) => error); + + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + expect.not.stringContaining(password), + ); + expect(inspect(error, { depth: null })).not.toContain(password); + expect(JSON.stringify(error)).not.toContain( + JSON.stringify(password).slice(1, -1), + ); + expect(error).not.toHaveProperty("originalError"); + expect(error).not.toHaveProperty("cause"); + }, + ); +}); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index d1a03fedc..2372df11f 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = { letsEncryptEmail: null, sshPrivateKey: null, enableDockerCleanup: false, + telemetryProviderIds: [], + vectorAgentOrganizationId: null, buildsConcurrency: 1, logCleanupCron: null, metricsConfig: { @@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = { errorPageDescription: null, ogImageUrl: null, footerText: null, + logoLightUrl: null, + loginLogoLightUrl: null, + faviconLightUrl: null, + dualModeBranding: false, }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, diff --git a/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts new file mode 100644 index 000000000..c9c2167b4 --- /dev/null +++ b/apps/dokploy/__test__/traefik/traefik-files-server-organization.test.ts @@ -0,0 +1,119 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// apiReadTraefikConfig validates against the real local paths(), which outside +// production live under the working directory. +const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`); + +const mocks = vi.hoisted(() => ({ + findServerById: vi.fn(), + readDirectory: vi.fn(), + readConfigInPath: vi.fn(), + writeTraefikConfigInPath: vi.fn(), +})); + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn(async () => undefined), +})); + +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }), + ...mocks, +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +vi.mock("@/server/queues/queueSetup", () => ({ + cleanAllDeploymentQueue: vi.fn(), +})); + +vi.mock("@/server/utils/backup", () => ({ + removeJob: vi.fn(), + schedule: vi.fn(), +})); + +const { settingsRouter } = await import("@/server/api/routers/settings"); + +const caller = settingsRouter.createCaller({ + session: { activeOrganizationId: "org-a" }, + user: { id: "user-1", email: "user@example.com", role: "member" }, +} as Parameters[0]); + +const filePath = path.join(traefikPath, "dynamic/app.yml"); + +describe("traefik file endpoints scope remote servers to the active organization", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findServerById.mockImplementation(async (serverId: string) => ({ + serverId, + organizationId: serverId === "server-a" ? "org-a" : "org-b", + })); + mocks.readDirectory.mockResolvedValue([]); + mocks.readConfigInPath.mockResolvedValue("http: {}"); + }); + + it("rejects listing a server from another organization", async () => { + await expect( + caller.readDirectories({ serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readDirectory).not.toHaveBeenCalled(); + }); + + it("rejects writing to a server from another organization", async () => { + await expect( + caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-b", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled(); + }); + + it("rejects reading from a server from another organization", async () => { + await expect( + caller.readTraefikFile({ path: filePath, serverId: "server-b" }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.readConfigInPath).not.toHaveBeenCalled(); + }); + + it("allows servers of the active organization", async () => { + await caller.readDirectories({ serverId: "server-a" }); + await caller.readTraefikFile({ path: filePath, serverId: "server-a" }); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + serverId: "server-a", + }); + + expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a"); + expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a"); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + "server-a", + ); + }); + + it("does not look up a server when none is given", async () => { + await caller.readDirectories({}); + await caller.updateTraefikFile({ + path: filePath, + traefikConfig: "http: {}", + }); + + expect(mocks.findServerById).not.toHaveBeenCalled(); + expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith( + filePath, + "http: {}", + undefined, + ); + }); +}); diff --git a/apps/dokploy/__test__/utils/account-deletion.test.ts b/apps/dokploy/__test__/utils/account-deletion.test.ts new file mode 100644 index 000000000..b83ad0b7f --- /dev/null +++ b/apps/dokploy/__test__/utils/account-deletion.test.ts @@ -0,0 +1,182 @@ +import { + checkDeletionCode, + generateDeletionCode, + serializeDeletionCode, +} from "@dokploy/server/services/account-deletion"; +import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot"; +import Stripe from "stripe"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { cancelStripeSubscriptions } from "@/server/utils/billing"; + +const makeStripe = ( + subscriptions: Array<{ id: string; status: string }>, + listError?: Error, +) => { + const stripe = { + subscriptions: { + list: vi.fn(() => + listError + ? Promise.reject(listError) + : Promise.resolve({ data: subscriptions }), + ), + cancel: vi.fn(() => Promise.resolve({})), + }, + }; + return stripe as unknown as Stripe & typeof stripe; +}; + +describe("cancelStripeSubscriptions", () => { + test("cancels every non-terminal subscription without invoicing", async () => { + const stripe = makeStripe([ + { id: "sub_active", status: "active" }, + { id: "sub_trial", status: "trialing" }, + { id: "sub_past_due", status: "past_due" }, + { id: "sub_canceled", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ]); + + const result = await cancelStripeSubscriptions("cus_1", stripe); + + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ + customer: "cus_1", + status: "all", + limit: 100, + }); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", { + invoice_now: false, + prorate: false, + }); + expect(result).toEqual({ + cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"], + }); + }); + + test("treats a missing customer as having nothing to cancel", async () => { + const missing = new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such customer", + }); + const stripe = makeStripe([], missing); + + const result = await cancelStripeSubscriptions("cus_missing", stripe); + + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelledSubscriptions: [] }); + }); + + test("propagates unexpected Stripe errors", async () => { + const failure = new Stripe.errors.StripeAPIError({ + type: "api_error", + message: "boom", + }); + const stripe = makeStripe([], failure); + + await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe( + failure, + ); + }); +}); + +describe("deleteHubSpotContactByEmail", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("is skipped when no access token is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe( + "skipped", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the GDPR delete endpoint with the email as id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "deleted", + ); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer token", + ); + expect(JSON.parse(init.body as string)).toEqual({ + idProperty: "email", + objectId: "a@b.com", + }); + }); + + test("maps 404 to not_found and other errors to failed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "not_found", + ); + + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "failed", + ); + }); +}); + +describe("account deletion codes", () => { + test("generates six-digit numeric codes", () => { + for (let i = 0; i < 50; i++) { + expect(generateDeletionCode()).toMatch(/^\d{6}$/); + } + }); + + test("accepts the right code, ignoring surrounding whitespace", () => { + const value = serializeDeletionCode("482913"); + + expect(checkDeletionCode(value, "482913")).toEqual({ + valid: true, + attempts: 0, + }); + expect(checkDeletionCode(value, " 482913 ").valid).toBe(true); + }); + + test("counts failed attempts without revealing the hash", () => { + const value = serializeDeletionCode("482913"); + + const first = checkDeletionCode(value, "000000"); + expect(first.valid).toBe(false); + if (first.valid) throw new Error("unreachable"); + expect(first.attempts).toBe(1); + expect(JSON.parse(first.nextValue)).toEqual({ + ...JSON.parse(value), + attempts: 1, + }); + + const second = checkDeletionCode(first.nextValue, "999999"); + expect(second.valid).toBe(false); + if (second.valid) throw new Error("unreachable"); + expect(second.attempts).toBe(2); + + expect(checkDeletionCode(second.nextValue, "482913")).toEqual({ + valid: true, + attempts: 2, + }); + expect(value).not.toContain("482913"); + }); +}); diff --git a/apps/dokploy/__test__/utils/remote-stream.test.ts b/apps/dokploy/__test__/utils/remote-stream.test.ts index 6f7a3b358..e428de9f3 100644 --- a/apps/dokploy/__test__/utils/remote-stream.test.ts +++ b/apps/dokploy/__test__/utils/remote-stream.test.ts @@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "sleep 0.3; cat > /dev/null" }, }); expect(bytes).toBe(11); - }); + }, 30_000); it("pipes data larger than the pipe buffer unchanged", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-")); @@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "echo boom >&2; exit 3" }, }), ).rejects.toThrow("target exited with code 3: boom"); - }); + }, 30_000); it("reports a source failure", async () => { await expect( @@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => { target: { serverId: null, command: "cat > /dev/null" }, }), ).rejects.toThrow("source exited with code 2"); - }); + }, 30_000); }); diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 63fe1b9ac..1c073c033 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -18,6 +18,7 @@ vi.mock("@dokploy/server", () => ({ import { canAccessDockerOverWss, canAccessTerminalOverWss, + isServiceContainerName, } from "@/server/wss/authorize"; const USER = { id: "user-1" }; @@ -78,6 +79,42 @@ describe("canAccessDockerOverWss", () => { }); }); +describe("isServiceContainerName", () => { + it("matches an application or database only by its exact appName", () => { + expect(isServiceContainerName("web-abc", "application", "web-abc")).toBe( + true, + ); + expect(isServiceContainerName("other-xyz", "application", "web-abc")).toBe( + false, + ); + // Service access must not unlock the host-wide "dokploy" stats. + expect(isServiceContainerName("dokploy", "application", "web-abc")).toBe( + false, + ); + }); + + it("matches stack tasks inside the service's namespace only", () => { + expect( + isServiceContainerName("stack-abc_web.1.task", "stack", "stack-abc"), + ).toBe(true); + expect( + isServiceContainerName("stack-abcdef_web.1.task", "stack", "stack-abc"), + ).toBe(false); + }); + + it("leaves compose containers to the project label filter", () => { + expect( + isServiceContainerName("custom-name", "docker-compose", "compose-abc"), + ).toBe(true); + }); + + it("rejects an unknown appType", () => { + // The socket would apply no ownership filter for it. + expect(isServiceContainerName("web-abc", "invalid", "web-abc")).toBe(false); + expect(isServiceContainerName("web-abc", "", "web-abc")).toBe(false); + }); +}); + describe("canAccessTerminalOverWss", () => { it("denies the local host terminal to a plain member", async () => { mockFindMember.mockResolvedValue({ role: "member" }); diff --git a/apps/dokploy/__test__/wss/container-ownership.test.ts b/apps/dokploy/__test__/wss/container-ownership.test.ts new file mode 100644 index 000000000..1dce706fc --- /dev/null +++ b/apps/dokploy/__test__/wss/container-ownership.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRows = vi.hoisted(() => new Map()); +const mockExec = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/db", () => ({ + db: { + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: async () => { + const row = mockRows.get(table); + return row ? [row] : []; + }, + }), + }), + }), + }, +})); +vi.mock("@dokploy/server/utils/process/execAsync", () => ({ + execAsync: mockExec, + execAsyncRemote: mockExec, +})); + +import { applications, libsql } from "@dokploy/server/db/schema"; +import { containerBelongsToService } from "@/server/wss/container-ownership"; + +const labels = (l: Record) => ({ stdout: JSON.stringify(l) }); + +beforeEach(() => { + vi.clearAllMocks(); + mockRows.clear(); + mockRows.set(applications, { appName: "app-mine-abc123" }); +}); + +describe("containerBelongsToService", () => { + it("allows a container of the service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "app-mine-abc123" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + + it("denies a container of another service", async () => { + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "dokploy" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("denies containers without labels or when docker fails", async () => { + mockExec.mockResolvedValue({ stdout: "null" }); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + mockExec.mockRejectedValue(new Error("No such object")); + expect(await containerBelongsToService("svc", "c1")).toBe(false); + }); + + it("resolves libsql services", async () => { + mockRows.clear(); + mockRows.set(libsql, { appName: "libsql-abc" }); + mockExec.mockResolvedValue( + labels({ "com.docker.swarm.service.name": "libsql-abc" }), + ); + expect(await containerBelongsToService("svc", "c1")).toBe(true); + }); + + it("denies an unknown service", async () => { + mockRows.clear(); + expect(await containerBelongsToService("nope", "c1")).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/wss/listen-deployment.test.ts b/apps/dokploy/__test__/wss/listen-deployment.test.ts new file mode 100644 index 000000000..947006da2 --- /dev/null +++ b/apps/dokploy/__test__/wss/listen-deployment.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from "node:events"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import WebSocket from "ws"; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed = false; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + signals: string[] = []; + // Mirrors ChildProcess: `killed` flips as soon as a signal is delivered, + // even if the process ignores it and keeps running. + ignoreSigterm = false; + + kill(signal: NodeJS.Signals = "SIGTERM") { + this.signals.push(signal); + this.killed = true; + if (signal === "SIGTERM" && this.ignoreSigterm) return true; + this.signalCode = signal; + this.emit("close", null, signal); + return true; + } +} + +const children = vi.hoisted(() => [] as FakeChild[]); +const nextChild = vi.hoisted(() => ({ ignoreSigterm: false })); +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(() => { + const child = new FakeChild(); + child.ignoreSigterm = nextChild.ignoreSigterm; + children.push(child); + return child; + }), +})); + +const sshClients = vi.hoisted( + () => [] as { connect: ReturnType }[], +); +vi.mock("ssh2", () => ({ + Client: vi.fn(function (this: Record) { + this.on = vi.fn(() => this); + this.connect = vi.fn(() => this); + this.end = vi.fn(); + sshClients.push(this as never); + }), +})); + +const mockValidateRequest = vi.hoisted(() => vi.fn()); +const mockFindServerById = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server", () => ({ + IS_CLOUD: false, + validateRequest: mockValidateRequest, + findServerById: mockFindServerById, +})); +vi.mock("@dokploy/server/wss/utils", () => ({ + readValidDirectory: () => true, +})); + +import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment"; + +const AUTH = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +const flush = () => new Promise((r) => setTimeout(r, 50)); + +let server: http.Server; +let url: string; + +beforeEach(async () => { + children.length = 0; + sshClients.length = 0; + nextChild.ignoreSigterm = false; + vi.clearAllMocks(); + server = http.createServer(); + setupDeploymentLogsWebSocketServer(server); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const { port } = server.address() as AddressInfo; + url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`; +}); + +afterEach(async () => { + vi.useRealTimers(); + await new Promise((r) => server.close(r)); +}); + +const connect = async (query = "") => { + const ws = new WebSocket(`${url}${query}`); + await new Promise((r) => ws.once("open", r)); + return ws; +}; + +const closeClient = async (ws: WebSocket) => { + ws.close(); + await new Promise((r) => ws.once("close", r)); + await flush(); +}; + +describe("deployment logs websocket (#5518)", () => { + it("kills tail when the client disconnects", async () => { + mockValidateRequest.mockResolvedValue(AUTH); + const ws = await connect(); + await flush(); + expect(children).toHaveLength(1); + + await closeClient(ws); + expect(children[0]!.signals).toContain("SIGTERM"); + }); + + it("does not spawn tail when the client disconnects during auth", async () => { + const auth = deferred(); + mockValidateRequest.mockReturnValue(auth.promise); + const ws = await connect(); + + await closeClient(ws); + auth.resolve(AUTH); + await flush(); + + const leaked = children.filter( + (c) => c.exitCode === null && c.signalCode === null, + ); + expect(leaked).toHaveLength(0); + }); + + it("does not open an SSH session when the client disconnects while loading the server", async () => { + mockValidateRequest.mockResolvedValue(AUTH); + const serverLookup = deferred(); + mockFindServerById.mockReturnValue(serverLookup.promise); + const ws = await connect("&serverId=srv-1"); + + await closeClient(ws); + serverLookup.resolve({ + organizationId: "org-1", + sshKeyId: "key-1", + sshKey: { privateKey: "key" }, + ipAddress: "10.0.0.1", + port: 22, + username: "root", + }); + await flush(); + + const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0); + expect(connected).toHaveLength(0); + }); + + it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => { + nextChild.ignoreSigterm = true; + mockValidateRequest.mockResolvedValue(AUTH); + const ws = await connect(); + await flush(); + + await closeClient(ws); + await new Promise((r) => setTimeout(r, 1100)); + expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx index e471b3fc1..3b794c1ed 100644 --- a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => { return ( - -
- Patches + +
+ Patches Apply code patches to your repository during build. Patches are applied after cloning the repository and before building. diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 3c76fb969..f6fb952ce 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { }; return ( - - -
-
- - Scheduled Tasks - - - Schedule tasks to run automatically at specified intervals. - -
- {schedules && schedules.length > 0 && ( - - )} + + +
+ Scheduled Tasks + + Schedule tasks to run automatically at specified intervals. +
+ {schedules && schedules.length > 0 && ( + + )}
- + {isLoadingSchedules ? (
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index 1a72b4640..29848e7bd 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({ }; return ( - - -
-
- - Volume Backups - - - Schedule volume backups to run automatically at specified - intervals - -
-
- {volumeBackups && volumeBackups.length > 0 && ( - <> - -
- -
- - )} -
+ + +
+ Volume Backups + + Schedule volume backups to run automatically at specified intervals + +
+
+ {volumeBackups && volumeBackups.length > 0 && ( + <> + +
+ +
+ + )}
- + {isLoadingVolumeBackups ? (
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 5d95aafce..0dd1fb211 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, composeId, sourceType: "bitbucket", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index a454ff610..2a22bdb36 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { composeId, sourceType: "git", composePath: values.composePath, - composeStatus: "idle", watchPaths: values.watchPaths || [], enableSubmodules: values.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index 24ed16117..219819b35 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { giteaId: data.giteaId, composeId, sourceType: "gitea", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, } as any) diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 2266e10dc..0f08be5fb 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { composePath: data.composePath, githubId: data.githubId, sourceType: "github", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, triggerType: data.triggerType, diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index c101d091b..baa22f6d5 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { gitlabProjectId: data.repository.id, gitlabPathNamespace: data.repository.gitlabPathNamespace, sourceType: "gitlab", - composeStatus: "idle", watchPaths: data.watchPaths, enableSubmodules: data.enableSubmodules, }) diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index d90ef92aa..4c9c3da41 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -554,7 +554,10 @@ export const RestoreBackup = ({ value={field.value} onValueChange={(value: DatabaseType) => { field.onChange(value); - form.setValue("metadata", {}); + form.setValue("metadata", { + serviceName: + form.getValues("metadata.serviceName") ?? "", + }); }} > @@ -581,7 +584,7 @@ export const RestoreBackup = ({
setGlobalFilter(e.target.value)} - className="max-w-xs" - /> - - -
-
- {isLoading ? ( -
- - Loading deployments... -
- ) : ( - <> -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - +
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+
+ + {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 deployments found

+

+ Deployments from applications and compose will appear + here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries +
-
-
- - Rows per page - - - - Showing{" "} - {filteredData.length === 0 - ? 0 - : pagination.pageIndex * pagination.pageSize + 1}{" "} - to{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )}{" "} - of {filteredData.length} entries - -
-
- - -
+
+ +
- - )} -
+
+ + )}
); } diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx index b4e50cc1e..115e07041 100644 --- a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -40,6 +40,7 @@ export function AnalyzeLogs({ logs, context }: Props) { const [open, setOpen] = useState(false); const [aiId, setAiId] = useState(""); const [copied, setCopied] = useState(false); + const { data: permissions } = api.user.getPermissions.useQuery(); const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, { enabled: open, }); @@ -51,6 +52,8 @@ export function AnalyzeLogs({ logs, context }: Props) { }, }); + if (!permissions?.ai.read) return null; + const handleAnalyze = () => { if (!aiId || logs.length === 0) return; diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index e2a78a696..f5766f7c4 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
-
+
+ const content = ( +
{label} @@ -63,6 +69,16 @@ function StatCard({
); + + if (href) { + return ( + + {content} + + ); + } + + return content; } function StatusListCard({ @@ -182,6 +198,7 @@ export const ShowHome = () => { label="Projects" value={String(totals.projects)} delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`} + href="/dashboard/projects" /> { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [record, setRecord] = useState(null); + const { mutateAsync, isPending } = + api.user.deleteAccountByEmail.useMutation(); + + const canConfirm = + email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD; + + const reset = () => { + setEmail(""); + setConfirmation(""); + setRecord(null); + }; + + const handleDelete = async () => { + try { + const result = await mutateAsync({ email: email.trim() }); + setRecord(JSON.stringify(result, null, 2)); + toast.success(`Account ${result.email} deleted`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deleting account", + ); + } + }; + + return ( + { + setOpen(value); + if (!value) reset(); + }} + > + + + + + + Delete an account by email + + Cancels every Stripe subscription and deletes the user with all the + organizations they own. Invoices stay in Stripe for accounting. Use + it to fulfil data deletion requests. + + + {record ? ( +
+ + Deletion completed. Keep this record as evidence of the request + being fulfilled. + +
+							{record}
+						
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setConfirmation(e.target.value)} + /> +
+
+ )} + + {record ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index c6c72fae6..8a66b5270 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; +import { DeleteAccountByEmail } from "./delete-account-by-email"; type User = typeof authClient.$Infer.Session.user; @@ -78,12 +79,12 @@ export const ImpersonationBar = () => { }); const filteredUsers = response.data?.users.filter( - // @ts-ignore + // @ts-expect-error (user) => user.allowImpersonation && data?.user?.email !== user.email, ); if (!response.error) { - // @ts-ignore + // @ts-expect-error setUsers(filteredUsers || []); } } catch (error) { @@ -185,6 +186,7 @@ export const ImpersonationBar = () => { {!isImpersonating ? (
@@ -282,6 +284,7 @@ export const ImpersonationBar = () => { Impersonate +
) : (
diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx index fc57221bd..5870d3ace 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx @@ -27,12 +27,14 @@ interface Props { appName: string; serverId?: string; appType: "stack" | "docker-compose"; + serviceId?: string; } export const ComposeFreeMonitoring = ({ appName, appType = "stack", serverId, + serviceId, }: Props) => { const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { @@ -123,6 +125,7 @@ export const ComposeFreeMonitoring = ({ 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 9722ae51e..8f313d8d4 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 @@ -46,6 +46,7 @@ const defaultData = { interface Props { appName: string; appType?: "application" | "stack" | "docker-compose"; + serviceId?: string; } export interface DockerStats { cpu: { @@ -120,6 +121,7 @@ export const convertMemoryToBytes = ( export const ContainerFreeMonitoring = ({ appName, appType = "application", + serviceId, }: Props) => { const { data } = api.application.readAppMonitoring.useQuery( { appName }, @@ -171,7 +173,11 @@ export const ContainerFreeMonitoring = ({ if (!appName) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; + const params = new URLSearchParams({ appName, appType }); + if (serviceId) { + params.append("serviceId", serviceId); + } + const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?${params.toString()}`; const ws = new WebSocket(wsUrl); ws.onmessage = (e) => { @@ -205,7 +211,7 @@ export const ContainerFreeMonitoring = ({ }; return () => ws.close(); - }, [appName]); + }, [appName, serviceId]); return (
diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx index 9863aaf38..c32a0dac2 100644 --- a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; import { displayFont } from "../font"; -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, -); +const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + ? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + : null; type Tier = "hobby" | "startup"; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index be12da353..71e5b36ce 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -1,5 +1,6 @@ import { Rocket } from "lucide-react"; import { useRouter } from "next/router"; +import { useState } from "react"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; import { @@ -8,6 +9,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const SUBTAB_VALUES = ["deployments", "queue"] as const; @@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => { ? router.query.subtab : DEFAULT_SUBTAB; + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const setSubtab = (value: string) => { if (!isValidSubtab(value)) return; const { subtab: _current, ...query } = router.query; @@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - - Deployments - Queue - +
+ + Deployments + Queue + + {subtab === "deployments" && ( +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+ )} +
- + diff --git a/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx b/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx index 88fd1d111..5f04082b7 100644 --- a/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx +++ b/apps/dokploy/components/dashboard/project/add-ai-assistant.tsx @@ -1,4 +1,5 @@ import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator"; +import { api } from "@/utils/api"; interface Props { environmentId: string; @@ -6,5 +7,9 @@ interface Props { } export const AddAiAssistant = ({ environmentId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + + if (!permissions?.ai.read) return null; + return ; }; diff --git a/apps/dokploy/components/dashboard/project/add-template.tsx b/apps/dokploy/components/dashboard/project/add-template.tsx index 44512d93a..07fe775dc 100644 --- a/apps/dokploy/components/dashboard/project/add-template.tsx +++ b/apps/dokploy/components/dashboard/project/add-template.tsx @@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => { {viewMode === "detailed" && template?.tags?.length > 0 && (
- {template?.tags?.map((tag) => ( + {template.tags.slice(0, 6).map((tag) => ( { {tag} ))} + {template.tags.length > 6 && ( + + +{template.tags.length - 6} + + )}
)}
diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 0a020ec68..1e496a68b 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { <>
-
+
{ enabled: !!session, }); const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(undefined, { + enabled: !!session, + }); React.useEffect(() => { const down = (e: KeyboardEvent) => { @@ -183,14 +186,16 @@ export const SearchCommand = () => { {!isCloud && ( <> - { - router.push("/dashboard/monitoring"); - setOpen(false); - }} - > - Monitoring - + {permissions?.monitoring.read && permissions?.docker.read && ( + { + router.push("/dashboard/monitoring"); + setOpen(false); + }} + > + Monitoring + + )} { router.push("/dashboard/traefik"); diff --git a/apps/dokploy/components/dashboard/settings/ai-form.tsx b/apps/dokploy/components/dashboard/settings/ai-form.tsx index ea131f029..abe1a1c3e 100644 --- a/apps/dokploy/components/dashboard/settings/ai-form.tsx +++ b/apps/dokploy/components/dashboard/settings/ai-form.tsx @@ -18,13 +18,15 @@ import { HandleAiProviders } from "./handle-ai-providers"; export const AiForm = () => { const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery(); const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation(); + const { data: permissions } = api.user.getPermissions.useQuery(); const { data: currentUser } = api.user.get.useQuery(); + const isOrgAdmin = currentUser?.role === "owner" || currentUser?.role === "admin"; return (
- +
@@ -36,7 +38,9 @@ export const AiForm = () => {
{isOrgAdmin && } - {aiConfigs && aiConfigs?.length > 0 && } + {permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && ( + + )}
@@ -53,7 +57,7 @@ export const AiForm = () => { You don't have any AI configurations - + {permissions?.ai.create && }
) : (
@@ -62,7 +66,7 @@ export const AiForm = () => { key={config.aiId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{config.name} @@ -70,33 +74,37 @@ export const AiForm = () => { {config.model}
- - { - await mutateAsync({ - aiId: config.aiId, - }) - .then(() => { - toast.success("AI deleted successfully"); - refetch(); + {permissions?.ai.update && ( + + )} + {permissions?.ai.delete && ( + { + await mutateAsync({ + aiId: config.aiId, }) - .catch(() => { - toast.error("Error deleting AI"); - }); - }} - > - - + + + )}
diff --git a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx index 38abf4107..2e90a9bb6 100644 --- a/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/api/show-api-keys.tsx @@ -22,7 +22,7 @@ export const ShowApiKeys = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx index adff665c4..fb48b49c0 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing-invoices.tsx @@ -34,7 +34,7 @@ export const ShowBillingInvoices = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index af3aa89e7..15beb65c3 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -43,9 +43,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -const stripePromise = loadStripe( - process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, -); +const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY + ? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + : null; /** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */ export const calculatePrice = (count: number, isAnnual = false) => { diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index 6f732657f..c69451c89 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -38,7 +38,7 @@ export const ShowCertificates = () => { return (
- +
@@ -111,7 +111,7 @@ export const ShowCertificates = () => { key={certificate.certificateId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx index 25eb01b09..86deb38a7 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/registry/show-registry.tsx @@ -20,7 +20,7 @@ export const ShowRegistry = () => { return (
- +
@@ -55,7 +55,7 @@ export const ShowRegistry = () => { key={registry.registryId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx index 6ce5789f4..39741b932 100644 --- a/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/show-destinations.tsx @@ -19,7 +19,7 @@ export const ShowDestinations = () => { const { data: permissions } = api.user.getPermissions.useQuery(); return (
- +
@@ -56,7 +56,7 @@ export const ShowDestinations = () => { key={destination.destinationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{index + 1}. {destination.name} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index d2d8c0d3f..66fdddc69 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({ applicationKey: z.string(), applicationSecret: z.string(), consumerKey: z.string(), + zone: z.string(), }); type DnsProviderForm = z.infer; @@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = { applicationKey: "", applicationSecret: "", consumerKey: "", + zone: "", }; const buildConfig = (data: DnsProviderForm) => { @@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => { applicationKey: data.applicationKey, applicationSecret: data.applicationSecret, consumerKey: data.consumerKey, + zone: data.zone.trim() || undefined, }; } }; @@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { applicationKey: provider.config.applicationKey, applicationSecret: provider.config.applicationSecret, consumerKey: provider.config.consumerKey, + zone: provider.config.zone ?? "", }), }); } else if (!dnsProviderId) { @@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { )} /> + ( + + Zone (optional) + + + + + Restrict this provider to a single DNS zone: Dokploy + will only list and change records in it. Leave empty to + use every zone the credentials can access. When set, the{" "} + GET /domain/zone right is not needed. This + is enforced by Dokploy only, so also limit the OVH token + to that zone if you need a hard guarantee. + + + + )} + /> )} diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx index d29e93495..d1980e66e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -35,7 +35,7 @@ export const ShowDnsProviders = () => { const { data: permissions } = api.user.getPermissions.useQuery(); return ( -
+
diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx index 9a3dbce71..f395018b2 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx @@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => { api.dnsProvider.listZones.useQuery({ dnsProviderId }); return ( -
+
diff --git a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx index df6d21d76..f4f000637 100644 --- a/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/git/show-git-providers.tsx @@ -69,7 +69,7 @@ export const ShowGitProviders = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/handle-ai.tsx b/apps/dokploy/components/dashboard/settings/handle-ai.tsx index b7cf186e7..0cd73d82a 100644 --- a/apps/dokploy/components/dashboard/settings/handle-ai.tsx +++ b/apps/dokploy/components/dashboard/settings/handle-ai.tsx @@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => { { apiUrl: apiUrl ?? "", apiKey: apiKey ?? "", + ...(aiId ? { aiId } : {}), }, { enabled: !!apiUrl && (isLocalOllama || !!apiKey), @@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => { apiUrl={apiUrl} apiKey={apiKey} model={form.watch("model")} + aiId={aiId} /> + ) : ( + + )} + + + + + {telemetryProviderId ? "Edit provider" : "Add a provider"} + + + Vector ships container logs and host/container metrics from every + server you deploy it on to this provider. + + + {(isError || testIsError || testByIdIsError) && ( + + {testError?.message || testByIdError?.message || error?.message} + + )} + +
+ + ( + + Name + + + + + + )} + /> + ( + + Provider + + + + )} + /> + + {selectedType && ( + ( + + Sends + + + + )} + /> + )} + + {visibleFields.map((credentialField) => ( + ( + + + {credentialField.label} + {!credentialField.required && " (Optional)"} + + {credentialField.helpText && ( + + {credentialField.helpText} + + )} +
+ + + + {telemetryProviderId && + TOP_LEVEL_KEYS.has(credentialField.key) && + !credentialField.required && ( + + )} +
+ +
+ )} + /> + ))} + + ( + + + + + Enabled + + )} + /> + + + + + {isPartiallyTouched && ( + + You changed something that needs a fresh credential to test — fill + in every credential field to test with the new values, or revert + it to test with what's already saved. + + )} +
+ + +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx new file mode 100644 index 000000000..bc375a3d2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-servers.tsx @@ -0,0 +1,361 @@ +import { Copy, Loader2, Server } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +type Status = "running" | "not-running" | "stopped" | "unknown"; + +const statusBadge = (status: Status) => { + if (status === "running") { + return Running; + } + if (status === "not-running") { + return Not running; + } + if (status === "stopped") { + return Not deployed; + } + return Unreachable; +}; + +export const providerLabel = (provider: { name: string; signals: string[] }) => + `${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`; + +export const ShowServers = () => { + const utils = api.useUtils(); + const [selection, setSelection] = useState>({}); + const [pendingTarget, setPendingTarget] = useState(null); + const [errors, setErrors] = useState< + Record + >({}); + + const { data: providers } = api.telemetryProvider.all.useQuery(); + const { data: targets, isPending } = + api.telemetryProvider.serverStatus.useQuery(undefined, { + refetchOnWindowFocus: false, + staleTime: 30_000, + trpc: { context: { skipBatch: true } }, + }); + + const { mutateAsync: deployOnServer } = + api.telemetryProvider.deployOnServer.useMutation(); + const { mutateAsync: removeOnServer } = + api.telemetryProvider.removeOnServer.useMutation(); + + const keyOf = (serverId: string | null) => serverId ?? "local"; + + // The selection can still hold providers deleted since it was made. + const idsFor = (serverId: string | null, saved: string[]) => { + const ids = selection[keyOf(serverId)] ?? saved; + return providers + ? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id)) + : ids; + }; + + const toggleProvider = ( + serverId: string | null, + saved: string[], + telemetryProviderId: string, + ) => { + const key = keyOf(serverId); + const current = idsFor(serverId, saved); + setSelection({ + ...selection, + [key]: current.includes(telemetryProviderId) + ? current.filter((id) => id !== telemetryProviderId) + : [...current, telemetryProviderId], + }); + }; + + const setError = ( + serverId: string | null, + error?: { title: string; message: string }, + ) => + setErrors((prev) => { + const next = { ...prev }; + if (error) { + next[keyOf(serverId)] = error; + } else { + delete next[keyOf(serverId)]; + } + return next; + }); + + // The task is still starting right after a deploy, so look again once it had time to come up. + const refreshStatus = () => { + utils.telemetryProvider.serverStatus.invalidate(); + setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000); + }; + + const handleDeploy = async (serverId: string | null, ids: string[]) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await deployOnServer({ serverId, providerIds: ids }) + .then(() => { + toast.success("Vector agent deployed"); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Deploy failed", + message: + error instanceof Error + ? error.message + : "Failed to deploy the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const handleRemove = async (serverId: string | null) => { + setPendingTarget(keyOf(serverId)); + setError(serverId); + await removeOnServer({ serverId }) + .then(() => { + toast.success("Vector agent removed"); + setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] })); + refreshStatus(); + }) + .catch((error) => { + setError(serverId, { + title: "Remove failed", + message: + error instanceof Error + ? error.message + : "Failed to remove the agent", + }); + refreshStatus(); + }) + .finally(() => setPendingTarget(null)); + }; + + const hasProviders = (providers?.length ?? 0) > 0; + const visibleTargets = hasProviders + ? targets + : targets?.filter( + (target) => + target.telemetryProviderIds.length > 0 || + target.status === "running" || + target.status === "not-running", + ); + + return ( +
+ +
+ + + + Servers + + + Pick which providers each server ships to, then deploy its Vector + agent; a provider receives every signal it sends, and cAdvisor + runs next to the agent on 127.0.0.1:4510 when metrics are shipped. + Services deployed before Logs & Metrics need a redeploy to get the + labels it uses; until then their logs and container metrics are + not shipped from the Dokploy server. + + + + {isPending ? ( +
+ Checking servers... + +
+ ) : !hasProviders && !visibleTargets?.length ? ( +
+ + + Add a provider first — there's nowhere to ship to yet + +
+ ) : ( +
+ {visibleTargets?.map((target) => { + const key = keyOf(target.serverId); + const saved = target.telemetryProviderIds; + const ids = idsFor(target.serverId, saved); + const isBusy = pendingTarget === key; + const agentDeployed = target.status !== "stopped"; + const hasEnabledSelected = ids.some( + (id) => + providers?.find((p) => p.telemetryProviderId === id) + ?.enabled, + ); + + return ( +
+
+
+ + {target.name} + + {target.ipAddress && ( + + {target.ipAddress} + + )} +
+
+ {statusBadge(target.status as Status)} + {agentDeployed && ( + handleRemove(target.serverId)} + > + + + )} +
+
+ +
+ {hasProviders && ( + Providers + )} +
+ {providers?.map((provider) => { + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + return ( +
+ + toggleProvider( + target.serverId, + saved, + provider.telemetryProviderId, + ) + } + /> + {ProviderIcon && ( + + )} + + {!provider.enabled && ( + Disabled + )} +
+ ); + })} +
+
+ + {errors[key] && ( + +
+ + {errors[key].title} + +
+															{errors[key].message}
+														
+ +
+
+ )} + + {hasProviders && ( +
+ {ids.length > 0 && !hasEnabledSelected && ( + + Every selected provider is disabled — nothing + would ship. + + )} + +
+ )} +
+ ); + })} + + {visibleTargets?.length === 0 && ( +
+ + + You don't have any server yet + +
+ )} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx new file mode 100644 index 000000000..afe48a79f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/logs-and-metrics/show-telemetry-providers.tsx @@ -0,0 +1,196 @@ +import { Loader2, ScrollText, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; +import { HandleTelemetryProvider } from "./handle-telemetry-provider"; + +interface TelemetryProviderRowProps { + provider: { + telemetryProviderId: string; + name: string; + enabled: boolean; + providerType: string; + signals: string[]; + }; + index: number; + typeLabel: string; + canEdit: boolean; + canDelete: boolean; + onDeleted: () => void; +} + +const TelemetryProviderRow = ({ + provider, + index, + typeLabel, + canEdit, + canDelete, + onDeleted, +}: TelemetryProviderRowProps) => { + const { mutateAsync, isPending: isRemoving } = + api.telemetryProvider.remove.useMutation(); + const ProviderIcon = + telemetryProviderIcons[ + provider.providerType as keyof typeof telemetryProviderIcons + ]; + + return ( +
+
+
+ {ProviderIcon && } +
+ + {index + 1}. {provider.name} + + {provider.enabled ? "Enabled" : "Disabled"} + + {provider.signals.includes("logs") && ( + Logs + )} + {provider.signals.includes("metrics") && ( + Metrics + )} + +
{typeLabel}
+
+
+ +
+ {canEdit && ( + + )} + + {canDelete && ( + { + await mutateAsync({ + telemetryProviderId: provider.telemetryProviderId, + }) + .then((result) => { + if (result.warning) { + toast.warning("Provider deleted", { + description: result.warning, + }); + } else { + toast.success( + "Provider deleted — the agents that used it were re-applied", + ); + } + onDeleted(); + }) + .catch(() => { + toast.error("Error deleting provider"); + }); + }} + > + + + )} +
+
+
+ ); +}; + +export const ShowTelemetryProviders = () => { + const utils = api.useUtils(); + const { data, isPending, refetch } = api.telemetryProvider.all.useQuery(); + const { data: availableTypes } = + api.telemetryProvider.availableTypes.useQuery(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const providerListLabel = availableTypes?.length + ? ` (${availableTypes.map((t) => t.label).join(", ")})` + : ""; + + return ( +
+ +
+ + + + Logs & Metrics + + + {`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`} + + + + {isPending ? ( +
+ Loading... + +
+ ) : ( + <> + {data?.length === 0 ? ( +
+ + + You don't have any provider configured + + {permissions?.telemetryProvider.create && ( + + )} +
+ ) : ( +
+
+ {data?.map((provider, index) => ( + t.type === provider.providerType, + )?.label ?? provider.providerType + } + canEdit={!!permissions?.telemetryProvider.create} + canDelete={!!permissions?.telemetryProvider.delete} + onDeleted={() => { + refetch(); + utils.telemetryProvider.serverStatus.invalidate(); + }} + /> + ))} +
+ + {permissions?.telemetryProvider.create && ( +
+ +
+ )} +
+ )} + + )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index 4411d22c7..ccb28ee4c 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -31,7 +31,7 @@ export const ShowNotifications = () => { return (
- +
@@ -70,7 +70,7 @@ export const ShowNotifications = () => { key={notification.notificationId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
{notification.notificationType === "slack" && (
diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx new file mode 100644 index 000000000..18a9f1bd2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -0,0 +1,214 @@ +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + +export const DeleteAccount = () => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"request" | "confirm">("request"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [expiresInMinutes, setExpiresInMinutes] = useState(10); + const { data } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: hasPassword } = api.user.hasPassword.useQuery(); + const requestCode = api.user.requestAccountDeletionCode.useMutation(); + const deleteAccount = api.user.deleteAccount.useMutation(); + + const email = data?.user?.email ?? ""; + const isBusy = requestCode.isPending || deleteAccount.isPending; + + if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) { + return null; + } + + const reset = () => { + setStep("request"); + setPassword(""); + setCode(""); + }; + + const handleRequestCode = async () => { + try { + const result = await requestCode.mutateAsync( + hasPassword ? { password } : {}, + ); + setExpiresInMinutes(result.expiresInMinutes); + setCode(""); + setStep("confirm"); + toast.success(`We sent a confirmation code to ${email}`); + } catch (error) { + toast.error(errorMessage(error, "Error sending the confirmation code")); + } + }; + + const handleDelete = async () => { + try { + await deleteAccount.mutateAsync({ code }); + toast.success("Your account has been deleted"); + await authClient.signOut().catch(() => undefined); + router.push("/"); + } catch (error) { + toast.error(errorMessage(error, "Error deleting account")); + } + }; + + return ( + + + + + Delete account + + + Permanently remove your account and all the data associated with it. + + + +
    +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • +
  • + Every organization you own is deleted, including its projects, + services, environments, domains and server connections. +
  • +
  • + In organizations you belong to but do not own, only your membership, + API keys and the Git providers you connected are removed. + Applications using those providers keep running but must be + reconnected to redeploy. +
  • +
  • Containers already running on your servers are not touched.
  • +
  • + We will email a confirmation code to{" "} + {email} before + anything is deleted. +
  • +
  • This action cannot be undone.
  • +
+
+ { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Delete your account? + + {step === "request" + ? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.` + : `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`} + + +
+ {step === "request" && hasPassword && ( + <> + + setPassword(e.target.value)} + /> + + )} + {step === "confirm" && ( + <> + + + setCode(e.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+ + Cancel + {step === "request" ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 82398be4d..ceb6d3cd1 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -150,7 +150,7 @@ export const ProfileForm = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx index 334d25b20..b1e12b440 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-server-actions.tsx @@ -1,6 +1,4 @@ -import { Activity } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const ShowServerActions = ({ serverId, asButton = false }: Props) => { +export const ShowServerActions = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - {asButton ? ( - - - + {children ? ( + {children} ) : ( - { - e.preventDefault(); - setIsOpen(true); - }} - > - View Actions - + + { + e.preventDefault(); + }} + > + View Actions + + )} - + e.preventDefault()} + >
Web server settings Reload or clean the web server. diff --git a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx index 6f6e72b11..eb5b1b711 100644 --- a/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/delete-server-modal.tsx @@ -109,8 +109,13 @@ export const DeleteServerModal = ({ const handleDeleteServer = async () => { try { - await deleteServer({ serverId }); + const result = await deleteServer({ serverId }); toast.success(`Server ${serverName} deleted successfully`); + if (result?.vectorAgentRemovalWarning) { + toast.error( + `Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`, + ); + } setOpen(false); utils.server.all.invalidate(); } catch (error: any) { @@ -121,7 +126,10 @@ export const DeleteServerModal = ({ return ( {children} - + e.preventDefault()} + > Delete Server diff --git a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx index d2c158f91..10518fc56 100644 --- a/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/handle-servers.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { Pencil, PlusIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -61,10 +61,10 @@ type Schema = z.infer; interface Props { serverId?: string; - asButton?: boolean; + children?: React.ReactNode; } -export const HandleServers = ({ serverId, asButton = false }: Props) => { +export const HandleServers = ({ serverId, children }: Props) => { const utils = api.useUtils(); const [isOpen, setIsOpen] = useState(false); const { data: canCreateMoreServers, refetch } = @@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => { return ( - {serverId ? ( - asButton ? ( - - - - ) : ( + {children ? ( + {children} + ) : serverId ? ( + { e.preventDefault(); - setIsOpen(true); }} > Edit Server - ) + ) : ( )} - + e.preventDefault()} + > {serverId ? "Edit" : "Create"} Server diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx index 71761536b..576e8eb44 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-server.tsx @@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server"; interface Props { serverId: string; - asButton?: boolean; + children?: React.ReactNode; } -export const SetupServer = ({ serverId, asButton = false }: Props) => { +export const SetupServer = ({ serverId, children }: Props) => { const [isOpen, setIsOpen] = useState(false); const { data: server } = api.server.one.useQuery( { @@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => { return ( - {asButton ? ( + {children ? ( + {children} + ) : ( - - ) : ( - )} - + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx index a318d0b40..bc7df5b0f 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-monitoring-modal.tsx @@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni interface Props { url: string; token: string; + children?: React.ReactNode; } -export const ShowMonitoringModal = ({ url, token }: Props) => { +export const ShowMonitoringModal = ({ url, token, children }: Props) => { const [isOpen, setIsOpen] = useState(false); return ( - - - - + {children ? ( + {children} + ) : ( + + + + )} + e.preventDefault()} + >
diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index 4eb3ae959..6931319bd 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -1,11 +1,15 @@ import { format } from "date-fns"; import { + Activity, + BarChartHorizontalBigIcon, Clock, Key, KeyIcon, Loader2, Network, + Pencil, ServerIcon, + Settings, Terminal, Trash2, User, @@ -49,7 +53,7 @@ export const ShowServers = () => { return (
{query?.success && isCloud && } - +
@@ -224,11 +228,19 @@ export const ShowServers = () => {
- - - + + + + + { {server.sshKeyId && permissions?.server.terminal && ( - -
- + + - -
-
+ + + +

Terminal

@@ -274,14 +284,19 @@ export const ShowServers = () => { )} - -
- -
-
+ + + + +

Edit Server

@@ -289,14 +304,19 @@ export const ShowServers = () => { {server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Web Server Actions

@@ -307,17 +327,23 @@ export const ShowServers = () => { server.sshKeyId && !isBuildServer && ( - -
- -
-
+ + + + +

Monitoring

@@ -328,22 +354,20 @@ export const ShowServers = () => { {permissions?.server.delete && ( - -
- + + - -
-
+ + + +

Delete Server

diff --git a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx index 413af8bb7..86ea0a2ea 100644 --- a/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx +++ b/apps/dokploy/components/dashboard/settings/ssh-keys/show-ssh-keys.tsx @@ -21,7 +21,7 @@ export const ShowDestinations = () => { return (
- +
@@ -57,7 +57,7 @@ export const ShowDestinations = () => { key={sshKey.sshKeyId} className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" > -
+
diff --git a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx index 232260cba..07eb9a7f3 100644 --- a/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx +++ b/apps/dokploy/components/dashboard/settings/tags/tag-manager.tsx @@ -22,7 +22,7 @@ export const TagManager = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 1c8952d43..7dbe5ac66 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -124,8 +124,11 @@ export const AddInvitation = () => { const mode = form.watch("mode"); useEffect(() => { - form.reset(); - }, [form, form.formState.isSubmitSuccessful, form.reset]); + if (!open) { + form.reset(); + setError(null); + } + }, [open, form, form.reset]); useEffect(() => { if (isCloud && form.getValues("mode") === "credentials") { @@ -225,7 +228,7 @@ export const AddInvitation = () => { Invite Method diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index 10543a626..098faf5f1 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
{isCustomRole && (
diff --git a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx index cca6fc7cc..b6e95cb75 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-invitations.tsx @@ -40,7 +40,7 @@ export const ShowInvitations = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 347aa04a1..6e6743fd4 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -52,7 +52,7 @@ export const ShowUsers = () => { return (
- +
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 14147594b..8c9b6e098 100644 --- a/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/vault/show-vault-providers.tsx @@ -33,7 +33,7 @@ export const ShowVaultProviders = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/web-domain.tsx b/apps/dokploy/components/dashboard/settings/web-domain.tsx index 4b92454ec..187bbb452 100644 --- a/apps/dokploy/components/dashboard/settings/web-domain.tsx +++ b/apps/dokploy/components/dashboard/settings/web-domain.tsx @@ -123,7 +123,7 @@ export const WebDomain = () => { return (
- +
diff --git a/apps/dokploy/components/dashboard/settings/web-server.tsx b/apps/dokploy/components/dashboard/settings/web-server.tsx index 8238e585b..a383fbf7d 100644 --- a/apps/dokploy/components/dashboard/settings/web-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server.tsx @@ -24,7 +24,7 @@ export const WebServer = () => { return (
{/* */} - +
diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index afb1d4451..4a4189d11 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -45,9 +45,8 @@ export const Terminal: React.FC = ({ id, serverId }) => { term.loadAddon(clipboardAddon); fixMacOsAltKeys(term); - // @ts-ignore + // @ts-expect-error term.open(termRef.current); - // @ts-ignore term.loadAddon(addonFit); addonFit.fit(); @@ -89,9 +88,9 @@ export const Terminal: React.FC = ({ id, serverId }) => { }, [id, serverId]); return ( -
+
-
+
); diff --git a/apps/dokploy/components/icons/telemetry-provider-icons.tsx b/apps/dokploy/components/icons/telemetry-provider-icons.tsx new file mode 100644 index 000000000..e8cfe5cd5 --- /dev/null +++ b/apps/dokploy/components/icons/telemetry-provider-icons.tsx @@ -0,0 +1,105 @@ +import { AwsIcon } from "@/components/icons/vault-provider-icons"; + +interface Props { + className?: string; +} + +export const GrafanaLokiIcon = ({ className }: Props) => ( + + + +); + +export const DatadogIcon = ({ className }: Props) => ( + + + +); + +export const BetterStackIcon = ({ className }: Props) => ( + + + +); + +export const ElasticsearchIcon = ({ className }: Props) => ( + + + +); + +export const SplunkIcon = ({ className }: Props) => ( + + + +); + +export const PrometheusIcon = ({ className }: Props) => ( + + + +); + +export const NewRelicIcon = ({ className }: Props) => ( + + + +); + +export const InfluxDbIcon = ({ className }: Props) => ( + + + +); + +export const telemetryProviderIcons = { + loki: GrafanaLokiIcon, + prometheus_remote_write: PrometheusIcon, + new_relic: NewRelicIcon, + influxdb: InfluxDbIcon, + datadog: DatadogIcon, + aws_cloudwatch: AwsIcon, + splunk_hec: SplunkIcon, + elasticsearch: ElasticsearchIcon, + betterstack: BetterStackIcon, +} as const; diff --git a/apps/dokploy/components/layouts/dashboard-layout.tsx b/apps/dokploy/components/layouts/dashboard-layout.tsx index 816e7bc6e..6762f127a 100644 --- a/apps/dokploy/components/layouts/dashboard-layout.tsx +++ b/apps/dokploy/components/layouts/dashboard-layout.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; 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 { useHubSpotChat } from "../shared/analytics"; import Page from "./side"; interface Props { @@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => { }); const isChatEnabled = isCloud === true && currentPlan === "startup"; + useHubSpotChat(isChatEnabled); const { data: onboardingStatus } = api.project.onboardingStatus.useQuery(); const shouldRedirectToOnboarding = @@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => { )} {children} - {isChatEnabled && ( - <> - - - )} - {haveRootAccess === true && } ); diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index ed46c1930..3776c2db5 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { "\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D"; const logoUrl = whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined; + const logoLightUrl = + whitelabeling?.loginLogoLightUrl || + whitelabeling?.logoLightUrl || + undefined; return (
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => { href="/" className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary" > - + {appName}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 1e20a0fbe..430349867 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -29,6 +29,7 @@ import { type LucideIcon, Package, Palette, + ScrollText, Server, ShieldCheck, Smartphone, @@ -191,9 +192,11 @@ const MENU: Menu = { title: "Monitoring", url: "/dashboard/monitoring", icon: BarChartHorizontalBigIcon, - // Only enabled in non-cloud environments and if user has monitoring.read + // Only enabled in non-cloud environments (product decision) and if user has monitoring.read and docker.read isEnabled: ({ isCloud, permissions }) => - !isCloud && !!permissions?.monitoring.read, + !isCloud && + !!permissions?.monitoring.read && + !!permissions?.docker.read, }, { isSingle: true, @@ -350,7 +353,13 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ permissions }) => !!permissions?.organization.update, + isEnabled: ({ permissions }) => + !!( + permissions?.ai.read || + permissions?.ai.create || + permissions?.ai.update || + permissions?.ai.delete + ), }, { isSingle: true, @@ -374,6 +383,13 @@ const MENU: Menu = { icon: Package, isEnabled: ({ permissions }) => !!permissions?.registry.read, }, + { + isSingle: true, + title: "Logs & Metrics", + url: "/dashboard/settings/logs-and-metrics", + icon: ScrollText, + isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read, + }, { isSingle: true, title: "Secrets", diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index aa9747cfb..1a68544f6 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -87,14 +87,16 @@ export const UserNav = () => { {!isCloud ? ( <> - { - router.push("/dashboard/monitoring"); - }} - > - Monitoring - + {permissions?.monitoring.read && permissions?.docker.read && ( + { + router.push("/dashboard/monitoring"); + }} + > + Monitoring + + )} {permissions?.traefikFiles.read && ( = { settings: "Settings", session: "Session", network: "Network", + telemetryProvider: "Logs & Metrics", }; function MetadataCell({ metadata }: { metadata: string | null }) { diff --git a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx index 73409dc1a..22f1dd507 100644 --- a/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx +++ b/apps/dokploy/components/proprietary/roles/manage-custom-roles.tsx @@ -180,6 +180,16 @@ const RESOURCE_META: Record = { description: "Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records", }, + telemetryProvider: { + label: "Logs & Metrics", + description: + "Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to", + }, + ai: { + label: "AI Settings", + description: + "Manage AI providers, models, and use AI features (log analysis, suggestions)", + }, }; /** Descriptions for each action within a resource */ @@ -475,6 +485,40 @@ const ACTION_META: Record< description: "Remove DNS providers and delete their records", }, }, + telemetryProvider: { + read: { + label: "Read", + description: "View configured logs and metrics providers", + }, + create: { + label: "Create", + description: + "Add new providers, test their connection and deploy the Vector agent", + }, + delete: { + label: "Delete", + description: "Remove logs and metrics providers", + }, + }, + ai: { + read: { + label: "Read", + description: + "View AI providers and use AI features (log analysis, suggestions)", + }, + create: { + label: "Create", + description: "Add new AI provider configurations", + }, + update: { + label: "Update", + description: "Edit AI provider settings and custom providers", + }, + delete: { + label: "Delete", + description: "Remove AI provider configurations", + }, + }, }; /** Resources that should be hidden from the custom role editor (better-auth internals) */ @@ -586,6 +630,7 @@ const ROLE_PRESETS: { logs: ["read"], monitoring: ["read"], auditLog: ["read"], + ai: ["read", "create", "update", "delete"], }, }, ]; @@ -605,7 +650,7 @@ type CreateRoleSchema = z.infer; export const ManageCustomRoles = () => { return ( - +
diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx index d53af90a2..5d1beea37 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-preview.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTheme } from "next-themes"; import { Card, CardContent, @@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps { config: { appName?: string; logoUrl?: string; + logoLightUrl?: string; + dualModeBranding?: boolean; footerText?: string; }; } export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) { const appName = config.appName || "Dokploy"; + const { resolvedTheme } = useTheme(); + const effectiveLogoUrl = + config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl + ? config.logoLightUrl + : config.logoUrl; return ( @@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
{/* Simulated sidebar header */}
- {config.logoUrl ? ( + {effectiveLogoUrl ? ( Preview Logo diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx index 3ba9e80bd..ee0e54e78 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx @@ -26,6 +26,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; import { WhitelabelingPreview } from "./whitelabeling-preview"; @@ -36,20 +37,42 @@ const safeUrlField = z message: "Only http:// and https:// URLs are allowed", }); -const formSchema = z.object({ - appName: z.string(), - appDescription: z.string(), - logoUrl: safeUrlField, - faviconUrl: safeUrlField, - customCss: z.string(), - loginLogoUrl: safeUrlField, - supportUrl: safeUrlField, - docsUrl: safeUrlField, - errorPageTitle: z.string(), - errorPageDescription: z.string(), - footerText: z.string(), - ogImageUrl: safeUrlField, -}); +const formSchema = z + .object({ + appName: z.string(), + appDescription: z.string(), + logoUrl: safeUrlField, + faviconUrl: safeUrlField, + customCss: z.string(), + loginLogoUrl: safeUrlField, + supportUrl: safeUrlField, + docsUrl: safeUrlField, + errorPageTitle: z.string(), + errorPageDescription: z.string(), + footerText: z.string(), + ogImageUrl: safeUrlField, + logoLightUrl: z.string(), + loginLogoLightUrl: z.string(), + faviconLightUrl: z.string(), + dualModeBranding: z.boolean(), + }) + .superRefine((data, ctx) => { + if (!data.dualModeBranding) return; + for (const field of [ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ] as const) { + const val = data[field]; + if (val !== "" && !/^https?:\/\//i.test(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Only http:// and https:// URLs are allowed", + path: [field], + }); + } + } + }); type FormSchema = z.infer; @@ -195,6 +218,10 @@ export function WhitelabelingSettings() { errorPageDescription: "", footerText: "", ogImageUrl: "", + logoLightUrl: "", + loginLogoLightUrl: "", + faviconLightUrl: "", + dualModeBranding: false, }, resolver: zodResolver(formSchema), }); @@ -214,6 +241,10 @@ export function WhitelabelingSettings() { errorPageDescription: data.errorPageDescription ?? "", footerText: data.footerText ?? "", ogImageUrl: data.ogImageUrl ?? "", + logoLightUrl: data.logoLightUrl ?? "", + loginLogoLightUrl: data.loginLogoLightUrl ?? "", + faviconLightUrl: data.faviconLightUrl ?? "", + dualModeBranding: data.dualModeBranding ?? false, }); } }, [data, form]); @@ -244,6 +275,10 @@ export function WhitelabelingSettings() { errorPageDescription: values.errorPageDescription || null, footerText: values.footerText || null, ogImageUrl: values.ogImageUrl || null, + logoLightUrl: values.logoLightUrl || null, + loginLogoLightUrl: values.loginLogoLightUrl || null, + faviconLightUrl: values.faviconLightUrl || null, + dualModeBranding: values.dualModeBranding, }, }) .then(async () => { @@ -412,6 +447,111 @@ export function WhitelabelingSettings() { + {/* Light Mode Logos Section */} + + +
+
+ Light Mode Logos + + Use separate logos for light and dark themes. When enabled, + the logos in Branding are used for dark mode. + +
+ ( + + + { + field.onChange(checked); + if (!checked) { + form.clearErrors([ + "logoLightUrl", + "loginLogoLightUrl", + "faviconLightUrl", + ]); + } + }} + /> + + + )} + /> +
+
+
+
+ + ( + + Logo URL + + + + + + )} + /> + + ( + + Login Page Logo URL + + + + + + )} + /> + + ( + + Favicon URL + + + + + Follows the browser/OS theme, not the website theme. + + + + )} + /> + +
+
+
+ {/* Appearance Section */} diff --git a/apps/dokploy/components/shared/HubSpotWidget.tsx b/apps/dokploy/components/shared/HubSpotWidget.tsx deleted file mode 100644 index ff3013459..000000000 --- a/apps/dokploy/components/shared/HubSpotWidget.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import Script from "next/script"; - -export const HubSpotWidget = () => { - return ( -