Compare commits

..

46 Commits

Author SHA1 Message Date
Narciso E. Núñez Arias 53464e9096 Merge pull request #5070 from bunlongheng/fix/traefik-file-path-traversal
fix(settings): confine Traefik file read/write to the Traefik config directory
2026-10-15 17:24:35 -04:00
Narciso 136a09d016 fix(settings): check server organization in traefik file endpoints 2026-10-15 15:24:24 -04:00
Narciso E. Núñez Arias c867a3c4c9 Merge pull request #5489 from imrja8/feat/ai-custom-role-permissions
feat(permissions): add AI module to enterprise custom roles
2026-10-15 13:13:52 -04:00
Narciso E. Núñez Arias 471e91caeb Merge pull request #5684 from Dokploy/fix/dependabot-high-emails
chore(deps): fix high-severity Dependabot alerts in emails lockfile
2026-10-15 13:11:10 -04:00
Narciso 16c22c74fd chore(deps): bump brace-expansion and source-map-js in emails lockfile 2026-10-15 13:02:00 -04:00
Yash Kumar 37df4e484e fix: suppress EPIPE errors in pipeBetweenServers when target exits early 2026-10-15 21:51:48 +05:30
Yash Kumar 5ff8d05ff7 perf(ai): optimize permission evaluation and remove redundant DB queries 2026-10-15 21:51:48 +05:30
Yash Kumar a9871f3a8d refactor(ai): DRY saveAiSettings and enforce strict NOT_FOUND validation 2026-10-15 21:51:48 +05:30
Yash Kumar 0049421095 fix(ai): fix PermissionCtx type on resolveInputApiKey 2026-10-15 21:51:48 +05:30
Yash Kumar 3d67665c33 fix(ai): require update permission for stored key resolution, deduplicate resolve logic 2026-10-15 21:51:48 +05:30
Yash Kumar 2bfcac58b0 fix(ai): mask apiKey exposure, add audit logging, resolve masked-key UX regression 2026-10-15 21:51:48 +05:30
Yash Kumar a7d8378f52 fix(permissions): mask apiKey on ai.one, add audit logging, remove dead endpoint 2026-10-15 21:51:48 +05:30
Yash Kumar bdefc02e77 fix: redact apiKey from getAll AI endpoint to prevent credential exposure 2026-10-15 21:51:31 +05:30
Yash Kumar 293e19703a fix: resolve cross-tenant access and granular role UI issues for AI settings 2026-10-15 21:51:31 +05:30
Yash Kumar cd9edb290b fix(permissions): resolve AI module authorization edge cases and test failures 2026-10-15 21:51:31 +05:30
Yash Kumar b5473e63fd feat(permissions): add AI module to enterprise custom roles 2026-10-15 21:51:31 +05:30
Narciso E. Núñez Arias e0006758c0 Merge pull request #5517 from Dokploy/dependabots-alerts
Dependabots alerts fixes
2026-10-14 16:14:50 -04:00
Yash Kumar d9fe046396 Merge pull request #5661 from manozzo/fix/template-card-tags-overflow
fix(templates): cap visible tags so the Create button stays visible
2026-10-15 01:33:09 +05:30
Yash Kumar bfe24f89f9 Update undici version to 6.28.1 2026-10-15 01:07:55 +05:30
Yash Kumar 502d6c8a18 Merge branch 'canary' into dependabots-alerts 2026-10-15 00:44:53 +05:30
Yash Kumar 9e87b9e401 Merge pull request #5613 from Dokploy/fix/member-server-list
fix(server): let members list servers in server pickers
2026-10-15 00:30:43 +05:30
Yash Kumar a7d8dff360 Merge pull request #5624 from Dokploy/feat/logs-and-metrics
feat(logs-and-metrics): ship container logs and metrics to external providers via Vector
2026-10-15 00:08:48 +05:30
Rodrigo Manozzo 94f3815eb4 fix(templates): cap visible tags so the Create button stays visible
Template cards have a fixed height, and the header rendered every tag.
Portabase has 27, which pushed the description and footer (Create
button) out of the card. Show the first 6 tags plus a "+N" badge whose
title lists the rest.

Closes #5660

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:59:09 -03:00
Narciso E. Núñez Arias 931f5c1335 Merge pull request #5654 from olivier-drieux/fix/restore-backup-service-name
fix(backups): preserve service name when changing database type
2026-10-12 10:46:09 -04:00
Narciso E. Núñez Arias aae2dd9377 Merge pull request #5656 from Totopo27/fix/gh-5600-service-card-description-overflow
fix(ui): wrap and clamp service card description on environment page
2026-10-12 10:38:37 -04:00
Narciso E. Núñez Arias fe3912dc4b Merge pull request #5657 from Totopo27/feat/gh-5546-clickable-projects-card-home
feat(ui): make projects card clickable on home dashboard
2026-10-12 10:37:53 -04:00
Narciso E. Núñez Arias ccd26e946b Merge pull request #5655 from ahmedisam99/fix/gitlab-token-refresh-race
fix(gitlab): share one token refresh between concurrent callers
2026-10-12 10:08:10 -04:00
Narciso E. Núñez Arias 29107d88fc Merge pull request #5649 from imrja8/feat/dual-mode-branding
feat(enterprise): dual-mode branding with separate light/dark theme logos
2026-10-12 09:59:25 -04:00
Narciso E. Núñez Arias c7ee0430f7 Merge pull request #5614 from tomron/feat/ovh-optional-zone
feat(dns): add optional zone to OVH provider
2026-10-12 09:38:51 -04:00
Totopo27 3cc8158fdc feat(ui): make projects card clickable on home dashboard 2026-10-12 07:35:35 -06:00
Totopo27 726664ab3c fix(ui): wrap and clamp service card description on environment page 2026-10-12 07:30:51 -06:00
ahmedisam99 20701ba272 test(gitlab): cover concurrent token refreshes 2026-10-12 11:54:35 +03:00
ahmedisam99 412516d47b fix(gitlab): share one token refresh between concurrent callers
GitLab invalidates a refresh token when it is used. Two deployments that found the token expired at the same time sent the same refresh token, and the second one failed with "Failed to refresh token: Bad Request".
2026-10-12 11:54:35 +03:00
Olivier Drieux b4681b318f fix(backups): preserve service selection when changing database type 2026-10-12 09:55:31 +02:00
Yash Kumar 0898642e32 fix(enterprise): preserve light-mode URLs when toggling dual branding off 2026-10-11 20:00:32 +05:30
Yash Kumar d2779136c5 fix(enterprise): address review feedback for dual-mode branding 2026-10-11 19:51:21 +05:30
Yash Kumar 0b537a815d feat(enterprise): dual-mode branding with separate light/dark theme logos 2026-10-11 19:10:06 +05:30
Narciso b329595370 feat(logs-and-metrics): ship container logs and metrics to external providers via Vector 2026-10-08 10:08:10 -04:00
Tom Ron 2674eb200d fix(dns): enforce the configured OVH zone on record operations
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 09:13:50 +03:00
Tom Ron e8f236c9db feat(dns): add optional zone to OVH provider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 22:02:45 +03:00
Narciso 749bb2c1ce fix(server): let members list servers in server pickers 2026-10-07 14:51:15 -04:00
Narciso 9992659f24 fix(deps): bump nodemailer and patch transitive high advisories 2026-10-07 09:49:40 -04:00
Narciso 88cb057e06 Merge remote-tracking branch 'origin/canary' into dependabots-alerts 2026-10-07 08:49:24 -04:00
Narciso 7289a3f68e fix(deps): bump next, shell-quote and proxy-addr for critical advisories 2026-10-07 08:49:23 -04:00
Narciso Nunez 5e0b021ec2 fix(deps): resolve open Dependabot alerts 2026-09-23 17:40:39 -04:00
Bunlong Heng 1f1acacf58 fix(settings): confine Traefik file read/write to the Traefik config directory 2026-08-12 17:38:48 -04:00
126 changed files with 21379 additions and 2726 deletions
+1
View File
@@ -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.).
+3 -3
View File
@@ -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",
@@ -1,271 +0,0 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findBackupById: vi.fn(),
findDestinationById: vi.fn(),
findServerById: vi.fn(),
findMemberByUserId: vi.fn(),
checkServicePermissionAndAccess: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/services/backup", () => ({
findBackupById: mocks.findBackupById,
}));
vi.mock("@dokploy/server/services/destination", () => ({
findDestinationById: mocks.findDestinationById,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/permission", () => ({
findMemberByUserId: mocks.findMemberByUserId,
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
...(await importOriginal<object>()),
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const baseBackup = {
backupId: "backup-1",
destinationId: "dest-1",
databaseType: "postgres",
prefix: "db",
appName: "web",
postgresId: "pg-1",
postgres: { appName: "pg-app", serverId: null },
};
const destination = {
destinationId: "dest-1",
organizationId: "org-1",
bucket: "bucket",
accessKey: "ak",
secretAccessKey: "sk",
region: "us-east-1",
endpoint: "http://s3.local",
provider: "Minio",
additionalFlags: [],
};
const loadModule = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", async (importOriginal) => ({
...(await importOriginal<object>()),
IS_CLOUD: isCloud,
}));
return await import("@dokploy/server/utils/backups/files");
};
beforeEach(() => {
vi.clearAllMocks();
mocks.findBackupById.mockResolvedValue(baseBackup);
mocks.findDestinationById.mockResolvedValue(destination);
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-1",
});
mocks.findMemberByUserId.mockResolvedValue({ role: "owner" });
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
});
describe("getBackupServerId", () => {
it("returns the serverId of the backed up service", async () => {
const { getBackupServerId } = await loadModule(false);
const backup = baseBackup as Parameters<typeof getBackupServerId>[0];
expect(getBackupServerId(backup)).toBeNull();
for (const type of [
"postgres",
"mysql",
"mariadb",
"mongo",
"libsql",
"compose",
]) {
expect(
getBackupServerId({ ...backup, [type]: { serverId: `srv-${type}` } }),
).toBe(`srv-${type}`);
}
});
});
describe("resolveBackupAccess", () => {
it("rejects backups without a server on cloud", async () => {
const { resolveBackupAccess } = await loadModule(true);
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Server not found",
});
});
it("rejects a destination from another organization", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findDestinationById.mockResolvedValue({
...destination,
organizationId: "org-2",
});
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
});
it("rejects a server from another organization", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
postgres: { appName: "pg-app", serverId: "server-1" },
});
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-2",
});
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
});
it("only lets owners and admins reach web-server backups", async () => {
const { resolveBackupAccess } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
databaseType: "web-server",
postgresId: null,
postgres: null,
});
mocks.findMemberByUserId.mockResolvedValue({ role: "member" });
await expect(resolveBackupAccess(ctx, "backup-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
mocks.findMemberByUserId.mockResolvedValue({ role: "admin" });
await expect(resolveBackupAccess(ctx, "backup-1")).resolves.toMatchObject({
serverId: null,
path: ":s3:bucket/web/db/",
});
});
});
describe("listBackupFilesByBackupId", () => {
it("lists on the service server sorted by ModTime", async () => {
const { listBackupFilesByBackupId } = await loadModule(false);
mocks.findBackupById.mockResolvedValue({
...baseBackup,
postgres: { appName: "pg-app", serverId: "server-1" },
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: JSON.stringify([
{ Name: "first.sql.gz", ModTime: "2026-01-01T00:00:00Z" },
{ Name: "third.sql.gz", ModTime: "2026-01-01T04:00:00+03:00" },
{ Name: "second.sql.gz", ModTime: "2026-01-01T00:30:00Z" },
]),
stderr: "",
});
const files = await listBackupFilesByBackupId(ctx, "backup-1");
expect(files.map((file) => file.Name)).toEqual([
"third.sql.gz",
"second.sql.gz",
"first.sql.gz",
]);
expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
ctx,
"pg-1",
{ backup: ["read"] },
);
const [serverId, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(serverId).toBe("server-1");
expect(command.replace(/\\/g, "")).toContain(
"--use-server-modtime --include *.{sql.gz,bson.gz} :s3:bucket/pg-app/db/",
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("returns an empty list when the folder does not exist", async () => {
const { listBackupFilesByBackupId } = await loadModule(false);
mocks.execAsync.mockRejectedValue(
new ExecError("Command execution failed", {
command: "rclone",
stderr: "ERROR : directory not found",
}),
);
await expect(listBackupFilesByBackupId(ctx, "backup-1")).resolves.toEqual(
[],
);
});
});
describe("getBackupDownloadUrl", () => {
it.each(["../x.sql.gz", "a/b.sql.gz", ".hidden.sql.gz", "x.txt"])(
"rejects %s",
async (fileName) => {
const { getBackupDownloadUrl } = await loadModule(false);
await expect(
getBackupDownloadUrl(ctx, "backup-1", fileName),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.execAsync).not.toHaveBeenCalled();
},
);
it("returns the presigned url from rclone link", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
mocks.execAsync.mockResolvedValue({
stdout: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1\n",
stderr: "",
});
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).resolves.toEqual({
url: "https://s3.local/bucket/pg-app/db/x.sql.gz?X-Amz-Signature=1",
});
const [command] = mocks.execAsync.mock.calls[0] ?? [];
expect(command).toContain("rclone link --expire 15m");
expect(command.replace(/\\/g, "")).toContain(
":s3:bucket/pg-app/db/x.sql.gz",
);
});
it("maps object not found to NOT_FOUND", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
mocks.execAsync.mockRejectedValue(
new ExecError("Command execution failed", {
command: "rclone",
stderr: "ERROR : object not found",
}),
);
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("never logs the command on other failures", async () => {
const { getBackupDownloadUrl } = await loadModule(false);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mocks.execAsync.mockRejectedValue(
new ExecError(
"Command failed: rclone link --s3-secret-access-key=SUPERSECRET",
{
command: "rclone link --s3-secret-access-key=SUPERSECRET",
stderr: "ERROR : AccessDenied",
},
),
);
await expect(
getBackupDownloadUrl(ctx, "backup-1", "x.sql.gz"),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(JSON.stringify(consoleError.mock.calls)).not.toContain(
"SUPERSECRET",
);
consoleError.mockRestore();
});
});
@@ -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<typeof addDomainToCompose>[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));
});
});
+61
View File
@@ -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`,
);
});
});
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const store = vi.hoisted(() => ({
provider: {} as Record<string, unknown>,
}));
vi.mock("@dokploy/server/services/gitlab", () => ({
findGitlabById: vi.fn(async () => ({ ...store.provider })),
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
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<typeof createGitlab>;
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");
});
});
@@ -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"]);
});
});
@@ -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();
});
});
@@ -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<unknown>) =>
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();
});
});
@@ -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<void>((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 }]);
});
});
@@ -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)");
});
});
@@ -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<string, unknown> }>,
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<string, unknown>) => ({
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([]);
});
});
@@ -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 }}");
});
});
@@ -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/);
});
});
@@ -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<string, Promise<unknown>>();
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<unknown>,
) => {
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");
});
});
@@ -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<string, unknown>) =>
({
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<Parameters<typeof buildVectorConfigYaml>[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"');
});
});
@@ -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();
});
});
@@ -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<void>((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"]);
});
});
@@ -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<string, unknown>) =>
({
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<typeof buildVectorConfigYaml>[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,
);
},
);
@@ -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<string, unknown>; where: unknown }>,
returning: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { webServerSettings: { findFirst: mocks.findFirst } },
update: () => ({
set: (values: Record<string, unknown>) => ({
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',
);
});
});
@@ -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", () => {
@@ -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", () => {
@@ -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);
});
});
@@ -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,
@@ -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<typeof settingsRouter.createCaller>[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,
);
});
});
@@ -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);
});
@@ -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") ?? "",
});
}}
>
<SelectTrigger>
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
@@ -1,146 +0,0 @@
import { DatabaseBackup, Download, FolderOpen, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { formatBytes } from "./restore-backup";
interface Props {
backupId: string;
}
export const ShowBackupFiles = ({ backupId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const {
data: files,
isLoading,
isError,
error,
} = api.backup.listBackupFilesByBackupId.useQuery(
{ backupId },
{ enabled: isOpen },
);
const download = api.backup.getDownloadUrl.useMutation({
onSuccess: ({ url }) => window.location.assign(url),
onError: (error) => toast.error(error.message),
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<FolderOpen className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Backup Files</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Backup Files</DialogTitle>
<DialogDescription>
Files stored in the destination for this backup.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error.message}</AlertBlock>}
{isLoading && (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && !isError && files?.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No backup files found yet
</span>
</div>
)}
{!isError && !!files?.length && (
<ScrollArea className="h-[60vh]">
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Date</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-16" />
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.Name}>
<TableCell className="font-medium break-all">
{file.Name}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{file.ModTime
? new Date(file.ModTime).toLocaleString()
: "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatBytes(file.Size)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="size-8"
disabled={download.isPending}
onClick={() =>
download.mutate({ backupId, fileName: file.Name })
}
>
{download.isPending &&
download.variables?.fileName === file.Name ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
};
@@ -30,7 +30,6 @@ import type { ServiceType } from "../../application/advanced/show-resources";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
import { HandleBackup } from "./handle-backup";
import { RestoreBackup } from "./restore-backup";
import { ShowBackupFiles } from "./show-backup-files";
interface Props {
id: string;
@@ -326,7 +325,6 @@ export const ShowBackups = ({
</Tooltip>
</TooltipProvider>
<ShowBackupFiles backupId={backup.backupId} />
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
@@ -45,13 +45,19 @@ function StatCard({
label,
value,
delta,
href,
}: {
label: string;
value: string;
delta?: string;
href?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
const content = (
<div
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
}`}
>
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
@@ -63,6 +69,16 @@ function StatCard({
</div>
</div>
);
if (href) {
return (
<Link href={href} className="block h-full">
{content}
</Link>
);
}
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"
/>
<StatCard
label="Services"
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
<Logo
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
/>
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{viewMode === "detailed" &&
template?.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{template?.tags?.map((tag) => (
{template.tags.slice(0, 6).map((tag) => (
<Badge
key={tag}
variant="green"
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
{tag}
</Badge>
))}
{template.tags.length > 6 && (
<Badge
variant="green"
className="text-[10px] px-2 py-0"
title={template.tags.slice(6).join(", ")}
>
+{template.tags.length - 6}
</Badge>
)}
</div>
)}
</div>
@@ -18,7 +18,9 @@ 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";
@@ -36,7 +38,9 @@ export const AiForm = () => {
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
<HandleAi />
)}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -53,7 +57,7 @@ export const AiForm = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
<HandleAi />
{permissions?.ai.create && <HandleAi />}
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
@@ -70,33 +74,37 @@ export const AiForm = () => {
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
{permissions?.ai.update && (
<HandleAi aiId={config.aiId} />
)}
{permissions?.ai.delete && (
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
applicationKey: z.string(),
applicationSecret: z.string(),
consumerKey: z.string(),
zone: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -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) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="zone"
render={({ field }) => (
<FormItem>
<FormLabel>Zone (optional)</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
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{" "}
<code>GET /domain/zone</code> 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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -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}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
@@ -498,10 +500,12 @@ function TestConnectionButton({
apiUrl,
apiKey,
model,
aiId,
}: {
apiUrl: string;
apiKey: string;
model: string;
aiId?: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
@@ -521,7 +525,9 @@ function TestConnectionButton({
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
onClick={() =>
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -0,0 +1,575 @@
import type {
TelemetryProviderType,
TelemetrySignal,
} from "@dokploy/server/services/logs-and-metrics/types";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
const SIGNAL_OPTIONS: Array<{
value: string;
label: string;
signals: TelemetrySignal[];
}> = [
{ value: "logs", label: "Logs", signals: ["logs"] },
{ value: "metrics", label: "Metrics", signals: ["metrics"] },
{
value: "logs,metrics",
label: "Logs & Metrics",
signals: ["logs", "metrics"],
},
];
const providerForm = z.object({
name: z.string().min(1, "Name is required"),
providerType: z.string().min(1, "Provider is required"),
signals: z.string().min(1, "Select what this provider sends"),
enabled: z.boolean(),
fields: z.record(z.string(), z.string().optional()),
});
type ProviderForm = z.infer<typeof providerForm>;
const toSignals = (value: string) =>
SIGNAL_OPTIONS.find((option) => option.value === value)?.signals ?? [];
interface Props {
telemetryProviderId?: string;
}
export const agentsUsingProvider = (
targets:
| Array<{
name: string;
telemetryProviderIds: string[];
}>
| undefined,
telemetryProviderId: string,
) =>
(targets ?? [])
.filter((target) =>
target.telemetryProviderIds.includes(telemetryProviderId),
)
.map((target) => target.name)
.join(", ");
export const HandleTelemetryProvider = ({ telemetryProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [initialFields, setInitialFields] = useState<Record<string, string>>(
{},
);
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
const { data: availableTypes } =
api.telemetryProvider.availableTypes.useQuery();
const { data: provider } = api.telemetryProvider.one.useQuery(
{ telemetryProviderId: telemetryProviderId || "" },
{ enabled: !!telemetryProviderId, refetchOnWindowFocus: false },
);
const createMutation = api.telemetryProvider.create.useMutation();
const updateMutation = api.telemetryProvider.update.useMutation();
const {
isPending: isSaving,
error,
isError,
} = telemetryProviderId ? updateMutation : createMutation;
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.telemetryProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.telemetryProvider.testConnectionById.useMutation();
const form = useForm<ProviderForm>({
defaultValues: {
name: "",
providerType: "",
signals: "",
enabled: true,
fields: {},
},
resolver: zodResolver(providerForm),
});
const providerType = form.watch("providerType");
const signalsValue = form.watch("signals");
const fieldValues = form.watch("fields");
const signals = toSignals(signalsValue);
const selectedType = availableTypes?.find((t) => t.type === providerType);
const signalOptions = SIGNAL_OPTIONS.filter((option) =>
option.signals.every((s) => selectedType?.signals.includes(s)),
);
const visibleFields = (selectedType?.credentialFields ?? []).filter(
(field) => !field.signal || signals.includes(field.signal),
);
useEffect(() => {
if (provider) {
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
form.reset({
name: provider.name,
providerType: provider.providerType,
signals: provider.signals.join(","),
enabled: provider.enabled,
fields: extraConfig,
});
setInitialFields(extraConfig);
} else {
form.reset();
setInitialFields({});
}
setClearedKeys({});
}, [form, provider, isOpen]);
const buildPayload = () => {
const payload: {
name: string;
providerType: TelemetryProviderType;
signals: TelemetrySignal[];
enabled: boolean;
endpoint?: string | null;
apiKey?: string | null;
apiSecret?: string | null;
extraConfig?: Record<string, string>;
} = {
name: form.getValues("name"),
providerType: providerType as TelemetryProviderType,
signals,
enabled: form.getValues("enabled"),
};
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of visibleFields) {
const value = fieldValues[field.key] ?? "";
if (
field.key === "endpoint" ||
field.key === "apiKey" ||
field.key === "apiSecret"
) {
if (value) {
payload[field.key] = value;
} else if (clearedKeys[field.key]) {
payload[field.key] = null;
}
} else {
hasExtraConfigFields = true;
extraConfig[field.key] = value;
}
}
if (hasExtraConfigFields) {
payload.extraConfig = extraConfig;
}
return payload;
};
// Required fields are per type and signal, so zod can't know them up front.
const validateRequiredFields = () => {
let valid = true;
for (const field of visibleFields) {
const value = fieldValues[field.key] ?? "";
const keepsStored =
!!telemetryProviderId &&
TOP_LEVEL_KEYS.has(field.key) &&
!clearedKeys[field.key];
if (field.required && !value && !keepsStored) {
form.setError(`fields.${field.key}`, {
message: `${field.label} is required`,
});
valid = false;
}
}
return valid;
};
const topLevelKeys = visibleFields
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter(
(key) => !!fieldValues[key] || clearedKeys[key],
);
const touchedExtraConfigKeys = visibleFields
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key)
.filter((key) => (fieldValues[key] ?? "") !== (initialFields[key] ?? ""));
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!telemetryProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const signalsChanged =
!!provider && signalsValue !== provider.signals.join(",");
const showTestResult = (result: { warning?: string }) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
};
const onTest = async () => {
if (!(await form.trigger(["providerType", "signals"]))) return;
if (telemetryProviderId && !touchedAnyCredentialField) {
await testConnectionById({
telemetryProviderId,
...(signalsChanged ? { signals } : {}),
})
.then(showTestResult)
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
if (!validateRequiredFields()) return;
await testConnection(buildPayload())
.then(showTestResult)
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
if (!validateRequiredFields()) return;
const payload = buildPayload();
let warning: string | undefined;
let reapplied = false;
try {
if (telemetryProviderId) {
const result = await updateMutation.mutateAsync({
telemetryProviderId,
...payload,
});
warning = result.warning;
reapplied = result.reapplied;
} else {
await createMutation.mutateAsync(payload);
}
} catch {
toast.error(
telemetryProviderId
? "Error updating provider"
: "Error adding provider",
);
return;
}
utils.telemetryProvider.all.invalidate();
utils.telemetryProvider.serverStatus.invalidate();
if (telemetryProviderId) {
utils.telemetryProvider.one.invalidate({ telemetryProviderId });
}
const agents =
telemetryProviderId &&
agentsUsingProvider(
utils.telemetryProvider.serverStatus.getData(),
telemetryProviderId,
);
if (warning) {
toast.warning("Provider updated", { description: warning });
} else {
toast.success(
telemetryProviderId ? "Provider updated" : "Provider added",
reapplied && agents
? { description: `The Vector agent on ${agents} was re-applied.` }
: undefined,
);
}
setIsOpen(false);
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{telemetryProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{telemetryProviderId ? "Edit provider" : "Add a provider"}
</DialogTitle>
<DialogDescription>
Vector ships container logs and host/container metrics from every
server you deploy it on to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<AlertBlock type="error" className="w-full">
{testError?.message || testByIdError?.message || error?.message}
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form-telemetry-provider"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Production Loki" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
form.setValue("signals", "");
form.setValue("fields", {});
form.clearErrors();
}}
disabled={!!telemetryProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableTypes?.map((type) => {
const ProviderIcon =
telemetryProviderIcons[
type.type as keyof typeof telemetryProviderIcons
];
return (
<SelectItem key={type.type} value={type.type}>
<div className="flex flex-row items-center gap-2">
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
{type.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{selectedType && (
<FormField
control={form.control}
name="signals"
render={({ field }) => (
<FormItem>
<FormLabel>Sends</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select what this provider sends" />
</SelectTrigger>
</FormControl>
<SelectContent>
{signalOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{visibleFields.map((credentialField) => (
<FormField
key={credentialField.key}
control={form.control}
name={`fields.${credentialField.key}`}
render={({ field }) => (
<FormItem>
<FormLabel>
{credentialField.label}
{!credentialField.required && " (Optional)"}
</FormLabel>
{credentialField.helpText && (
<FormDescription>
{credentialField.helpText}
</FormDescription>
)}
<div className="flex flex-row gap-2">
<FormControl>
<Input
type={
credentialField.type === "password"
? "password"
: "text"
}
disabled={!!clearedKeys[credentialField.key]}
placeholder={
clearedKeys[credentialField.key]
? "Will be cleared on update"
: telemetryProviderId &&
TOP_LEVEL_KEYS.has(credentialField.key)
? "Leave blank to keep existing"
: credentialField.placeholder
}
autoComplete={
credentialField.type === "password"
? "new-password"
: "off"
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
{telemetryProviderId &&
TOP_LEVEL_KEYS.has(credentialField.key) &&
!credentialField.required && (
<Button
type="button"
variant="outline"
onClick={() => {
setClearedKeys((prev) => ({
...prev,
[credentialField.key]:
!prev[credentialField.key],
}));
field.onChange("");
}}
>
{clearedKeys[credentialField.key]
? "Keep"
: "Clear"}
</Button>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
))}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Enabled</FormLabel>
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
{isPartiallyTouched && (
<span className="text-xs text-muted-foreground">
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.
</span>
)}
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
disabled={!providerType || isPartiallyTouched}
onClick={onTest}
>
Test connection
</Button>
<Button
type="submit"
form="hook-form-telemetry-provider"
isLoading={isSaving}
disabled={isSaving}
>
{telemetryProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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 <Badge variant="green">Running</Badge>;
}
if (status === "not-running") {
return <Badge variant="yellow">Not running</Badge>;
}
if (status === "stopped") {
return <Badge variant="secondary">Not deployed</Badge>;
}
return <Badge variant="destructive">Unreachable</Badge>;
};
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<Record<string, string[]>>({});
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
const [errors, setErrors] = useState<
Record<string, { title: string; message: string }>
>({});
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 (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Checking servers...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a provider first — there's nowhere to ship to yet
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{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 (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{target.name}
</span>
{target.ipAddress && (
<span className="text-xs text-muted-foreground">
{target.ipAddress}
</span>
)}
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{agentDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isBusy}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">Providers</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
telemetryProviderIcons[
provider.providerType as keyof typeof telemetryProviderIcons
];
return (
<div
key={provider.telemetryProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.telemetryProviderId}`}
disabled={
!provider.enabled &&
!ids.includes(provider.telemetryProviderId)
}
checked={ids.includes(
provider.telemetryProviderId,
)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
saved,
provider.telemetryProviderId,
)
}
/>
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.telemetryProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{providerLabel(provider)}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
);
})}
</div>
</div>
{errors[key] && (
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
{errors[key].title}
</span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
{errors[key].message}
</pre>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key]?.message ?? "",
);
toast.success("Error copied");
}}
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</AlertBlock>
)}
{hasProviders && (
<div className="flex items-center justify-end gap-3">
{ids.length > 0 && !hasEnabledSelected && (
<span className="text-xs text-muted-foreground">
Every selected provider is disabled — nothing
would ship.
</span>
)}
<Button
size="sm"
isLoading={isBusy}
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{agentDeployed || saved.length > 0
? "Redeploy"
: "Deploy"}
</Button>
</div>
)}
</div>
);
})}
{visibleTargets?.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any server yet
</span>
</div>
)}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -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 (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium flex items-center gap-2">
{index + 1}. {provider.name}
<Badge variant={provider.enabled ? "green" : "secondary"}>
{provider.enabled ? "Enabled" : "Disabled"}
</Badge>
{provider.signals.includes("logs") && (
<Badge variant="blue">Logs</Badge>
)}
{provider.signals.includes("metrics") && (
<Badge variant="orange">Metrics</Badge>
)}
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<HandleTelemetryProvider
telemetryProviderId={provider.telemetryProviderId}
/>
)}
{canDelete && (
<DialogAction
title="Delete provider"
description="Are you sure you want to delete this provider? Every agent shipping to it is re-applied without it, and removed if nothing is left."
type="destructive"
onClick={async () => {
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");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
};
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 (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Logs & Metrics
</CardTitle>
<CardDescription>
{`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.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any provider configured
</span>
{permissions?.telemetryProvider.create && (
<HandleTelemetryProvider />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((provider, index) => (
<TelemetryProviderRow
key={provider.telemetryProviderId}
provider={provider}
index={index}
typeLabel={
availableTypes?.find(
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.telemetryProvider.create}
canDelete={!!permissions?.telemetryProvider.delete}
onDeleted={() => {
refetch();
utils.telemetryProvider.serverStatus.invalidate();
}}
/>
))}
</div>
{permissions?.telemetryProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleTelemetryProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -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) {
@@ -0,0 +1,105 @@
import { AwsIcon } from "@/components/icons/vault-provider-icons";
interface Props {
className?: string;
}
export const GrafanaLokiIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#F46800"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
</svg>
);
export const DatadogIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#632CA6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
</svg>
);
export const BetterStackIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
</svg>
);
export const ElasticsearchIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#00BFB3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
</svg>
);
export const SplunkIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
</svg>
);
export const PrometheusIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#E6522C"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M12 0C5.373 0 0 5.372 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12c0-6.628-5.373-12-12-12zm0 22.46c-1.885 0-3.414-1.26-3.414-2.814h6.828c0 1.553-1.528 2.813-3.414 2.813zm5.64-3.745H6.36v-2.046h11.28v2.046zm-.04-3.098H6.391c-.037-.043-.075-.086-.111-.13-1.155-1.401-1.427-2.133-1.69-2.879-.005-.025 1.4.287 2.395.511 0 0 .513.119 1.262.255-.72-.843-1.147-1.915-1.147-3.01 0-2.406 1.845-4.508 1.18-6.207.648.053 1.34 1.367 1.387 3.422.689-.951.977-2.69.977-3.755 0-1.103.727-2.385 1.454-2.429-.648 1.069.168 1.984.894 4.256.272.854.237 2.29.447 3.201.07-1.892.395-4.652 1.595-5.605-.529 1.2.079 2.702.494 3.424.671 1.164 1.078 2.047 1.078 3.716a4.642 4.642 0 01-1.11 2.996c.792-.149 1.34-.283 1.34-.283l2.573-.502s-.374 1.538-1.81 3.019z" />
</svg>
);
export const NewRelicIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#1CE783"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M8.0015 14.3091v7.384L12.0008 24V12.0008L1.6078 5.9996v4.6167ZM12.0008 0 2.8232 5.2976 6.8209 7.606l5.1799-2.9893 6.3936 3.6913v7.384l-5.1783 2.9908v4.6167l9.176-5.2991V5.9996Z" />
</svg>
);
export const InfluxDbIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#22ADF6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64zM10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26zm4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 01.026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 01-.364-.675l1.378-4.421a.547.547 0 01.675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 01-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 01-.65-.39l-1.922-8.372a.534.534 0 01.39-.65L19.1 5.335a.534.534 0 01.649.39l1.923 8.371c.079.31-.102.596-.39.65Z" />
</svg>
);
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;
@@ -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 (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
@@ -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"
>
<Logo className="size-10" logoUrl={logoUrl} />
<Logo
className="size-10"
logoUrl={logoUrl}
logoLightUrl={logoLightUrl}
/>
{appName}
</Link>
<div className="relative z-20 mt-auto">
+15 -1
View File
@@ -29,6 +29,7 @@ import {
type LucideIcon,
Package,
Palette,
ScrollText,
Server,
ShieldCheck,
Smartphone,
@@ -350,7 +351,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 +381,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",
@@ -5,7 +5,6 @@ import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import {
ArrowUpDown,
Download,
FileJson,
LogIn,
LogOut,
@@ -78,11 +77,6 @@ const ACTION_CONFIG: Record<
className:
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
download: {
label: "Downloaded",
icon: Download,
className: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
},
};
const RESOURCE_LABELS: Record<string, string> = {
@@ -102,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
telemetryProvider: "Logs & Metrics",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -53,7 +53,6 @@ const ACTION_OPTIONS = [
{ value: "redeploy", label: "Redeployed" },
{ value: "login", label: "Login" },
{ value: "logout", label: "Logout" },
{ value: "download", label: "Downloaded" },
];
const RESOURCE_OPTIONS = [
@@ -84,8 +83,7 @@ type AuditAction =
| "cancel"
| "redeploy"
| "login"
| "logout"
| "download";
| "logout";
type AuditResourceType =
| "project"
| "service"
@@ -180,6 +180,16 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
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"],
},
},
];
@@ -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 (
<Card className="bg-transparent">
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
<div className="rounded-lg border overflow-hidden">
{/* Simulated sidebar header */}
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
{config.logoUrl ? (
{effectiveLogoUrl ? (
<img
src={config.logoUrl}
src={effectiveLogoUrl}
alt="Preview Logo"
className="size-8 rounded-sm object-contain"
/>
@@ -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<typeof formSchema>;
@@ -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() {
</CardContent>
</Card>
{/* Light Mode Logos Section */}
<Card className="bg-transparent">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<CardTitle>Light Mode Logos</CardTitle>
<CardDescription>
Use separate logos for light and dark themes. When enabled,
the logos in Branding are used for dark mode.
</CardDescription>
</div>
<FormField
control={form.control}
name="dualModeBranding"
render={({ field }) => (
<FormItem>
<FormControl>
<Switch
aria-label="Enable light mode logos"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.clearErrors([
"logoLightUrl",
"loginLogoLightUrl",
"faviconLightUrl",
]);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardHeader>
<div
className="grid transition-all duration-300 ease-in-out"
style={{
gridTemplateRows: form.watch("dualModeBranding")
? "1fr"
: "0fr",
}}
>
<div className="overflow-hidden">
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="logoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="loginLogoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login Page Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon-light.ico"
{...field}
/>
</FormControl>
<FormDescription>
Follows the browser/OS theme, not the website theme.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</div>
</div>
</Card>
{/* Appearance Section */}
<Card className="bg-transparent">
<CardHeader>
+13 -4
View File
@@ -1,16 +1,25 @@
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface Props {
className?: string;
logoUrl?: string;
logoLightUrl?: string;
}
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
export const Logo = ({
className = "size-14",
logoUrl,
logoLightUrl,
}: Props) => {
const { resolvedTheme } = useTheme();
const effectiveUrl =
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
if (effectiveUrl) {
return (
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
<img
src={logoUrl}
src={effectiveUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>
@@ -0,0 +1,20 @@
CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint
CREATE TABLE "telemetryProvider" (
"telemetryProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "TelemetryProviderType" NOT NULL,
"signals" text[] NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint
ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1791456930312,
"tag": "0197_logs_and_metrics",
"breakpoints": true
}
]
}
+16 -17
View File
@@ -48,6 +48,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch": "^3.1108.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
@@ -65,7 +67,7 @@
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.18",
"@faker-js/faker": "^8.4.1",
"@faker-js/faker": "^10.5.0",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
@@ -85,7 +87,7 @@
"@xterm/addon-attach": "0.10.0",
"@xterm/addon-clipboard": "0.1.0",
"@xterm/xterm": "^5.5.0",
"adm-zip": "^0.5.16",
"adm-zip": "^0.6.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
@@ -98,24 +100,24 @@
"copy-to-clipboard": "^3.3.3",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dompurify": "^3.4.13",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"lodash": "4.17.21",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "16.3.4",
"nanoid": "3.3.18",
"next": "16.3.8",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"nodemailer": "10.0.15",
"octokit": "3.1.2",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -133,7 +135,7 @@
"resend": "^6.0.2",
"semver": "7.7.3",
"shadcn": "^4.11.0",
"shell-quote": "^1.8.4",
"shell-quote": "^1.11.0",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
@@ -142,19 +144,18 @@
"swagger-ui-react": "^5.32.6",
"tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7",
"toml": "3.0.0",
"toml": "4.2.0",
"tw-animate-css": "^1.4.0",
"undici": "^6.21.3",
"undici": "^6.28.0",
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"ws": "8.21.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"yaml": "2.8.3",
"zod": "^4.3.6",
"zod-form-data": "^3.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dompurify": "^3.2.0",
"@types/js-cookie": "^3.0.6",
@@ -162,17 +163,15 @@
"@types/micromatch": "4.0.9",
"@types/node": "^24.4.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"lint-staged": "^15.5.2",
"memfs": "^4.17.2",
"simple-icons": "^16.14.0",
+23 -1
View File
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
baseUrl: string;
}
@@ -22,6 +23,7 @@ export default function Document({
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
}: WhitelabelingDocumentProps) {
@@ -44,7 +46,22 @@ export default function Document({
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<link rel="icon" href={faviconHref || "/icon.svg"} />
{faviconLightHref ? (
<>
<link
rel="icon"
href={faviconHref || "/icon.svg"}
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
href={faviconLightHref}
media="(prefers-color-scheme: light)"
/>
</>
) : (
<link rel="icon" href={faviconHref || "/icon.svg"} />
)}
{customCss && (
<style
id="whitelabeling-styles"
@@ -69,6 +86,7 @@ declare global {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
};
expiresAt: number;
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
let appDescription: string | null = null;
let ogImageUrl: string | null = null;
let faviconHref: string | null = null;
let faviconLightHref: string | null = null;
let customCss: string | null = null;
const host = ctx.req?.headers?.host || "localhost:3000";
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
faviconLightHref = config.faviconLightUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
};
+2 -1
View File
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const logoUrl = whitelabeling?.logoUrl || undefined;
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
const errorTitle = whitelabeling?.errorPageTitle;
const errorDescription = whitelabeling?.errorPageDescription;
@@ -22,7 +23,7 @@ export default function ErrorPage({ statusCode }: Props) {
<header className="mb-auto flex justify-center z-50 w-full py-4">
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo logoUrl={logoUrl} />
<Logo logoUrl={logoUrl} logoLightUrl={logoLightUrl} />
<span className="font-medium text-sm">{appName}</span>
</Link>
</nav>
@@ -1663,19 +1663,19 @@ const EnvironmentPage = (
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
<div className="flex flex-row items-center gap-2 justify-between w-full min-w-0">
<div className="flex flex-col gap-2 min-w-0 flex-1">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap break-words">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground wrap-anywhere line-clamp-3">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
<span className="text-sm font-medium text-muted-foreground self-start shrink-0">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
@@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
import { ShowTelemetryProviders } from "@/components/dashboard/settings/logs-and-metrics/show-telemetry-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowTelemetryProviders />
<ShowServers />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Logs & Metrics">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
+6 -1
View File
@@ -5,7 +5,6 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const LoginSchema = z.object({
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
Sign in
</div>
+6 -1
View File
@@ -1,6 +1,5 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -164,6 +164,11 @@ const Invitation = ({
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Invitation
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
{isCloud ? "Sign Up" : "Setup the server"}
+5
View File
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Reset Password
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -24,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -92,6 +92,11 @@ export default function Home() {
logoUrl={
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
<span className="font-medium text-sm">
{whitelabeling?.appName || "Dokploy"}
+2
View File
@@ -53,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { telemetryProviderRouter } from "./routers/telemetry-provider";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider";
@@ -85,6 +86,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
telemetryProvider: telemetryProviderRouter,
mariadb: mariadbRouter,
mongo: mongoRouter,
mounts: mountRouter,
+118 -48
View File
@@ -15,6 +15,9 @@ import {
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
maskAiApiKey,
mergeAiApiKey,
resolveAiApiKey,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
import {
addNewService,
checkServiceAccess,
hasPermission,
} from "@dokploy/server/services/permission";
import { findProjectById } from "@dokploy/server/services/project";
import {
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
withAnyPermission,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { generatePassword } from "@/templates/utils";
const resolveInputApiKey = (
input: { apiKey: string; apiUrl: string; aiId?: string },
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
) =>
resolveAiApiKey({
apiKey: input.apiKey,
apiUrl: input.apiUrl,
aiId: input.aiId,
organizationId: ctx.session.activeOrganizationId,
authorize: () => hasPermission(ctx, { ai: ["update"] }),
});
export const aiRouter = createTRPCRouter({
one: adminProcedure
one: withAnyPermission("ai", ["read", "update"])
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
.query(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
return maskAiApiKey(setting);
}),
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
getModels: withAnyPermission("ai", ["read", "create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
aiId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const headers = getProviderHeaders(input.apiUrl, apiKey);
let response = null;
switch (providerName) {
case "ollama":
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
{ headers: {} },
);
break;
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!input.apiKey)
if (!apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
create: withPermission("ai", "create")
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
);
await audit(ctx, {
action: "create",
resourceType: "ai",
resourceName: input.name,
});
return result;
}),
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
update: withPermission("ai", "update")
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
const existing = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (input.apiKey) {
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
}
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
false,
);
await audit(ctx, {
action: "update",
resourceType: "ai",
resourceId: input.aiId,
resourceName: input.name,
});
return result;
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
return settings.map(({ apiKey, ...rest }) => rest);
},
),
delete: withPermission("ai", "delete")
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "delete",
resourceType: "ai",
resourceId: input.aiId,
resourceName: setting.name,
});
return true;
}),
getCustomProviders: withAnyPermission("ai", [
"read",
"create",
"update",
]).query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
}),
analyzeLogs: protectedProcedure
analyzeLogs: withPermission("ai", "read")
.input(
z.object({
aiId: z.string().min(1),
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const aiSettings = await getAiSettingById(input.aiId);
const aiSettings = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
});
}
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
@@ -283,19 +351,21 @@ ${input.logs}`,
}
}),
testConnection: protectedProcedure
testConnection: withAnyPermission("ai", ["create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
aiId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey: input.apiKey,
apiKey,
});
const model = provider(input.model);
const result = await generateText({
@@ -317,7 +387,7 @@ ${input.logs}`,
}
}),
suggest: protectedProcedure
suggest: withPermission("ai", "read")
.input(
z.object({
aiId: z.string(),
@@ -338,7 +408,7 @@ ${input.logs}`,
});
}
}),
deploy: protectedProcedure
deploy: withPermission("ai", "read")
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
+12 -24
View File
@@ -15,11 +15,8 @@ import {
findPostgresByBackupId,
findPostgresById,
findServerById,
getBackupDownloadUrl,
IS_CLOUD,
keepLatestNBackups,
listBackupFilesByBackupId,
type RcloneFile,
removeBackupById,
removeScheduleBackup,
runLibsqlBackup,
@@ -70,6 +67,18 @@ import {
} from "@/server/db/schema";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
interface RcloneFile {
Path: string;
Name: string;
Size: number;
IsDir: boolean;
Tier?: string;
Hashes?: {
MD5?: string;
SHA1?: string;
};
}
export const backupRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateBackup)
@@ -554,27 +563,6 @@ export const backupRouter = createTRPCRouter({
}
}),
listBackupFilesByBackupId: withPermission("backup", "read")
.input(z.object({ backupId: z.string() }))
.query(({ input, ctx }) => listBackupFilesByBackupId(ctx, input.backupId)),
getDownloadUrl: withPermission("backup", "read")
.input(z.object({ backupId: z.string(), fileName: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const result = await getBackupDownloadUrl(
ctx,
input.backupId,
input.fileName,
);
await audit(ctx, {
action: "download",
resourceType: "backup",
resourceId: input.backupId,
metadata: { file: input.fileName },
});
return result;
}),
restoreBackupWithLogs: protectedProcedure
.meta({
openapi: {
@@ -1,5 +1,6 @@
import { db } from "@dokploy/server/db";
import {
deleteOrganization,
hasValidLicense,
IS_CLOUD,
sendInvitationEmail,
@@ -285,9 +286,7 @@ export const organizationRouter = createTRPCRouter({
});
}
const result = await db
.delete(organization)
.where(eq(organization.id, input.organizationId));
const result = await deleteOrganization(input.organizationId);
await audit(ctx, {
action: "delete",
@@ -31,7 +31,6 @@ export const auditLogRouter = createTRPCRouter({
"redeploy",
"login",
"logout",
"download",
])
.optional(),
resourceType: z
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
});
+40 -6
View File
@@ -12,6 +12,7 @@ import {
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
removeServerVectorAgent,
serverAudit,
serverSetup,
serverValidate,
@@ -19,7 +20,10 @@ import {
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { findMemberByUserId } from "@dokploy/server/services/permission";
import {
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
@@ -51,6 +55,22 @@ import {
} from "@/server/db/schema";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
// Users without server.read only get what the server pickers render
const toServerOptions = async (
ctx: Parameters<typeof hasPermission>[0],
servers: (typeof server.$inferSelect)[],
) => {
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
if (await hasPermission(ctx, { server: ["read"] })) {
return servers;
}
return servers.map(({ serverId, name, ipAddress }) => ({
serverId,
name,
ipAddress,
}));
};
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
.input(apiCreateServer)
@@ -216,7 +236,7 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0;
}),
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -234,9 +254,12 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "deploy"),
),
});
return result.filter((s) => accessibleIds.has(s.serverId));
return toServerOptions(
ctx,
result.filter((s) => accessibleIds.has(s.serverId)),
);
}),
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
buildServers: protectedProcedure.query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
@@ -254,7 +277,10 @@ export const serverRouter = createTRPCRouter({
eq(server.serverType, "build"),
),
});
return result.filter((s) => accessibleIds.has(s.serverId));
return toServerOptions(
ctx,
result.filter((s) => accessibleIds.has(s.serverId)),
);
}),
setup: withPermission("server", "create")
.input(apiFindOneServer)
@@ -473,11 +499,16 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
const vectorAgentRemovalWarning =
await removeServerVectorAgent(currentServer);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: currentServer.serverId,
resourceName: currentServer.name,
...(vectorAgentRemovalWarning
? { metadata: { vectorAgentRemovalWarning } }
: {}),
});
await removeDeploymentsByServerId(currentServer);
await deleteServer(input.serverId);
@@ -488,7 +519,10 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return redactServerSshKey(currentServer);
return {
...redactServerSshKey(currentServer),
vectorAgentRemovalWarning,
};
} catch (error) {
throw error;
}
+43 -2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import {
CLEANUP_CRON_JOB,
checkGPUStatus,
@@ -81,6 +82,32 @@ import {
publicProcedure,
} from "../trpc";
/**
* Confines a caller-supplied Traefik file path to the Traefik configuration
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
* expose files listed under this directory, but the raw path was passed
* straight to the filesystem helpers, letting a user with the `traefikFiles`
* permission read or overwrite arbitrary files on the host (for example
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
* `../` traversal. Reject anything that escapes the directory.
*/
const resolveTraefikFilePath = (
filePath: string,
serverId?: string | null,
): string => {
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
const base = path.resolve(MAIN_TRAEFIK_PATH);
const resolved = path.resolve(base, filePath);
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Invalid path: file must be inside the Traefik configuration directory",
});
}
return resolved;
};
export const settingsRouter = createTRPCRouter({
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
await checkPermission(ctx, { traefikFiles: ["read"] });
if (input?.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
await checkPermission(ctx, { traefikFiles: ["write"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
await writeTraefikConfigInPath(
input.path,
safePath,
input.traefikConfig,
input?.serverId,
);
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
}
}
return readConfigInPath(input.path, input.serverId);
const safePath = resolveTraefikFilePath(input.path, input.serverId);
return readConfigInPath(safePath, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
@@ -0,0 +1,188 @@
import {
applyVectorAgentSelection,
createTelemetryProvider,
findTelemetryProviderForOrganization,
findTelemetryProvidersByOrganization,
getVectorAgentTargets,
reconcileVectorTargets,
removeTelemetryProvider,
sanitizeTelemetryProvider,
telemetryProviderAdapters,
testTelemetryProviderConnection,
updateTelemetryProvider,
} from "@dokploy/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateTelemetryProvider,
apiFindOneTelemetryProvider,
apiRemoveTelemetryProvider,
apiTestTelemetryProvider,
apiUpdateTelemetryProvider,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const telemetryProviderRouter = createTRPCRouter({
create: withPermission("telemetryProvider", "create")
.input(apiCreateTelemetryProvider)
.mutation(async ({ ctx, input }) => {
const provider = await createTelemetryProvider(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "telemetryProvider",
resourceId: provider.telemetryProviderId,
resourceName: provider.name,
});
return sanitizeTelemetryProvider(provider);
}),
update: withPermission("telemetryProvider", "create")
.input(apiUpdateTelemetryProvider)
.mutation(async ({ ctx, input }) => {
const { telemetryProviderId, ...rest } = input;
const provider = await findTelemetryProviderForOrganization(
telemetryProviderId,
ctx.session.activeOrganizationId,
);
const { provider: updated, targets } = await updateTelemetryProvider(
telemetryProviderId,
rest,
);
await audit(ctx, {
action: "update",
resourceType: "telemetryProvider",
resourceId: telemetryProviderId,
resourceName: provider.name,
});
const warning = await reconcileVectorTargets(targets);
return {
...sanitizeTelemetryProvider(updated),
warning,
reapplied: targets.length > 0,
};
}),
remove: withPermission("telemetryProvider", "delete")
.input(apiRemoveTelemetryProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findTelemetryProviderForOrganization(
input.telemetryProviderId,
ctx.session.activeOrganizationId,
);
const { provider: removed, targets } = await removeTelemetryProvider(
input.telemetryProviderId,
);
await audit(ctx, {
action: "delete",
resourceType: "telemetryProvider",
resourceId: provider.telemetryProviderId,
resourceName: provider.name,
});
const warning = await reconcileVectorTargets(targets);
return { ...sanitizeTelemetryProvider(removed), warning };
}),
all: withPermission("telemetryProvider", "read").query(async ({ ctx }) => {
return await findTelemetryProvidersByOrganization(
ctx.session.activeOrganizationId,
);
}),
one: withPermission("telemetryProvider", "read")
.input(apiFindOneTelemetryProvider)
.query(async ({ ctx, input }) => {
return await findTelemetryProviderForOrganization(
input.telemetryProviderId,
ctx.session.activeOrganizationId,
);
}),
testConnection: withPermission("telemetryProvider", "create")
.input(apiTestTelemetryProvider)
.mutation(async ({ input }) => {
return await testTelemetryProviderConnection({
providerType: input.providerType,
config: {
telemetryProviderId: "test",
name: input.name ?? "test",
signals: input.signals,
endpoint: input.endpoint ?? null,
apiKey: input.apiKey ?? null,
apiSecret: input.apiSecret ?? null,
extraConfig: input.extraConfig ?? null,
},
});
}),
testConnectionById: withPermission("telemetryProvider", "create")
.input(
apiFindOneTelemetryProvider.extend({
signals: z
.array(z.enum(["logs", "metrics"]))
.min(1)
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await findTelemetryProviderForOrganization(
input.telemetryProviderId,
ctx.session.activeOrganizationId,
);
return await testTelemetryProviderConnection({
telemetryProviderId: input.telemetryProviderId,
signals: input.signals,
});
}),
serverStatus: withPermission("telemetryProvider", "read").query(({ ctx }) =>
getVectorAgentTargets(ctx.session),
),
deployOnServer: withPermission("telemetryProvider", "create")
.input(
z.object({
serverId: z.string().min(1).nullable().optional(),
providerIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const { serverName } = await applyVectorAgentSelection(
ctx.session,
input.serverId ?? undefined,
input.providerIds,
);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: serverName,
metadata: { providerIds: input.providerIds },
});
return { installed: true };
}),
removeOnServer: withPermission("telemetryProvider", "create")
.input(
z.object({
serverId: z.string().min(1).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { serverName } = await applyVectorAgentSelection(
ctx.session,
input.serverId ?? undefined,
[],
);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: serverName,
metadata: { providerIds: [] },
});
return { installed: false };
}),
availableTypes: protectedProcedure.query(() => {
return Object.values(telemetryProviderAdapters).map((adapter) => ({
type: adapter.type,
label: adapter.label,
docsUrl: adapter.docsUrl,
credentialFields: adapter.credentialFields,
signals: adapter.signals,
}));
}),
});
+28 -3
View File
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/index";
import type { statements } from "@dokploy/server/lib/access-control";
import { validateRequest } from "@dokploy/server/lib/auth";
import { checkPermission } from "@dokploy/server/services/permission";
import {
checkPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({
req,
res,
// @ts-ignore
// @ts-expect-error
session: session
? {
...session,
activeOrganizationId: session.activeOrganizationId || "",
}
: null,
// @ts-ignore
// @ts-expect-error
user: user
? {
...user,
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
await checkPermission(ctx, { [resource]: [action] } as any);
return next();
});
export const withAnyPermission = <R extends Resource>(
resource: R,
actions: ActionOf<R>[],
) =>
protectedProcedure.use(async ({ ctx, next }) => {
const permissions = await resolvePermissions(ctx);
const resourcePerms = permissions[resource];
if (resourcePerms) {
for (const action of actions) {
if ((resourcePerms as Record<string, boolean>)[action as string]) {
return next();
}
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
});
+2 -1
View File
@@ -52,7 +52,8 @@ const links =
transformer: superjson,
}),
false: splitLink({
condition: (op) => op.input instanceof FormData,
condition: (op) =>
op.input instanceof FormData || op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/mauriciogm/dokploy/apps/monitoring
go 1.20
require (
github.com/gofiber/fiber/v2 v2.52.11
github.com/gofiber/fiber/v2 v2.52.14
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/robfig/cron/v3 v3.0.1
+2 -2
View File
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+3 -3
View File
@@ -9,12 +9,12 @@
},
"dependencies": {
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/node-server": "^1.19.15",
"@hono/zod-validator": "0.7.6",
"bullmq": "5.67.3",
"dotenv": "^16.4.5",
"drizzle-orm": "0.45.1",
"hono": "^4.11.7",
"drizzle-orm": "0.45.2",
"hono": "^4.13.5",
"ioredis": "5.4.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
+23 -3
View File
@@ -26,7 +26,7 @@
"@biomejs/biome": "2.5.7",
"@types/node": "^24.4.0",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"lint-staged": "^15.5.2",
"tsx": "4.22.4"
},
@@ -46,11 +46,31 @@
},
"pnpm": {
"overrides": {
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"better-call": "1.3.7",
"@better-fetch/fetch": "1.3.1",
"tar": ">=7.5.19 <8.0.0",
"protobufjs": ">=7.5.5 <8.0.0"
"protobufjs": ">=7.5.5 <8.0.0",
"js-yaml": ">=4.3.2 <5.0.0",
"mysql2": ">=3.23.1 <4.0.0",
"deepmerge-ts": ">=8.0.0 <9.0.0",
"@ai-sdk/provider-utils": ">=4.0.33 <5.0.0",
"hono": ">=4.13.5 <5.0.0",
"@hono/node-server": ">=1.19.15 <2.0.0",
"lodash": "4.18.1",
"postcss": ">=8.5.23 <9.0.0",
"effect": ">=3.20.0 <4.0.0",
"h3": ">=1.15.9 <2.0.0",
"tar-fs": ">=2.1.4 <3.0.0",
"uuid": ">=11.1.1 <12.0.0",
"valibot": ">=1.4.2 <2.0.0",
"proxy-addr": ">=2.0.8 <3.0.0",
"axios": ">=1.20.0 <2.0.0",
"source-map-js": ">=1.2.2 <2.0.0",
"ip-address": ">=10.7.1 <11.0.0",
"sharp": ">=0.35.5 <0.36.0",
"@modelcontextprotocol/sdk": ">=1.31.0 <2.0.0",
"fast-copy": ">=3.1.0 <4.0.0"
},
"peerDependencyRules": {
"ignoreMissing": [
+13 -13
View File
@@ -37,6 +37,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch": "^3.1108.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
@@ -45,14 +47,14 @@
"@better-auth/scim": "1.6.23",
"@better-auth/sso": "1.6.23",
"@better-auth/utils": "0.4.2",
"@faker-js/faker": "^8.4.1",
"@faker-js/faker": "^10.5.0",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^1.0.12",
"@trpc/server": "11.10.0",
"adm-zip": "^0.5.16",
"adm-zip": "^0.6.1",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
@@ -65,13 +67,13 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"lodash": "4.18.1",
"micromatch": "4.0.8",
"nanoid": "3.3.18",
"node-os-utils": "2.0.1",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"nodemailer": "10.0.15",
"octokit": "3.1.2",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
@@ -82,34 +84,32 @@
"react-dom": "19.2.7",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.4",
"shell-quote": "^1.11.0",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"toml": "3.0.0",
"ws": "8.16.0",
"yaml": "2.8.1",
"toml": "4.2.0",
"undici": "^6.21.3",
"ws": "8.21.0",
"yaml": "2.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^24.4.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild": "0.25.0",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",
"postcss": "^8.5.23",
"rimraf": "6.1.3",
"tailwindcss": "^3.4.17",
"tsc-alias": "1.8.10",
+1
View File
@@ -134,6 +134,7 @@ export const paths = (isServer = false) => {
CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`,
VECTOR_PATH: `${BASE_PATH}/vector`,
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
+3 -2
View File
@@ -54,7 +54,6 @@ export type AuditAction =
| "login"
| "logout"
| "restore"
| "download"
| "run"
| "start"
| "stop"
@@ -95,4 +94,6 @@ export type AuditResourceType =
| "compose"
| "network"
| "vaultProvider"
| "dnsProvider";
| "dnsProvider"
| "telemetryProvider"
| "ai";
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
applicationKey: z.string().trim().min(1),
applicationSecret: z.string().trim().min(1),
consumerKey: z.string().trim().min(1),
zone: z.string().trim().min(1).optional(),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
+1
View File
@@ -41,6 +41,7 @@ export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./telemetry-provider";
export * from "./transfer";
export * from "./user";
export * from "./utils";
+5 -1
View File
@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -42,6 +42,10 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")
@@ -0,0 +1,90 @@
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { encryptedText } from "./utils";
export const telemetryProviderType = pgEnum("TelemetryProviderType", [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
"prometheus_remote_write",
"new_relic",
"influxdb",
]);
export const telemetryProvider = pgTable("telemetryProvider", {
telemetryProviderId: text("telemetryProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: telemetryProviderType("providerType").notNull(),
signals: text("signals").array().notNull(),
endpoint: encryptedText("endpoint"),
apiKey: encryptedText("apiKey"),
apiSecret: encryptedText("apiSecret"),
extraConfig: jsonb("extraConfig").$type<Record<string, unknown>>(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
});
const createSchema = createInsertSchema(telemetryProvider, {
telemetryProviderId: z.string().min(1),
name: z.string().min(1),
providerType: z.enum(telemetryProviderType.enumValues),
signals: z.array(z.enum(["logs", "metrics"])).min(1),
endpoint: z
.string()
.min(1)
.refine((value) => !/\s/.test(value), {
message: "Endpoint cannot contain whitespace",
})
.nullable()
.optional(),
apiKey: z.string().min(1).nullable().optional(),
apiSecret: z.string().min(1).nullable().optional(),
extraConfig: z.record(z.string(), z.unknown()).nullable().optional(),
enabled: z.boolean().optional(),
organizationId: z.string().min(1),
});
export const apiCreateTelemetryProvider = createSchema
.pick({
name: true,
providerType: true,
signals: true,
endpoint: true,
apiKey: true,
apiSecret: true,
extraConfig: true,
enabled: true,
})
.required({ name: true, providerType: true, signals: true });
export const apiUpdateTelemetryProvider = apiCreateTelemetryProvider
.partial()
.extend({
telemetryProviderId: z.string().min(1),
});
export const apiRemoveTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiFindOneTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiTestTelemetryProvider = apiCreateTelemetryProvider.partial({
name: true,
});
@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -10,6 +10,7 @@ import {
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { certificateType } from "./shared";
export const webServerSettings = pgTable("webServerSettings", {
@@ -26,6 +27,14 @@ export const webServerSettings = pgTable("webServerSettings", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
vectorAgentOrganizationId: text("vectorAgentOrganizationId").references(
() => organization.id,
{ onDelete: "set null" },
),
// Metrics Configuration
metricsConfig: jsonb("metricsConfig")
.$type<{
@@ -88,6 +97,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: string | null;
footerText: string | null;
ogImageUrl: string | null;
logoLightUrl: string | null;
loginLogoLightUrl: string | null;
faviconLightUrl: string | null;
dualModeBranding: boolean;
}>()
.default({
appName: null,
@@ -102,6 +115,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: null,
footerText: null,
ogImageUrl: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
@@ -132,46 +149,52 @@ const createSchema = createInsertSchema(webServerSettings, {
id: z.string().min(1),
});
export const apiUpdateWebServerSettings = createSchema.partial().extend({
serverIp: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
https: z.boolean().optional(),
host: z.string().optional(),
letsEncryptEmail: z.string().email().optional().nullable(),
sshPrivateKey: z.string().optional(),
enableDockerCleanup: z.boolean().optional(),
logCleanupCron: z.string().optional().nullable(),
metricsConfig: z
.object({
server: z.object({
type: z.enum(["Dokploy", "Remote"]),
refreshRate: z.number(),
port: z.number(),
token: z.string(),
urlCallback: z.string(),
retentionDays: z.number(),
cronJob: z.string(),
thresholds: z.object({
cpu: z.number(),
memory: z.number(),
export const apiUpdateWebServerSettings = createSchema
.partial()
.extend({
serverIp: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
https: z.boolean().optional(),
host: z.string().optional(),
letsEncryptEmail: z.string().email().optional().nullable(),
sshPrivateKey: z.string().optional(),
enableDockerCleanup: z.boolean().optional(),
logCleanupCron: z.string().optional().nullable(),
metricsConfig: z
.object({
server: z.object({
type: z.enum(["Dokploy", "Remote"]),
refreshRate: z.number(),
port: z.number(),
token: z.string(),
urlCallback: z.string(),
retentionDays: z.number(),
cronJob: z.string(),
thresholds: z.object({
cpu: z.number(),
memory: z.number(),
}),
}),
}),
containers: z.object({
refreshRate: z.number(),
services: z.object({
include: z.array(z.string()),
exclude: z.array(z.string()),
containers: z.object({
refreshRate: z.number(),
services: z.object({
include: z.array(z.string()),
exclude: z.array(z.string()),
}),
}),
}),
})
.optional(),
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
remoteServersOnly: z.boolean().optional(),
enforceSSO: z.boolean().optional(),
buildsConcurrency: z.number().int().min(1).max(100).optional(),
});
})
.optional(),
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
remoteServersOnly: z.boolean().optional(),
enforceSSO: z.boolean().optional(),
buildsConcurrency: z.number().int().min(1).max(100).optional(),
})
.omit({
telemetryProviderIds: true,
vectorAgentOrganizationId: true,
});
export const apiUpdateWebServerBuildsConcurrency = z.object({
buildsConcurrency: z.number().int().min(1).max(100),
@@ -225,6 +248,10 @@ export const whitelabelingConfigSchema = z.object({
errorPageDescription: z.string().nullable(),
footerText: z.string().nullable(),
ogImageUrl: safeUrl,
logoLightUrl: safeUrl,
loginLogoLightUrl: safeUrl,
faviconLightUrl: safeUrl,
dualModeBranding: z.boolean(),
});
export const apiUpdateWhitelabeling = z.object({
+8 -8
View File
@@ -476,8 +476,8 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
chokidar@4.0.3:
@@ -773,8 +773,8 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
strip-bom@3.0.0:
@@ -1166,7 +1166,7 @@ snapshots:
base64id@2.0.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -1200,7 +1200,7 @@ snapshots:
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
source-map-js: 1.2.2
csstype@3.2.3: {}
@@ -1361,7 +1361,7 @@ snapshots:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
brace-expansion: 5.0.12
minimist@1.2.8: {}
@@ -1490,7 +1490,7 @@ snapshots:
- supports-color
- utf-8-validate
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
strip-bom@3.0.0: {}
+7 -1
View File
@@ -28,12 +28,18 @@ export * from "./services/gitea";
export * from "./services/github";
export * from "./services/gitlab";
export * from "./services/libsql";
export * from "./services/logs-and-metrics/provider-fetch";
export * from "./services/logs-and-metrics/providers/registry";
export * from "./services/logs-and-metrics/service";
export * from "./services/logs-and-metrics/types";
export * from "./services/logs-and-metrics/vector-agent";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/organization";
export * from "./services/overview";
export * from "./services/patch";
export * from "./services/patch-repo";
@@ -69,6 +75,7 @@ export * from "./setup/server-setup";
export * from "./setup/server-validate";
export * from "./setup/setup";
export * from "./setup/traefik-setup";
export * from "./setup/vector-setup";
export * from "./templates/processors";
export * from "./utils/access-log/handler";
export {
@@ -79,7 +86,6 @@ export {
export * from "./utils/access-log/types";
export * from "./utils/access-log/utils";
export * from "./utils/backups/compose";
export * from "./utils/backups/files";
export * from "./utils/backups/index";
export * from "./utils/backups/libsql";
export * from "./utils/backups/mariadb";
+10 -1
View File
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
*/
export const statements = {
// better-auth organization plugin defaults
@@ -28,6 +28,7 @@ export const statements = {
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
telemetryProvider: ["read", "create", "delete"],
// Enterprise-only resources (custom roles only)
volume: ["read", "create", "delete"],
@@ -50,6 +51,7 @@ export const statements = {
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
ai: ["read", "create", "update", "delete"],
} as const;
/**
@@ -78,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
]);
export const ac = createAccessControl(statements);
@@ -119,6 +122,8 @@ export const ownerRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -158,6 +163,8 @@ export const adminRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -198,9 +205,11 @@ export const memberRole = ac.newRole({
certificate: [],
destination: [],
notification: [],
telemetryProvider: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
dnsProvider: [],
ai: [],
});
@@ -8,6 +8,7 @@ import {
user,
verification,
} from "@dokploy/server/db/schema";
import { removeVectorAgents } from "@dokploy/server/services/organization";
import { TRPCError } from "@trpc/server";
import { eq, inArray } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -128,6 +129,15 @@ export const consumeAccountDeletionCode = async (
};
export const deleteUserAccountData = async (userId: string) => {
// The owned organizations go away by FK cascade, which skips deleteOrganization's agent cleanup.
const ownedOrganizations = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
columns: { id: true },
});
for (const org of ownedOrganizations) {
await removeVectorAgents(org.id);
}
return db.transaction(async (tx) => {
const target = await tx.query.user.findFirst({
where: eq(user.id, userId),
+60 -11
View File
@@ -4,12 +4,22 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
export const AI_SECRET_MASK = "********";
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
...settings,
apiKey: AI_SECRET_MASK,
});
export const mergeAiApiKey = (incoming: string, existing: string): string =>
incoming === AI_SECRET_MASK ? existing : incoming;
interface SuggestionItem {
id: string;
name: string;
@@ -36,9 +46,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
return aiSettings;
};
export const getAiSettingById = async (aiId: string) => {
export const getAiSettingById = async (
aiId: string,
organizationId: string,
) => {
const aiSetting = await db.query.ai.findFirst({
where: eq(ai.aiId, aiId),
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
});
if (!aiSetting) {
throw new TRPCError({
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
export const resolveAiApiKey = async (opts: {
apiKey: string;
apiUrl: string;
aiId: string | undefined;
organizationId: string;
authorize: () => Promise<boolean>;
}): Promise<string> => {
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
if (!(await opts.authorize())) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API URL does not match the stored configuration",
});
}
return stored.apiKey;
};
export const saveAiSettings = async (
organizationId: string,
settings: any,
checkExistence = true,
) => {
const aiId = settings.aiId;
if (aiId && checkExistence) {
await getAiSettingById(aiId, organizationId);
}
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
@@ -132,8 +177,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => {
});
};
export const deleteAiSettings = async (aiId: string) => {
return db.delete(ai).where(eq(ai.aiId, aiId));
export const deleteAiSettings = async (
aiId: string,
organizationId: string,
) => {
return db
.delete(ai)
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
};
interface Props {
@@ -144,14 +194,14 @@ interface Props {
}
export const suggestVariants = async ({
organizationId: _organizationId,
organizationId,
aiId,
input,
serverId,
}: Props) => {
try {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
const aiSettings = await getAiSettingById(aiId, organizationId);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled for this configuration",
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
const result = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: fullSchema }),
prompt: `
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
@@ -0,0 +1,108 @@
import { type LookupAddress, lookup } from "node:dns";
import { BlockList, isIP, type LookupFunction } from "node:net";
import { IS_CLOUD } from "@dokploy/server/constants";
import { Agent } from "undici";
export const PROVIDER_REQUEST_TIMEOUT_MS = 15_000;
const metadataAddresses = new BlockList();
metadataAddresses.addSubnet("169.254.0.0", 16, "ipv4");
metadataAddresses.addSubnet("fe80::", 10, "ipv6");
metadataAddresses.addAddress("fd00:ec2::254", "ipv6");
const privateAddresses = new BlockList();
privateAddresses.addSubnet("0.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("100.64.0.0", 10, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addAddress("::", "ipv6");
privateAddresses.addAddress("::1", "ipv6");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
const isBlockedAddress = (address: string): boolean => {
const ip = address.replace(/^\[|\]$/g, "");
const family = isIP(ip);
if (family === 0) {
return IS_CLOUD && ip.toLowerCase() === "localhost";
}
const type = family === 4 ? "ipv4" : "ipv6";
return (
metadataAddresses.check(ip, type) ||
(IS_CLOUD && privateAddresses.check(ip, type))
);
};
const METADATA_HOSTNAMES = new Set([
"metadata.google.internal",
"metadata.goog",
]);
class BlockedEndpointError extends Error {
constructor() {
super(
"This endpoint resolves to a blocked address (cloud metadata or private network) and can't be used here.",
);
}
}
const assertNotBlockedEndpoint = (rawUrl: string): void => {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return;
}
if (
METADATA_HOSTNAMES.has(url.hostname.toLowerCase()) ||
isBlockedAddress(url.hostname)
) {
throw new BlockedEndpointError();
}
};
// Checks the addresses the socket actually connects to; a separate pre-flight lookup could be answered differently (DNS rebinding).
export const guardedLookup: LookupFunction = (hostname, options, callback) => {
lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) {
callback(error, "");
return;
}
const list = addresses as LookupAddress[];
const first = list[0];
if (!first) {
callback(new Error(`No addresses found for ${hostname}`), "");
return;
}
if (list.some((entry) => isBlockedAddress(entry.address))) {
callback(new BlockedEndpointError(), "");
return;
}
if (options.all) {
callback(null, list);
} else {
callback(null, first.address, first.family);
}
});
};
const guardedAgent = new Agent({ connect: { lookup: guardedLookup } });
export const providerFetch = async (url: string, init: RequestInit = {}) => {
assertNotBlockedEndpoint(url);
try {
return await fetch(url, {
...init,
redirect: "error",
signal: AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS),
// @ts-expect-error Node's fetch accepts an undici dispatcher, but lib.dom's RequestInit doesn't declare it.
dispatcher: guardedAgent,
});
} catch (error) {
if (error instanceof Error && error.cause instanceof BlockedEndpointError) {
throw error.cause;
}
throw error;
}
};
@@ -0,0 +1,227 @@
import {
CloudWatchClient,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import {
CloudWatchLogsClient,
DescribeLogGroupsCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import { PROVIDER_REQUEST_TIMEOUT_MS } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, stringValue, vrlString } from "../types";
const DEFAULT_STREAM_TEMPLATE = "{{ container_name }}";
export const CLOUDWATCH_METRICS_ALLOWLIST = [
"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",
];
const resolveMetricsNamespace = (config: TelemetryProviderRuntimeConfig) => {
const namespace = stringValue(config.extraConfig?.metricsNamespace);
if (namespace.startsWith("AWS/")) {
throw new Error("The metrics namespace cannot start with AWS/");
}
return namespace;
};
const credentials = (config: TelemetryProviderRuntimeConfig) => ({
accessKeyId: config.apiKey ?? "",
secretAccessKey: config.apiSecret ?? "",
});
export const awsCloudwatchAdapter: TelemetryProviderAdapter = {
type: "aws_cloudwatch",
signals: ["logs", "metrics"],
label: "AWS CloudWatch",
docsUrl:
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html",
credentialFields: [
{
key: "apiKey",
label: "Access Key ID",
type: "text",
required: true,
},
{
key: "apiSecret",
label: "Secret Access Key",
type: "password",
required: true,
},
{
key: "region",
label: "Region",
type: "text",
required: true,
placeholder: "us-east-1",
},
{
key: "logGroup",
label: "Log Group Name",
type: "text",
required: true,
signal: "logs",
placeholder: "/dokploy/logs",
helpText: "Created automatically on first write if it doesn't exist.",
fullWidth: true,
},
{
key: "logStream",
label: "Log Stream Name Template",
type: "text",
required: false,
signal: "logs",
placeholder: DEFAULT_STREAM_TEMPLATE,
helpText:
"Vector event template. Defaults to one stream per container name if left blank.",
fullWidth: true,
},
{
key: "metricsNamespace",
label: "Metrics namespace",
type: "text",
required: true,
signal: "metrics",
placeholder: "Dokploy",
helpText:
"E.g. Dokploy. Cannot start with AWS/. The IAM user needs cloudwatch:PutMetricData; testing the connection writes a real data point.",
fullWidth: true,
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
if (config.signals.includes("metrics")) {
resolveMetricsNamespace(config);
}
},
toVectorTransform(
config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "logs") return null;
return {
type: "remap",
inputs: [scopeTransformId],
drop_on_abort: true,
source: [
`if !includes(${JSON.stringify(CLOUDWATCH_METRICS_ALLOWLIST)}, .name) { abort }`,
`.namespace = ${vrlString(resolveMetricsNamespace(config))}`,
].join("\n"),
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
const region = stringValue(config.extraConfig?.region);
if (signal === "metrics") {
return {
type: "aws_cloudwatch_metrics",
inputs: [inputId],
default_namespace: resolveMetricsNamespace(config),
region,
auth: {
access_key_id: config.apiKey ?? "",
secret_access_key: config.apiSecret ?? "",
},
buffer: DEFAULT_DISK_BUFFER,
};
}
const logGroup = config.extraConfig?.logGroup;
const logStream = config.extraConfig?.logStream;
return {
type: "aws_cloudwatch_logs",
inputs: [inputId],
group_name: typeof logGroup === "string" ? logGroup : "",
stream_name:
typeof logStream === "string" && logStream.length > 0
? logStream
: DEFAULT_STREAM_TEMPLATE,
region,
encoding: { codec: "json" },
auth: {
access_key_id: config.apiKey ?? "",
secret_access_key: config.apiSecret ?? "",
},
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const region = stringValue(config.extraConfig?.region);
if (!config.apiKey || !config.apiSecret || !region) {
throw new Error("AWS access key, secret key and region are required");
}
const abortSignal = AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS);
if (config.signals.includes("logs")) {
const logGroup = stringValue(config.extraConfig?.logGroup);
if (!logGroup) {
throw new Error("AWS log group is required");
}
const client = new CloudWatchLogsClient({
region,
credentials: credentials(config),
});
await client.send(
new DescribeLogGroupsCommand({
logGroupNamePrefix: logGroup,
limit: 1,
}),
{ abortSignal },
);
}
if (config.signals.includes("metrics")) {
const namespace = resolveMetricsNamespace(config);
if (!namespace) {
throw new Error("AWS metrics namespace is required");
}
const client = new CloudWatchClient({
region,
credentials: credentials(config),
});
try {
await client.send(
new PutMetricDataCommand({
Namespace: namespace,
MetricData: [
{
MetricName: "dokploy_connection_test",
Value: 1,
Dimensions: [{ Name: "dokploy_test", Value: "true" }],
},
],
}),
{ abortSignal },
);
} catch (error) {
if ((error as { name?: string })?.name === "AccessDenied") {
throw new Error(
"The IAM user needs cloudwatch:PutMetricData to ship metrics",
);
}
throw error;
}
}
},
};
@@ -0,0 +1,107 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
const baseUri = (config: TelemetryProviderRuntimeConfig) =>
normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "");
export const betterStackAdapter: TelemetryProviderAdapter = {
type: "betterstack",
signals: ["logs", "metrics"],
label: "Better Stack",
docsUrl: "https://betterstack.com/docs/logs/vector/",
credentialFields: [
{
key: "apiKey",
label: "Source Token",
type: "password",
required: true,
helpText:
"Bearer token from your Better Stack source. Testing the connection sends a real test log and/or metric to your account. Not every source accepts metrics; if yours does not, create a second provider for metrics with a source that does.",
fullWidth: true,
},
{
key: "endpoint",
label: "Ingesting Host",
type: "url",
required: true,
placeholder: "in.logs.betterstack.com",
helpText:
"Shown next to the Source Token when you create the source. Paste it as-is, no https:// needed.",
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: ".dt = del(.timestamp)",
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
return {
type: "http",
inputs: [inputId],
method: "post",
uri: `${baseUri(config)}/${signal === "metrics" ? "metrics" : ""}`,
encoding: { codec: "json" },
compression: "gzip",
auth: { strategy: "bearer", token: config.apiKey ?? "" },
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error(
"Better Stack ingesting host and source token are required",
);
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
};
if (config.signals.includes("logs")) {
const response = await providerFetch(`${baseUri(config)}/`, {
method: "POST",
headers,
body: JSON.stringify({
message: "dokploy-connection-test",
dokploy_test: true,
}),
});
if (!response.ok) {
throw new Error(
`Better Stack test event failed with status ${response.status}`,
);
}
}
if (config.signals.includes("metrics")) {
const response = await providerFetch(`${baseUri(config)}/metrics`, {
method: "POST",
headers,
body: JSON.stringify([{ name: "dokploy_test", gauge: { value: 1 } }]),
});
if (!response.ok) {
throw new Error(
`Better Stack test metric failed with status ${response.status} — this source may not accept metrics`,
);
}
}
},
};
@@ -0,0 +1,97 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER } from "../types";
const DDTAGS_VRL = `
tags = []
if .dokploy_project != "" { tags = push(tags, "dokploy_project:" + replace(to_string!(.dokploy_project), ",", "_")) }
if .dokploy_environment != "" { tags = push(tags, "dokploy_environment:" + replace(to_string!(.dokploy_environment), ",", "_")) }
if .dokploy_application != "" { tags = push(tags, "dokploy_application:" + replace(to_string!(.dokploy_application), ",", "_")) }
if .dokploy_organization != "" { tags = push(tags, "dokploy_organization:" + replace(to_string!(.dokploy_organization), ",", "_")) }
if .dokploy_server != "" { tags = push(tags, "dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")) }
.ddtags = join!(tags, ",")
`.trim();
const resolveSite = (config: TelemetryProviderRuntimeConfig): string => {
const raw = config.extraConfig?.site;
if (typeof raw !== "string" || raw.length === 0) return "datadoghq.com";
return raw
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");
};
export const datadogAdapter: TelemetryProviderAdapter = {
type: "datadog",
signals: ["logs", "metrics"],
label: "Datadog",
docsUrl: "https://docs.datadoghq.com/logs/",
credentialFields: [
{
key: "apiKey",
label: "API Key",
type: "password",
required: true,
fullWidth: true,
},
{
key: "site",
label: "Site",
type: "text",
required: false,
placeholder: "datadoghq.com",
helpText: "E.g. datadoghq.com (US1) or datadoghq.eu (EU).",
fullWidth: true,
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: DDTAGS_VRL,
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
return {
type: signal === "metrics" ? "datadog_metrics" : "datadog_logs",
inputs: [inputId],
default_api_key: config.apiKey ?? "",
site: resolveSite(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.apiKey) {
throw new Error("Datadog API key is required");
}
const site = resolveSite(config);
const response = await providerFetch(
`https://api.${site}/api/v1/validate`,
{
headers: { "DD-API-KEY": config.apiKey },
},
);
if (!response.ok) {
throw new Error(
`Datadog API key validation failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,185 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
const LABEL_TO_JSON_VRL = `
if exists(.label) { .label = encode_json(.label) }
`.trim();
const DEFAULT_METRICS_INDEX = "dokploy-metrics-%Y.%m.%d";
type AuthMode =
| { kind: "basic"; username: string; password: string }
| { kind: "apiKey"; apiKey: string }
| { kind: "none" };
const resolveAuthMode = (config: TelemetryProviderRuntimeConfig): AuthMode => {
const username = config.extraConfig?.username;
const hasUsername = typeof username === "string" && username.length > 0;
if (hasUsername && !config.apiKey) {
throw new Error(
"A Password / API Key is required when Username is set (leave both blank for no auth).",
);
}
if (hasUsername && config.apiKey) {
return {
kind: "basic",
username: username as string,
password: config.apiKey,
};
}
if (config.apiKey) {
return { kind: "apiKey", apiKey: config.apiKey };
}
return { kind: "none" };
};
const buildAuthAndHeaders = (
config: TelemetryProviderRuntimeConfig,
): Record<string, unknown> => {
const mode = resolveAuthMode(config);
if (mode.kind === "basic") {
return {
auth: { strategy: "basic", user: mode.username, password: mode.password },
};
}
if (mode.kind === "apiKey") {
return {
request: { headers: { Authorization: `ApiKey ${mode.apiKey}` } },
};
}
return {};
};
export const elasticsearchAdapter: TelemetryProviderAdapter = {
type: "elasticsearch",
signals: ["logs", "metrics"],
label: "Elasticsearch / OpenSearch",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/elasticsearch/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://elasticsearch.example.com:9200",
},
{
key: "username",
label: "Username",
type: "text",
required: false,
helpText: "Leave blank to use an API key instead of basic auth.",
},
{
key: "apiKey",
label: "Password / API Key",
type: "password",
required: false,
helpText:
"Password for Username, or an API key alone. Leave both blank for no auth.",
fullWidth: true,
},
{
key: "index",
label: "Index",
type: "text",
required: false,
signal: "logs",
placeholder: "vector-%Y.%m.%d",
helpText: "Defaults to vector-%Y.%m.%d, a daily index.",
fullWidth: true,
},
{
key: "metricsIndex",
label: "Metrics index",
type: "text",
required: false,
signal: "metrics",
placeholder: DEFAULT_METRICS_INDEX,
helpText: `Defaults to ${DEFAULT_METRICS_INDEX}, a daily index. Keep it separate from the logs index.`,
fullWidth: true,
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: LABEL_TO_JSON_VRL,
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
const common = {
inputs: [inputId],
endpoints: [normalizeEndpointUrl(config.endpoint ?? "")],
api_version: "v8",
...buildAuthAndHeaders(config),
buffer: DEFAULT_DISK_BUFFER,
};
if (signal === "metrics") {
const metricsIndex = config.extraConfig?.metricsIndex;
return {
type: "elasticsearch",
...common,
bulk: {
index:
typeof metricsIndex === "string" && metricsIndex.length > 0
? metricsIndex
: DEFAULT_METRICS_INDEX,
},
};
}
const index = config.extraConfig?.index;
return {
type: "elasticsearch",
...common,
...(typeof index === "string" && index.length > 0
? { bulk: { index } }
: {}),
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint) {
throw new Error("Elasticsearch/OpenSearch endpoint is required");
}
const mode = resolveAuthMode(config);
const headers: Record<string, string> = {};
if (mode.kind === "basic") {
headers.Authorization = `Basic ${Buffer.from(
`${mode.username}:${mode.password}`,
).toString("base64")}`;
} else if (mode.kind === "apiKey") {
headers.Authorization = `ApiKey ${mode.apiKey}`;
}
const response = await providerFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/_cluster/health`,
{ headers },
);
if (!response.ok) {
throw new Error(
`Elasticsearch/OpenSearch health check failed with status ${response.status}`,
);
}
},
validateConfig(config: TelemetryProviderRuntimeConfig): void {
resolveAuthMode(config);
},
};
@@ -0,0 +1,102 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
normalizeEndpointUrl,
stringValue,
} from "../types";
export const influxdbAdapter: TelemetryProviderAdapter = {
type: "influxdb",
signals: ["metrics"],
label: "InfluxDB",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/influxdb_metrics/",
credentialFields: [
{
key: "endpoint",
label: "URL",
type: "url",
required: true,
placeholder: "http://influxdb.example.com:8086",
helpText: "http://host:8086 or your InfluxDB Cloud URL.",
fullWidth: true,
},
{
key: "org",
label: "Organization",
type: "text",
required: true,
helpText: "InfluxDB 3 ignores it but the API requires it.",
},
{
key: "bucket",
label: "Bucket",
type: "text",
required: true,
helpText: "Bucket (2.x) or database (3.x).",
},
{
key: "apiKey",
label: "API token",
type: "password",
required: true,
helpText:
"A token with write access to the bucket. Testing the connection writes a real data point.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
return {
type: "influxdb_metrics",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
org: stringValue(config.extraConfig?.org),
bucket: stringValue(config.extraConfig?.bucket),
token: config.apiKey ?? "",
default_namespace: "dokploy",
version: "2",
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const org = stringValue(config.extraConfig?.org);
const bucket = stringValue(config.extraConfig?.bucket);
if (!config.endpoint || !config.apiKey || !org || !bucket) {
throw new Error(
"InfluxDB URL, organization, bucket and token are required",
);
}
const base = normalizeEndpointUrl(config.endpoint).replace(/\/$/, "");
const query = new URLSearchParams({ org, bucket, precision: "s" });
const response = await providerFetch(`${base}/api/v2/write?${query}`, {
method: "POST",
headers: {
"Content-Type": "text/plain; charset=utf-8",
Authorization: `Token ${config.apiKey}`,
},
body: `dokploy_test,dokploy_test=true value=1 ${Math.floor(Date.now() / 1000)}`,
});
if (response.status === 401 || response.status === 403) {
throw new Error(
`InfluxDB rejected the token (status ${response.status})`,
);
}
if (response.status === 404) {
throw new Error("InfluxDB organization or bucket not found (status 404)");
}
if (!response.ok) {
throw new Error(
`InfluxDB write test failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,120 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
export const lokiAdapter: TelemetryProviderAdapter = {
type: "loki",
signals: ["logs"],
label: "Grafana Loki",
docsUrl: "https://grafana.com/docs/loki/latest/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://loki.example.com",
helpText:
"Base URL of your Loki instance (push API). Loki only takes logs; for Grafana Cloud metrics, add a Prometheus remote write provider with the Prometheus Instance ID.",
},
{
key: "username",
label: "User / Instance ID",
type: "text",
required: false,
helpText:
"Leave blank for an unauthenticated Loki. For Grafana Cloud, this is your numeric Instance ID.",
},
{
key: "apiKey",
label: "Password / API Token",
type: "password",
required: false,
helpText:
"Leave blank for an unauthenticated Loki. For Grafana Cloud, use an API token here.",
},
{
key: "tenantId",
label: "Tenant ID",
type: "text",
required: false,
helpText: "Only if your Loki runs in multi-tenant mode.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const tenantId = config.extraConfig?.tenantId;
const username = config.extraConfig?.username;
return {
type: "loki",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
encoding: { codec: "json" },
labels: {
dokploy_project: "{{ dokploy_project }}",
dokploy_environment: "{{ dokploy_environment }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
dokploy_server: "{{ dokploy_server }}",
},
...(typeof tenantId === "string" && tenantId.length > 0
? { tenant_id: tenantId }
: {}),
...(config.apiKey
? {
auth: {
strategy: "basic",
user: typeof username === "string" ? username : "",
password: config.apiKey,
},
}
: {}),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint) {
throw new Error("Loki endpoint is required");
}
const username = config.extraConfig?.username;
const tenantId = config.extraConfig?.tenantId;
const response = await providerFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/loki/api/v1/push`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(config.apiKey
? {
Authorization: `Basic ${Buffer.from(
`${typeof username === "string" ? username : ""}:${config.apiKey}`,
).toString("base64")}`,
}
: {}),
...(typeof tenantId === "string" && tenantId.length > 0
? { "X-Scope-OrgID": tenantId }
: {}),
},
body: JSON.stringify({
streams: [
{
stream: { dokploy_test: "true" },
values: [[`${Date.now()}000000`, "dokploy-connection-test"]],
},
],
}),
},
);
if (!response.ok) {
throw new Error(`Loki push test failed with status ${response.status}`);
}
},
};
@@ -0,0 +1,106 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER } from "../types";
const resolveRegion = (config: TelemetryProviderRuntimeConfig): "us" | "eu" => {
const raw = config.extraConfig?.region;
const region = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (region === "" || region === "us") return "us";
if (region === "eu") return "eu";
throw new Error('New Relic region must be "us" or "eu"');
};
export const newRelicAdapter: TelemetryProviderAdapter = {
type: "new_relic",
signals: ["metrics"],
label: "New Relic",
docsUrl:
"https://docs.newrelic.com/docs/data-apis/ingest-apis/metric-api/introduction-metric-api/",
credentialFields: [
{
key: "apiKey",
label: "License key",
type: "password",
required: true,
helpText:
"An INGEST - LICENSE key. Testing the connection sends a real data point.",
fullWidth: true,
},
{
key: "accountId",
label: "Account ID",
type: "text",
required: true,
placeholder: "1234567",
helpText: "The numeric account ID.",
},
{
key: "region",
label: "Region",
type: "text",
required: false,
placeholder: "us",
helpText: "us (default) or eu.",
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
resolveRegion(config);
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const accountId = config.extraConfig?.accountId;
return {
type: "new_relic",
inputs: [inputId],
api: "metrics",
license_key: config.apiKey ?? "",
account_id: typeof accountId === "string" ? accountId.trim() : "",
region: resolveRegion(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.apiKey) {
throw new Error("New Relic license key is required");
}
const host =
resolveRegion(config) === "eu"
? "metric-api.eu.newrelic.com"
: "metric-api.newrelic.com";
const response = await providerFetch(`https://${host}/metric/v1`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Api-Key": config.apiKey,
},
body: JSON.stringify([
{
metrics: [
{
name: "dokploy.test",
type: "gauge",
value: 1,
timestamp: Math.floor(Date.now() / 1000),
attributes: { dokploy_test: "true" },
},
],
},
]),
});
if (response.status === 403) {
throw new Error("New Relic rejected the license key (status 403)");
}
if (!response.ok) {
throw new Error(
`New Relic metric test failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,131 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, stringValue } from "../types";
const assertHttpUrl = (endpoint: string | null) => {
let url: URL;
try {
url = new URL(endpoint ?? "");
} catch {
throw new Error("Remote write URL must be a valid http(s) URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Remote write URL must be a valid http(s) URL");
}
return url;
};
const buildAuth = (config: TelemetryProviderRuntimeConfig) => {
const username = stringValue(config.extraConfig?.username);
if (!config.apiKey) return {};
return username
? { auth: { strategy: "basic", user: username, password: config.apiKey } }
: { auth: { strategy: "bearer", token: config.apiKey } };
};
const buildAuthHeader = (
config: TelemetryProviderRuntimeConfig,
): Record<string, string> => {
const username = stringValue(config.extraConfig?.username);
if (!config.apiKey) return {};
return username
? {
Authorization: `Basic ${Buffer.from(`${username}:${config.apiKey}`).toString("base64")}`,
}
: { Authorization: `Bearer ${config.apiKey}` };
};
export const prometheusRemoteWriteAdapter: TelemetryProviderAdapter = {
type: "prometheus_remote_write",
signals: ["metrics"],
label: "Prometheus remote write",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/prometheus_remote_write/",
credentialFields: [
{
key: "endpoint",
label: "Remote write URL",
type: "url",
required: true,
placeholder: "http://prometheus.example.com:9090/api/v1/write",
helpText:
"Prometheus: http://host:9090/api/v1/write (start it with --web.enable-remote-write-receiver). Grafana Cloud: the /api/prom/push URL from the Prometheus card. Mimir: http://host:9009/api/v1/push. VictoriaMetrics: http://host:8428/api/v1/write.",
fullWidth: true,
},
{
key: "username",
label: "Username / Instance ID",
type: "text",
required: false,
helpText:
"Leave blank if the endpoint needs no auth. Grafana Cloud: the Prometheus Instance ID (not the Loki one).",
},
{
key: "apiKey",
label: "Password / Token",
type: "password",
required: false,
helpText:
"With a username it is sent as basic auth; alone, as a bearer token. Grafana Cloud: an Access Policy token with metrics:write.",
},
{
key: "tenantId",
label: "Tenant ID",
type: "text",
required: false,
helpText: "Only for multi-tenant Mimir/Cortex (X-Scope-OrgID header).",
fullWidth: true,
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
assertHttpUrl(config.endpoint);
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const tenantId = stringValue(config.extraConfig?.tenantId);
return {
type: "prometheus_remote_write",
inputs: [inputId],
endpoint: config.endpoint ?? "",
// The sink healthcheck is an empty POST that Prometheus and Mimir answer with 400.
healthcheck: { enabled: false },
...(tenantId ? { tenant_id: tenantId } : {}),
...buildAuth(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const url = assertHttpUrl(config.endpoint);
const tenantId = stringValue(config.extraConfig?.tenantId);
// Remote write needs snappy protobuf, so the empty test body gets a 400 once auth and path pass.
const response = await providerFetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/x-protobuf",
"Content-Encoding": "snappy",
"X-Prometheus-Remote-Write-Version": "0.1.0",
...buildAuthHeader(config),
...(tenantId ? { "X-Scope-OrgID": tenantId } : {}),
},
});
if (response.ok || response.status === 400) return;
if (response.status === 401 || response.status === 403) {
throw new Error(
`Remote write endpoint rejected the credentials (status ${response.status})`,
);
}
if (response.status === 404 || response.status === 405) {
throw new Error(
`Remote write endpoint not found at this URL (status ${response.status}) — check the path`,
);
}
throw new Error(`Remote write test failed with status ${response.status}`);
},
};
@@ -0,0 +1,37 @@
import type { TelemetryProviderAdapter, TelemetryProviderType } from "../types";
import { awsCloudwatchAdapter } from "./aws-cloudwatch";
import { betterStackAdapter } from "./betterstack";
import { datadogAdapter } from "./datadog";
import { elasticsearchAdapter } from "./elasticsearch";
import { influxdbAdapter } from "./influxdb";
import { lokiAdapter } from "./loki";
import { newRelicAdapter } from "./new-relic";
import { prometheusRemoteWriteAdapter } from "./prometheus-remote-write";
import { splunkAdapter } from "./splunk";
export const telemetryProviderAdapters: Record<
TelemetryProviderType,
TelemetryProviderAdapter
> = {
loki: lokiAdapter,
prometheus_remote_write: prometheusRemoteWriteAdapter,
new_relic: newRelicAdapter,
influxdb: influxdbAdapter,
datadog: datadogAdapter,
aws_cloudwatch: awsCloudwatchAdapter,
splunk_hec: splunkAdapter,
elasticsearch: elasticsearchAdapter,
betterstack: betterStackAdapter,
};
export function getTelemetryProviderAdapter(
type: TelemetryProviderType,
): TelemetryProviderAdapter {
const adapter = telemetryProviderAdapters[type];
if (!adapter) {
throw new Error(
`No TelemetryProviderAdapter registered for type "${type}"`,
);
}
return adapter;
}
@@ -0,0 +1,154 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
normalizeEndpointUrl,
stringValue,
} from "../types";
const baseUrl = (config: TelemetryProviderRuntimeConfig) =>
normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "");
export const splunkAdapter: TelemetryProviderAdapter = {
type: "splunk_hec",
signals: ["logs", "metrics"],
label: "Splunk HTTP Event Collector",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/splunk_hec_logs/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://splunk.example.com:8088",
helpText: "Base URL of your Splunk instance, including the HEC port.",
},
{
key: "apiKey",
label: "HEC Token",
type: "password",
required: true,
},
{
key: "index",
label: "Index",
type: "text",
required: false,
signal: "logs",
},
{
key: "sourcetype",
label: "Source Type",
type: "text",
required: false,
signal: "logs",
placeholder: "httpevent",
},
{
key: "metricsIndex",
label: "Metrics index",
type: "text",
required: true,
signal: "metrics",
placeholder: "dokploy_metrics",
helpText:
"An index of type Metrics the token can write to (Allowed Indexes). If the token can't, create a second Splunk provider for metrics with another token. Testing the connection writes a real data point.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
if (signal === "metrics") {
return {
type: "splunk_hec_metrics",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
default_token: config.apiKey ?? "",
index: stringValue(config.extraConfig?.metricsIndex),
source: "dokploy",
sourcetype: "dokploy:metrics",
buffer: DEFAULT_DISK_BUFFER,
};
}
const index = config.extraConfig?.index;
const sourcetype = config.extraConfig?.sourcetype;
return {
type: "splunk_hec_logs",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
default_token: config.apiKey ?? "",
encoding: { codec: "json" },
...(typeof index === "string" && index.length > 0 ? { index } : {}),
...(typeof sourcetype === "string" && sourcetype.length > 0
? { sourcetype }
: {}),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error("Splunk endpoint and HEC token are required");
}
const authorization = { Authorization: `Splunk ${config.apiKey}` };
if (config.signals.includes("logs")) {
const response = await providerFetch(
`${baseUrl(config)}/services/collector/health`,
{ headers: authorization },
);
if (!response.ok) {
throw new Error(
`Splunk HEC health check failed with status ${response.status}`,
);
}
}
if (config.signals.includes("metrics")) {
const metricsIndex = stringValue(config.extraConfig?.metricsIndex);
if (!metricsIndex) {
throw new Error("Splunk metrics index is required");
}
const response = await providerFetch(
`${baseUrl(config)}/services/collector`,
{
method: "POST",
headers: { ...authorization, "Content-Type": "application/json" },
body: JSON.stringify({
time: Math.floor(Date.now() / 1000),
event: "metric",
source: "dokploy",
index: metricsIndex,
fields: { metric_name: "dokploy.test", _value: 1 },
}),
},
);
if (response.status === 401 || response.status === 403) {
throw new Error(
`Splunk rejected the HEC token (status ${response.status})`,
);
}
const body = (await response.json().catch(() => null)) as {
code?: number;
text?: string;
} | null;
if (body?.code === 7) {
throw new Error(
`The HEC token can't write to the metrics index "${metricsIndex}"`,
);
}
if (!response.ok || (body && body.code !== 0)) {
throw new Error(
`Splunk metric test failed with status ${response.status}${body?.text ? `: ${body.text}` : ""}`,
);
}
}
},
};

Some files were not shown because too many files have changed in this diff Show More