Compare commits

..

58 Commits

Author SHA1 Message Date
Souvik Kumar e0c8d80cde fix: decode Route53 octal escapes in record names (#5553)
* fix: decode Route53 octal escapes in record names

* fix: keep escaped dots and backslashes in Route53 record names
2026-10-16 21:10:45 +05:30
Mauricio Siu 49d250aaa9 Merge pull request #5590 from GlebsonS4ntos/fix/prevent-service-header-overflow-mobile
fix(ui): prevent service header overflow on mobile
2026-10-16 09:14:28 -06:00
Mauricio Siu bef77ac84f Merge pull request #5694 from imrja8/fix/telegram-notification-escape-truncate
fix: escape HTML and truncate long messages in Telegram notifications
2026-10-16 09:13:58 -06:00
Mauricio Siu a67d9e3522 Merge pull request #5693 from imrja8/fix/guard-stripe-init-self-hosted
fix(billing): guard loadStripe behind key check for self-hosted instances
2026-10-16 09:11:55 -06:00
autofix-ci[bot] e38a980d9f [autofix.ci] apply automated fixes 2026-10-16 11:42:40 +00:00
Yash Kumar 829f8af63f fix(ui): fix server badge and appName overflow on mobile 2026-10-16 17:09:26 +05:30
Yash Kumar 41cecde735 fix: escape metadata fields and fix entity boundary off-by-one 2026-10-16 16:09:00 +05:30
Yash Kumar 3e75dc64f3 Update packages/server/src/utils/notifications/utils.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-16 16:04:26 +05:30
Yash Kumar 7430afd701 fix: escape HTML and truncate long messages in Telegram notifications 2026-10-16 15:53:30 +05:30
Yash Kumar 837730038c fix(billing): guard loadStripe behind key check for self-hosted instances 2026-10-16 14:54:24 +05:30
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
Glebson Santos 949ac8e0a2 fix(ui): prevent mobile service header overflow 2026-10-05 14:54:30 -03:00
Glebson Santos 5ccc15f00d fix(ui): prevent service header overflow on mobile 2026-10-05 12:29:44 -03: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
146 changed files with 10289 additions and 5501 deletions
+1 -1
View File
@@ -26,7 +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.
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, tagged by project/environment/application.
- **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",
@@ -74,4 +74,60 @@ volumes:
);
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`,
);
});
});
+66
View File
@@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => {
]);
});
it("decodes octal escapes such as \\052 for wildcard names", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "\\052.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]).toMatchObject({
id: "A:*.example.com",
name: "*.example.com",
});
});
it("keeps escaped dots so they stay distinct from label separators", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "a\\056b.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
},
{
Name: "a.b.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "5.6.7.8" }],
},
],
IsTruncated: false,
});
const records = await route53Client.listRecords(config, "Z123");
expect(records.map((r) => r.id)).toEqual([
"A:a\\056b.example.com",
"A:a.b.example.com",
]);
});
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
send.mockResolvedValueOnce({
ResourceRecordSets: [
@@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => {
]);
});
it("finds a wildcard record set that Route53 returns escaped", async () => {
const existingSet = {
Name: "\\052.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.2.3.4" }],
};
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
send.mockResolvedValueOnce({});
await route53Client.deleteRecord(config, "Z123", "A:*.example.com");
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
{ Action: "DELETE", ResourceRecordSet: existingSet },
]);
});
it("throws when the record no longer exists", async () => {
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
@@ -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");
});
});
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: mocks.lookup,
}));
const loadFetch = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
const { logProviderFetch } = await import(
"@dokploy/server/services/log-management/types"
);
return logProviderFetch;
};
describe("logProviderFetch — 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 logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("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 logProviderFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
logProviderFetch(`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 logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("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 logProviderFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
logProviderFetch(`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 logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
await expect(
logProviderFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a private-network endpoint when self-hosted (Loki behind a VPN, say)", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
await logProviderFetch("http://loki.internal.example.com:3100/ready");
await logProviderFetch("http://10.0.0.5:3100/ready");
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("rejects loopback and private-network endpoints in cloud, literal or resolved", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
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",
"http://loki.internal.example.com:3100/ready",
]) {
await expect(
logProviderFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a normal public endpoint", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch(
"https://api.us-east-1.datadoghq.com/api/v1/validate",
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("does not follow redirects", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
await logProviderFetch("http://does-not-exist.invalid/");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -1,315 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: 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: {
logProvider: {
findFirst: mocks.findFirst,
},
},
},
}));
const { createLogProvider, updateLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
describe("createLogProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createLogProvider(
{ name: "loki-prod", providerType: "loki" } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createLogProvider(
{ name: "dd", providerType: "datadog" } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createLogProvider(
{
name: "bs",
providerType: "betterstack",
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(
createLogProvider(
{
name: "splunk",
providerType: "splunk_hec",
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(
createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
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(
createLogProvider(
{
name: "dd",
providerType: "datadog",
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(
createLogProvider(
{
name: "es",
providerType: "elasticsearch",
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([
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
]);
const created = await createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createLogProvider(
{
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateLogProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "old-name",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ logProviderId: "lp-1", name: "new-name" },
]);
const updated = await updateLogProvider("lp-1", { name: "new-name" });
expect(updated.name).toBe("new-name");
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(updateLogProvider("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({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
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({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("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({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("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({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("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({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(updateLogProvider("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({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateLogProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
@@ -1,71 +0,0 @@
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> }>,
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
mocks.updates.push({ table, values });
return Promise.resolve();
},
}),
}),
};
return {
db: { transaction: (fn: (tx: unknown) => unknown) => fn(tx) },
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeLogProvider", () => {
beforeEach(() => {
mocks.updates = [];
});
it("drops the provider id from every server and from the local host", async () => {
mocks.deleted = [{ logProviderId: "lp-1" }];
await removeLogProvider("lp-1");
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.logProviderIds)).toBe(
'array_remove("server"."logProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.logProviderIds)).toBe(
'array_remove("webServerSettings"."logProviderIds", $1)',
);
expect(mocks.updates[1]?.values).not.toHaveProperty(
"logManagementOrganizationId",
);
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeLogProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -1,306 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
claimWebServerLogManagement: vi.fn(),
releaseWebServerLogManagement: vi.fn(),
getWebServerSettings: vi.fn(),
updateServerLogProviders: vi.fn(),
getAccessibleServerIds: vi.fn(),
removeVectorAgent: vi.fn(),
setupVectorAgent: vi.fn(),
assertServerBelongsToOrg: vi.fn(),
assertLogProvidersBelongToOrg: vi.fn(),
filterExistingLogProviderIds: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
updateServerLogProviders: mocks.updateServerLogProviders,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
claimWebServerLogManagement: mocks.claimWebServerLogManagement,
releaseWebServerLogManagement: mocks.releaseWebServerLogManagement,
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
setupVectorAgent: mocks.setupVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
vi.doMock("@dokploy/server/services/log-management/service", () => ({
assertServerBelongsToOrg: mocks.assertServerBelongsToOrg,
assertLogProvidersBelongToOrg: mocks.assertLogProvidersBelongToOrg,
filterExistingLogProviderIds: mocks.filterExistingLogProviderIds,
}));
return await import("@dokploy/server/services/log-management/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
describe("local Vector agent guards", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("does not remove the local agent when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionB, undefined),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes the local agent and only then releases the claim", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
expect(mocks.removeVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.releaseWebServerLogManagement.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the claim when removing the local agent fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeLogManagement } = await loadAgent(false);
await expect(removeLogManagement(sessionA, undefined)).rejects.toThrow(
"docker down",
);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes an unclaimed local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
});
it("rejects deploying the local agent in cloud", async () => {
const { deployLogManagement } = await loadAgent(true);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.claimWebServerLogManagement).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing the local agent in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await expect(
removeLogManagement(sessionA, undefined),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.getWebServerSettings).not.toHaveBeenCalled();
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("still manages remote servers in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await removeLogManagement(sessionA, "server-1");
expect(mocks.assertServerBelongsToOrg).toHaveBeenCalledWith(
"server-1",
"org-a",
);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", []);
});
});
describe("remote server access", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("rejects deploying on a server the member cannot access", async () => {
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing from a server the member cannot access", async () => {
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionA, "server-2"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("deploys on an accessible server", async () => {
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1"]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
expect(mocks.setupVectorAgent).toHaveBeenCalledWith("org-a", "server-1", [
"lp-1",
]);
});
it("does not save a provider deleted while the server deploy ran", async () => {
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1", "lp-2"]);
expect(mocks.filterExistingLogProviderIds).toHaveBeenCalledWith([
"lp-1",
"lp-2",
]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
});
it("keeps the saved providers when the server deploy fails", async () => {
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
});
it("removes the agent on server deletion even with no providers left", async () => {
const { removeServerLogManagement } = await loadAgent(false);
await removeServerLogManagement({
serverId: "server-1",
sshKeyId: "key-1",
logProviderIds: [],
} as never);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
});
describe("local agent deploy", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.claimWebServerLogManagement.mockResolvedValue({});
});
it("saves the new providers only after the deploy succeeds", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
1,
"org-a",
["lp-1"],
);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
2,
"org-a",
["lp-2"],
);
expect(mocks.setupVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.claimWebServerLogManagement.mock.invocationCallOrder[1] ?? 0,
);
});
it("does not save a provider deleted while the local deploy ran", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-1", "lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenLastCalledWith(
"org-a",
["lp-1"],
);
});
it("keeps the previous providers when a redeploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledTimes(1);
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("releases the claim when the first local deploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
logProviderIds: [],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toThrow("validation failed");
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
});
it("rejects deploying when another organization owns the local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.claimWebServerLogManagement.mockResolvedValue(null);
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionB, undefined, ["lp-9"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
});
@@ -1,145 +0,0 @@
import { buildVectorConfigYaml } 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,
...overrides,
}) as any;
describe("buildVectorConfigYaml", () => {
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "log-provider-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
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",
]);
const sinkIds = Object.keys(config.sinks);
expect(sinkIds).toEqual(["sink_log-provider-1"]);
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
"dokploy_scope",
]);
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
provider({
logProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
provider({
logProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
apiKey: "dd-key",
}),
]);
const config = parse(yamlStr) as any;
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("reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope.source).toBe(
[
'.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", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
provider({
logProviderId: "good-loki",
name: "loki-ok",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", async () => {
const yamlStr = await buildVectorConfigYaml(
[
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
],
{ organizationId: "org-a" },
);
const config = parse(yamlStr) as any;
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", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
@@ -1,37 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getRemoteDocker: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForLogManagement: vi.fn(),
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: mocks.getRemoteDocker,
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: mocks.execAsyncRemote,
}));
const { removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("removeVectorAgent on a server without SSH key", () => {
it("refuses instead of falling back to the local docker socket", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getRemoteDocker).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { projects: { findMany: vi.fn() } },
},
}));
const { collectSecretValues, redactSecrets } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
const providers = [
{
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a" },
},
{
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
] as any;
expect(collectSecretValues(providers)).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"tenant-a",
"dd-key",
"datadoghq.eu",
]),
);
});
it("ignores non-string extraConfig values and null/empty credentials", () => {
const providers = [
{
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 = [
{
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: null,
extraConfig: { other: "hunter2" },
},
] 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]",
);
});
});
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForLogManagement: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: 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(),
findServersByOrganizationForLogManagement:
mocks.findServersByOrganizationForLogManagement,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getLogManagementServerStatus, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getLogManagementServerStatus", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-1",
logProviderIds: ["lp-1"],
});
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getLogManagementServerStatus(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 getLogManagementServerStatus(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 getLogManagementServerStatus(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 getLogManagementServerStatus(session);
expect(local?.status).toBe("unknown");
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([
{ serverId: "server-1", name: "one", ipAddress: "1.1.1.1" },
{ serverId: "server-2", name: "two", ipAddress: "2.2.2.2" },
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getLogManagementServerStatus(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,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([]);
});
});
@@ -1,52 +1,60 @@
import {
getLogProviderAdapter,
logProviderAdapters,
} from "@dokploy/server/services/log-management/providers/registry";
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
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: LogProviderRuntimeConfig = {
logProviderId: "log-provider-1",
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["logs"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getLogProviderAdapter", () => {
describe("getTelemetryProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getLogProviderAdapter("loki").type).toBe("loki");
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
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(() => getLogProviderAdapter("unknown")).toThrow(
/No LogProviderAdapter registered/,
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
/No TelemetryProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = logProviderAdapters.loki;
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({
@@ -57,7 +65,7 @@ describe("lokiAdapter.toVectorSink", () => {
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = logProviderAdapters.loki;
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -66,22 +74,24 @@ describe("lokiAdapter.toVectorSink", () => {
},
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
const adapter = logProviderAdapters.datadog;
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");
@@ -91,7 +101,7 @@ describe("datadogAdapter.toVectorSink", () => {
});
it("uses extraConfig.site when present", () => {
const adapter = logProviderAdapters.datadog;
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -100,12 +110,13 @@ describe("datadogAdapter.toVectorSink", () => {
},
"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 = logProviderAdapters.datadog;
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -114,29 +125,35 @@ describe("datadogAdapter.toVectorSink", () => {
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("has toVectorTransform to build .ddtags from the scoping fields — confirmado que datadog_logs no tiene un campo `tags` de config (vector generate-schema)", () => {
const adapter = logProviderAdapters.datadog;
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 = logProviderAdapters.datadog;
const adapter = telemetryProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
@@ -146,12 +163,13 @@ describe("datadogAdapter.toVectorSink", () => {
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = logProviderAdapters.betterstack;
const adapter = telemetryProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
);
expect(transform).toEqual({
type: "remap",
@@ -161,7 +179,7 @@ describe("betterStackAdapter", () => {
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = logProviderAdapters.betterstack;
const adapter = telemetryProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -170,6 +188,7 @@ describe("betterStackAdapter", () => {
},
"sink_3",
"transform_1",
"logs",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
@@ -181,12 +200,13 @@ describe("betterStackAdapter", () => {
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = logProviderAdapters.elasticsearch;
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"]);
@@ -194,7 +214,7 @@ describe("elasticsearchAdapter", () => {
});
it("uses basic auth when a username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -204,6 +224,7 @@ describe("elasticsearchAdapter", () => {
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
@@ -216,7 +237,7 @@ describe("elasticsearchAdapter", () => {
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -225,6 +246,7 @@ describe("elasticsearchAdapter", () => {
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
@@ -233,7 +255,7 @@ describe("elasticsearchAdapter", () => {
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = logProviderAdapters.elasticsearch;
const adapter = telemetryProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
@@ -243,16 +265,18 @@ describe("elasticsearchAdapter", () => {
},
"sink_4",
"dokploy_scope",
"logs",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = logProviderAdapters.elasticsearch;
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(
@@ -263,6 +287,7 @@ describe("elasticsearchAdapter", () => {
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
@@ -270,11 +295,13 @@ describe("elasticsearchAdapter", () => {
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
expect(
telemetryProviderAdapters.splunk_hec.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = logProviderAdapters.splunk_hec;
const adapter = telemetryProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -283,6 +310,7 @@ describe("splunkAdapter", () => {
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
@@ -300,6 +328,7 @@ describe("splunkAdapter", () => {
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
@@ -307,14 +336,19 @@ describe("splunkAdapter", () => {
});
describe("awsCloudwatchAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
expect(
logProviderAdapters.aws_cloudwatch.toVectorTransform,
).toBeUndefined();
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
),
).toBeNull();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -324,6 +358,7 @@ describe("awsCloudwatchAdapter", () => {
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
@@ -336,7 +371,7 @@ describe("awsCloudwatchAdapter", () => {
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
@@ -350,6 +385,7 @@ describe("awsCloudwatchAdapter", () => {
},
"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,17 +73,17 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses logProvider.read", async () => {
it("owner bypasses telemetryProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
checkPermission(ctx, { telemetryProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses logProvider.create", async () => {
it("admin bypasses telemetryProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
checkPermission(ctx, { telemetryProvider: ["create"] }),
).resolves.toBeUndefined();
});
@@ -154,17 +154,17 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
).rejects.toThrow();
});
it("member is denied logProvider.read", async () => {
it("member is denied telemetryProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
checkPermission(ctx, { telemetryProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied logProvider.create", async () => {
it("member is denied telemetryProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
checkPermission(ctx, { telemetryProvider: ["create"] }),
).rejects.toThrow();
});
});
@@ -18,7 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
"telemetryProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
];
describe("enterpriseOnlyResources set", () => {
@@ -111,7 +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.logProvider.read).toBe(false);
expect(perms.telemetryProvider.read).toBe(false);
});
});
@@ -25,8 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logProviderIds: [],
logManagementOrganizationId: null,
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -64,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-"));
@@ -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>
@@ -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">
@@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { displayFont } from "../font";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
: null;
type Tier = "hobby" | "startup";
@@ -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>
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
: null;
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
export const calculatePrice = (count: number, isAnnual = false) => {
@@ -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" />
@@ -1,402 +0,0 @@
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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"]);
interface Props {
logProviderId?: string;
}
export const agentsUsingProvider = (
targets: Array<{ name: string; logProviderIds: string[] }> | undefined,
logProviderId: string,
) =>
(targets ?? [])
.filter((target) => target.logProviderIds.includes(logProviderId))
.map((target) => target.name)
.join(", ");
export const HandleLogProvider = ({ logProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
const [providerType, setProviderType] = useState("");
const [enabled, setEnabled] = useState(true);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [initialFieldValues, setInitialFieldValues] = useState<
Record<string, string>
>({});
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: provider } = api.logProvider.one.useQuery(
{ logProviderId: logProviderId || "" },
{ enabled: !!logProviderId },
);
const {
mutateAsync,
isPending: isSaving,
error,
isError,
} = logProviderId
? api.logProvider.update.useMutation()
: api.logProvider.create.useMutation();
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.logProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.logProvider.testConnectionById.useMutation();
const selectedType = availableTypes?.find((t) => t.type === providerType);
const handleProviderTypeChange = (value: string) => {
setProviderType(value);
setFieldValues({});
};
useEffect(() => {
if (provider) {
setName(provider.name);
setProviderType(provider.providerType);
setEnabled(provider.enabled);
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
setFieldValues(extraConfig);
setInitialFieldValues(extraConfig);
setClearedKeys({});
} else if (isOpen) {
setName("");
setProviderType("");
setEnabled(true);
setFieldValues({});
setInitialFieldValues({});
setClearedKeys({});
}
}, [provider, isOpen]);
const buildPayload = () => {
const payload: Record<string, unknown> = { name, providerType, enabled };
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of selectedType?.credentialFields ?? []) {
const value = fieldValues[field.key];
if (TOP_LEVEL_KEYS.has(field.key)) {
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;
};
const topLevelKeys = (selectedType?.credentialFields ?? [])
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter(
(key) => !!fieldValues[key] || clearedKeys[key],
);
const extraConfigKeys = (selectedType?.credentialFields ?? [])
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedExtraConfigKeys = extraConfigKeys.filter(
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
);
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!logProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const onTest = async () => {
if (logProviderId && !touchedAnyCredentialField) {
await testConnectionById({ logProviderId })
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
const payload = buildPayload();
await testConnection({
name: payload.name as string,
providerType: payload.providerType as LogProviderType,
enabled: payload.enabled as boolean,
endpoint: (payload.endpoint as string) ?? null,
apiKey: (payload.apiKey as string) ?? null,
apiSecret: (payload.apiSecret as string) ?? null,
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
})
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
const payload = buildPayload();
await mutateAsync({
...(logProviderId ? { logProviderId } : {}),
...payload,
} as any)
.then(() => {
utils.logProvider.all.invalidate();
if (logProviderId) {
utils.logProvider.one.invalidate({ logProviderId });
}
const agents =
logProviderId &&
agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
logProviderId,
);
toast.success(
logProviderId ? "Log provider updated" : "Log provider added",
agents
? {
description: `Redeploy the Vector agent on ${agents} to apply the changes.`,
}
: undefined,
);
setIsOpen(false);
})
.catch(() => {
toast.error(
logProviderId
? "Error updating log provider"
: "Error adding log provider",
);
});
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{logProviderId ? (
<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 Log Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
</DialogTitle>
<DialogDescription>
Vector will ship container logs from every server with Log
Management enabled to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testError?.message ||
testByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
<div className="flex flex-col w-full gap-4">
<div className="flex flex-col gap-2">
<Label>Name</Label>
<Input
placeholder="e.g. Production Loki"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Provider</Label>
<Select
value={providerType}
onValueChange={handleProviderTypeChange}
disabled={!!logProviderId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{availableTypes?.map((type) => {
const ProviderIcon =
logProviderIcons[
type.type as keyof typeof logProviderIcons
];
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>
</div>
{selectedType?.credentialFields.map((field) => (
<div key={field.key} className="flex flex-col gap-2">
<Label>
{field.label}
{!field.required && " (Optional)"}
</Label>
{field.helpText && (
<span className="text-xs text-muted-foreground">
{field.helpText}
</span>
)}
<div className="flex flex-row gap-2">
<Input
type={field.type === "password" ? "password" : "text"}
disabled={!!clearedKeys[field.key]}
placeholder={
clearedKeys[field.key]
? "Will be cleared on update"
: logProviderId && TOP_LEVEL_KEYS.has(field.key)
? "Leave blank to keep existing"
: field.placeholder
}
autoComplete={
field.type === "password" ? "one-time-code" : "off"
}
value={fieldValues[field.key] ?? ""}
onChange={(e) =>
setFieldValues((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
/>
{logProviderId &&
TOP_LEVEL_KEYS.has(field.key) &&
!field.required && (
<Button
type="button"
variant="outline"
onClick={() => {
setClearedKeys((prev) => ({
...prev,
[field.key]: !prev[field.key],
}));
setFieldValues((prev) => ({
...prev,
[field.key]: "",
}));
}}
>
{clearedKeys[field.key] ? "Keep" : "Clear"}
</Button>
)}
</div>
</div>
))}
<div className="flex flex-row items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>Enabled</Label>
</div>
</div>
<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
onClick={onSubmit}
isLoading={isSaving}
disabled={!providerType || !name || isSaving}
>
{logProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -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>
);
};
@@ -1,7 +1,7 @@
import { Copy, Loader2, ScrollText, Server } from "lucide-react";
import { Copy, Loader2, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
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";
@@ -32,71 +32,92 @@ const statusBadge = (status: Status) => {
return <Badge variant="destructive">Unreachable</Badge>;
};
export const ShowLogManagementServers = () => {
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, string>>({});
const [errors, setErrors] = useState<
Record<string, { title: string; message: string }>
>({});
const { data: providers } = api.logProvider.all.useQuery();
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
undefined,
{
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.logProvider.deployOnServer.useMutation();
api.telemetryProvider.deployOnServer.useMutation();
const { mutateAsync: removeOnServer } =
api.logProvider.removeOnServer.useMutation();
api.telemetryProvider.removeOnServer.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
const idsFor = (serverId: string | null, saved: string[]) =>
selection[keyOf(serverId)] ?? saved;
// 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[],
logProviderId: string,
telemetryProviderId: string,
) => {
const key = keyOf(serverId);
const current = selection[key] ?? saved;
const current = idsFor(serverId, saved);
setSelection({
...selection,
[key]: current.includes(logProviderId)
? current.filter((id) => id !== logProviderId)
: [...current, logProviderId],
[key]: current.includes(telemetryProviderId)
? current.filter((id) => id !== telemetryProviderId)
: [...current, telemetryProviderId],
});
};
const setError = (serverId: string | null, message?: string) =>
const setError = (
serverId: string | null,
error?: { title: string; message: string },
) =>
setErrors((prev) => {
const next = { ...prev };
if (message) {
next[keyOf(serverId)] = message;
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, logProviderIds: ids })
await deployOnServer({ serverId, providerIds: ids })
.then(() => {
toast.success("Vector agent deployed");
utils.logProvider.serverStatus.invalidate();
refreshStatus();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to deploy the agent",
);
setError(serverId, {
title: "Deploy failed",
message:
error instanceof Error
? error.message
: "Failed to deploy the agent",
});
refreshStatus();
})
.finally(() => setPendingTarget(null));
};
@@ -108,13 +129,17 @@ export const ShowLogManagementServers = () => {
.then(() => {
toast.success("Vector agent removed");
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
utils.logProvider.serverStatus.invalidate();
refreshStatus();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to remove the agent",
);
setError(serverId, {
title: "Remove failed",
message:
error instanceof Error
? error.message
: "Failed to remove the agent",
});
refreshStatus();
})
.finally(() => setPendingTarget(null));
};
@@ -122,7 +147,12 @@ export const ShowLogManagementServers = () => {
const hasProviders = (providers?.length ?? 0) > 0;
const visibleTargets = hasProviders
? targets
: targets?.filter((target) => target.status !== "stopped");
: targets?.filter(
(target) =>
target.telemetryProviderIds.length > 0 ||
target.status === "running" ||
target.status === "not-running",
);
return (
<div className="w-full">
@@ -135,10 +165,11 @@ export const ShowLogManagementServers = () => {
</CardTitle>
<CardDescription>
Pick which providers each server ships to, then deploy its Vector
agent. Changing a provider only takes effect on a server after you
redeploy it. Services deployed before Log Management need a
redeploy to get the labels it uses; until then their logs are not
shipped from the Dokploy server.
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">
@@ -149,21 +180,23 @@ export const ShowLogManagementServers = () => {
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a log provider first — there's nowhere to ship logs to yet
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 ids = idsFor(target.serverId, target.logProviderIds);
const saved = target.telemetryProviderIds;
const ids = idsFor(target.serverId, saved);
const isBusy = pendingTarget === key;
const isDeployed = target.status !== "stopped";
const agentDeployed = target.status !== "stopped";
const hasEnabledSelected = ids.some(
(id) =>
providers?.find((p) => p.logProviderId === id)?.enabled,
providers?.find((p) => p.telemetryProviderId === id)
?.enabled,
);
return (
@@ -184,10 +217,10 @@ export const ShowLogManagementServers = () => {
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{isDeployed && (
{agentDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server will stop shipping container logs until you deploy it again. Continue?"
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
@@ -205,32 +238,35 @@ export const ShowLogManagementServers = () => {
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">
Log providers
</span>
<span className="text-xs font-medium">Providers</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
logProviderIcons[
provider.providerType as keyof typeof logProviderIcons
telemetryProviderIcons[
provider.providerType as keyof typeof telemetryProviderIcons
];
return (
<div
key={provider.logProviderId}
key={provider.telemetryProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.logProviderId}`}
disabled={!provider.enabled}
checked={ids.includes(provider.logProviderId)}
id={`${key}-${provider.telemetryProviderId}`}
disabled={
!provider.enabled &&
!ids.includes(provider.telemetryProviderId)
}
checked={ids.includes(
provider.telemetryProviderId,
)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
target.logProviderIds,
provider.logProviderId,
saved,
provider.telemetryProviderId,
)
}
/>
@@ -238,14 +274,14 @@ export const ShowLogManagementServers = () => {
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.logProviderId}`}
htmlFor={`${key}-${provider.telemetryProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{provider.name}
{providerLabel(provider)}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
@@ -260,11 +296,10 @@ export const ShowLogManagementServers = () => {
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
Deploy failed — the config was not applied, this
server keeps shipping with its previous one.
{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]}
{errors[key].message}
</pre>
<Button
variant="ghost"
@@ -272,7 +307,7 @@ export const ShowLogManagementServers = () => {
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key] ?? "",
errors[key]?.message ?? "",
);
toast.success("Error copied");
}}
@@ -298,7 +333,9 @@ export const ShowLogManagementServers = () => {
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{isDeployed ? "Redeploy" : "Deploy"}
{agentDeployed || saved.length > 0
? "Redeploy"
: "Deploy"}
</Button>
</div>
)}
@@ -1,6 +1,6 @@
import { Loader2, ScrollText, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
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";
@@ -12,14 +12,15 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { agentsUsingProvider, HandleLogProvider } from "./handle-log-provider";
import { HandleTelemetryProvider } from "./handle-telemetry-provider";
interface LogProviderRowProps {
interface TelemetryProviderRowProps {
provider: {
logProviderId: string;
telemetryProviderId: string;
name: string;
enabled: boolean;
providerType: string;
signals: string[];
};
index: number;
typeLabel: string;
@@ -28,19 +29,20 @@ interface LogProviderRowProps {
onDeleted: () => void;
}
const LogProviderRow = ({
const TelemetryProviderRow = ({
provider,
index,
typeLabel,
canEdit,
canDelete,
onDeleted,
}: LogProviderRowProps) => {
const utils = api.useUtils();
}: TelemetryProviderRowProps) => {
const { mutateAsync, isPending: isRemoving } =
api.logProvider.remove.useMutation();
api.telemetryProvider.remove.useMutation();
const ProviderIcon =
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
telemetryProviderIcons[
provider.providerType as keyof typeof telemetryProviderIcons
];
return (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
@@ -53,6 +55,12 @@ const LogProviderRow = ({
<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>
@@ -60,35 +68,34 @@ const LogProviderRow = ({
<div className="flex flex-row gap-1">
{canEdit && (
<HandleLogProvider logProviderId={provider.logProviderId} />
<HandleTelemetryProvider
telemetryProviderId={provider.telemetryProviderId}
/>
)}
{canDelete && (
<DialogAction
title="Delete Log Provider"
description="Are you sure you want to delete this log provider? Agents already deployed keep shipping logs to it until you redeploy or remove them."
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 () => {
const agents = agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
provider.logProviderId,
);
await mutateAsync({
logProviderId: provider.logProviderId,
telemetryProviderId: provider.telemetryProviderId,
})
.then(() => {
toast.success(
"Log provider deleted successfully",
agents
? {
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
}
: undefined,
);
.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 log provider");
toast.error("Error deleting provider");
});
}}
>
@@ -108,10 +115,11 @@ const LogProviderRow = ({
);
};
export const ShowLogProviders = () => {
export const ShowTelemetryProviders = () => {
const utils = api.useUtils();
const { data, isPending, refetch } = api.logProvider.all.useQuery();
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
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(", ")})`
@@ -124,10 +132,10 @@ export const ShowLogProviders = () => {
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Log Management
Logs & Metrics
</CardTitle>
<CardDescription>
{`Ship container logs to an external provider${providerListLabel}. Add one here, then deploy the Vector agent on the servers below.`}
{`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">
@@ -142,16 +150,18 @@ export const ShowLogProviders = () => {
<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 log provider configured
You don't have any provider configured
</span>
{permissions?.logProvider.create && <HandleLogProvider />}
{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) => (
<LogProviderRow
key={provider.logProviderId}
<TelemetryProviderRow
key={provider.telemetryProviderId}
provider={provider}
index={index}
typeLabel={
@@ -159,19 +169,19 @@ export const ShowLogProviders = () => {
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.logProvider.create}
canDelete={!!permissions?.logProvider.delete}
canEdit={!!permissions?.telemetryProvider.create}
canDelete={!!permissions?.telemetryProvider.delete}
onDeleted={() => {
refetch();
utils.logProvider.serverStatus.invalidate();
utils.telemetryProvider.serverStatus.invalidate();
}}
/>
))}
</div>
{permissions?.logProvider.create && (
{permissions?.telemetryProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleLogProvider />
<HandleTelemetryProvider />
</div>
)}
</div>
@@ -113,7 +113,7 @@ export const DeleteServerModal = ({
toast.success(`Server ${serverName} deleted successfully`);
if (result?.vectorAgentRemovalWarning) {
toast.error(
`Couldn't remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
`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);
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async () => {
.then(async (_data) => {
await utils.server.all.invalidate();
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
@@ -59,11 +59,47 @@ export const SplunkIcon = ({ className }: Props) => (
</svg>
);
export const logProviderIcons = {
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,
betterstack: BetterStackIcon,
elasticsearch: ElasticsearchIcon,
splunk_hec: SplunkIcon,
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">
+10 -4
View File
@@ -351,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,
@@ -377,10 +383,10 @@ const MENU: Menu = {
},
{
isSingle: true,
title: "Log Management",
url: "/dashboard/settings/log-providers",
title: "Logs & Metrics",
url: "/dashboard/settings/logs-and-metrics",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
},
{
isSingle: true,
@@ -96,7 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
logProvider: "Log Provider",
telemetryProvider: "Logs & Metrics",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,10 +180,15 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
logProvider: {
label: "Log Providers",
telemetryProvider: {
label: "Logs & Metrics",
description:
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
"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)",
},
};
@@ -480,13 +485,39 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
logProvider: {
read: { label: "Read", description: "View configured log providers" },
telemetryProvider: {
read: {
label: "Read",
description: "View configured logs and metrics providers",
},
create: {
label: "Create",
description: "Add new log providers and test their connection",
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",
},
delete: { label: "Delete", description: "Remove log providers" },
},
};
@@ -599,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>
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<FolderInput className="size-4 text-muted-foreground" />
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span
className="font-medium max-w-[50px] md:max-w-[150px] truncate"
title={currentProject?.name}
>
{currentProject?.name || "Select Project"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
+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;
@@ -1,19 +0,0 @@
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
CREATE TABLE "logProvider" (
"logProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "LogProviderType" 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 "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_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_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
+120 -111
View File
@@ -1,5 +1,5 @@
{
"id": "bcb4d155-4bad-4373-bbfa-73705d3a9714",
"id": "e2f2cd03-52dd-457a-8854-1ef6d0b5d168",
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
"version": "7",
"dialect": "postgresql",
@@ -4310,95 +4310,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.logProvider": {
"name": "logProvider",
"schema": "",
"columns": {
"logProviderId": {
"name": "logProviderId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerType": {
"name": "providerType",
"type": "LogProviderType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiSecret": {
"name": "apiSecret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"extraConfig": {
"name": "extraConfig",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organizationId": {
"name": "organizationId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"logProvider_organizationId_organization_id_fk": {
"name": "logProvider_organizationId_organization_id_fk",
"tableFrom": "logProvider",
"tableTo": "organization",
"columnsFrom": [
"organizationId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.mariadb": {
"name": "mariadb",
"schema": "",
@@ -7765,8 +7676,8 @@
"notNull": true,
"default": false
},
"logProviderIds": {
"name": "logProviderIds",
"telemetryProviderIds": {
"name": "telemetryProviderIds",
"type": "text[]",
"primaryKey": false,
"notNull": true,
@@ -8277,6 +8188,101 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.telemetryProvider": {
"name": "telemetryProvider",
"schema": "",
"columns": {
"telemetryProviderId": {
"name": "telemetryProviderId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerType": {
"name": "providerType",
"type": "TelemetryProviderType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"signals": {
"name": "signals",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiSecret": {
"name": "apiSecret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"extraConfig": {
"name": "extraConfig",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organizationId": {
"name": "organizationId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"telemetryProvider_organizationId_organization_id_fk": {
"name": "telemetryProvider_organizationId_organization_id_fk",
"tableFrom": "telemetryProvider",
"tableTo": "organization",
"columnsFrom": [
"organizationId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
@@ -8901,15 +8907,15 @@
"notNull": false,
"default": "'0 0 * * *'"
},
"logProviderIds": {
"name": "logProviderIds",
"telemetryProviderIds": {
"name": "telemetryProviderIds",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "ARRAY[]::text[]"
},
"logManagementOrganizationId": {
"name": "logManagementOrganizationId",
"vectorAgentOrganizationId": {
"name": "vectorAgentOrganizationId",
"type": "text",
"primaryKey": false,
"notNull": false
@@ -8987,12 +8993,12 @@
},
"indexes": {},
"foreignKeys": {
"webServerSettings_logManagementOrganizationId_organization_id_fk": {
"name": "webServerSettings_logManagementOrganizationId_organization_id_fk",
"webServerSettings_vectorAgentOrganizationId_organization_id_fk": {
"name": "webServerSettings_vectorAgentOrganizationId_organization_id_fk",
"tableFrom": "webServerSettings",
"tableTo": "organization",
"columnsFrom": [
"logManagementOrganizationId"
"vectorAgentOrganizationId"
],
"columnsTo": [
"id"
@@ -9114,18 +9120,6 @@
"gitea"
]
},
"public.LogProviderType": {
"name": "LogProviderType",
"schema": "public",
"values": [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch"
]
},
"public.mountType": {
"name": "mountType",
"schema": "public",
@@ -9277,6 +9271,21 @@
"tag"
]
},
"public.TelemetryProviderType": {
"name": "TelemetryProviderType",
"schema": "public",
"values": [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
"prometheus_remote_write",
"new_relic",
"influxdb"
]
},
"public.VaultProviderType": {
"name": "VaultProviderType",
"schema": "public",
+2 -2
View File
@@ -1384,8 +1384,8 @@
{
"idx": 197,
"version": "7",
"when": 1791377838843,
"tag": "0197_smiling_madrox",
"when": 1791456930312,
"tag": "0197_logs_and_metrics",
"breakpoints": true
}
]
+15 -17
View File
@@ -48,6 +48,7 @@
"@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",
@@ -66,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",
@@ -86,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",
@@ -99,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",
@@ -134,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",
@@ -143,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",
@@ -163,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" />
)}
@@ -123,9 +123,9 @@ const Service = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
serviceId={applicationId}
@@ -136,20 +136,22 @@ const Service = (
<StatusTooltip status={data?.applicationStatus} />
</div>
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -165,7 +167,9 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -114,9 +114,9 @@ const Service = (
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<div className="flex flex-col gap-4">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
serviceId={composeId}
@@ -127,20 +127,22 @@ const Service = (
<StatusTooltip status={data?.composeStatus} />
</div>
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -156,7 +158,9 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider>
@@ -80,9 +80,9 @@ const Libsql = (
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -90,20 +90,22 @@ const Libsql = (
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -119,7 +121,9 @@ const Libsql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -92,9 +92,9 @@ const Mariadb = (
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -102,20 +102,22 @@ const Mariadb = (
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -131,7 +133,9 @@ const Mariadb = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -91,9 +91,9 @@ const Mongo = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -101,20 +101,22 @@ const Mongo = (
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -130,7 +132,9 @@ const Mongo = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -91,9 +91,9 @@ const MySql = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -101,20 +101,22 @@ const MySql = (
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -130,7 +132,9 @@ const MySql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -90,9 +90,9 @@ const Postgresql = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -100,20 +100,22 @@ const Postgresql = (
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -129,7 +131,9 @@ const Postgresql = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -90,9 +90,9 @@ const Redis = (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
<div className="flex min-w-0 flex-1 flex-col">
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<StatusTooltip status={data?.applicationStatus} />
@@ -100,20 +100,22 @@ const Redis = (
<RedisIcon className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
<span className="min-w-0 truncate" title={data?.name}>
{data?.name}
</span>
</CardTitle>
{data?.description && (
<CardDescription>{data?.description}</CardDescription>
)}
<span className="text-sm text-muted-foreground">
<span className="text-sm text-muted-foreground min-w-0 truncate">
{data?.appName}
</span>
</div>
<div className="flex flex-col h-fit w-fit gap-2">
<div className="flex flex-row h-fit w-fit gap-2">
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
<Badge
className="cursor-pointer"
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
@@ -129,7 +131,9 @@ const Redis = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
<span className="truncate">
{data?.server?.name || "Dokploy Server"}
</span>
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>
@@ -3,16 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
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">
<ShowLogProviders />
<ShowLogManagementServers />
<ShowTelemetryProviders />
<ShowServers />
</div>
);
};
@@ -20,7 +20,7 @@ const Page = () => {
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
return <DashboardLayout metaName="Logs & Metrics">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
+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 -2
View File
@@ -21,7 +21,6 @@ import { giteaRouter } from "./routers/gitea";
import { githubRouter } from "./routers/github";
import { gitlabRouter } from "./routers/gitlab";
import { libsqlRouter } from "./routers/libsql";
import { logProviderRouter } from "./routers/log-provider";
import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount";
@@ -54,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";
@@ -86,7 +86,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
logProvider: logProviderRouter,
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);
@@ -1,157 +0,0 @@
import {
createLogProvider,
deployLogManagement,
findLogProviderForOrganization,
findLogProvidersByOrganization,
getLogManagementServerStatus,
logProviderAdapters,
removeLogManagement,
removeLogProvider,
sanitizeLogProvider,
testLogProviderConnection,
updateLogProvider,
} from "@dokploy/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateLogProvider,
apiFindOneLogProvider,
apiRemoveLogProvider,
apiTestLogProvider,
apiUpdateLogProvider,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const logProviderRouter = createTRPCRouter({
create: withPermission("logProvider", "create")
.input(apiCreateLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await createLogProvider(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(provider);
}),
update: withPermission("logProvider", "create")
.input(apiUpdateLogProvider)
.mutation(async ({ ctx, input }) => {
const { logProviderId, ...rest } = input;
const provider = await findLogProviderForOrganization(
logProviderId,
ctx.session.activeOrganizationId,
);
const updated = await updateLogProvider(logProviderId, rest);
await audit(ctx, {
action: "update",
resourceType: "logProvider",
resourceId: logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(updated);
}),
remove: withPermission("logProvider", "delete")
.input(apiRemoveLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
const removed = await removeLogProvider(input.logProviderId);
await audit(ctx, {
action: "delete",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(removed);
}),
all: withPermission("logProvider", "read").query(async ({ ctx }) => {
return await findLogProvidersByOrganization(
ctx.session.activeOrganizationId,
);
}),
one: withPermission("logProvider", "read")
.input(apiFindOneLogProvider)
.query(async ({ ctx, input }) => {
return await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
}),
testConnection: withPermission("logProvider", "create")
.input(apiTestLogProvider)
.mutation(async ({ input }) => {
return await testLogProviderConnection({
providerType: input.providerType,
config: {
logProviderId: "test",
name: input.name ?? "test",
endpoint: input.endpoint ?? null,
apiKey: input.apiKey ?? null,
apiSecret: input.apiSecret ?? null,
extraConfig: input.extraConfig ?? null,
},
});
}),
testConnectionById: withPermission("logProvider", "create")
.input(apiFindOneLogProvider)
.mutation(async ({ ctx, input }) => {
await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
return await testLogProviderConnection({
logProviderId: input.logProviderId,
});
}),
serverStatus: withPermission("logProvider", "read").query(({ ctx }) =>
getLogManagementServerStatus(ctx.session),
),
deployOnServer: withPermission("logProvider", "create")
.input(
z.object({
serverId: z.string().nullable().optional(),
logProviderIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
await deployLogManagement(
ctx.session,
input.serverId ?? undefined,
input.logProviderIds,
);
await audit(ctx, {
action: "create",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "log-management",
});
return { installed: true };
}),
removeOnServer: withPermission("logProvider", "create")
.input(z.object({ serverId: z.string().nullable().optional() }))
.mutation(async ({ ctx, input }) => {
await removeLogManagement(ctx.session, input.serverId ?? undefined);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "log-management",
});
return { installed: false };
}),
availableTypes: protectedProcedure.query(() => {
return Object.values(logProviderAdapters).map((adapter) => ({
type: adapter.type,
label: adapter.label,
docsUrl: adapter.docsUrl,
credentialFields: adapter.credentialFields,
}));
}),
});
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
});
+32 -8
View File
@@ -12,7 +12,7 @@ import {
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
removeServerLogManagement,
removeServerVectorAgent,
serverAudit,
serverSetup,
serverValidate,
@@ -20,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";
@@ -52,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)
@@ -217,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({
@@ -235,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({
@@ -255,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)
@@ -475,7 +500,7 @@ export const serverRouter = createTRPCRouter({
});
}
const vectorAgentRemovalWarning =
await removeServerLogManagement(currentServer);
await removeServerVectorAgent(currentServer);
await audit(ctx, {
action: "delete",
resourceType: "server",
@@ -520,7 +545,6 @@ export const serverRouter = createTRPCRouter({
message: "Server is inactive",
});
}
const currentServer = await updateServerById(input.serverId, {
...input,
});
+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",
});
});
+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": [
+12 -13
View File
@@ -37,6 +37,7 @@
"@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",
@@ -46,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",
@@ -66,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",
@@ -83,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",
+2 -1
View File
@@ -95,4 +95,5 @@ export type AuditResourceType =
| "network"
| "vaultProvider"
| "dnsProvider"
| "logProvider";
| "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 -1
View File
@@ -17,7 +17,6 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./log-provider";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
@@ -42,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";
+1 -1
View File
@@ -42,7 +42,7 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logProviderIds: text("logProviderIds")
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
@@ -5,22 +5,26 @@ import { z } from "zod";
import { organization } from "./account";
import { encryptedText } from "./utils";
export const logProviderType = pgEnum("LogProviderType", [
export const telemetryProviderType = pgEnum("TelemetryProviderType", [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
"prometheus_remote_write",
"new_relic",
"influxdb",
]);
export const logProvider = pgTable("logProvider", {
logProviderId: text("logProviderId")
export const telemetryProvider = pgTable("telemetryProvider", {
telemetryProviderId: text("telemetryProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: logProviderType("providerType").notNull(),
providerType: telemetryProviderType("providerType").notNull(),
signals: text("signals").array().notNull(),
endpoint: encryptedText("endpoint"),
apiKey: encryptedText("apiKey"),
apiSecret: encryptedText("apiSecret"),
@@ -34,17 +38,11 @@ export const logProvider = pgTable("logProvider", {
.references(() => organization.id, { onDelete: "cascade" }),
});
const createSchema = createInsertSchema(logProvider, {
logProviderId: z.string().min(1),
const createSchema = createInsertSchema(telemetryProvider, {
telemetryProviderId: z.string().min(1),
name: z.string().min(1),
providerType: z.enum([
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
]),
providerType: z.enum(telemetryProviderType.enumValues),
signals: z.array(z.enum(["logs", "metrics"])).min(1),
endpoint: z
.string()
.min(1)
@@ -60,28 +58,33 @@ const createSchema = createInsertSchema(logProvider, {
organizationId: z.string().min(1),
});
export const apiCreateLogProvider = createSchema
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 });
.required({ name: true, providerType: true, signals: true });
export const apiUpdateLogProvider = apiCreateLogProvider.partial().extend({
logProviderId: z.string().min(1),
export const apiUpdateTelemetryProvider = apiCreateTelemetryProvider
.partial()
.extend({
telemetryProviderId: z.string().min(1),
});
export const apiRemoveTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiRemoveLogProvider = z.object({
logProviderId: z.string().min(1),
export const apiFindOneTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiFindOneLogProvider = z.object({
logProviderId: z.string().min(1),
export const apiTestTelemetryProvider = apiCreateTelemetryProvider.partial({
name: true,
});
export const apiTestLogProvider = apiCreateLogProvider.partial({ name: true });
@@ -27,11 +27,11 @@ export const webServerSettings = pgTable("webServerSettings", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
logProviderIds: text("logProviderIds")
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
logManagementOrganizationId: text("logManagementOrganizationId").references(
vectorAgentOrganizationId: text("vectorAgentOrganizationId").references(
() => organization.id,
{ onDelete: "set null" },
),
@@ -97,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,
@@ -111,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),
@@ -141,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),
@@ -234,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: {}

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