Merge pull request #5624 from Dokploy/feat/logs-and-metrics

feat(logs-and-metrics): ship container logs and metrics to external providers via Vector
This commit is contained in:
Yash Kumar
2026-10-15 00:08:48 +05:30
committed by GitHub
76 changed files with 18326 additions and 74 deletions
+1
View File
@@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
@@ -33,8 +33,16 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -54,6 +62,72 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
const compose = {
appName: "dollar-stack",
composeFile: `
services:
mapped:
image: alpine:latest
labels:
custom: keep
deploy:
labels:
custom: keep
listed:
image: alpine:latest
labels:
- custom=keep
`,
composeId: "compose-1",
name: "app $DB_PASSWORD",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "env ${HOME}",
projectId: "project-1",
project: { name: "Budget $100", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
serverId: null,
sourceType: "raw",
suffix: "",
} as unknown as Parameters<typeof addDomainToCompose>[0];
const converted = await addDomainToCompose(compose, []);
const escaped = {
"dokploy.project": "Budget $$100",
"dokploy.environment": "env $${HOME}",
"dokploy.application": "app $$DB_PASSWORD",
};
const mapped = converted?.services?.mapped;
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
expect(mapped?.deploy?.labels).toMatchObject({
custom: "keep",
...escaped,
});
const listed = converted?.services?.listed;
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
expect(listed?.labels).toEqual(
expect.arrayContaining(["custom=keep", ...asList]),
);
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
});
});
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findOwnedOrganizations: vi.fn(),
transaction: vi.fn(),
removeVectorAgents: vi.fn(),
calls: [] as string[],
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { organization: { findMany: mocks.findOwnedOrganizations } },
transaction: mocks.transaction,
},
}));
vi.mock("@dokploy/server/services/organization", () => ({
removeVectorAgents: mocks.removeVectorAgents,
}));
const { deleteUserAccountData } = await import(
"@dokploy/server/services/account-deletion"
);
beforeEach(() => {
mocks.calls.length = 0;
mocks.findOwnedOrganizations.mockReset();
mocks.removeVectorAgents
.mockReset()
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
mocks.transaction.mockReset().mockImplementation(async () => {
mocks.calls.push("delete");
return { email: "a@b.c", organizationsDeleted: 2 };
});
});
describe("deleteUserAccountData", () => {
it("removes the Vector agents of every owned organization before deleting the user", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([
{ id: "org-a" },
{ id: "org-b" },
]);
await deleteUserAccountData("user-1");
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
});
it("deletes the user without touching agents when it owns no organization", async () => {
mocks.findOwnedOrganizations.mockResolvedValue([]);
await deleteUserAccountData("user-1");
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
expect(mocks.calls).toEqual(["delete"]);
});
});
@@ -0,0 +1,542 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const aws = vi.hoisted(() => ({
describeLogGroups: vi.fn(),
putMetricData: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: async () => [{ address: "93.184.216.34" }],
}));
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
CloudWatchLogsClient: class {
send = aws.describeLogGroups;
},
DescribeLogGroupsCommand: class {
constructor(public input: unknown) {}
},
}));
vi.mock("@aws-sdk/client-cloudwatch", () => ({
CloudWatchClient: class {
send = aws.putMetricData;
},
PutMetricDataCommand: class {
constructor(public input: unknown) {}
},
}));
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["metrics"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
const originalFetch = global.fetch;
const fetchMock = vi.fn();
const respond = (status: number, body: unknown = "") =>
new Response(
status === 204
? null
: typeof body === "string"
? body
: JSON.stringify(body),
{ status },
);
beforeEach(() => {
fetchMock.mockReset();
aws.describeLogGroups.mockReset();
aws.putMetricData.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
describe("registry", () => {
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
const types = [
"loki",
"prometheus_remote_write",
"new_relic",
"influxdb",
"datadog",
"aws_cloudwatch",
"splunk_hec",
"elasticsearch",
"betterstack",
] as const;
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
[...types].sort(),
);
for (const type of types) {
const adapter = getTelemetryProviderAdapter(type);
expect(adapter.type).toBe(type);
expect(adapter.signals.length).toBeGreaterThan(0);
expect(
adapter.signals.every((s) => s === "logs" || s === "metrics"),
).toBe(true);
}
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
"metrics",
]);
expect(telemetryProviderAdapters.datadog.signals).toEqual([
"logs",
"metrics",
]);
});
});
describe("prometheusRemoteWriteAdapter", () => {
const adapter = telemetryProviderAdapters.prometheus_remote_write;
const endpoint = "http://prom.example.com:9090/api/v1/write";
it("builds a sink without auth, with the healthcheck disabled", () => {
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint },
"sink_1",
"dokploy_metrics_scope",
"metrics",
);
expect(sink).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
endpoint,
healthcheck: { enabled: false },
});
expect(sink.auth).toBeUndefined();
expect(sink.tenant_id).toBeUndefined();
});
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
const basic = adapter.toVectorSink(
{
...baseConfig,
endpoint,
apiKey: "pw",
extraConfig: { username: "12345", tenantId: "team-a" },
},
"sink_1",
"scope",
"metrics",
);
expect(basic.auth).toEqual({
strategy: "basic",
user: "12345",
password: "pw",
});
expect(basic.tenant_id).toBe("team-a");
const bearer = adapter.toVectorSink(
{ ...baseConfig, endpoint, apiKey: "tok" },
"sink_1",
"scope",
"metrics",
);
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
});
it("rejects an endpoint that is not an http(s) URL", () => {
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
).toThrow(/valid http\(s\) URL/);
expect(() =>
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
).toThrow(/valid http\(s\) URL/);
});
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(400));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).resolves.toBeUndefined();
fetchMock.mockResolvedValueOnce(respond(401));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
).rejects.toThrow(/rejected the credentials \(status 401\)/);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(
adapter.testConnection?.({ ...baseConfig, endpoint }),
).rejects.toThrow(/not found at this URL \(status 404\)/);
});
it("sends the auth header and the tenant header on the test request", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await adapter.testConnection?.({
...baseConfig,
endpoint,
apiKey: "tok",
extraConfig: { tenantId: "team-a" },
});
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.method).toBe("POST");
expect(init.headers).toMatchObject({
Authorization: "Bearer tok",
"X-Scope-OrgID": "team-a",
});
});
});
describe("newRelicAdapter", () => {
const adapter = telemetryProviderAdapters.new_relic;
const config = {
...baseConfig,
apiKey: "license",
extraConfig: { accountId: "123" },
};
it("defaults to the us region and accepts eu", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "new_relic",
api: "metrics",
license_key: "license",
account_id: "123",
region: "us",
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
"s",
"scope",
"metrics",
).region,
).toBe("eu");
});
it("rejects an unknown region", () => {
expect(() =>
adapter.validateConfig?.({
...config,
extraConfig: { accountId: "123", region: "jp" },
}),
).toThrow(/"us" or "eu"/);
});
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
fetchMock.mockResolvedValueOnce(respond(202));
await expect(
adapter.testConnection?.({
...config,
extraConfig: { accountId: "123", region: "eu" },
}),
).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"https://metric-api.eu.newrelic.com/metric/v1",
);
fetchMock.mockResolvedValueOnce(respond(403));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the license key/,
);
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
});
});
describe("influxdbAdapter", () => {
const adapter = telemetryProviderAdapters.influxdb;
const config = {
...baseConfig,
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
};
it("builds an influxdb_metrics v2 sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
type: "influxdb_metrics",
inputs: ["scope"],
endpoint: "http://influx.example.com:8086",
org: "dokploy",
bucket: "metrics",
token: "token",
default_namespace: "dokploy",
version: "2",
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
});
});
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
fetchMock.mockResolvedValueOnce(respond(204));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()[0]).toBe(
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
);
fetchMock.mockResolvedValueOnce(respond(401));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/rejected the token/,
);
fetchMock.mockResolvedValueOnce(respond(404));
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/organization or bucket not found/,
);
});
});
describe("datadogAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.datadog;
const config = {
...baseConfig,
signals: ["logs", "metrics"] as const,
apiKey: "dd",
};
it("ships metrics through datadog_metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
).toBeNull();
expect(
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
).toMatchObject({
type: "datadog_metrics",
inputs: ["scope"],
default_api_key: "dd",
site: "datadoghq.com",
});
});
it("keeps the logs sink and ddtags transform unchanged", () => {
expect(
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
).toMatchObject({ type: "remap", inputs: ["scope"] });
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
"datadog_logs",
);
});
});
describe("betterStackAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.betterstack;
const config = {
...baseConfig,
endpoint: "in.logs.betterstack.com",
apiKey: "token",
};
it("posts metrics to /metrics without a transform", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "http",
uri: "https://in.logs.betterstack.com/metrics",
auth: { strategy: "bearer", token: "token" },
},
);
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
"https://in.logs.betterstack.com/",
);
});
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
fetchMock.mockResolvedValue(respond(202));
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
expect(calledUrls()).toEqual([
"https://in.logs.betterstack.com/",
"https://in.logs.betterstack.com/metrics",
]);
fetchMock.mockClear();
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
});
});
describe("awsCloudwatchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const config = {
...baseConfig,
apiKey: "AKIA",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
};
it("scopes the log fields to logs and the namespace to metrics", () => {
const bySignal = Object.fromEntries(
adapter.credentialFields.map((f) => [f.key, f.signal]),
);
expect(bySignal).toMatchObject({
logGroup: "logs",
logStream: "logs",
metricsNamespace: "metrics",
});
expect(bySignal.region).toBeUndefined();
});
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
const bad = {
...config,
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
};
expect(() => adapter.validateConfig?.(bad)).toThrow(
/cannot start with AWS\//,
);
expect(() =>
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
).not.toThrow();
});
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
const transform = adapter.toVectorTransform?.(
config,
"t",
"scope",
"metrics",
);
expect(transform).toMatchObject({
type: "remap",
inputs: ["scope"],
drop_on_abort: true,
});
expect(transform?.source).toContain(
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
);
expect(transform?.source).toContain('.namespace = "Dokploy"');
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
type: "aws_cloudwatch_metrics",
inputs: ["t"],
default_namespace: "Dokploy",
region: "us-east-1",
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
});
});
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
aws.putMetricData.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
expect(aws.describeLogGroups).not.toHaveBeenCalled();
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
input: { Namespace: "Dokploy" },
});
aws.putMetricData.mockRejectedValue(
Object.assign(new Error("denied"), { name: "AccessDenied" }),
);
await expect(
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
});
it("tests logs with DescribeLogGroups only", async () => {
aws.describeLogGroups.mockResolvedValue({});
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
expect(aws.putMetricData).not.toHaveBeenCalled();
});
});
describe("splunkAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const config = {
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { metricsIndex: "dokploy_metrics" },
};
it("requires the metrics index only for metrics", () => {
const field = adapter.credentialFields.find(
(f) => f.key === "metricsIndex",
);
expect(field).toMatchObject({ required: true, signal: "metrics" });
expect(
adapter.credentialFields.find((f) => f.key === "index")?.signal,
).toBe("logs");
});
it("builds a splunk_hec_metrics sink", () => {
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "splunk_hec_metrics",
endpoint: "https://splunk.example.com:8088",
default_token: "hec",
index: "dokploy_metrics",
source: "dokploy",
sourcetype: "dokploy:metrics",
},
);
});
it("writes a test metric and translates HEC code 7", async () => {
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector",
]);
fetchMock.mockResolvedValueOnce(
respond(400, { text: "Incorrect index", code: 7 }),
);
await expect(adapter.testConnection?.(config)).rejects.toThrow(
/can't write to the metrics index "dokploy_metrics"/,
);
});
it("only checks the health endpoint for logs", async () => {
fetchMock.mockResolvedValueOnce(respond(200));
await adapter.testConnection?.({ ...config, signals: ["logs"] });
expect(calledUrls()).toEqual([
"https://splunk.example.com:8088/services/collector/health",
]);
});
});
describe("elasticsearchAdapter with metrics", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
expect(
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
).toBeNull();
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
{
type: "elasticsearch",
api_version: "v8",
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
},
);
expect(
adapter.toVectorSink(
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
"s",
"scope",
"metrics",
).bulk,
).toEqual({ index: "metrics-custom" });
});
it("keeps the logs sink without a bulk index unless one is set", () => {
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
});
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServersByOrganizationForVectorAgent: vi.fn(),
getWebServerSettings: vi.fn(),
releaseWebServerAgent: vi.fn(),
removeVectorAgent: vi.fn(),
deleteWhere: vi.fn(),
}));
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
vi.mock("@dokploy/server/db", () => ({
db: { delete: () => ({ where: mocks.deleteWhere }) },
}));
vi.mock("@dokploy/server/services/server", () => ({
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
releaseWebServerAgent: mocks.releaseWebServerAgent,
}));
vi.mock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
const { deleteOrganization } = await import(
"@dokploy/server/services/organization"
);
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.deleteWhere.mockResolvedValue({ count: 1 });
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("deleteOrganization", () => {
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
{ serverId: "server-3", telemetryProviderIds: [] },
]);
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-1") throw new Error("ssh down");
});
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
it("leaves the local agent alone when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-b",
});
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
});
it("releases the local agent even when removing it fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
await deleteOrganization("org-a");
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
expect(mocks.deleteWhere).toHaveBeenCalled();
});
});
@@ -0,0 +1,220 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns", () => ({
lookup: mocks.lookup,
}));
const loadModule = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
return await import(
"@dokploy/server/services/logs-and-metrics/provider-fetch"
);
};
const loadFetch = async (isCloud: boolean) =>
(await loadModule(isCloud)).providerFetch;
const resolvesTo = (...addresses: string[]) =>
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(
null,
addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4,
})),
),
);
describe("providerFetch — blocked endpoint guard", () => {
const originalFetch = global.fetch;
beforeEach(() => {
mocks.lookup.mockReset();
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
const providerFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
providerFetch(`http://${address}/latest/meta-data/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
const providerFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
providerFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not block a hostname just because it starts like a link-local prefix", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const providerFetch = await loadFetch(false);
await expect(
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects loopback and private-network endpoints in cloud", async () => {
const providerFetch = await loadFetch(true);
for (const url of [
"http://10.0.0.5:3100/ready",
"http://127.0.0.1:3100/ready",
"http://localhost:3100/ready",
"http://[::1]:3100/ready",
"http://0.0.0.0:3100/ready",
"http://100.100.100.200/latest/meta-data/",
"http://[::]:3100/ready",
]) {
await expect(
providerFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not follow redirects", async () => {
const providerFetch = await loadFetch(false);
await providerFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
});
describe("providerFetch — address checked at connect time", () => {
let server: Server;
let port: number;
let hostHeader: string | undefined;
beforeEach(async () => {
mocks.lookup.mockReset();
hostHeader = undefined;
server = createServer((req, res) => {
hostHeader = req.headers.host;
res.end("ok");
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
port = (server.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("rejects a hostname whose lookup returns a metadata address", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("203.0.113.10", "169.254.169.254");
await expect(
providerFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(mocks.lookup).toHaveBeenCalledWith(
"attacker-controlled.example.com",
expect.anything(),
expect.any(Function),
);
});
it("rejects a hostname resolving to a private address in cloud", async () => {
const providerFetch = await loadFetch(true);
resolvesTo("127.0.0.1");
await expect(
providerFetch(`http://loki.internal.example.com:${port}/ready`),
).rejects.toThrow(/blocked/i);
expect(hostHeader).toBeUndefined();
});
it("rejects when DNS resolution fails", async () => {
const providerFetch = await loadFetch(false);
mocks.lookup.mockImplementation(
(_host: string, _options: unknown, callback: Function) =>
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
);
await expect(
providerFetch("http://does-not-exist.invalid/"),
).rejects.toThrow();
expect(hostHeader).toBeUndefined();
});
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
const providerFetch = await loadFetch(false);
resolvesTo("127.0.0.1");
const response = await providerFetch(
`http://loki.internal.example.com:${port}/ready`,
);
expect(await response.text()).toBe("ok");
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
});
it("passes a public address through in cloud", async () => {
const { guardedLookup } = await loadModule(true);
resolvesTo("203.0.113.10");
const result = await new Promise((resolve, reject) =>
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
error ? reject(error) : resolve(addresses),
),
);
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
describe("providerLabel", () => {
it("names what the provider sends next to its name", () => {
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
"Loki prod (logs)",
);
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
"Splunk (metrics)",
);
expect(
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
).toBe("Datadog (logs and metrics)");
});
});
@@ -0,0 +1,101 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleted: [] as unknown[],
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
updatedServers: [] as Array<{ serverId: string }>,
updatedLocal: [] as Array<{ id: string }>,
queried: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => ({
returning: () => {
mocks.updates.push({ table, values });
return Promise.resolve(
mocks.updates.length === 1
? mocks.updatedServers
: mocks.updatedLocal,
);
},
}),
}),
}),
};
return {
db: {
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
query: {
server: { findMany: mocks.queried },
webServerSettings: { findFirst: mocks.queried },
},
},
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeTelemetryProvider } = await import(
"@dokploy/server/services/logs-and-metrics/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeTelemetryProvider", () => {
beforeEach(() => {
mocks.updates = [];
mocks.updatedServers = [{ serverId: "server-1" }];
mocks.updatedLocal = [{ id: "ws-1" }];
mocks.queried.mockReset();
});
it("drops the provider id from the selection of every server and of the local host", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
const { provider, targets } = await removeTelemetryProvider("lp-1");
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
expect(targets).toEqual(["server-1", null]);
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
'array_remove("server"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
);
});
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
mocks.updatedLocal = [];
const { targets } = await removeTelemetryProvider("lp-1");
expect(targets).toEqual(["server-1", "server-2"]);
expect(mocks.queried).not.toHaveBeenCalled();
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -0,0 +1,392 @@
import {
getTelemetryProviderAdapter,
telemetryProviderAdapters,
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
import { describe, expect, it } from "vitest";
const baseConfig: TelemetryProviderRuntimeConfig = {
telemetryProviderId: "provider-1",
name: "test",
signals: ["logs"],
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getTelemetryProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
"elasticsearch",
);
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
"aws_cloudwatch",
);
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
/No TelemetryProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("loki");
expect(sink.inputs).toEqual(["dokploy_scope"]);
expect(sink.endpoint).toBe("https://loki.example.com");
expect(sink.labels).toMatchObject({
dokploy_project: "{{ dokploy_project }}",
dokploy_environment: "{{ dokploy_environment }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
dokploy_server: "{{ dokploy_server }}",
});
expect(sink.tenant_id).toBeUndefined();
expect(sink.buffer).toEqual({
type: "disk",
max_size: 268_435_488,
when_full: "block",
});
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = telemetryProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
"logs",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
"logs",
);
expect(sink.type).toBe("datadog_logs");
expect(sink.default_api_key).toBe("dd-key");
expect(sink.site).toBe("datadoghq.com");
expect(sink.tags).toBeUndefined();
expect(sink.inputs).toEqual(["transform_2"]);
});
it("uses extraConfig.site when present", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = telemetryProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
"logs",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
const adapter = telemetryProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
expect(transform?.source).toContain(
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
);
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = telemetryProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
"logs",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = telemetryProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
);
expect(transform).toEqual({
type: "remap",
inputs: ["dokploy_scope"],
source: ".dt = del(.timestamp)",
});
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = telemetryProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
"logs",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
expect(sink.compression).toBe("gzip");
});
});
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
"logs",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain("encode_json(.label)");
});
it("uses basic auth when a username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
expect(sink.auth).toEqual({
strategy: "basic",
user: "elastic",
password: "secret",
});
expect(sink.request).toBeUndefined();
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
headers: { Authorization: "ApiKey key-123" },
});
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
"logs",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = telemetryProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
"logs",
);
expect(withoutIndex.bulk).toBeUndefined();
const withIndex = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { index: "dokploy-%Y.%m.%d" },
},
"sink_4",
"dokploy_scope",
"logs",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.splunk_hec.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = telemetryProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
expect(sink.default_token).toBe("hec-token");
expect(sink.encoding).toEqual({ codec: "json" });
expect(sink.index).toBeUndefined();
expect(sink.sourcetype).toBeUndefined();
const withExtras = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
extraConfig: { index: "dokploy", sourcetype: "docker" },
},
"sink_5",
"dokploy_scope",
"logs",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
expect(
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
"logs",
),
).toBeNull();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
expect(sink.stream_name).toBe("{{ container_name }}");
expect(sink.region).toBe("us-east-1");
expect(sink.auth).toEqual({
access_key_id: "AKIA...",
secret_access_key: "shh",
});
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = telemetryProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
logStream: "{{ dokploy_application }}",
},
},
"sink_6",
"dokploy_scope",
"logs",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -0,0 +1,661 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
serverFindFirst: vi.fn(),
serverFindMany: vi.fn(),
webServerFindFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
telemetryProvider: {
findFirst: mocks.findFirst,
findMany: mocks.findMany,
},
server: {
findFirst: mocks.serverFindFirst,
findMany: mocks.serverFindMany,
},
webServerSettings: { findFirst: mocks.webServerFindFirst },
},
},
}));
const {
assertProvidersBelongToOrg,
assertSignalsSupported,
createTelemetryProvider,
testTelemetryProviderConnection,
updateTelemetryProvider,
} = await import("@dokploy/server/services/logs-and-metrics/service");
const { telemetryProviderAdapters } = await import(
"@dokploy/server/services/logs-and-metrics/providers/registry"
);
beforeEach(() => {
mocks.serverFindMany.mockResolvedValue([]);
mocks.webServerFindFirst.mockResolvedValue(undefined);
});
describe("updateTelemetryProvider — targets to re-apply", () => {
const existing = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old",
apiSecret: null,
extraConfig: null,
enabled: true,
};
beforeEach(() => {
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
});
it("returns the servers and the local host that ship with it when a credential changes", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
apiKey: "new",
});
expect(targets).toEqual(["server-1", null]);
});
it("returns the same targets when the provider is disabled", async () => {
const { targets } = await updateTelemetryProvider("lp-1", {
enabled: false,
});
expect(targets).toEqual(["server-1", null]);
});
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
expect(
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
).toEqual([]);
expect(
(
await updateTelemetryProvider("lp-1", {
endpoint: "https://loki.example.com",
})
).targets,
).toEqual([]);
});
});
describe("createTelemetryProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createTelemetryProvider(
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createTelemetryProvider(
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createTelemetryProvider(
{
name: "bs",
providerType: "betterstack",
signals: ["logs"],
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createTelemetryProvider(
{
name: "splunk",
providerType: "splunk_hec",
signals: ["logs"],
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["logs"],
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createTelemetryProvider(
{
name: "es",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{
telemetryProviderId: "lp-2",
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
},
]);
const created = await createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createTelemetryProvider(
{
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.telemetryProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "old-name",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ telemetryProviderId: "lp-1", name: "new-name" },
]);
const { provider: updated, targets } = await updateTelemetryProvider(
"lp-1",
{ name: "new-name" },
);
expect(updated.name).toBe("new-name");
expect(targets).toEqual([]);
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { endpoint: null }),
).rejects.toThrow(/endpoint/i);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", {
extraConfig: { username: "new-user" },
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("clears an optional credential when it is explicitly set to null", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
await updateTelemetryProvider("lp-1", { apiKey: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: null }),
);
});
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
signals: ["logs"],
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(
updateTelemetryProvider("lp-1", { apiKey: null }),
).rejects.toThrow(/Password/);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateTelemetryProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
describe("assertSignalsSupported", () => {
it("rejects a repeated signal", () => {
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
/only be selected once/,
);
});
it("rejects a signal the type cannot send", () => {
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
/Grafana Loki cannot send metrics/,
);
});
it("accepts a supported subset", () => {
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
});
});
describe("createTelemetryProvider — signals", () => {
it("rejects a provider whose type cannot send the selected signal", async () => {
mocks.insertValues.mockClear();
await expect(
createTelemetryProvider(
{
name: "loki",
providerType: "loki",
signals: ["metrics"],
endpoint: "https://loki.example.com",
} as any,
"org-1",
),
).rejects.toThrow(/cannot send metrics/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("assertProvidersBelongToOrg", () => {
beforeEach(() => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: true,
signals: ["logs"],
},
]);
});
it("accepts a provider of the organization regardless of what it sends", async () => {
await expect(
assertProvidersBelongToOrg(["lp-1"], "org-1"),
).resolves.toBeUndefined();
});
it("rejects an id that is not in the organization", async () => {
await expect(
assertProvidersBelongToOrg(["lp-9"], "org-1"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("rejects a selection where every provider is disabled", async () => {
mocks.findMany.mockResolvedValue([
{
telemetryProviderId: "lp-1",
name: "loki",
enabled: false,
signals: ["logs"],
},
]);
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
/at least one enabled provider/,
);
});
});
describe("required fields scoped to a signal", () => {
beforeEach(() => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
});
it("does not require the log group when only metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("does not require the metrics namespace when only logs are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
});
it("requires the metrics namespace when metrics are selected", async () => {
await expect(
createTelemetryProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
),
).rejects.toThrow(/Metrics namespace/);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
});
describe("signals are stored in a fixed order", () => {
it("normalizes the order on create", async () => {
mocks.insertValues.mockReset();
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await createTelemetryProvider(
{
name: "dd",
providerType: "datadog",
signals: ["metrics", "logs"],
apiKey: "dd-key",
} as any,
"org-1",
);
expect(mocks.insertValues).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
it("normalizes the order on update", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "dd-1",
name: "dd",
providerType: "datadog",
signals: ["logs"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["logs", "metrics"] }),
);
});
});
describe("testTelemetryProviderConnection by id", () => {
it("tests the saved provider with the signals override from the form", async () => {
mocks.findFirst.mockResolvedValue({
telemetryProviderId: "bs-1",
name: "bs",
providerType: "betterstack",
signals: ["logs"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
apiSecret: null,
extraConfig: null,
});
const spy = vi
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
.mockResolvedValue(undefined);
await testTelemetryProviderConnection({
telemetryProviderId: "bs-1",
signals: ["logs", "metrics"],
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: "token",
signals: ["logs", "metrics"],
}),
);
spy.mockRestore();
});
});
describe("updateTelemetryProvider — signals", () => {
const existing = {
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
};
beforeEach(() => {
mocks.updateSet.mockReset();
mocks.serverFindFirst.mockReset();
mocks.webServerFindFirst.mockReset();
mocks.findFirst.mockResolvedValue(existing);
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
});
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
await expect(
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
).resolves.toMatchObject({
provider: { signals: ["metrics"] },
targets: ["server-1", null],
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ signals: ["metrics"] }),
);
});
it("rejects a type change that cannot send the saved signals", async () => {
await expect(
updateTelemetryProvider("dd-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
}),
).rejects.toThrow(/Grafana Loki cannot send metrics/);
});
});
@@ -0,0 +1,305 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
setWebServerProviderIds: vi.fn(),
setServerProviderIds: vi.fn(),
getAccessibleServerIds: vi.fn(),
reconcileVectorAgent: vi.fn(),
removeVectorAgent: vi.fn(),
findServerById: vi.fn(),
assertProvidersBelongToOrg: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
setServerProviderIds: mocks.setServerProviderIds,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
setWebServerProviderIds: mocks.setWebServerProviderIds,
}));
const locks = new Map<string, Promise<unknown>>();
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
LOCAL_SERVER_NAME: "Dokploy Server (local)",
reconcileVectorAgent: mocks.reconcileVectorAgent,
removeVectorAgent: mocks.removeVectorAgent,
withVectorTargetLock: (
serverId: string | undefined,
fn: () => Promise<unknown>,
) => {
const key = serverId ?? "local";
const current = (locks.get(key) ?? Promise.resolve())
.catch(() => {})
.then(fn);
locks.set(key, current);
return current;
},
}));
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
}));
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
name: `name-of-${serverId}`,
organizationId: "org-a",
sshKeyId: "key-1",
}));
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
});
describe("applyVectorAgentSelection on the local host", () => {
it("rejects the local host in cloud without touching the database", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
await expect(
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
mocks.setWebServerProviderIds.mockResolvedValue(null);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
"mp-1",
]);
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the selection and then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
["lp-1"],
"org-a",
);
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
expect(
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
});
it("removing the agent saves an empty selection without checking providers", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, undefined, []);
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
});
});
describe("applyVectorAgentSelection on a remote server", () => {
it("still manages remote servers in cloud", async () => {
const { applyVectorAgentSelection } = await loadAgent(true);
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
expect(result).toEqual({ serverName: "name-of-server-1" });
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
it("rejects a server of another organization", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-b",
sshKeyId: "key-1",
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
});
it("rejects a server without an SSH key before saving anything", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
sshKeyId: null,
});
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("rejects a server the member cannot access", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
});
it("saves the array and only then reconciles", async () => {
const { applyVectorAgentSelection } = await loadAgent(false);
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"mp-1",
]);
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the saved selection when the reconcile fails", async () => {
mocks.reconcileVectorAgent.mockRejectedValue(
new Error("validation failed"),
);
const { applyVectorAgentSelection } = await loadAgent(false);
await expect(
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
"lp-2",
]);
});
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
const saved: string[][] = [];
const seen: string[][] = [];
mocks.setServerProviderIds.mockImplementation(
async (_serverId: string, ids: string[]) => {
saved.push(ids);
return { serverId: "server-1" };
},
);
mocks.reconcileVectorAgent.mockImplementation(async () => {
seen.push(saved.at(-1) ?? []);
await new Promise((resolve) => setTimeout(resolve, 20));
});
const { applyVectorAgentSelection } = await loadAgent(false);
await Promise.all([
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
]);
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
});
});
describe("removeServerVectorAgent", () => {
it("removes the agent on server deletion when providers are assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "key-1",
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
it("skips servers without an SSH key, warning when providers were assigned", async () => {
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: [],
} as never),
).resolves.toBeUndefined();
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: null,
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toMatch(/no SSH key/);
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("returns the failure as a message instead of throwing", async () => {
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeServerVectorAgent } = await loadAgent(false);
await expect(
removeServerVectorAgent({
serverId: "server-1",
sshKeyId: "k",
telemetryProviderIds: ["lp-1"],
} as never),
).resolves.toBe("docker down");
});
});
describe("reconcileVectorTargets", () => {
it("re-applies every target under its lock and reports failures as a warning", async () => {
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
if (serverId === "server-2") throw new Error("ssh down");
});
const { reconcileVectorTargets } = await loadAgent(false);
const warning = await reconcileVectorTargets([
"server-1",
"server-2",
null,
]);
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
"server-1",
"server-2",
undefined,
]);
expect(warning).toBe(
"The Vector agent could not be re-applied on server-2: ssh down",
);
});
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
const { reconcileVectorTargets } = await loadAgent(true);
await expect(
reconcileVectorTargets(["server-1", null]),
).resolves.toBeUndefined();
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
});
});
@@ -0,0 +1,375 @@
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
import {
buildMetricsScopeTransformSource,
buildVectorConfigYaml,
collectSecretValues,
redactSecrets,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
signals: ["logs"],
...overrides,
}) as any;
const loki = provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
});
const prom = provider({
telemetryProviderId: "prom-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
});
const datadog = provider({
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
});
const build = (
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
) =>
parse(
buildVectorConfigYaml({
logProviders: [],
metricsProviders: [],
serverName: "my server",
...overrides,
}),
) as any;
describe("buildVectorConfigYaml — logs", () => {
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
const config = build({ logProviders: [loki] });
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
expect(config.sinks["sink_loki-1"].type).toBe("loki");
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(config.sources.cadvisor_source).toBeUndefined();
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
const config = build({
logProviders: [
loki,
provider({
telemetryProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
datadog,
],
});
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_server = "srv \\"quoted\\""',
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n"),
);
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
const config = build({
logProviders: [
provider({
telemetryProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
loki,
],
});
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
const config = build({ logProviders: [loki], organizationId: "org-a" });
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the organization filter for the per-server (non-local) path", () => {
const config = build({ logProviders: [loki] });
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
describe("buildVectorConfigYaml — metrics", () => {
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
const config = build({ metricsProviders: [prom] });
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.transforms.dokploy_scope).toBeUndefined();
expect(config.sources.host_metrics_source).toMatchObject({
type: "host_metrics",
scrape_interval_secs: 30,
collectors: [
"cpu",
"memory",
"disk",
"filesystem",
"network",
"load",
"host",
],
});
expect(
config.sources.host_metrics_source.filesystem.filesystems.excludes,
).toEqual(
expect.arrayContaining([
"overlay",
"tmpfs",
"squashfs",
"devtmpfs",
"nsfs",
"proc",
"sysfs",
"devpts",
"cgroup2",
]),
);
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
["veth*", "br-*", "docker*"],
);
expect(config.sources.cadvisor_source).toEqual({
type: "prometheus_scrape",
endpoints: ["http://127.0.0.1:4510/metrics"],
scrape_interval_secs: 30,
scrape_timeout_secs: 10,
});
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
type: "remap",
inputs: ["host_metrics_source", "cadvisor_source"],
drop_on_abort: true,
});
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
type: "prometheus_remote_write",
inputs: ["dokploy_metrics_scope"],
});
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
});
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_metrics_scope"],
condition:
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
"dokploy_metrics_scope_local_only",
]);
});
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
const config = build({
logProviders: [datadog],
metricsProviders: [datadog],
organizationId: "org-a",
});
expect(config.sinks["sink_dd-1"]).toMatchObject({
type: "datadog_logs",
inputs: ["transform_dd-1"],
});
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
type: "datadog_metrics",
inputs: ["dokploy_metrics_scope_local_only"],
});
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
});
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
const source = buildMetricsScopeTransformSource('srv "quoted"');
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
expect(source).toContain(
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
);
expect(source).toContain(
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
);
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
expect(
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
.source,
).toBe(buildMetricsScopeTransformSource("my server"));
});
});
describe("buildVectorConfigYaml — dollar signs", () => {
it("keeps every $ literal because the agent runs without env var interpolation", () => {
const yamlStr = buildVectorConfigYaml({
logProviders: [
provider({
telemetryProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
],
metricsProviders: [prom],
serverName: "srv $ONE",
});
expect(yamlStr).not.toContain("$$");
expect(yamlStr).toContain("srv $ONE");
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
"Pa$5w0rd$HOME",
);
});
});
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
const providers = [
{
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a", username: "12345" },
},
{
providerType: "datadog",
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
{
providerType: "aws_cloudwatch",
endpoint: null,
apiKey: "AKIA",
apiSecret: "shh",
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
},
] as any;
const values = collectSecretValues(providers);
expect(values).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"dd-key",
"AKIA",
"shh",
]),
);
for (const plain of [
"tenant-a",
"12345",
"datadoghq.eu",
"us-east-1",
"Dokploy",
]) {
expect(values).not.toContain(plain);
}
});
it("ignores null/empty credentials", () => {
const providers = [
{
providerType: "elasticsearch",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
providerType: "loki",
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: "hunter2",
extraConfig: null,
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
describe("vrlString", () => {
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
});
});
@@ -0,0 +1,526 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getWebServerSettings: vi.fn(),
findEnabledTelemetryProvidersByOrganization: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
inspectService: vi.fn(),
updateService: vi.fn(),
removeService: vi.fn(),
createService: vi.fn(),
getService: vi.fn(),
listContainers: vi.fn(),
killContainer: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: (isServer: boolean) => ({
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
}),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findEnabledTelemetryProvidersByOrganization:
mocks.findEnabledTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
getService: mocks.getService,
createService: mocks.createService,
listContainers: mocks.listContainers,
getContainer: () => ({ kill: mocks.killContainer }),
}),
}));
const { reconcileVectorAgent, removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
const lokiProvider = {
telemetryProviderId: "lp-1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "https://loki.example.com",
apiKey: "sekret-token",
apiSecret: null,
extraConfig: null,
enabled: true,
organizationId: "org-a",
};
const promProvider = {
...lokiProvider,
telemetryProviderId: "mp-1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: null,
};
const notFound = () =>
Object.assign(new Error("not found"), { statusCode: 404 });
const writtenYaml = (command: string) => {
const match = command.match(/echo "([^"]+)" \| base64 -d/);
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
};
const commandsOf = (mock: typeof mocks.execAsync) =>
mock.mock.calls.map((call) => String(call[call.length - 1]));
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.getService.mockImplementation(() => ({
inspect: mocks.inspectService,
update: mocks.updateService,
remove: mocks.removeService,
}));
mocks.inspectService.mockRejectedValue(notFound());
mocks.removeService.mockRejectedValue(notFound());
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
mocks.killContainer.mockResolvedValue(undefined);
});
describe("reconcileVectorAgent", () => {
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const commands = commandsOf(mocks.execAsync);
expect(commands).toHaveLength(3);
expect(commands[0]).toContain(
"/local/dokploy/vector/vector.yaml.candidate.",
);
expect(commands[1]).toMatch(
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
);
expect(commands[2]).toMatch(
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
const config = writtenYaml(commands[0] ?? "");
expect(config.sinks["sink_lp-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
expect(config.sources.host_metrics_source).toBeUndefined();
expect(mocks.createService).toHaveBeenCalledTimes(1);
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
Name: "dokploy-vector",
});
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
const datadogProvider = {
...lokiProvider,
telemetryProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
endpoint: null,
apiKey: "dd-key",
};
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
promProvider,
datadogProvider,
unselected,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
const sinkIds = Object.keys(config.sinks);
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
expect(config.sources.docker_logs_source).toBeDefined();
expect(config.sources.cadvisor_source).toBeDefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
});
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1", "mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.cadvisor_source).toBeUndefined();
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
]);
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.removeService).toHaveBeenCalledTimes(1);
});
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
await reconcileVectorAgent("server-1");
expect(mocks.execAsync).not.toHaveBeenCalled();
expect(
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
).toBe(true);
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
});
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.includes("validate")) {
throw new Error("bad sink: password sekret-token rejected");
}
return { stdout: "", stderr: "" };
});
await expect(reconcileVectorAgent()).rejects.toThrow(
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
);
const commands = commandsOf(mocks.execAsync);
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when no enabled provider is left in the selection", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["deleted-or-disabled"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent();
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
expect(mocks.createService).not.toHaveBeenCalled();
});
it("removes the agent when the selection is empty, without an owner", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: [],
});
await reconcileVectorAgent();
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).not.toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
});
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["mp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
promProvider,
]);
await reconcileVectorAgent();
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
expect(config.sources.docker_logs_source).toBeUndefined();
expect(config.sources.cadvisor_source.endpoints).toEqual([
"http://127.0.0.1:4510/metrics",
]);
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
"dokploy-vector",
"dokploy-cadvisor",
]);
const cadvisor = mocks.createService.mock.calls[1]?.[0];
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
);
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
expect.arrayContaining([
"--listen_ip=127.0.0.1",
"--port=4510",
"--docker_only=true",
"--store_container_labels=false",
]),
);
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
Test: ["NONE"],
});
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
expect(mocks.removeService).not.toHaveBeenCalled();
});
it("removes the agent on a remote server when the selection is empty", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: "key-1",
organizationId: "org-a",
name: "edge-1",
telemetryProviderIds: [],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
await reconcileVectorAgent("server-1");
expect(
mocks.findEnabledTelemetryProvidersByOrganization,
).toHaveBeenCalled();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(mocks.createService).not.toHaveBeenCalled();
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
"rm -rf /etc/dokploy/vector",
]);
});
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
const echoCommand = async (command: string) => {
if (command.includes("base64 -d") || command.startsWith("mv ")) {
throw new Error(`Command failed: ${command} (token sekret-token)`);
}
return { stdout: "", stderr: "" };
};
mocks.execAsync.mockImplementation(echoCommand);
const writeError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(writeError).toMatch(/^Failed to write the Vector config: /);
expect(writeError).not.toContain("sekret-token");
expect(writeError).not.toContain(
Buffer.from("data_dir").toString("base64").slice(0, 8),
);
expect(writeError).toContain("[redacted]");
mocks.execAsync.mockImplementation(async (command: string) => {
if (command.startsWith("mv ")) return echoCommand(command);
return { stdout: "", stderr: "" };
});
const applyError = await reconcileVectorAgent().catch(
(e: Error) => e.message,
);
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
expect(applyError).not.toContain("sekret-token");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
mocks.updateService.mockRejectedValue(
new Error("rpc error: invalid mount"),
);
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("propagates an inspect failure that is not a 404", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
);
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
expect(mocks.createService).not.toHaveBeenCalled();
});
it("does not restart Vector on a config-only redeploy", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockImplementation(async () => ({
Version: { Index: "7" },
Spec: {
TaskTemplate: {
ForceUpdate: 2,
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
? undefined
: undefined,
},
},
}));
await reconcileVectorAgent();
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
expect(mocks.listContainers).toHaveBeenCalledWith({
filters: JSON.stringify({
label: ["com.docker.swarm.service.name=dokploy-vector"],
}),
});
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
mocks.inspectService.mockResolvedValue({
Version: { Index: "8" },
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
});
mocks.updateService.mockClear();
await reconcileVectorAgent();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 8,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
it("updates an existing Vector service instead of creating it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-a",
telemetryProviderIds: ["lp-1"],
});
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
lokiProvider,
]);
mocks.inspectService.mockResolvedValue({
Version: { Index: "7" },
Spec: { TaskTemplate: { ForceUpdate: 2 } },
});
await reconcileVectorAgent();
expect(mocks.createService).not.toHaveBeenCalled();
expect(mocks.updateService).toHaveBeenCalledWith(
expect.objectContaining({
version: 7,
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
}),
);
});
});
describe("removeVectorAgent", () => {
it("removes cAdvisor, then Vector, then the config directory", async () => {
await removeVectorAgent();
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
"dokploy-cadvisor",
"dokploy-vector",
]);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("keeps going when cAdvisor cannot be removed", async () => {
mocks.removeService
.mockRejectedValueOnce(new Error("docker hiccup"))
.mockRejectedValueOnce(notFound());
await removeVectorAgent();
expect(mocks.removeService).toHaveBeenCalledTimes(2);
expect(commandsOf(mocks.execAsync)).toEqual([
"rm -rf /local/dokploy/vector",
]);
});
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getService).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForVectorAgent: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: vi.fn(),
findTelemetryProvidersByOrganization: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
findServersByOrganizationForVectorAgent:
mocks.findServersByOrganizationForVectorAgent,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/logs-and-metrics/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/logs-and-metrics/service")
>()),
findTelemetryProvidersByOrganization:
mocks.findTelemetryProvidersByOrganization,
}),
);
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getVectorAgentTargets, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getVectorAgentTargets", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
]);
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1"],
});
});
it("requires a running cAdvisor task when the target ships metrics", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockImplementation(
async ({ filters }: { filters: string }) =>
JSON.parse(filters).service[0] === "dokploy-vector"
? [{ Status: { State: "running" } }]
: [],
);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
});
it("reports running when both Vector and cAdvisor have a running task", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["mp-1"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
});
it("does not look at cAdvisor for a logs-only target", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
await getVectorAgentTargets(session);
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: "org-1",
telemetryProviderIds: ["lp-1", "mp-off"],
});
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledWith({
filters: JSON.stringify({
service: ["dokploy-vector"],
"desired-state": ["running"],
}),
});
});
it("reports not-running when the service has no running task", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("not-running");
});
it("reports stopped when the service does not exist", async () => {
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("not found"), { statusCode: 404 }),
);
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("stopped");
expect(mocks.listTasks).not.toHaveBeenCalled();
});
it("reports unknown on any other docker error", async () => {
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
const [local] = await getVectorAgentTargets(session);
expect(local?.status).toBe("unknown");
});
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
mocks.getWebServerSettings.mockResolvedValue({
vectorAgentOrganizationId: null,
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
});
mocks.listTasks.mockResolvedValue([]);
const [local] = await getVectorAgentTargets(session);
expect(local).toMatchObject({
serverId: null,
telemetryProviderIds: [],
});
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
{
serverId: "server-1",
name: "one",
ipAddress: "1.1.1.1",
telemetryProviderIds: [],
},
{
serverId: "server-2",
name: "two",
ipAddress: "2.2.2.2",
telemetryProviderIds: [],
},
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getVectorAgentTargets(session);
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
});
});
describe("withVectorTargetLock", () => {
it("runs operations on the same target one after another", async () => {
const events: string[] = [];
let releaseFirst = () => {};
const first = withVectorTargetLock("server-1", async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
throw new Error("first failed");
});
const second = withVectorTargetLock("server-1", async () => {
events.push("second");
});
const other = withVectorTargetLock("server-2", async () => {
events.push("other");
});
await other;
expect(events).toEqual(["first:start", "other"]);
releaseFirst();
await expect(first).rejects.toThrow("first failed");
await second;
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
});
});
@@ -0,0 +1,197 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
buildVectorConfigYaml,
VECTOR_IMAGE,
} from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
const dockerAvailable = (() => {
try {
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
return true;
} catch {
return false;
}
})();
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
enabled: true,
...overrides,
}) as any;
const logsOnly = [
provider({
telemetryProviderId: "loki1",
name: "loki",
providerType: "loki",
signals: ["logs"],
endpoint: "http://loki.example.com:3100",
apiKey: "Pa$5w0rd$HOME",
extraConfig: { username: "12345" },
}),
];
const metricsOnly = [
provider({
telemetryProviderId: "prom1",
name: "prom",
providerType: "prometheus_remote_write",
signals: ["metrics"],
endpoint: "http://prom.example.com:9090/api/v1/write",
apiKey: "token",
extraConfig: { tenantId: "team-a" },
}),
provider({
telemetryProviderId: "nr1",
name: "new relic",
providerType: "new_relic",
signals: ["metrics"],
apiKey: "license",
extraConfig: { accountId: "123", region: "eu" },
}),
provider({
telemetryProviderId: "influx1",
name: "influx",
providerType: "influxdb",
signals: ["metrics"],
endpoint: "http://influx.example.com:8086",
apiKey: "token",
extraConfig: { org: "dokploy", bucket: "metrics" },
}),
];
const both = [
provider({
telemetryProviderId: "dd1",
name: "datadog",
providerType: "datadog",
signals: ["logs", "metrics"],
apiKey: "dd-key",
}),
provider({
telemetryProviderId: "cw1",
name: "cloudwatch",
providerType: "aws_cloudwatch",
signals: ["logs", "metrics"],
apiKey: "AKIAFAKE",
apiSecret: "secret",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
metricsNamespace: "Dokploy",
},
}),
provider({
telemetryProviderId: "splunk1",
name: "splunk",
providerType: "splunk_hec",
signals: ["logs", "metrics"],
endpoint: "https://splunk.example.com:8088",
apiKey: "hec",
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
}),
provider({
telemetryProviderId: "es1",
name: "elastic",
providerType: "elasticsearch",
signals: ["logs", "metrics"],
endpoint: "http://es.example.com:9200",
apiKey: "pw",
extraConfig: { username: "elastic" },
}),
provider({
telemetryProviderId: "bs1",
name: "better stack",
providerType: "betterstack",
signals: ["logs", "metrics"],
endpoint: "in.logs.betterstack.com",
apiKey: "token",
}),
];
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
[
"local logs only",
{
logProviders: logsOnly,
metricsProviders: [],
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local metrics only",
{
logProviders: [],
metricsProviders: metricsOnly,
organizationId: "org_a",
serverName: "Dokploy Server (local)",
},
],
[
"local every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
organizationId: "org_a",
serverName: 'Dokploy "Server" $HOSTNAME (local)',
},
],
[
"remote metrics only",
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
],
[
"remote every provider on both signals",
{
logProviders: [...logsOnly, ...both],
metricsProviders: [...metricsOnly, ...both],
serverName: "edge-1",
},
],
];
describe.skipIf(!dockerAvailable)(
"vector validate on generated configs",
() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
it.each(cases)(
"%s",
(name, input) => {
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
writeFileSync(file, buildVectorConfigYaml(input));
const output = execFileSync(
"docker",
[
"run",
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock:ro",
"-v",
`${file}:/etc/vector/vector.yaml:ro`,
VECTOR_IMAGE,
"validate",
"--skip-healthchecks",
"/etc/vector/vector.yaml",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 120_000,
},
);
expect(output).toContain("Validated");
},
150_000,
);
},
);
@@ -0,0 +1,86 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findFirst: vi.fn(),
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
returning: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { webServerSettings: { findFirst: mocks.findFirst } },
update: () => ({
set: (values: Record<string, unknown>) => ({
where: (where: unknown) => {
mocks.updates.push({ values, where });
return { returning: () => Promise.resolve(mocks.returning()) };
},
}),
}),
},
}));
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
"@dokploy/server/services/web-server-settings"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
beforeEach(() => {
mocks.updates = [];
mocks.findFirst.mockResolvedValue({
id: "ws-1",
vectorAgentOrganizationId: "org-a",
});
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
});
describe("setWebServerProviderIds", () => {
it("claims the local agent for the organization when assigning providers", async () => {
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
expect(row).toEqual({ id: "ws-1" });
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: ["lp-1", "mp-1"],
vectorAgentOrganizationId: "org-a",
});
expect(render(update?.where)).toBe(
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
);
});
it("releases the owner when the selection is cleared", async () => {
await setWebServerProviderIds("org-a", []);
expect(mocks.updates[0]?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
});
it("returns null when another organization owns the local agent", async () => {
mocks.returning.mockReturnValue([]);
await expect(
setWebServerProviderIds("org-b", ["lp-9"]),
).resolves.toBeNull();
});
});
describe("releaseWebServerAgent", () => {
it("clears the selection and the owner only for the owning organization", async () => {
await releaseWebServerAgent("org-a");
const update = mocks.updates[0];
expect(update?.values).toMatchObject({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
});
expect(render(update?.where)).toBe(
'"webServerSettings"."vectorAgentOrganizationId" = $1',
);
});
});
@@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses telemetryProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses telemetryProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied telemetryProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { telemetryProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"telemetryProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.telemetryProvider.read).toBe(false);
});
});
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -0,0 +1,575 @@
import type {
TelemetryProviderType,
TelemetrySignal,
} from "@dokploy/server/services/logs-and-metrics/types";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
const SIGNAL_OPTIONS: Array<{
value: string;
label: string;
signals: TelemetrySignal[];
}> = [
{ value: "logs", label: "Logs", signals: ["logs"] },
{ value: "metrics", label: "Metrics", signals: ["metrics"] },
{
value: "logs,metrics",
label: "Logs & Metrics",
signals: ["logs", "metrics"],
},
];
const providerForm = z.object({
name: z.string().min(1, "Name is required"),
providerType: z.string().min(1, "Provider is required"),
signals: z.string().min(1, "Select what this provider sends"),
enabled: z.boolean(),
fields: z.record(z.string(), z.string().optional()),
});
type ProviderForm = z.infer<typeof providerForm>;
const toSignals = (value: string) =>
SIGNAL_OPTIONS.find((option) => option.value === value)?.signals ?? [];
interface Props {
telemetryProviderId?: string;
}
export const agentsUsingProvider = (
targets:
| Array<{
name: string;
telemetryProviderIds: string[];
}>
| undefined,
telemetryProviderId: string,
) =>
(targets ?? [])
.filter((target) =>
target.telemetryProviderIds.includes(telemetryProviderId),
)
.map((target) => target.name)
.join(", ");
export const HandleTelemetryProvider = ({ telemetryProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [initialFields, setInitialFields] = useState<Record<string, string>>(
{},
);
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
const { data: availableTypes } =
api.telemetryProvider.availableTypes.useQuery();
const { data: provider } = api.telemetryProvider.one.useQuery(
{ telemetryProviderId: telemetryProviderId || "" },
{ enabled: !!telemetryProviderId, refetchOnWindowFocus: false },
);
const createMutation = api.telemetryProvider.create.useMutation();
const updateMutation = api.telemetryProvider.update.useMutation();
const {
isPending: isSaving,
error,
isError,
} = telemetryProviderId ? updateMutation : createMutation;
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.telemetryProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.telemetryProvider.testConnectionById.useMutation();
const form = useForm<ProviderForm>({
defaultValues: {
name: "",
providerType: "",
signals: "",
enabled: true,
fields: {},
},
resolver: zodResolver(providerForm),
});
const providerType = form.watch("providerType");
const signalsValue = form.watch("signals");
const fieldValues = form.watch("fields");
const signals = toSignals(signalsValue);
const selectedType = availableTypes?.find((t) => t.type === providerType);
const signalOptions = SIGNAL_OPTIONS.filter((option) =>
option.signals.every((s) => selectedType?.signals.includes(s)),
);
const visibleFields = (selectedType?.credentialFields ?? []).filter(
(field) => !field.signal || signals.includes(field.signal),
);
useEffect(() => {
if (provider) {
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
form.reset({
name: provider.name,
providerType: provider.providerType,
signals: provider.signals.join(","),
enabled: provider.enabled,
fields: extraConfig,
});
setInitialFields(extraConfig);
} else {
form.reset();
setInitialFields({});
}
setClearedKeys({});
}, [form, provider, isOpen]);
const buildPayload = () => {
const payload: {
name: string;
providerType: TelemetryProviderType;
signals: TelemetrySignal[];
enabled: boolean;
endpoint?: string | null;
apiKey?: string | null;
apiSecret?: string | null;
extraConfig?: Record<string, string>;
} = {
name: form.getValues("name"),
providerType: providerType as TelemetryProviderType,
signals,
enabled: form.getValues("enabled"),
};
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of visibleFields) {
const value = fieldValues[field.key] ?? "";
if (
field.key === "endpoint" ||
field.key === "apiKey" ||
field.key === "apiSecret"
) {
if (value) {
payload[field.key] = value;
} else if (clearedKeys[field.key]) {
payload[field.key] = null;
}
} else {
hasExtraConfigFields = true;
extraConfig[field.key] = value;
}
}
if (hasExtraConfigFields) {
payload.extraConfig = extraConfig;
}
return payload;
};
// Required fields are per type and signal, so zod can't know them up front.
const validateRequiredFields = () => {
let valid = true;
for (const field of visibleFields) {
const value = fieldValues[field.key] ?? "";
const keepsStored =
!!telemetryProviderId &&
TOP_LEVEL_KEYS.has(field.key) &&
!clearedKeys[field.key];
if (field.required && !value && !keepsStored) {
form.setError(`fields.${field.key}`, {
message: `${field.label} is required`,
});
valid = false;
}
}
return valid;
};
const topLevelKeys = visibleFields
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter(
(key) => !!fieldValues[key] || clearedKeys[key],
);
const touchedExtraConfigKeys = visibleFields
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key)
.filter((key) => (fieldValues[key] ?? "") !== (initialFields[key] ?? ""));
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!telemetryProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const signalsChanged =
!!provider && signalsValue !== provider.signals.join(",");
const showTestResult = (result: { warning?: string }) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
};
const onTest = async () => {
if (!(await form.trigger(["providerType", "signals"]))) return;
if (telemetryProviderId && !touchedAnyCredentialField) {
await testConnectionById({
telemetryProviderId,
...(signalsChanged ? { signals } : {}),
})
.then(showTestResult)
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
if (!validateRequiredFields()) return;
await testConnection(buildPayload())
.then(showTestResult)
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
if (!validateRequiredFields()) return;
const payload = buildPayload();
let warning: string | undefined;
let reapplied = false;
try {
if (telemetryProviderId) {
const result = await updateMutation.mutateAsync({
telemetryProviderId,
...payload,
});
warning = result.warning;
reapplied = result.reapplied;
} else {
await createMutation.mutateAsync(payload);
}
} catch {
toast.error(
telemetryProviderId
? "Error updating provider"
: "Error adding provider",
);
return;
}
utils.telemetryProvider.all.invalidate();
utils.telemetryProvider.serverStatus.invalidate();
if (telemetryProviderId) {
utils.telemetryProvider.one.invalidate({ telemetryProviderId });
}
const agents =
telemetryProviderId &&
agentsUsingProvider(
utils.telemetryProvider.serverStatus.getData(),
telemetryProviderId,
);
if (warning) {
toast.warning("Provider updated", { description: warning });
} else {
toast.success(
telemetryProviderId ? "Provider updated" : "Provider added",
reapplied && agents
? { description: `The Vector agent on ${agents} was re-applied.` }
: undefined,
);
}
setIsOpen(false);
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{telemetryProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{telemetryProviderId ? "Edit provider" : "Add a provider"}
</DialogTitle>
<DialogDescription>
Vector ships container logs and host/container metrics from every
server you deploy it on to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<AlertBlock type="error" className="w-full">
{testError?.message || testByIdError?.message || error?.message}
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form-telemetry-provider"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g. Production Loki" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="providerType"
render={({ field }) => (
<FormItem>
<FormLabel>Provider</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
form.setValue("signals", "");
form.setValue("fields", {});
form.clearErrors();
}}
disabled={!!telemetryProviderId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableTypes?.map((type) => {
const ProviderIcon =
telemetryProviderIcons[
type.type as keyof typeof telemetryProviderIcons
];
return (
<SelectItem key={type.type} value={type.type}>
<div className="flex flex-row items-center gap-2">
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
{type.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{selectedType && (
<FormField
control={form.control}
name="signals"
render={({ field }) => (
<FormItem>
<FormLabel>Sends</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select what this provider sends" />
</SelectTrigger>
</FormControl>
<SelectContent>
{signalOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{visibleFields.map((credentialField) => (
<FormField
key={credentialField.key}
control={form.control}
name={`fields.${credentialField.key}`}
render={({ field }) => (
<FormItem>
<FormLabel>
{credentialField.label}
{!credentialField.required && " (Optional)"}
</FormLabel>
{credentialField.helpText && (
<FormDescription>
{credentialField.helpText}
</FormDescription>
)}
<div className="flex flex-row gap-2">
<FormControl>
<Input
type={
credentialField.type === "password"
? "password"
: "text"
}
disabled={!!clearedKeys[credentialField.key]}
placeholder={
clearedKeys[credentialField.key]
? "Will be cleared on update"
: telemetryProviderId &&
TOP_LEVEL_KEYS.has(credentialField.key)
? "Leave blank to keep existing"
: credentialField.placeholder
}
autoComplete={
credentialField.type === "password"
? "new-password"
: "off"
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
{telemetryProviderId &&
TOP_LEVEL_KEYS.has(credentialField.key) &&
!credentialField.required && (
<Button
type="button"
variant="outline"
onClick={() => {
setClearedKeys((prev) => ({
...prev,
[credentialField.key]:
!prev[credentialField.key],
}));
field.onChange("");
}}
>
{clearedKeys[credentialField.key]
? "Keep"
: "Clear"}
</Button>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
))}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>Enabled</FormLabel>
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
{isPartiallyTouched && (
<span className="text-xs text-muted-foreground">
You changed something that needs a fresh credential to test — fill
in every credential field to test with the new values, or revert
it to test with what's already saved.
</span>
)}
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
disabled={!providerType || isPartiallyTouched}
onClick={onTest}
>
Test connection
</Button>
<Button
type="submit"
form="hook-form-telemetry-provider"
isLoading={isSaving}
disabled={isSaving}
>
{telemetryProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,361 @@
import { Copy, Loader2, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
type Status = "running" | "not-running" | "stopped" | "unknown";
const statusBadge = (status: Status) => {
if (status === "running") {
return <Badge variant="green">Running</Badge>;
}
if (status === "not-running") {
return <Badge variant="yellow">Not running</Badge>;
}
if (status === "stopped") {
return <Badge variant="secondary">Not deployed</Badge>;
}
return <Badge variant="destructive">Unreachable</Badge>;
};
export const providerLabel = (provider: { name: string; signals: string[] }) =>
`${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`;
export const ShowServers = () => {
const utils = api.useUtils();
const [selection, setSelection] = useState<Record<string, string[]>>({});
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
const [errors, setErrors] = useState<
Record<string, { title: string; message: string }>
>({});
const { data: providers } = api.telemetryProvider.all.useQuery();
const { data: targets, isPending } =
api.telemetryProvider.serverStatus.useQuery(undefined, {
refetchOnWindowFocus: false,
staleTime: 30_000,
trpc: { context: { skipBatch: true } },
});
const { mutateAsync: deployOnServer } =
api.telemetryProvider.deployOnServer.useMutation();
const { mutateAsync: removeOnServer } =
api.telemetryProvider.removeOnServer.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
// The selection can still hold providers deleted since it was made.
const idsFor = (serverId: string | null, saved: string[]) => {
const ids = selection[keyOf(serverId)] ?? saved;
return providers
? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id))
: ids;
};
const toggleProvider = (
serverId: string | null,
saved: string[],
telemetryProviderId: string,
) => {
const key = keyOf(serverId);
const current = idsFor(serverId, saved);
setSelection({
...selection,
[key]: current.includes(telemetryProviderId)
? current.filter((id) => id !== telemetryProviderId)
: [...current, telemetryProviderId],
});
};
const setError = (
serverId: string | null,
error?: { title: string; message: string },
) =>
setErrors((prev) => {
const next = { ...prev };
if (error) {
next[keyOf(serverId)] = error;
} else {
delete next[keyOf(serverId)];
}
return next;
});
// The task is still starting right after a deploy, so look again once it had time to come up.
const refreshStatus = () => {
utils.telemetryProvider.serverStatus.invalidate();
setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000);
};
const handleDeploy = async (serverId: string | null, ids: string[]) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await deployOnServer({ serverId, providerIds: ids })
.then(() => {
toast.success("Vector agent deployed");
refreshStatus();
})
.catch((error) => {
setError(serverId, {
title: "Deploy failed",
message:
error instanceof Error
? error.message
: "Failed to deploy the agent",
});
refreshStatus();
})
.finally(() => setPendingTarget(null));
};
const handleRemove = async (serverId: string | null) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await removeOnServer({ serverId })
.then(() => {
toast.success("Vector agent removed");
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
refreshStatus();
})
.catch((error) => {
setError(serverId, {
title: "Remove failed",
message:
error instanceof Error
? error.message
: "Failed to remove the agent",
});
refreshStatus();
})
.finally(() => setPendingTarget(null));
};
const hasProviders = (providers?.length ?? 0) > 0;
const visibleTargets = hasProviders
? targets
: targets?.filter(
(target) =>
target.telemetryProviderIds.length > 0 ||
target.status === "running" ||
target.status === "not-running",
);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
Pick which providers each server ships to, then deploy its Vector
agent; a provider receives every signal it sends, and cAdvisor
runs next to the agent on 127.0.0.1:4510 when metrics are shipped.
Services deployed before Logs & Metrics need a redeploy to get the
labels it uses; until then their logs and container metrics are
not shipped from the Dokploy server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Checking servers...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a provider first — there's nowhere to ship to yet
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{visibleTargets?.map((target) => {
const key = keyOf(target.serverId);
const saved = target.telemetryProviderIds;
const ids = idsFor(target.serverId, saved);
const isBusy = pendingTarget === key;
const agentDeployed = target.status !== "stopped";
const hasEnabledSelected = ids.some(
(id) =>
providers?.find((p) => p.telemetryProviderId === id)
?.enabled,
);
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{target.name}
</span>
{target.ipAddress && (
<span className="text-xs text-muted-foreground">
{target.ipAddress}
</span>
)}
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{agentDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isBusy}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">Providers</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
telemetryProviderIcons[
provider.providerType as keyof typeof telemetryProviderIcons
];
return (
<div
key={provider.telemetryProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.telemetryProviderId}`}
disabled={
!provider.enabled &&
!ids.includes(provider.telemetryProviderId)
}
checked={ids.includes(
provider.telemetryProviderId,
)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
saved,
provider.telemetryProviderId,
)
}
/>
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.telemetryProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{providerLabel(provider)}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
);
})}
</div>
</div>
{errors[key] && (
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
{errors[key].title}
</span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
{errors[key].message}
</pre>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key]?.message ?? "",
);
toast.success("Error copied");
}}
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</AlertBlock>
)}
{hasProviders && (
<div className="flex items-center justify-end gap-3">
{ids.length > 0 && !hasEnabledSelected && (
<span className="text-xs text-muted-foreground">
Every selected provider is disabled — nothing
would ship.
</span>
)}
<Button
size="sm"
isLoading={isBusy}
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{agentDeployed || saved.length > 0
? "Redeploy"
: "Deploy"}
</Button>
</div>
)}
</div>
);
})}
{visibleTargets?.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any server yet
</span>
</div>
)}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,196 @@
import { Loader2, ScrollText, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleTelemetryProvider } from "./handle-telemetry-provider";
interface TelemetryProviderRowProps {
provider: {
telemetryProviderId: string;
name: string;
enabled: boolean;
providerType: string;
signals: string[];
};
index: number;
typeLabel: string;
canEdit: boolean;
canDelete: boolean;
onDeleted: () => void;
}
const TelemetryProviderRow = ({
provider,
index,
typeLabel,
canEdit,
canDelete,
onDeleted,
}: TelemetryProviderRowProps) => {
const { mutateAsync, isPending: isRemoving } =
api.telemetryProvider.remove.useMutation();
const ProviderIcon =
telemetryProviderIcons[
provider.providerType as keyof typeof telemetryProviderIcons
];
return (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium flex items-center gap-2">
{index + 1}. {provider.name}
<Badge variant={provider.enabled ? "green" : "secondary"}>
{provider.enabled ? "Enabled" : "Disabled"}
</Badge>
{provider.signals.includes("logs") && (
<Badge variant="blue">Logs</Badge>
)}
{provider.signals.includes("metrics") && (
<Badge variant="orange">Metrics</Badge>
)}
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<HandleTelemetryProvider
telemetryProviderId={provider.telemetryProviderId}
/>
)}
{canDelete && (
<DialogAction
title="Delete provider"
description="Are you sure you want to delete this provider? Every agent shipping to it is re-applied without it, and removed if nothing is left."
type="destructive"
onClick={async () => {
await mutateAsync({
telemetryProviderId: provider.telemetryProviderId,
})
.then((result) => {
if (result.warning) {
toast.warning("Provider deleted", {
description: result.warning,
});
} else {
toast.success(
"Provider deleted — the agents that used it were re-applied",
);
}
onDeleted();
})
.catch(() => {
toast.error("Error deleting provider");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
};
export const ShowTelemetryProviders = () => {
const utils = api.useUtils();
const { data, isPending, refetch } = api.telemetryProvider.all.useQuery();
const { data: availableTypes } =
api.telemetryProvider.availableTypes.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const providerListLabel = availableTypes?.length
? ` (${availableTypes.map((t) => t.label).join(", ")})`
: "";
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Logs & Metrics
</CardTitle>
<CardDescription>
{`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any provider configured
</span>
{permissions?.telemetryProvider.create && (
<HandleTelemetryProvider />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((provider, index) => (
<TelemetryProviderRow
key={provider.telemetryProviderId}
provider={provider}
index={index}
typeLabel={
availableTypes?.find(
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.telemetryProvider.create}
canDelete={!!permissions?.telemetryProvider.delete}
onDeleted={() => {
refetch();
utils.telemetryProvider.serverStatus.invalidate();
}}
/>
))}
</div>
{permissions?.telemetryProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleTelemetryProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -109,8 +109,13 @@ export const DeleteServerModal = ({
const handleDeleteServer = async () => {
try {
await deleteServer({ serverId });
const result = await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
if (result?.vectorAgentRemovalWarning) {
toast.error(
`Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`,
);
}
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
@@ -0,0 +1,105 @@
import { AwsIcon } from "@/components/icons/vault-provider-icons";
interface Props {
className?: string;
}
export const GrafanaLokiIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#F46800"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
</svg>
);
export const DatadogIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#632CA6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
</svg>
);
export const BetterStackIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
</svg>
);
export const ElasticsearchIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#00BFB3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
</svg>
);
export const SplunkIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
</svg>
);
export const PrometheusIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#E6522C"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M12 0C5.373 0 0 5.372 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12c0-6.628-5.373-12-12-12zm0 22.46c-1.885 0-3.414-1.26-3.414-2.814h6.828c0 1.553-1.528 2.813-3.414 2.813zm5.64-3.745H6.36v-2.046h11.28v2.046zm-.04-3.098H6.391c-.037-.043-.075-.086-.111-.13-1.155-1.401-1.427-2.133-1.69-2.879-.005-.025 1.4.287 2.395.511 0 0 .513.119 1.262.255-.72-.843-1.147-1.915-1.147-3.01 0-2.406 1.845-4.508 1.18-6.207.648.053 1.34 1.367 1.387 3.422.689-.951.977-2.69.977-3.755 0-1.103.727-2.385 1.454-2.429-.648 1.069.168 1.984.894 4.256.272.854.237 2.29.447 3.201.07-1.892.395-4.652 1.595-5.605-.529 1.2.079 2.702.494 3.424.671 1.164 1.078 2.047 1.078 3.716a4.642 4.642 0 01-1.11 2.996c.792-.149 1.34-.283 1.34-.283l2.573-.502s-.374 1.538-1.81 3.019z" />
</svg>
);
export const NewRelicIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#1CE783"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M8.0015 14.3091v7.384L12.0008 24V12.0008L1.6078 5.9996v4.6167ZM12.0008 0 2.8232 5.2976 6.8209 7.606l5.1799-2.9893 6.3936 3.6913v7.384l-5.1783 2.9908v4.6167l9.176-5.2991V5.9996Z" />
</svg>
);
export const InfluxDbIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#22ADF6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64zM10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26zm4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 01.026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 01-.364-.675l1.378-4.421a.547.547 0 01.675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 01-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 01-.65-.39l-1.922-8.372a.534.534 0 01.39-.65L19.1 5.335a.534.534 0 01.649.39l1.923 8.371c.079.31-.102.596-.39.65Z" />
</svg>
);
export const telemetryProviderIcons = {
loki: GrafanaLokiIcon,
prometheus_remote_write: PrometheusIcon,
new_relic: NewRelicIcon,
influxdb: InfluxDbIcon,
datadog: DatadogIcon,
aws_cloudwatch: AwsIcon,
splunk_hec: SplunkIcon,
elasticsearch: ElasticsearchIcon,
betterstack: BetterStackIcon,
} as const;
+8
View File
@@ -29,6 +29,7 @@ import {
type LucideIcon,
Package,
Palette,
ScrollText,
Server,
ShieldCheck,
Smartphone,
@@ -374,6 +375,13 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Logs & Metrics",
url: "/dashboard/settings/logs-and-metrics",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
},
{
isSingle: true,
title: "Secrets",
@@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
telemetryProvider: "Logs & Metrics",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,6 +180,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
telemetryProvider: {
label: "Logs & Metrics",
description:
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
},
};
/** Descriptions for each action within a resource */
@@ -475,6 +480,21 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
telemetryProvider: {
read: {
label: "Read",
description: "View configured logs and metrics providers",
},
create: {
label: "Create",
description:
"Add new providers, test their connection and deploy the Vector agent",
},
delete: {
label: "Delete",
description: "Remove logs and metrics providers",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -0,0 +1,20 @@
CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint
CREATE TABLE "telemetryProvider" (
"telemetryProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "TelemetryProviderType" NOT NULL,
"signals" text[] NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint
ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1791456930312,
"tag": "0197_logs_and_metrics",
"breakpoints": true
}
]
}
+2
View File
@@ -48,6 +48,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch": "^3.1108.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
@@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowServers } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
import { ShowTelemetryProviders } from "@/components/dashboard/settings/logs-and-metrics/show-telemetry-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowTelemetryProviders />
<ShowServers />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Logs & Metrics">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
+2
View File
@@ -53,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { tagRouter } from "./routers/tag";
import { telemetryProviderRouter } from "./routers/telemetry-provider";
import { transferRouter } from "./routers/transfer";
import { userRouter } from "./routers/user";
import { vaultProviderRouter } from "./routers/vault-provider";
@@ -85,6 +86,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
telemetryProvider: telemetryProviderRouter,
mariadb: mariadbRouter,
mongo: mongoRouter,
mounts: mountRouter,
@@ -1,5 +1,6 @@
import { db } from "@dokploy/server/db";
import {
deleteOrganization,
hasValidLicense,
IS_CLOUD,
sendInvitationEmail,
@@ -285,9 +286,7 @@ export const organizationRouter = createTRPCRouter({
});
}
const result = await db
.delete(organization)
.where(eq(organization.id, input.organizationId));
const result = await deleteOrganization(input.organizationId);
await audit(ctx, {
action: "delete",
+10 -1
View File
@@ -12,6 +12,7 @@ import {
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
removeServerVectorAgent,
serverAudit,
serverSetup,
serverValidate,
@@ -473,11 +474,16 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
const vectorAgentRemovalWarning =
await removeServerVectorAgent(currentServer);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: currentServer.serverId,
resourceName: currentServer.name,
...(vectorAgentRemovalWarning
? { metadata: { vectorAgentRemovalWarning } }
: {}),
});
await removeDeploymentsByServerId(currentServer);
await deleteServer(input.serverId);
@@ -488,7 +494,10 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return redactServerSshKey(currentServer);
return {
...redactServerSshKey(currentServer),
vectorAgentRemovalWarning,
};
} catch (error) {
throw error;
}
@@ -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,
}));
}),
});
+2 -1
View File
@@ -52,7 +52,8 @@ const links =
transformer: superjson,
}),
false: splitLink({
condition: (op) => op.input instanceof FormData,
condition: (op) =>
op.input instanceof FormData || op.context.skipBatch === true,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
+3
View File
@@ -37,6 +37,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch": "^3.1108.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
@@ -86,6 +88,7 @@
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"toml": "3.0.0",
"undici": "^6.21.3",
"ws": "8.16.0",
"yaml": "2.8.1",
"zod": "^4.3.6"
+1
View File
@@ -134,6 +134,7 @@ export const paths = (isServer = false) => {
CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`,
VECTOR_PATH: `${BASE_PATH}/vector`,
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
+2 -1
View File
@@ -94,4 +94,5 @@ export type AuditResourceType =
| "compose"
| "network"
| "vaultProvider"
| "dnsProvider";
| "dnsProvider"
| "telemetryProvider";
+1
View File
@@ -41,6 +41,7 @@ export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./telemetry-provider";
export * from "./transfer";
export * from "./user";
export * from "./utils";
+5 -1
View File
@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -42,6 +42,10 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")
@@ -0,0 +1,90 @@
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { encryptedText } from "./utils";
export const telemetryProviderType = pgEnum("TelemetryProviderType", [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
"prometheus_remote_write",
"new_relic",
"influxdb",
]);
export const telemetryProvider = pgTable("telemetryProvider", {
telemetryProviderId: text("telemetryProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: telemetryProviderType("providerType").notNull(),
signals: text("signals").array().notNull(),
endpoint: encryptedText("endpoint"),
apiKey: encryptedText("apiKey"),
apiSecret: encryptedText("apiSecret"),
extraConfig: jsonb("extraConfig").$type<Record<string, unknown>>(),
enabled: boolean("enabled").notNull().default(true),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
});
const createSchema = createInsertSchema(telemetryProvider, {
telemetryProviderId: z.string().min(1),
name: z.string().min(1),
providerType: z.enum(telemetryProviderType.enumValues),
signals: z.array(z.enum(["logs", "metrics"])).min(1),
endpoint: z
.string()
.min(1)
.refine((value) => !/\s/.test(value), {
message: "Endpoint cannot contain whitespace",
})
.nullable()
.optional(),
apiKey: z.string().min(1).nullable().optional(),
apiSecret: z.string().min(1).nullable().optional(),
extraConfig: z.record(z.string(), z.unknown()).nullable().optional(),
enabled: z.boolean().optional(),
organizationId: z.string().min(1),
});
export const apiCreateTelemetryProvider = createSchema
.pick({
name: true,
providerType: true,
signals: true,
endpoint: true,
apiKey: true,
apiSecret: true,
extraConfig: true,
enabled: true,
})
.required({ name: true, providerType: true, signals: true });
export const apiUpdateTelemetryProvider = apiCreateTelemetryProvider
.partial()
.extend({
telemetryProviderId: z.string().min(1),
});
export const apiRemoveTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiFindOneTelemetryProvider = z.object({
telemetryProviderId: z.string().min(1),
});
export const apiTestTelemetryProvider = apiCreateTelemetryProvider.partial({
name: true,
});
@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -10,6 +10,7 @@ import {
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { certificateType } from "./shared";
export const webServerSettings = pgTable("webServerSettings", {
@@ -26,6 +27,14 @@ export const webServerSettings = pgTable("webServerSettings", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
telemetryProviderIds: text("telemetryProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
vectorAgentOrganizationId: text("vectorAgentOrganizationId").references(
() => organization.id,
{ onDelete: "set null" },
),
// Metrics Configuration
metricsConfig: jsonb("metricsConfig")
.$type<{
@@ -140,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),
+7
View File
@@ -28,12 +28,18 @@ export * from "./services/gitea";
export * from "./services/github";
export * from "./services/gitlab";
export * from "./services/libsql";
export * from "./services/logs-and-metrics/provider-fetch";
export * from "./services/logs-and-metrics/providers/registry";
export * from "./services/logs-and-metrics/service";
export * from "./services/logs-and-metrics/types";
export * from "./services/logs-and-metrics/vector-agent";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/organization";
export * from "./services/overview";
export * from "./services/patch";
export * from "./services/patch-repo";
@@ -69,6 +75,7 @@ export * from "./setup/server-setup";
export * from "./setup/server-validate";
export * from "./setup/setup";
export * from "./setup/traefik-setup";
export * from "./setup/vector-setup";
export * from "./templates/processors";
export * from "./utils/access-log/handler";
export {
@@ -28,6 +28,7 @@ export const statements = {
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
telemetryProvider: ["read", "create", "delete"],
// Enterprise-only resources (custom roles only)
volume: ["read", "create", "delete"],
@@ -119,6 +120,7 @@ export const ownerRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
});
/**
@@ -158,6 +160,7 @@ export const adminRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
});
/**
@@ -198,6 +201,7 @@ export const memberRole = ac.newRole({
certificate: [],
destination: [],
notification: [],
telemetryProvider: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
@@ -8,6 +8,7 @@ import {
user,
verification,
} from "@dokploy/server/db/schema";
import { removeVectorAgents } from "@dokploy/server/services/organization";
import { TRPCError } from "@trpc/server";
import { eq, inArray } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -128,6 +129,15 @@ export const consumeAccountDeletionCode = async (
};
export const deleteUserAccountData = async (userId: string) => {
// The owned organizations go away by FK cascade, which skips deleteOrganization's agent cleanup.
const ownedOrganizations = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
columns: { id: true },
});
for (const org of ownedOrganizations) {
await removeVectorAgents(org.id);
}
return db.transaction(async (tx) => {
const target = await tx.query.user.findFirst({
where: eq(user.id, userId),
@@ -0,0 +1,108 @@
import { type LookupAddress, lookup } from "node:dns";
import { BlockList, isIP, type LookupFunction } from "node:net";
import { IS_CLOUD } from "@dokploy/server/constants";
import { Agent } from "undici";
export const PROVIDER_REQUEST_TIMEOUT_MS = 15_000;
const metadataAddresses = new BlockList();
metadataAddresses.addSubnet("169.254.0.0", 16, "ipv4");
metadataAddresses.addSubnet("fe80::", 10, "ipv6");
metadataAddresses.addAddress("fd00:ec2::254", "ipv6");
const privateAddresses = new BlockList();
privateAddresses.addSubnet("0.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("100.64.0.0", 10, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addAddress("::", "ipv6");
privateAddresses.addAddress("::1", "ipv6");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
const isBlockedAddress = (address: string): boolean => {
const ip = address.replace(/^\[|\]$/g, "");
const family = isIP(ip);
if (family === 0) {
return IS_CLOUD && ip.toLowerCase() === "localhost";
}
const type = family === 4 ? "ipv4" : "ipv6";
return (
metadataAddresses.check(ip, type) ||
(IS_CLOUD && privateAddresses.check(ip, type))
);
};
const METADATA_HOSTNAMES = new Set([
"metadata.google.internal",
"metadata.goog",
]);
class BlockedEndpointError extends Error {
constructor() {
super(
"This endpoint resolves to a blocked address (cloud metadata or private network) and can't be used here.",
);
}
}
const assertNotBlockedEndpoint = (rawUrl: string): void => {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return;
}
if (
METADATA_HOSTNAMES.has(url.hostname.toLowerCase()) ||
isBlockedAddress(url.hostname)
) {
throw new BlockedEndpointError();
}
};
// Checks the addresses the socket actually connects to; a separate pre-flight lookup could be answered differently (DNS rebinding).
export const guardedLookup: LookupFunction = (hostname, options, callback) => {
lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) {
callback(error, "");
return;
}
const list = addresses as LookupAddress[];
const first = list[0];
if (!first) {
callback(new Error(`No addresses found for ${hostname}`), "");
return;
}
if (list.some((entry) => isBlockedAddress(entry.address))) {
callback(new BlockedEndpointError(), "");
return;
}
if (options.all) {
callback(null, list);
} else {
callback(null, first.address, first.family);
}
});
};
const guardedAgent = new Agent({ connect: { lookup: guardedLookup } });
export const providerFetch = async (url: string, init: RequestInit = {}) => {
assertNotBlockedEndpoint(url);
try {
return await fetch(url, {
...init,
redirect: "error",
signal: AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS),
// @ts-expect-error Node's fetch accepts an undici dispatcher, but lib.dom's RequestInit doesn't declare it.
dispatcher: guardedAgent,
});
} catch (error) {
if (error instanceof Error && error.cause instanceof BlockedEndpointError) {
throw error.cause;
}
throw error;
}
};
@@ -0,0 +1,227 @@
import {
CloudWatchClient,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import {
CloudWatchLogsClient,
DescribeLogGroupsCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import { PROVIDER_REQUEST_TIMEOUT_MS } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, stringValue, vrlString } from "../types";
const DEFAULT_STREAM_TEMPLATE = "{{ container_name }}";
export const CLOUDWATCH_METRICS_ALLOWLIST = [
"cpu_seconds_total",
"memory_used_bytes",
"memory_total_bytes",
"filesystem_used_bytes",
"filesystem_total_bytes",
"network_receive_bytes_total",
"network_transmit_bytes_total",
"load1",
"load5",
"load15",
"container_cpu_usage_seconds_total",
"container_memory_usage_bytes",
"container_network_receive_bytes_total",
"container_network_transmit_bytes_total",
];
const resolveMetricsNamespace = (config: TelemetryProviderRuntimeConfig) => {
const namespace = stringValue(config.extraConfig?.metricsNamespace);
if (namespace.startsWith("AWS/")) {
throw new Error("The metrics namespace cannot start with AWS/");
}
return namespace;
};
const credentials = (config: TelemetryProviderRuntimeConfig) => ({
accessKeyId: config.apiKey ?? "",
secretAccessKey: config.apiSecret ?? "",
});
export const awsCloudwatchAdapter: TelemetryProviderAdapter = {
type: "aws_cloudwatch",
signals: ["logs", "metrics"],
label: "AWS CloudWatch",
docsUrl:
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html",
credentialFields: [
{
key: "apiKey",
label: "Access Key ID",
type: "text",
required: true,
},
{
key: "apiSecret",
label: "Secret Access Key",
type: "password",
required: true,
},
{
key: "region",
label: "Region",
type: "text",
required: true,
placeholder: "us-east-1",
},
{
key: "logGroup",
label: "Log Group Name",
type: "text",
required: true,
signal: "logs",
placeholder: "/dokploy/logs",
helpText: "Created automatically on first write if it doesn't exist.",
fullWidth: true,
},
{
key: "logStream",
label: "Log Stream Name Template",
type: "text",
required: false,
signal: "logs",
placeholder: DEFAULT_STREAM_TEMPLATE,
helpText:
"Vector event template. Defaults to one stream per container name if left blank.",
fullWidth: true,
},
{
key: "metricsNamespace",
label: "Metrics namespace",
type: "text",
required: true,
signal: "metrics",
placeholder: "Dokploy",
helpText:
"E.g. Dokploy. Cannot start with AWS/. The IAM user needs cloudwatch:PutMetricData; testing the connection writes a real data point.",
fullWidth: true,
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
if (config.signals.includes("metrics")) {
resolveMetricsNamespace(config);
}
},
toVectorTransform(
config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "logs") return null;
return {
type: "remap",
inputs: [scopeTransformId],
drop_on_abort: true,
source: [
`if !includes(${JSON.stringify(CLOUDWATCH_METRICS_ALLOWLIST)}, .name) { abort }`,
`.namespace = ${vrlString(resolveMetricsNamespace(config))}`,
].join("\n"),
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
const region = stringValue(config.extraConfig?.region);
if (signal === "metrics") {
return {
type: "aws_cloudwatch_metrics",
inputs: [inputId],
default_namespace: resolveMetricsNamespace(config),
region,
auth: {
access_key_id: config.apiKey ?? "",
secret_access_key: config.apiSecret ?? "",
},
buffer: DEFAULT_DISK_BUFFER,
};
}
const logGroup = config.extraConfig?.logGroup;
const logStream = config.extraConfig?.logStream;
return {
type: "aws_cloudwatch_logs",
inputs: [inputId],
group_name: typeof logGroup === "string" ? logGroup : "",
stream_name:
typeof logStream === "string" && logStream.length > 0
? logStream
: DEFAULT_STREAM_TEMPLATE,
region,
encoding: { codec: "json" },
auth: {
access_key_id: config.apiKey ?? "",
secret_access_key: config.apiSecret ?? "",
},
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const region = stringValue(config.extraConfig?.region);
if (!config.apiKey || !config.apiSecret || !region) {
throw new Error("AWS access key, secret key and region are required");
}
const abortSignal = AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS);
if (config.signals.includes("logs")) {
const logGroup = stringValue(config.extraConfig?.logGroup);
if (!logGroup) {
throw new Error("AWS log group is required");
}
const client = new CloudWatchLogsClient({
region,
credentials: credentials(config),
});
await client.send(
new DescribeLogGroupsCommand({
logGroupNamePrefix: logGroup,
limit: 1,
}),
{ abortSignal },
);
}
if (config.signals.includes("metrics")) {
const namespace = resolveMetricsNamespace(config);
if (!namespace) {
throw new Error("AWS metrics namespace is required");
}
const client = new CloudWatchClient({
region,
credentials: credentials(config),
});
try {
await client.send(
new PutMetricDataCommand({
Namespace: namespace,
MetricData: [
{
MetricName: "dokploy_connection_test",
Value: 1,
Dimensions: [{ Name: "dokploy_test", Value: "true" }],
},
],
}),
{ abortSignal },
);
} catch (error) {
if ((error as { name?: string })?.name === "AccessDenied") {
throw new Error(
"The IAM user needs cloudwatch:PutMetricData to ship metrics",
);
}
throw error;
}
}
},
};
@@ -0,0 +1,107 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
const baseUri = (config: TelemetryProviderRuntimeConfig) =>
normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "");
export const betterStackAdapter: TelemetryProviderAdapter = {
type: "betterstack",
signals: ["logs", "metrics"],
label: "Better Stack",
docsUrl: "https://betterstack.com/docs/logs/vector/",
credentialFields: [
{
key: "apiKey",
label: "Source Token",
type: "password",
required: true,
helpText:
"Bearer token from your Better Stack source. Testing the connection sends a real test log and/or metric to your account. Not every source accepts metrics; if yours does not, create a second provider for metrics with a source that does.",
fullWidth: true,
},
{
key: "endpoint",
label: "Ingesting Host",
type: "url",
required: true,
placeholder: "in.logs.betterstack.com",
helpText:
"Shown next to the Source Token when you create the source. Paste it as-is, no https:// needed.",
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: ".dt = del(.timestamp)",
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
return {
type: "http",
inputs: [inputId],
method: "post",
uri: `${baseUri(config)}/${signal === "metrics" ? "metrics" : ""}`,
encoding: { codec: "json" },
compression: "gzip",
auth: { strategy: "bearer", token: config.apiKey ?? "" },
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error(
"Better Stack ingesting host and source token are required",
);
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
};
if (config.signals.includes("logs")) {
const response = await providerFetch(`${baseUri(config)}/`, {
method: "POST",
headers,
body: JSON.stringify({
message: "dokploy-connection-test",
dokploy_test: true,
}),
});
if (!response.ok) {
throw new Error(
`Better Stack test event failed with status ${response.status}`,
);
}
}
if (config.signals.includes("metrics")) {
const response = await providerFetch(`${baseUri(config)}/metrics`, {
method: "POST",
headers,
body: JSON.stringify([{ name: "dokploy_test", gauge: { value: 1 } }]),
});
if (!response.ok) {
throw new Error(
`Better Stack test metric failed with status ${response.status} — this source may not accept metrics`,
);
}
}
},
};
@@ -0,0 +1,97 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER } from "../types";
const DDTAGS_VRL = `
tags = []
if .dokploy_project != "" { tags = push(tags, "dokploy_project:" + replace(to_string!(.dokploy_project), ",", "_")) }
if .dokploy_environment != "" { tags = push(tags, "dokploy_environment:" + replace(to_string!(.dokploy_environment), ",", "_")) }
if .dokploy_application != "" { tags = push(tags, "dokploy_application:" + replace(to_string!(.dokploy_application), ",", "_")) }
if .dokploy_organization != "" { tags = push(tags, "dokploy_organization:" + replace(to_string!(.dokploy_organization), ",", "_")) }
if .dokploy_server != "" { tags = push(tags, "dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")) }
.ddtags = join!(tags, ",")
`.trim();
const resolveSite = (config: TelemetryProviderRuntimeConfig): string => {
const raw = config.extraConfig?.site;
if (typeof raw !== "string" || raw.length === 0) return "datadoghq.com";
return raw
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "");
};
export const datadogAdapter: TelemetryProviderAdapter = {
type: "datadog",
signals: ["logs", "metrics"],
label: "Datadog",
docsUrl: "https://docs.datadoghq.com/logs/",
credentialFields: [
{
key: "apiKey",
label: "API Key",
type: "password",
required: true,
fullWidth: true,
},
{
key: "site",
label: "Site",
type: "text",
required: false,
placeholder: "datadoghq.com",
helpText: "E.g. datadoghq.com (US1) or datadoghq.eu (EU).",
fullWidth: true,
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: DDTAGS_VRL,
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
return {
type: signal === "metrics" ? "datadog_metrics" : "datadog_logs",
inputs: [inputId],
default_api_key: config.apiKey ?? "",
site: resolveSite(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.apiKey) {
throw new Error("Datadog API key is required");
}
const site = resolveSite(config);
const response = await providerFetch(
`https://api.${site}/api/v1/validate`,
{
headers: { "DD-API-KEY": config.apiKey },
},
);
if (!response.ok) {
throw new Error(
`Datadog API key validation failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,185 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
const LABEL_TO_JSON_VRL = `
if exists(.label) { .label = encode_json(.label) }
`.trim();
const DEFAULT_METRICS_INDEX = "dokploy-metrics-%Y.%m.%d";
type AuthMode =
| { kind: "basic"; username: string; password: string }
| { kind: "apiKey"; apiKey: string }
| { kind: "none" };
const resolveAuthMode = (config: TelemetryProviderRuntimeConfig): AuthMode => {
const username = config.extraConfig?.username;
const hasUsername = typeof username === "string" && username.length > 0;
if (hasUsername && !config.apiKey) {
throw new Error(
"A Password / API Key is required when Username is set (leave both blank for no auth).",
);
}
if (hasUsername && config.apiKey) {
return {
kind: "basic",
username: username as string,
password: config.apiKey,
};
}
if (config.apiKey) {
return { kind: "apiKey", apiKey: config.apiKey };
}
return { kind: "none" };
};
const buildAuthAndHeaders = (
config: TelemetryProviderRuntimeConfig,
): Record<string, unknown> => {
const mode = resolveAuthMode(config);
if (mode.kind === "basic") {
return {
auth: { strategy: "basic", user: mode.username, password: mode.password },
};
}
if (mode.kind === "apiKey") {
return {
request: { headers: { Authorization: `ApiKey ${mode.apiKey}` } },
};
}
return {};
};
export const elasticsearchAdapter: TelemetryProviderAdapter = {
type: "elasticsearch",
signals: ["logs", "metrics"],
label: "Elasticsearch / OpenSearch",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/elasticsearch/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://elasticsearch.example.com:9200",
},
{
key: "username",
label: "Username",
type: "text",
required: false,
helpText: "Leave blank to use an API key instead of basic auth.",
},
{
key: "apiKey",
label: "Password / API Key",
type: "password",
required: false,
helpText:
"Password for Username, or an API key alone. Leave both blank for no auth.",
fullWidth: true,
},
{
key: "index",
label: "Index",
type: "text",
required: false,
signal: "logs",
placeholder: "vector-%Y.%m.%d",
helpText: "Defaults to vector-%Y.%m.%d, a daily index.",
fullWidth: true,
},
{
key: "metricsIndex",
label: "Metrics index",
type: "text",
required: false,
signal: "metrics",
placeholder: DEFAULT_METRICS_INDEX,
helpText: `Defaults to ${DEFAULT_METRICS_INDEX}, a daily index. Keep it separate from the logs index.`,
fullWidth: true,
},
],
toVectorTransform(
_config: TelemetryProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null {
if (signal === "metrics") return null;
return {
type: "remap",
inputs: [scopeTransformId],
source: LABEL_TO_JSON_VRL,
};
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
const common = {
inputs: [inputId],
endpoints: [normalizeEndpointUrl(config.endpoint ?? "")],
api_version: "v8",
...buildAuthAndHeaders(config),
buffer: DEFAULT_DISK_BUFFER,
};
if (signal === "metrics") {
const metricsIndex = config.extraConfig?.metricsIndex;
return {
type: "elasticsearch",
...common,
bulk: {
index:
typeof metricsIndex === "string" && metricsIndex.length > 0
? metricsIndex
: DEFAULT_METRICS_INDEX,
},
};
}
const index = config.extraConfig?.index;
return {
type: "elasticsearch",
...common,
...(typeof index === "string" && index.length > 0
? { bulk: { index } }
: {}),
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint) {
throw new Error("Elasticsearch/OpenSearch endpoint is required");
}
const mode = resolveAuthMode(config);
const headers: Record<string, string> = {};
if (mode.kind === "basic") {
headers.Authorization = `Basic ${Buffer.from(
`${mode.username}:${mode.password}`,
).toString("base64")}`;
} else if (mode.kind === "apiKey") {
headers.Authorization = `ApiKey ${mode.apiKey}`;
}
const response = await providerFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/_cluster/health`,
{ headers },
);
if (!response.ok) {
throw new Error(
`Elasticsearch/OpenSearch health check failed with status ${response.status}`,
);
}
},
validateConfig(config: TelemetryProviderRuntimeConfig): void {
resolveAuthMode(config);
},
};
@@ -0,0 +1,102 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
normalizeEndpointUrl,
stringValue,
} from "../types";
export const influxdbAdapter: TelemetryProviderAdapter = {
type: "influxdb",
signals: ["metrics"],
label: "InfluxDB",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/influxdb_metrics/",
credentialFields: [
{
key: "endpoint",
label: "URL",
type: "url",
required: true,
placeholder: "http://influxdb.example.com:8086",
helpText: "http://host:8086 or your InfluxDB Cloud URL.",
fullWidth: true,
},
{
key: "org",
label: "Organization",
type: "text",
required: true,
helpText: "InfluxDB 3 ignores it but the API requires it.",
},
{
key: "bucket",
label: "Bucket",
type: "text",
required: true,
helpText: "Bucket (2.x) or database (3.x).",
},
{
key: "apiKey",
label: "API token",
type: "password",
required: true,
helpText:
"A token with write access to the bucket. Testing the connection writes a real data point.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
return {
type: "influxdb_metrics",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
org: stringValue(config.extraConfig?.org),
bucket: stringValue(config.extraConfig?.bucket),
token: config.apiKey ?? "",
default_namespace: "dokploy",
version: "2",
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const org = stringValue(config.extraConfig?.org);
const bucket = stringValue(config.extraConfig?.bucket);
if (!config.endpoint || !config.apiKey || !org || !bucket) {
throw new Error(
"InfluxDB URL, organization, bucket and token are required",
);
}
const base = normalizeEndpointUrl(config.endpoint).replace(/\/$/, "");
const query = new URLSearchParams({ org, bucket, precision: "s" });
const response = await providerFetch(`${base}/api/v2/write?${query}`, {
method: "POST",
headers: {
"Content-Type": "text/plain; charset=utf-8",
Authorization: `Token ${config.apiKey}`,
},
body: `dokploy_test,dokploy_test=true value=1 ${Math.floor(Date.now() / 1000)}`,
});
if (response.status === 401 || response.status === 403) {
throw new Error(
`InfluxDB rejected the token (status ${response.status})`,
);
}
if (response.status === 404) {
throw new Error("InfluxDB organization or bucket not found (status 404)");
}
if (!response.ok) {
throw new Error(
`InfluxDB write test failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,120 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, normalizeEndpointUrl } from "../types";
export const lokiAdapter: TelemetryProviderAdapter = {
type: "loki",
signals: ["logs"],
label: "Grafana Loki",
docsUrl: "https://grafana.com/docs/loki/latest/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://loki.example.com",
helpText:
"Base URL of your Loki instance (push API). Loki only takes logs; for Grafana Cloud metrics, add a Prometheus remote write provider with the Prometheus Instance ID.",
},
{
key: "username",
label: "User / Instance ID",
type: "text",
required: false,
helpText:
"Leave blank for an unauthenticated Loki. For Grafana Cloud, this is your numeric Instance ID.",
},
{
key: "apiKey",
label: "Password / API Token",
type: "password",
required: false,
helpText:
"Leave blank for an unauthenticated Loki. For Grafana Cloud, use an API token here.",
},
{
key: "tenantId",
label: "Tenant ID",
type: "text",
required: false,
helpText: "Only if your Loki runs in multi-tenant mode.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const tenantId = config.extraConfig?.tenantId;
const username = config.extraConfig?.username;
return {
type: "loki",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
encoding: { codec: "json" },
labels: {
dokploy_project: "{{ dokploy_project }}",
dokploy_environment: "{{ dokploy_environment }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
dokploy_server: "{{ dokploy_server }}",
},
...(typeof tenantId === "string" && tenantId.length > 0
? { tenant_id: tenantId }
: {}),
...(config.apiKey
? {
auth: {
strategy: "basic",
user: typeof username === "string" ? username : "",
password: config.apiKey,
},
}
: {}),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint) {
throw new Error("Loki endpoint is required");
}
const username = config.extraConfig?.username;
const tenantId = config.extraConfig?.tenantId;
const response = await providerFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/loki/api/v1/push`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(config.apiKey
? {
Authorization: `Basic ${Buffer.from(
`${typeof username === "string" ? username : ""}:${config.apiKey}`,
).toString("base64")}`,
}
: {}),
...(typeof tenantId === "string" && tenantId.length > 0
? { "X-Scope-OrgID": tenantId }
: {}),
},
body: JSON.stringify({
streams: [
{
stream: { dokploy_test: "true" },
values: [[`${Date.now()}000000`, "dokploy-connection-test"]],
},
],
}),
},
);
if (!response.ok) {
throw new Error(`Loki push test failed with status ${response.status}`);
}
},
};
@@ -0,0 +1,106 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER } from "../types";
const resolveRegion = (config: TelemetryProviderRuntimeConfig): "us" | "eu" => {
const raw = config.extraConfig?.region;
const region = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (region === "" || region === "us") return "us";
if (region === "eu") return "eu";
throw new Error('New Relic region must be "us" or "eu"');
};
export const newRelicAdapter: TelemetryProviderAdapter = {
type: "new_relic",
signals: ["metrics"],
label: "New Relic",
docsUrl:
"https://docs.newrelic.com/docs/data-apis/ingest-apis/metric-api/introduction-metric-api/",
credentialFields: [
{
key: "apiKey",
label: "License key",
type: "password",
required: true,
helpText:
"An INGEST - LICENSE key. Testing the connection sends a real data point.",
fullWidth: true,
},
{
key: "accountId",
label: "Account ID",
type: "text",
required: true,
placeholder: "1234567",
helpText: "The numeric account ID.",
},
{
key: "region",
label: "Region",
type: "text",
required: false,
placeholder: "us",
helpText: "us (default) or eu.",
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
resolveRegion(config);
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const accountId = config.extraConfig?.accountId;
return {
type: "new_relic",
inputs: [inputId],
api: "metrics",
license_key: config.apiKey ?? "",
account_id: typeof accountId === "string" ? accountId.trim() : "",
region: resolveRegion(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.apiKey) {
throw new Error("New Relic license key is required");
}
const host =
resolveRegion(config) === "eu"
? "metric-api.eu.newrelic.com"
: "metric-api.newrelic.com";
const response = await providerFetch(`https://${host}/metric/v1`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Api-Key": config.apiKey,
},
body: JSON.stringify([
{
metrics: [
{
name: "dokploy.test",
type: "gauge",
value: 1,
timestamp: Math.floor(Date.now() / 1000),
attributes: { dokploy_test: "true" },
},
],
},
]),
});
if (response.status === 403) {
throw new Error("New Relic rejected the license key (status 403)");
}
if (!response.ok) {
throw new Error(
`New Relic metric test failed with status ${response.status}`,
);
}
},
};
@@ -0,0 +1,131 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, stringValue } from "../types";
const assertHttpUrl = (endpoint: string | null) => {
let url: URL;
try {
url = new URL(endpoint ?? "");
} catch {
throw new Error("Remote write URL must be a valid http(s) URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Remote write URL must be a valid http(s) URL");
}
return url;
};
const buildAuth = (config: TelemetryProviderRuntimeConfig) => {
const username = stringValue(config.extraConfig?.username);
if (!config.apiKey) return {};
return username
? { auth: { strategy: "basic", user: username, password: config.apiKey } }
: { auth: { strategy: "bearer", token: config.apiKey } };
};
const buildAuthHeader = (
config: TelemetryProviderRuntimeConfig,
): Record<string, string> => {
const username = stringValue(config.extraConfig?.username);
if (!config.apiKey) return {};
return username
? {
Authorization: `Basic ${Buffer.from(`${username}:${config.apiKey}`).toString("base64")}`,
}
: { Authorization: `Bearer ${config.apiKey}` };
};
export const prometheusRemoteWriteAdapter: TelemetryProviderAdapter = {
type: "prometheus_remote_write",
signals: ["metrics"],
label: "Prometheus remote write",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/prometheus_remote_write/",
credentialFields: [
{
key: "endpoint",
label: "Remote write URL",
type: "url",
required: true,
placeholder: "http://prometheus.example.com:9090/api/v1/write",
helpText:
"Prometheus: http://host:9090/api/v1/write (start it with --web.enable-remote-write-receiver). Grafana Cloud: the /api/prom/push URL from the Prometheus card. Mimir: http://host:9009/api/v1/push. VictoriaMetrics: http://host:8428/api/v1/write.",
fullWidth: true,
},
{
key: "username",
label: "Username / Instance ID",
type: "text",
required: false,
helpText:
"Leave blank if the endpoint needs no auth. Grafana Cloud: the Prometheus Instance ID (not the Loki one).",
},
{
key: "apiKey",
label: "Password / Token",
type: "password",
required: false,
helpText:
"With a username it is sent as basic auth; alone, as a bearer token. Grafana Cloud: an Access Policy token with metrics:write.",
},
{
key: "tenantId",
label: "Tenant ID",
type: "text",
required: false,
helpText: "Only for multi-tenant Mimir/Cortex (X-Scope-OrgID header).",
fullWidth: true,
},
],
validateConfig(config: TelemetryProviderRuntimeConfig): void {
assertHttpUrl(config.endpoint);
},
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const tenantId = stringValue(config.extraConfig?.tenantId);
return {
type: "prometheus_remote_write",
inputs: [inputId],
endpoint: config.endpoint ?? "",
// The sink healthcheck is an empty POST that Prometheus and Mimir answer with 400.
healthcheck: { enabled: false },
...(tenantId ? { tenant_id: tenantId } : {}),
...buildAuth(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
const url = assertHttpUrl(config.endpoint);
const tenantId = stringValue(config.extraConfig?.tenantId);
// Remote write needs snappy protobuf, so the empty test body gets a 400 once auth and path pass.
const response = await providerFetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/x-protobuf",
"Content-Encoding": "snappy",
"X-Prometheus-Remote-Write-Version": "0.1.0",
...buildAuthHeader(config),
...(tenantId ? { "X-Scope-OrgID": tenantId } : {}),
},
});
if (response.ok || response.status === 400) return;
if (response.status === 401 || response.status === 403) {
throw new Error(
`Remote write endpoint rejected the credentials (status ${response.status})`,
);
}
if (response.status === 404 || response.status === 405) {
throw new Error(
`Remote write endpoint not found at this URL (status ${response.status}) — check the path`,
);
}
throw new Error(`Remote write test failed with status ${response.status}`);
},
};
@@ -0,0 +1,37 @@
import type { TelemetryProviderAdapter, TelemetryProviderType } from "../types";
import { awsCloudwatchAdapter } from "./aws-cloudwatch";
import { betterStackAdapter } from "./betterstack";
import { datadogAdapter } from "./datadog";
import { elasticsearchAdapter } from "./elasticsearch";
import { influxdbAdapter } from "./influxdb";
import { lokiAdapter } from "./loki";
import { newRelicAdapter } from "./new-relic";
import { prometheusRemoteWriteAdapter } from "./prometheus-remote-write";
import { splunkAdapter } from "./splunk";
export const telemetryProviderAdapters: Record<
TelemetryProviderType,
TelemetryProviderAdapter
> = {
loki: lokiAdapter,
prometheus_remote_write: prometheusRemoteWriteAdapter,
new_relic: newRelicAdapter,
influxdb: influxdbAdapter,
datadog: datadogAdapter,
aws_cloudwatch: awsCloudwatchAdapter,
splunk_hec: splunkAdapter,
elasticsearch: elasticsearchAdapter,
betterstack: betterStackAdapter,
};
export function getTelemetryProviderAdapter(
type: TelemetryProviderType,
): TelemetryProviderAdapter {
const adapter = telemetryProviderAdapters[type];
if (!adapter) {
throw new Error(
`No TelemetryProviderAdapter registered for type "${type}"`,
);
}
return adapter;
}
@@ -0,0 +1,154 @@
import { providerFetch } from "../provider-fetch";
import type {
TelemetryProviderAdapter,
TelemetryProviderRuntimeConfig,
TelemetrySignal,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
normalizeEndpointUrl,
stringValue,
} from "../types";
const baseUrl = (config: TelemetryProviderRuntimeConfig) =>
normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "");
export const splunkAdapter: TelemetryProviderAdapter = {
type: "splunk_hec",
signals: ["logs", "metrics"],
label: "Splunk HTTP Event Collector",
docsUrl:
"https://vector.dev/docs/reference/configuration/sinks/splunk_hec_logs/",
credentialFields: [
{
key: "endpoint",
label: "Endpoint",
type: "url",
required: true,
placeholder: "https://splunk.example.com:8088",
helpText: "Base URL of your Splunk instance, including the HEC port.",
},
{
key: "apiKey",
label: "HEC Token",
type: "password",
required: true,
},
{
key: "index",
label: "Index",
type: "text",
required: false,
signal: "logs",
},
{
key: "sourcetype",
label: "Source Type",
type: "text",
required: false,
signal: "logs",
placeholder: "httpevent",
},
{
key: "metricsIndex",
label: "Metrics index",
type: "text",
required: true,
signal: "metrics",
placeholder: "dokploy_metrics",
helpText:
"An index of type Metrics the token can write to (Allowed Indexes). If the token can't, create a second Splunk provider for metrics with another token. Testing the connection writes a real data point.",
fullWidth: true,
},
],
toVectorSink(
config: TelemetryProviderRuntimeConfig,
_sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig {
if (signal === "metrics") {
return {
type: "splunk_hec_metrics",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
default_token: config.apiKey ?? "",
index: stringValue(config.extraConfig?.metricsIndex),
source: "dokploy",
sourcetype: "dokploy:metrics",
buffer: DEFAULT_DISK_BUFFER,
};
}
const index = config.extraConfig?.index;
const sourcetype = config.extraConfig?.sourcetype;
return {
type: "splunk_hec_logs",
inputs: [inputId],
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
default_token: config.apiKey ?? "",
encoding: { codec: "json" },
...(typeof index === "string" && index.length > 0 ? { index } : {}),
...(typeof sourcetype === "string" && sourcetype.length > 0
? { sourcetype }
: {}),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: TelemetryProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error("Splunk endpoint and HEC token are required");
}
const authorization = { Authorization: `Splunk ${config.apiKey}` };
if (config.signals.includes("logs")) {
const response = await providerFetch(
`${baseUrl(config)}/services/collector/health`,
{ headers: authorization },
);
if (!response.ok) {
throw new Error(
`Splunk HEC health check failed with status ${response.status}`,
);
}
}
if (config.signals.includes("metrics")) {
const metricsIndex = stringValue(config.extraConfig?.metricsIndex);
if (!metricsIndex) {
throw new Error("Splunk metrics index is required");
}
const response = await providerFetch(
`${baseUrl(config)}/services/collector`,
{
method: "POST",
headers: { ...authorization, "Content-Type": "application/json" },
body: JSON.stringify({
time: Math.floor(Date.now() / 1000),
event: "metric",
source: "dokploy",
index: metricsIndex,
fields: { metric_name: "dokploy.test", _value: 1 },
}),
},
);
if (response.status === 401 || response.status === 403) {
throw new Error(
`Splunk rejected the HEC token (status ${response.status})`,
);
}
const body = (await response.json().catch(() => null)) as {
code?: number;
text?: string;
} | null;
if (body?.code === 7) {
throw new Error(
`The HEC token can't write to the metrics index "${metricsIndex}"`,
);
}
if (!response.ok || (body && body.code !== 0)) {
throw new Error(
`Splunk metric test failed with status ${response.status}${body?.text ? `: ${body.text}` : ""}`,
);
}
}
},
};
@@ -0,0 +1,415 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateTelemetryProvider,
server,
telemetryProvider,
webServerSettings,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, arrayContains, eq, sql } from "drizzle-orm";
import type { z } from "zod";
import { getTelemetryProviderAdapter } from "./providers/registry";
import {
TELEMETRY_SIGNALS,
type TelemetryProviderRuntimeConfig,
type TelemetryProviderType,
type TelemetrySignal,
} from "./types";
export type TelemetryProvider = typeof telemetryProvider.$inferSelect;
const CREDENTIAL_COLUMNS = {
endpoint: false,
apiKey: false,
apiSecret: false,
} as const;
const sortSignals = (signals: TelemetrySignal[]) =>
TELEMETRY_SIGNALS.filter((signal) => signals.includes(signal));
export const assertSignalsSupported = (
providerType: TelemetryProviderType,
signals: TelemetrySignal[],
) => {
const adapter = getTelemetryProviderAdapter(providerType);
if (new Set(signals).size !== signals.length) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A signal can only be selected once",
});
}
const unsupported = signals.filter((s) => !adapter.signals.includes(s));
if (unsupported.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${adapter.label} cannot send ${unsupported.join(", ")}`,
});
}
};
export const assertRequiredCredentialFields = (
providerType: TelemetryProviderType,
signals: TelemetrySignal[],
values: {
endpoint?: string | null;
apiKey?: string | null;
apiSecret?: string | null;
extraConfig?: Record<string, unknown> | null;
},
) => {
const adapter = getTelemetryProviderAdapter(providerType);
const fieldValues: Record<string, unknown> = {
...(values.extraConfig ?? {}),
endpoint: values.endpoint,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
};
const missing = adapter.credentialFields
.filter((field) => !field.signal || signals.includes(field.signal))
.filter((field) => field.required)
.filter((field) => {
const value = fieldValues[field.key];
return value === undefined || value === null || value === "";
})
.map((field) => field.label);
if (missing.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Missing required field(s) for ${adapter.label}: ${missing.join(", ")}`,
});
}
if (adapter.validateConfig) {
try {
adapter.validateConfig({
telemetryProviderId: "validate",
name: "",
signals,
endpoint: values.endpoint ?? null,
apiKey: values.apiKey ?? null,
apiSecret: values.apiSecret ?? null,
extraConfig: values.extraConfig ?? null,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Invalid configuration",
});
}
}
};
export const createTelemetryProvider = async (
input: z.infer<typeof apiCreateTelemetryProvider>,
organizationId: string,
) => {
assertSignalsSupported(input.providerType, input.signals);
assertRequiredCredentialFields(input.providerType, input.signals, input);
const created = await db
.insert(telemetryProvider)
.values({
...input,
signals: sortSignals(input.signals),
organizationId,
})
.returning()
.then((rows) => rows[0]);
if (!created) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating provider",
});
}
return created;
};
// null is the Dokploy host.
export type VectorTarget = string | null;
const findTargetsUsingProvider = async (
telemetryProviderId: string,
): Promise<VectorTarget[]> => {
const [servers, local] = await Promise.all([
db.query.server.findMany({
where: arrayContains(server.telemetryProviderIds, [telemetryProviderId]),
columns: { serverId: true },
}),
db.query.webServerSettings.findFirst({
where: arrayContains(webServerSettings.telemetryProviderIds, [
telemetryProviderId,
]),
columns: { id: true },
}),
]);
return [...servers.map((s) => s.serverId), ...(local ? [null] : [])];
};
const SHIPPING_FIELDS = [
"providerType",
"enabled",
"endpoint",
"apiKey",
"apiSecret",
"extraConfig",
"signals",
] as const;
export const updateTelemetryProvider = async (
telemetryProviderId: string,
data: Partial<z.infer<typeof apiCreateTelemetryProvider>>,
) => {
const existing =
await findTelemetryProviderByIdWithCredentials(telemetryProviderId);
const isChangingType =
data.providerType !== undefined &&
data.providerType !== existing.providerType;
const dataToApply = isChangingType
? {
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
...data,
}
: data.extraConfig != null
? {
...data,
extraConfig: { ...(existing.extraConfig ?? {}), ...data.extraConfig },
}
: data;
const merged = { ...existing, ...dataToApply };
const signals = merged.signals as TelemetrySignal[];
assertSignalsSupported(merged.providerType, signals);
assertRequiredCredentialFields(merged.providerType, signals, merged);
if (dataToApply.signals) {
dataToApply.signals = sortSignals(dataToApply.signals);
}
const updated = await db
.update(telemetryProvider)
.set(dataToApply)
.where(eq(telemetryProvider.telemetryProviderId, telemetryProviderId))
.returning()
.then((rows) => rows[0]);
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Provider not found",
});
}
const shippingChanged = SHIPPING_FIELDS.some(
(field) =>
field in dataToApply &&
JSON.stringify(dataToApply[field]) !== JSON.stringify(existing[field]),
);
return {
provider: updated,
targets: shippingChanged
? await findTargetsUsingProvider(telemetryProviderId)
: [],
};
};
export const removeTelemetryProvider = async (telemetryProviderId: string) => {
return await db.transaction(async (tx) => {
const removed = await tx
.delete(telemetryProvider)
.where(eq(telemetryProvider.telemetryProviderId, telemetryProviderId))
.returning()
.then((rows) => rows[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Provider not found",
});
}
const servers = await tx
.update(server)
.set({
telemetryProviderIds: sql`array_remove(${server.telemetryProviderIds}, ${telemetryProviderId})`,
})
.where(arrayContains(server.telemetryProviderIds, [telemetryProviderId]))
.returning({ serverId: server.serverId });
const local = await tx
.update(webServerSettings)
.set({
telemetryProviderIds: sql`array_remove(${webServerSettings.telemetryProviderIds}, ${telemetryProviderId})`,
vectorAgentOrganizationId: sql`CASE WHEN cardinality(array_remove(${webServerSettings.telemetryProviderIds}, ${telemetryProviderId})) = 0 THEN NULL ELSE ${webServerSettings.vectorAgentOrganizationId} END`,
})
.where(
arrayContains(webServerSettings.telemetryProviderIds, [
telemetryProviderId,
]),
)
.returning({ id: webServerSettings.id });
const targets: VectorTarget[] = [
...servers.map((s) => s.serverId),
...(local.length > 0 ? [null] : []),
];
return { provider: removed, targets };
});
};
export const findTelemetryProviderForOrganization = async (
telemetryProviderId: string,
organizationId: string,
) => {
const provider = await findTelemetryProviderById(telemetryProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this provider",
});
}
return provider;
};
export const findTelemetryProviderById = async (
telemetryProviderId: string,
) => {
const found = await db.query.telemetryProvider.findFirst({
where: eq(telemetryProvider.telemetryProviderId, telemetryProviderId),
columns: CREDENTIAL_COLUMNS,
});
if (!found) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Provider not found",
});
}
return found;
};
export const findTelemetryProviderByIdWithCredentials = async (
telemetryProviderId: string,
) => {
const found = await db.query.telemetryProvider.findFirst({
where: eq(telemetryProvider.telemetryProviderId, telemetryProviderId),
});
if (!found) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Provider not found",
});
}
return found;
};
export const findTelemetryProvidersByOrganization = async (
organizationId: string,
) => {
return await db.query.telemetryProvider.findMany({
where: eq(telemetryProvider.organizationId, organizationId),
columns: CREDENTIAL_COLUMNS,
});
};
export const findEnabledTelemetryProvidersByOrganization = async (
organizationId: string,
) => {
return await db.query.telemetryProvider.findMany({
where: and(
eq(telemetryProvider.organizationId, organizationId),
eq(telemetryProvider.enabled, true),
),
});
};
export const toRuntimeConfig = (
provider: TelemetryProvider,
): TelemetryProviderRuntimeConfig => ({
telemetryProviderId: provider.telemetryProviderId,
name: provider.name,
signals: provider.signals as TelemetrySignal[],
endpoint: provider.endpoint,
apiKey: provider.apiKey,
apiSecret: provider.apiSecret,
extraConfig: provider.extraConfig,
});
export const testTelemetryProviderConnection = async (
params:
| { telemetryProviderId: string; signals?: TelemetrySignal[] }
| {
providerType: TelemetryProviderType;
config: TelemetryProviderRuntimeConfig;
},
) => {
let providerType: TelemetryProviderType;
let runtimeConfig: TelemetryProviderRuntimeConfig;
if ("telemetryProviderId" in params) {
const provider = await findTelemetryProviderByIdWithCredentials(
params.telemetryProviderId,
);
providerType = provider.providerType;
runtimeConfig = {
...toRuntimeConfig(provider),
...(params.signals ? { signals: params.signals } : {}),
};
} else {
providerType = params.providerType;
runtimeConfig = params.config;
}
const adapter = getTelemetryProviderAdapter(providerType);
if (!adapter.testConnection) {
return {
success: true,
warning: "This provider does not support connection testing",
};
}
try {
await adapter.testConnection(runtimeConfig);
return { success: true };
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Connection test failed",
});
}
};
export const sanitizeTelemetryProvider = <
T extends { endpoint?: unknown; apiKey?: unknown; apiSecret?: unknown },
>(
provider: T,
) => {
const { endpoint, apiKey, apiSecret, ...rest } = provider;
return rest;
};
export const assertProvidersBelongToOrg = async (
telemetryProviderIds: string[],
organizationId: string,
) => {
const providers = await findTelemetryProvidersByOrganization(organizationId);
const byId = new Map(providers.map((p) => [p.telemetryProviderId, p]));
for (const id of telemetryProviderIds) {
const provider = byId.get(id);
if (!provider) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "One of the selected providers is not in this organization",
});
}
}
if (!telemetryProviderIds.some((id) => byId.get(id)?.enabled)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Select at least one enabled provider — a disabled one ships nothing.",
});
}
};
@@ -0,0 +1,85 @@
import type { telemetryProviderType } from "@dokploy/server/db/schema/telemetry-provider";
export type TelemetrySignal = "logs" | "metrics";
export const TELEMETRY_SIGNALS: TelemetrySignal[] = ["logs", "metrics"];
export interface TelemetryProviderCredentialField {
key: string;
label: string;
type: "text" | "password" | "url";
required: boolean;
signal?: TelemetrySignal;
placeholder?: string;
helpText?: string;
fullWidth?: boolean;
}
export interface VectorTransformConfig {
type: string;
inputs: string[];
[key: string]: unknown;
}
export interface VectorSinkConfig {
type: string;
inputs: string[];
buffer: {
type: "disk" | "memory";
max_size: number;
when_full?: "block" | "drop_newest";
};
[key: string]: unknown;
}
export interface TelemetryProviderRuntimeConfig {
telemetryProviderId: string;
name: string;
signals: TelemetrySignal[];
endpoint: string | null;
apiKey: string | null;
apiSecret: string | null;
extraConfig: Record<string, unknown> | null;
}
export type TelemetryProviderType =
(typeof telemetryProviderType.enumValues)[number];
export const DEFAULT_DISK_BUFFER = {
type: "disk" as const,
max_size: 268_435_488,
when_full: "block" as const,
};
export const normalizeEndpointUrl = (endpoint: string): string =>
!endpoint || /^https?:\/\//.test(endpoint) ? endpoint : `https://${endpoint}`;
export const stringValue = (value: unknown): string =>
typeof value === "string" ? value.trim() : "";
// VRL string literals do not accept JSON's \b, \f or \uXXXX escapes, so control characters are dropped first.
export const vrlString = (value: string): string =>
// biome-ignore lint/suspicious/noControlCharactersInRegex: that is the point
JSON.stringify(value.replace(/[\u0000-\u001f\u007f]/g, ""));
export interface TelemetryProviderAdapter {
type: TelemetryProviderType;
signals: TelemetrySignal[];
label: string;
docsUrl?: string;
credentialFields: TelemetryProviderCredentialField[];
toVectorTransform?(
config: TelemetryProviderRuntimeConfig,
transformId: string,
scopeTransformId: string,
signal: TelemetrySignal,
): VectorTransformConfig | null;
toVectorSink(
config: TelemetryProviderRuntimeConfig,
sinkId: string,
inputId: string,
signal: TelemetrySignal,
): VectorSinkConfig;
testConnection?(config: TelemetryProviderRuntimeConfig): Promise<void>;
validateConfig?(config: TelemetryProviderRuntimeConfig): void;
}
@@ -0,0 +1,119 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import {
findServerById,
getAccessibleServerIds,
type Server,
setServerProviderIds,
} from "@dokploy/server/services/server";
import { setWebServerProviderIds } from "@dokploy/server/services/web-server-settings";
import {
LOCAL_SERVER_NAME,
reconcileVectorAgent,
removeVectorAgent,
withVectorTargetLock,
} from "@dokploy/server/setup/vector-setup";
import { TRPCError } from "@trpc/server";
import { assertProvidersBelongToOrg, type VectorTarget } from "./service";
const assertLocalAgentAllowed = (serverId: string | undefined) => {
if (!serverId && IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The Vector agent on the Dokploy host is not available in Dokploy Cloud",
});
}
};
const assertServerAccessible = async (
serverId: string,
session: { userId: string; activeOrganizationId: string },
) => {
const server = await findServerById(serverId);
const accessibleIds = await getAccessibleServerIds(session);
if (
server.organizationId !== session.activeOrganizationId ||
!accessibleIds.has(serverId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
if (!server.sshKeyId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"This server has no SSH key, so the Vector agent can't be managed on it",
});
}
return server;
};
export const applyVectorAgentSelection = async (
session: { userId: string; activeOrganizationId: string },
serverId: string | undefined,
providerIds: string[],
) => {
const organizationId = session.activeOrganizationId;
assertLocalAgentAllowed(serverId);
const server = serverId
? await assertServerAccessible(serverId, session)
: undefined;
if (providerIds.length > 0) {
await assertProvidersBelongToOrg(providerIds, organizationId);
}
await withVectorTargetLock(serverId, async () => {
if (serverId) {
await setServerProviderIds(serverId, providerIds);
} else if (!(await setWebServerProviderIds(organizationId, providerIds))) {
throw new TRPCError({
code: "CONFLICT",
message: "The local Vector agent is claimed by another organization",
});
}
await reconcileVectorAgent(serverId);
});
return { serverName: server?.name ?? LOCAL_SERVER_NAME };
};
// Re-applies the saved selection after a provider changed; a failure is reported, not thrown.
export const reconcileVectorTargets = async (targets: VectorTarget[]) => {
const failures: string[] = [];
for (const target of targets) {
if (target === null && IS_CLOUD) continue;
try {
await withVectorTargetLock(target ?? undefined, () =>
reconcileVectorAgent(target ?? undefined),
);
} catch (error) {
failures.push(
`${target ?? LOCAL_SERVER_NAME}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
return failures.length > 0
? `The Vector agent could not be re-applied on ${failures.join("; ")}`
: undefined;
};
// Even with an empty selection: Remove saves it before stopping the agent, so a failed Remove can leave one running.
export const removeServerVectorAgent = async (server: Server) => {
if (!server.sshKeyId) {
return server.telemetryProviderIds.length > 0
? "This server has no SSH key, so the Vector agent could not be removed from it"
: undefined;
}
try {
await withVectorTargetLock(server.serverId, () =>
removeVectorAgent(server.serverId),
);
} catch (error) {
console.error(
`[Vector] Failed to remove agent for server ${server.serverId} before deletion:`,
error,
);
return error instanceof Error ? error.message : String(error);
}
};
@@ -0,0 +1,50 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { organization } from "@dokploy/server/db/schema";
import { findServersByOrganizationForVectorAgent } from "@dokploy/server/services/server";
import {
getWebServerSettings,
releaseWebServerAgent,
} from "@dokploy/server/services/web-server-settings";
import {
removeVectorAgent,
withVectorTargetLock,
} from "@dokploy/server/setup/vector-setup";
import { eq } from "drizzle-orm";
export const removeVectorAgents = async (organizationId: string) => {
// Even with an empty selection: Remove saves it before stopping the agent, so a failed Remove can leave one running.
const servers = await findServersByOrganizationForVectorAgent(organizationId);
await Promise.all(
servers.map((server) =>
withVectorTargetLock(server.serverId, () =>
removeVectorAgent(server.serverId),
).catch((error) => {
console.error(
`[Vector] Failed to remove the agent on server ${server.serverId} before deleting organization ${organizationId}:`,
error,
);
}),
),
);
if (IS_CLOUD) return;
await withVectorTargetLock(undefined, async () => {
const settings = await getWebServerSettings();
if (settings?.vectorAgentOrganizationId !== organizationId) return;
await removeVectorAgent().catch((error) => {
console.error(
`[Vector] Failed to remove the local agent before deleting organization ${organizationId}:`,
error,
);
});
await releaseWebServerAgent(organizationId);
});
};
export const deleteOrganization = async (organizationId: string) => {
await removeVectorAgents(organizationId);
return await db
.delete(organization)
.where(eq(organization.id, organizationId));
};
+29 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { and, desc, eq, isNotNull } from "drizzle-orm";
import type { z } from "zod";
export type Server = typeof server.$inferSelect;
@@ -87,6 +87,34 @@ export const findServersByUserId = async (userId: string) => {
return servers;
};
export const findServersByOrganizationForVectorAgent = async (
organizationId: string,
) => {
return await db.query.server.findMany({
where: and(
eq(server.organizationId, organizationId),
isNotNull(server.sshKeyId),
eq(server.serverType, "deploy"),
),
columns: {
serverId: true,
name: true,
ipAddress: true,
telemetryProviderIds: true,
},
orderBy: [desc(server.createdAt)],
});
};
export const setServerProviderIds = async (serverId: string, ids: string[]) => {
const [updated] = await db
.update(server)
.set({ telemetryProviderIds: ids })
.where(eq(server.serverId, serverId))
.returning();
return updated ?? null;
};
export const deleteServer = async (serverId: string) => {
const currentServer = await db
.delete(server)
@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db";
import { webServerSettings } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
/**
* Get the web server settings (singleton - only one row should exist)
@@ -42,3 +42,44 @@ export const updateWebServerSettings = async (
return updated;
};
export const setWebServerProviderIds = async (
organizationId: string,
ids: string[],
) => {
const current = await getWebServerSettings();
if (!current) {
return null;
}
const [updated] = await db
.update(webServerSettings)
.set({
telemetryProviderIds: ids,
vectorAgentOrganizationId: ids.length > 0 ? organizationId : null,
updatedAt: new Date(),
})
.where(
and(
eq(webServerSettings.id, current.id),
or(
isNull(webServerSettings.vectorAgentOrganizationId),
eq(webServerSettings.vectorAgentOrganizationId, organizationId),
),
),
)
.returning();
return updated ?? null;
};
export const releaseWebServerAgent = async (organizationId: string) => {
await db
.update(webServerSettings)
.set({
telemetryProviderIds: [],
vectorAgentOrganizationId: null,
updatedAt: new Date(),
})
.where(eq(webServerSettings.vectorAgentOrganizationId, organizationId));
};
+785
View File
@@ -0,0 +1,785 @@
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import {
findEnabledTelemetryProvidersByOrganization,
findTelemetryProvidersByOrganization,
type TelemetryProvider,
toRuntimeConfig,
} from "@dokploy/server/services/logs-and-metrics/service";
import {
findServerById,
findServersByOrganizationForVectorAgent,
getAccessibleServerIds,
} from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { encodeBase64 } from "@dokploy/server/utils/docker/utils";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import type Dockerode from "dockerode";
import type { ContainerSpec, CreateServiceOptions } from "dockerode";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { stringify } from "yaml";
import { getTelemetryProviderAdapter } from "../services/logs-and-metrics/providers/registry";
import {
type TelemetrySignal,
type VectorSinkConfig,
type VectorTransformConfig,
vrlString,
} from "../services/logs-and-metrics/types";
export const VECTOR_IMAGE = "timberio/vector:0.58.0-alpine";
const VECTOR_SERVICE_NAME = "dokploy-vector";
const VECTOR_CONFIG_CONTAINER_DIR = "/etc/vector";
const VECTOR_CONFIG_CONTAINER_PATH = `${VECTOR_CONFIG_CONTAINER_DIR}/vector.yaml`;
const VECTOR_DATA_DIR_CONTAINER = "/var/lib/vector";
export const CADVISOR_IMAGE = "ghcr.io/google/cadvisor:v0.60.6";
export const CADVISOR_SERVICE_NAME = "dokploy-cadvisor";
export const CADVISOR_PORT = 4510;
export const METRICS_SCRAPE_INTERVAL_SECS = 30;
export const LOCAL_SERVER_NAME = "Dokploy Server (local)";
const DOKPLOY_LABELS = [
"dokploy.organization.id",
"dokploy.project",
"dokploy.project.id",
"dokploy.environment",
"dokploy.environment.id",
"dokploy.application",
"dokploy.application.id",
"dokploy.service",
];
// Pseudo filesystems report 0 bytes, so their used_ratio is NaN, which InfluxDB and CloudWatch reject.
const FILESYSTEM_EXCLUDES = [
"overlay",
"tmpfs",
"squashfs",
"devtmpfs",
"nsfs",
"proc",
"sysfs",
"devpts",
"cgroup",
"cgroup2",
"mqueue",
"hugetlbfs",
"binfmt_misc",
"debugfs",
"tracefs",
"securityfs",
"pstore",
"bpf",
"configfs",
"fusectl",
"autofs",
"efivarfs",
"selinuxfs",
"rpc_pipefs",
"ramfs",
"rootfs",
"fuse.lxcfs",
];
const METRIC_TAGS: Array<[label: string, tag: string]> = [
["container_label_dokploy_organization_id", "dokploy_organization"],
["container_label_dokploy_project", "dokploy_project"],
["container_label_dokploy_project_id", "dokploy_project_id"],
["container_label_dokploy_environment", "dokploy_environment"],
["container_label_dokploy_environment_id", "dokploy_environment_id"],
["container_label_dokploy_application", "dokploy_application"],
["container_label_dokploy_application_id", "dokploy_application_id"],
["container_label_dokploy_service", "dokploy_service"],
];
const buildScopeTransformSource = (serverName: string): string =>
[
`.dokploy_server = ${vrlString(serverName)}`,
'.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");
// cAdvisor also exposes go_*/process_*/machine_* series and sets every whitelisted label, empty, on all containers.
export const buildMetricsScopeTransformSource = (serverName: string): string =>
[
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
`.tags.dokploy_server = ${vrlString(serverName)}`,
...METRIC_TAGS.map(
([label, tag]) =>
`value = del(.tags.${label})\nif is_string(value) && value != "" { .tags.${tag} = value }`,
),
"del(.tags.id)",
].join("\n");
const buildSinksAndTransforms = (
providers: Array<TelemetryProvider>,
baseTransformId: string,
signal: TelemetrySignal,
): {
sinks: Record<string, VectorSinkConfig>;
transforms: Record<string, VectorTransformConfig>;
} => {
const sinks: Record<string, VectorSinkConfig> = {};
const transforms: Record<string, VectorTransformConfig> = {};
const suffix = signal === "metrics" ? "_metrics" : "";
for (const provider of providers) {
try {
const adapter = getTelemetryProviderAdapter(provider.providerType);
const runtimeConfig = toRuntimeConfig(provider);
const sinkId = `sink_${provider.telemetryProviderId}${suffix}`;
const transformId = `transform_${provider.telemetryProviderId}${suffix}`;
const transform = adapter.toVectorTransform?.(
runtimeConfig,
transformId,
baseTransformId,
signal,
);
const inputId = transform ? transformId : baseTransformId;
const sink = adapter.toVectorSink(runtimeConfig, sinkId, inputId, signal);
if (transform) {
transforms[transformId] = transform;
}
sinks[sinkId] = sink;
} catch (error) {
console.error(
`[Vector] Skipping provider "${provider.name}" (${provider.telemetryProviderId}) — invalid config:`,
error,
);
}
}
return { sinks, transforms };
};
export const buildVectorConfigYaml = ({
logProviders,
metricsProviders,
organizationId,
serverName,
}: {
logProviders: Array<TelemetryProvider>;
metricsProviders: Array<TelemetryProvider>;
organizationId?: string;
serverName: string;
}) => {
const logsBaseId = organizationId
? "dokploy_scope_local_only"
: "dokploy_scope";
const metricsBaseId = organizationId
? "dokploy_metrics_scope_local_only"
: "dokploy_metrics_scope";
const logs = buildSinksAndTransforms(logProviders, logsBaseId, "logs");
const metrics = buildSinksAndTransforms(
metricsProviders,
metricsBaseId,
"metrics",
);
const config = {
data_dir: VECTOR_DATA_DIR_CONTAINER,
sources: {
...(logProviders.length > 0
? {
docker_logs_source: {
type: "docker_logs",
docker_host: "unix:///var/run/docker.sock",
},
}
: {}),
...(metricsProviders.length > 0
? {
host_metrics_source: {
type: "host_metrics",
scrape_interval_secs: METRICS_SCRAPE_INTERVAL_SECS,
collectors: [
"cpu",
"memory",
"disk",
"filesystem",
"network",
"load",
"host",
],
filesystem: {
filesystems: { excludes: FILESYSTEM_EXCLUDES },
},
network: {
devices: { excludes: ["veth*", "br-*", "docker*"] },
},
},
cadvisor_source: {
type: "prometheus_scrape",
endpoints: [`http://127.0.0.1:${CADVISOR_PORT}/metrics`],
scrape_interval_secs: METRICS_SCRAPE_INTERVAL_SECS,
scrape_timeout_secs: 10,
},
}
: {}),
},
transforms: {
...(logProviders.length > 0
? {
dokploy_scope: {
type: "remap",
inputs: ["docker_logs_source"],
source: buildScopeTransformSource(serverName),
},
...(organizationId
? {
dokploy_scope_local_only: {
type: "filter",
inputs: ["dokploy_scope"],
condition: `.dokploy_organization == ${vrlString(organizationId)}`,
},
}
: {}),
}
: {}),
...(metricsProviders.length > 0
? {
dokploy_metrics_scope: {
type: "remap",
inputs: ["host_metrics_source", "cadvisor_source"],
drop_on_abort: true,
source: buildMetricsScopeTransformSource(serverName),
},
...(organizationId
? {
dokploy_metrics_scope_local_only: {
type: "filter",
inputs: ["dokploy_metrics_scope"],
condition: `.namespace == "host" || .tags.dokploy_organization == ${vrlString(organizationId)}`,
},
}
: {}),
}
: {}),
...logs.transforms,
...metrics.transforms,
},
sinks: { ...logs.sinks, ...metrics.sinks },
};
// Vector 0.58 only expands $VAR with --dangerously-allow-env-var-interpolation, so a $ stays literal.
return stringify(config);
};
export const collectSecretValues = (
providers: Array<TelemetryProvider>,
): string[] => {
const values: string[] = [];
for (const provider of providers) {
if (provider.endpoint) values.push(provider.endpoint);
if (provider.apiKey) values.push(provider.apiKey);
if (provider.apiSecret) values.push(provider.apiSecret);
const secretKeys = getTelemetryProviderAdapter(provider.providerType)
.credentialFields.filter((field) => field.type === "password")
.map((field) => field.key);
for (const key of secretKeys) {
const value = provider.extraConfig?.[key];
if (typeof value === "string" && value.length > 0) values.push(value);
}
}
return values.filter((v) => v.length > 0).sort((a, b) => b.length - a.length);
};
export const redactSecrets = (text: string, secrets: string[]): string => {
let redacted = text;
for (const secret of secrets) {
redacted = redacted.split(secret).join("[redacted]");
}
return redacted;
};
const runOnTarget = async (serverId: string | undefined, command: string) => {
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
const describeError = (error: unknown) =>
error instanceof ExecError
? error.stderr || error.stdout || error.message
: error instanceof Error
? error.message
: String(error);
const syncVectorConfig = async (
serverId: string | undefined,
yamlStr: string,
secrets: string[],
) => {
const { VECTOR_PATH } = paths(!!serverId);
const configPath = `${VECTOR_PATH}/vector.yaml`;
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate.${nanoid(10)}`;
const encodedYaml = encodeBase64(yamlStr);
// Failed commands echo their command line, which carries the whole config.
const redact = (error: unknown) =>
redactSecrets(describeError(error), [encodedYaml, ...secrets]);
try {
await runOnTarget(
serverId,
`umask 077 && mkdir -p ${quote([`${VECTOR_PATH}/data`])} && chmod 700 ${quote([VECTOR_PATH])} ${quote([`${VECTOR_PATH}/data`])} && echo "${encodedYaml}" | base64 -d > ${quote([candidatePath])}`,
);
} catch (error) {
throw new Error(`Failed to write the Vector config: ${redact(error)}`);
}
try {
await runOnTarget(
serverId,
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${quote([`${candidatePath}:/etc/vector/vector.yaml:ro`])} ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
);
} catch (error) {
const sanitizedDetail = redact(error);
try {
await runOnTarget(serverId, `rm -f ${quote([candidatePath])}`);
} catch (cleanupError) {
console.error(
`[Vector] Failed to remove invalid config candidate ${candidatePath}${
serverId ? ` on server ${serverId}` : ""
}:`,
cleanupError,
);
}
throw new Error(
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
);
}
try {
await runOnTarget(
serverId,
`mv ${quote([candidatePath])} ${quote([configPath])}`,
);
} catch (error) {
throw new Error(`Failed to apply the Vector config: ${redact(error)}`);
}
};
const deploySwarmService = async (
docker: Dockerode,
settings: CreateServiceOptions,
label: string,
) => {
const service = docker.getService(settings.Name as string);
let inspect: Awaited<ReturnType<typeof service.inspect>> | undefined;
try {
inspect = await service.inspect();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
if (!inspect) {
try {
await docker.createService(settings);
console.log(`${label} Started ✅`);
} catch (error: any) {
if (error?.statusCode !== 409) {
throw error;
}
console.log(`${label} service already exists, continuing...`);
}
return;
}
// --watch-config reloads a changed file; only a changed container spec needs a restart.
// Docker rewrites mount sources and drops defaults on inspect, so compare what we set.
const containerSpecKey = (template: unknown) => {
const spec = (template as { ContainerSpec?: ContainerSpec })?.ContainerSpec;
return JSON.stringify({
image: spec?.Image,
args: spec?.Args,
env: spec?.Env,
mounts: spec?.Mounts?.map(
(mount) => `${mount.Target}:${mount.ReadOnly ? "ro" : "rw"}`,
),
});
};
const specChanged =
containerSpecKey(inspect.Spec.TaskTemplate) !==
containerSpecKey(settings.TaskTemplate);
await service.update({
version: Number.parseInt(inspect.Version.Index, 10),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate:
(inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + (specChanged ? 1 : 0),
},
});
console.log(`${label} Updated ✅`);
};
const removeSwarmService = async (docker: Dockerode, name: string) => {
try {
await docker.getService(name).remove();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
};
// --watch-config misses a replaced file on some mounts, and SIGHUP makes Vector reload it anyway.
const reloadVectorConfig = async (docker: Dockerode) => {
const containers = await docker.listContainers({
filters: JSON.stringify({
label: [`com.docker.swarm.service.name=${VECTOR_SERVICE_NAME}`],
}),
});
for (const container of containers) {
try {
await docker.getContainer(container.Id).kill({ signal: "SIGHUP" });
} catch (error: any) {
if (error?.statusCode !== 404 && error?.statusCode !== 409) {
throw error;
}
}
}
};
const deployVectorService = async (serverId?: string) => {
const { VECTOR_PATH } = paths(!!serverId);
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: VECTOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: VECTOR_IMAGE,
Args: ["--config", VECTOR_CONFIG_CONTAINER_PATH, "--watch-config"],
Env: ["PROCFS_ROOT=/host/proc", "SYSFS_ROOT=/host/sys"],
Mounts: [
{
Type: "bind",
Source: "/var/run/docker.sock",
Target: "/var/run/docker.sock",
ReadOnly: true,
},
{
Type: "bind",
Source: VECTOR_PATH,
Target: VECTOR_CONFIG_CONTAINER_DIR,
ReadOnly: true,
},
{
Type: "bind",
Source: `${VECTOR_PATH}/data`,
Target: VECTOR_DATA_DIR_CONTAINER,
ReadOnly: false,
},
{
Type: "bind",
Source: "/proc",
Target: "/host/proc",
ReadOnly: true,
},
{
Type: "bind",
Source: "/sys",
Target: "/host/sys",
ReadOnly: true,
},
],
},
Networks: [{ Target: "host" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
await deploySwarmService(docker, settings, "Vector");
await reloadVectorConfig(docker);
};
export const deployCadvisorService = async (serverId?: string) => {
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: CADVISOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: CADVISOR_IMAGE,
Args: [
"--listen_ip=127.0.0.1",
`--port=${CADVISOR_PORT}`,
"--docker_only=true",
"--store_container_labels=false",
`--whitelisted_container_labels=${DOKPLOY_LABELS.join(",")}`,
`--housekeeping_interval=${METRICS_SCRAPE_INTERVAL_SECS}s`,
"--storage_duration=1m0s",
"--event_storage_event_limit=default=0",
"--event_storage_age_limit=default=0",
"--disable_metrics=advtcp,cpu_topology,cpuset,hugetlb,memory_numa,oom_event,percpu,perf_event,process,referenced_memory,resctrl,sched,tcp,udp",
],
// The image healthcheck probes the default port and would make Swarm restart the task.
HealthCheck: { Test: ["NONE"] },
Mounts: [
{ Type: "bind", Source: "/", Target: "/rootfs", ReadOnly: true },
// /run holds both docker.sock and containerd.sock; /var/run is a symlink to it.
{ Type: "bind", Source: "/run", Target: "/run", ReadOnly: true },
{ Type: "bind", Source: "/sys", Target: "/sys", ReadOnly: true },
{
Type: "bind",
Source: "/var/lib/docker",
Target: "/var/lib/docker",
ReadOnly: true,
},
],
},
Networks: [{ Target: "host" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
await deploySwarmService(docker, settings, "cAdvisor");
};
export const removeCadvisorService = async (serverId?: string) => {
const docker = await getRemoteDocker(serverId);
await removeSwarmService(docker, CADVISOR_SERVICE_NAME);
};
const vectorTargetLocks = new Map<string, Promise<unknown>>();
// In-process only: Dokploy runs a single dashboard process per host.
export const withVectorTargetLock = async <T>(
serverId: string | undefined,
fn: () => Promise<T>,
): Promise<T> => {
const key = serverId ?? "local";
const previous = vectorTargetLocks.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(fn);
vectorTargetLocks.set(key, current);
try {
return await current;
} finally {
if (vectorTargetLocks.get(key) === current) {
vectorTargetLocks.delete(key);
}
}
};
export const getVectorTargetState = async (serverId?: string) => {
if (serverId) {
const server = await findServerById(serverId);
return {
organizationId: server.organizationId,
serverName: server.name,
telemetryProviderIds: server.telemetryProviderIds,
};
}
const settings = await getWebServerSettings();
return {
organizationId: settings?.vectorAgentOrganizationId ?? null,
serverName: LOCAL_SERVER_NAME,
telemetryProviderIds: settings?.telemetryProviderIds ?? [],
};
};
// Always called inside withVectorTargetLock: makes the host match the saved selection.
export const reconcileVectorAgent = async (serverId?: string) => {
const state = await getVectorTargetState(serverId);
const providers = (
state.organizationId
? await findEnabledTelemetryProvidersByOrganization(state.organizationId)
: []
).filter((p) => state.telemetryProviderIds.includes(p.telemetryProviderId));
const logProviders = providers.filter((p) => p.signals.includes("logs"));
const metricsProviders = providers.filter((p) =>
p.signals.includes("metrics"),
);
if (logProviders.length === 0 && metricsProviders.length === 0) {
await removeVectorAgent(serverId);
return;
}
const yamlStr = buildVectorConfigYaml({
logProviders,
metricsProviders,
organizationId: serverId ? undefined : (state.organizationId ?? undefined),
serverName: state.serverName,
});
await syncVectorConfig(serverId, yamlStr, collectSecretValues(providers));
await deployVectorService(serverId);
if (metricsProviders.length > 0) {
await deployCadvisorService(serverId);
} else {
await removeCadvisorService(serverId);
}
};
export const removeVectorAgent = async (serverId?: string) => {
if (serverId) {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`Server ${serverId} has no SSH key`);
}
}
const docker = await getRemoteDocker(serverId);
try {
await removeSwarmService(docker, CADVISOR_SERVICE_NAME);
} catch (error) {
console.error(
`[Vector] Failed to remove cAdvisor${serverId ? ` on server ${serverId}` : ""}:`,
error,
);
}
await removeSwarmService(docker, VECTOR_SERVICE_NAME);
const { VECTOR_PATH } = paths(!!serverId);
await runOnTarget(serverId, `rm -rf ${quote([VECTOR_PATH])}`);
};
type VectorStatus = "running" | "not-running" | "stopped" | "unknown";
const serviceState = async (
docker: Dockerode,
name: string,
): Promise<VectorStatus> => {
// listTasks returns [] instead of 404 for a missing service, so inspect first.
try {
await docker.getService(name).inspect();
} catch (error: any) {
if (error?.statusCode === 404) {
return "stopped";
}
throw error;
}
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [name],
"desired-state": ["running"],
}),
});
return tasks.some((task) => task.Status?.State === "running")
? "running"
: "not-running";
};
const inspectVectorStatus = async (
serverId: string | undefined,
requireCadvisor: boolean,
): Promise<VectorStatus> => {
const docker = await getRemoteDocker(serverId);
const vector = await serviceState(docker, VECTOR_SERVICE_NAME);
if (vector !== "running" || !requireCadvisor) {
return vector;
}
const cadvisor = await serviceState(docker, CADVISOR_SERVICE_NAME);
return cadvisor === "running" ? "running" : "not-running";
};
const VECTOR_CHECK_TIMEOUT_MS = 5000;
const vectorStatus = async (
serverId: string | null,
requireCadvisor: boolean,
): Promise<VectorStatus> => {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
inspectVectorStatus(serverId ?? undefined, requireCadvisor),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("timeout")),
VECTOR_CHECK_TIMEOUT_MS,
);
}),
]);
} catch {
return "unknown";
} finally {
clearTimeout(timer);
}
};
export const getVectorAgentTargets = async (session: {
userId: string;
activeOrganizationId: string;
}) => {
const organizationId = session.activeOrganizationId;
const [allServers, settings, accessibleIds, providers] = await Promise.all([
findServersByOrganizationForVectorAgent(organizationId),
IS_CLOUD ? Promise.resolve(null) : getWebServerSettings(),
getAccessibleServerIds(session),
findTelemetryProvidersByOrganization(organizationId),
]);
const metricsIds = new Set(
providers
.filter((p) => p.enabled && p.signals.includes("metrics"))
.map((p) => p.telemetryProviderId),
);
const servers = allServers.filter((s) => accessibleIds.has(s.serverId));
const webOwnsThisOrg =
!!settings &&
(settings.vectorAgentOrganizationId === null ||
settings.vectorAgentOrganizationId === organizationId);
// Deleting the owner sets the column to null and leaves its ids behind.
const webIsOwned = !!settings?.vectorAgentOrganizationId;
const targets: Array<{
serverId: string | null;
name: string;
ipAddress: string | null;
telemetryProviderIds: string[];
}> = [
...(settings && webOwnsThisOrg
? [
{
serverId: null,
name: LOCAL_SERVER_NAME,
ipAddress: null,
telemetryProviderIds: webIsOwned
? settings.telemetryProviderIds
: [],
},
]
: []),
...servers.map((s) => ({
serverId: s.serverId,
name: s.name,
ipAddress: s.ipAddress,
telemetryProviderIds: s.telemetryProviderIds,
})),
];
return Promise.all(
targets.map(async (target) => ({
...target,
status: await vectorStatus(
target.serverId,
target.telemetryProviderIds.some((id) => metricsIds.has(id)),
),
})),
);
};
+13 -1
View File
@@ -3,6 +3,7 @@ import { findRegistryByIdWithCredentials } from "@dokploy/server/services/regist
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -150,7 +151,18 @@ export const mechanizeDockerContainer = async (
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: application.environment.project.organizationId,
projectId: application.environment.projectId,
projectName: application.environment.project.name,
environmentId: application.environmentId,
environmentName: application.environment.name,
applicationId: application.applicationId,
applicationName: application.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
+13 -1
View File
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -98,7 +99,18 @@ export const buildLibsql = async (rawLibsql: LibsqlNested) => {
Args: ["-c", finalCommand],
}
: {}),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: libsql.environment.project.organizationId,
projectId: libsql.environment.projectId,
projectName: libsql.environment.project.name,
environmentId: libsql.environmentId,
environmentName: libsql.environment.name,
applicationId: libsql.libsqlId,
applicationName: libsql.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
+13 -1
View File
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -89,7 +90,18 @@ export const buildMariadb = async (rawMariadb: MariadbNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mariadb.environment.project.organizationId,
projectId: mariadb.environment.projectId,
projectName: mariadb.environment.project.name,
environmentId: mariadb.environmentId,
environmentName: mariadb.environment.name,
applicationId: mariadb.mariadbId,
applicationName: mariadb.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
+13 -1
View File
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -145,7 +146,18 @@ ${command ?? "wait $MONGOD_PID"}`;
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mongo.environment.project.organizationId,
projectId: mongo.environment.projectId,
projectName: mongo.environment.project.name,
environmentId: mongo.environmentId,
environmentName: mongo.environment.name,
applicationId: mongo.mongoId,
applicationName: mongo.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
+13 -1
View File
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -95,7 +96,18 @@ export const buildMysql = async (rawMysql: MysqlNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mysql.environment.project.organizationId,
projectId: mysql.environment.projectId,
projectName: mysql.environment.project.name,
environmentId: mysql.environmentId,
environmentName: mysql.environment.name,
applicationId: mysql.mysqlId,
applicationName: mysql.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -87,7 +88,18 @@ export const buildPostgres = async (rawPostgres: PostgresNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: postgres.environment.project.organizationId,
projectId: postgres.environment.projectId,
projectName: postgres.environment.project.name,
environmentId: postgres.environmentId,
environmentName: postgres.environment.name,
applicationId: postgres.postgresId,
applicationName: postgres.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
+13 -1
View File
@@ -1,6 +1,7 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -93,7 +94,18 @@ export const buildRedis = async (rawRedis: RedisNested) => {
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
...(Ulimits && { Ulimits }),
Labels,
Labels: {
...Labels,
...buildDokployLabels({
organizationId: redis.environment.project.organizationId,
projectId: redis.environment.projectId,
projectName: redis.environment.project.name,
environmentId: redis.environmentId,
environmentName: redis.environment.name,
applicationId: redis.redisId,
applicationName: redis.name,
}),
},
},
Networks: resolvedNetworks,
RestartPolicy,
@@ -0,0 +1,27 @@
export interface DokployResourceMetadata {
organizationId: string;
projectId: string;
projectName: string;
environmentId: string;
environmentName: string;
applicationId: string;
applicationName: string;
serviceName?: string;
}
const DOKPLOY_LABEL_PREFIX = "dokploy";
export const buildDokployLabels = (
metadata: DokployResourceMetadata,
): Record<string, string> => ({
[`${DOKPLOY_LABEL_PREFIX}.organization.id`]: metadata.organizationId,
[`${DOKPLOY_LABEL_PREFIX}.project.id`]: metadata.projectId,
[`${DOKPLOY_LABEL_PREFIX}.project`]: metadata.projectName,
[`${DOKPLOY_LABEL_PREFIX}.environment.id`]: metadata.environmentId,
[`${DOKPLOY_LABEL_PREFIX}.environment`]: metadata.environmentName,
[`${DOKPLOY_LABEL_PREFIX}.application.id`]: metadata.applicationId,
[`${DOKPLOY_LABEL_PREFIX}.application`]: metadata.applicationName,
...(metadata.serviceName
? { [`${DOKPLOY_LABEL_PREFIX}.service`]: metadata.serviceName }
: {}),
});
+70 -2
View File
@@ -17,6 +17,7 @@ import { cloneGitlabRepository } from "../providers/gitlab";
import { getCreateComposeFileCommand } from "../providers/raw";
import { randomizeDeployableSpecificationFile } from "./collision";
import { randomizeSpecificationFile } from "./compose";
import { buildDokployLabels } from "./dokploy-labels";
import type {
ComposeSpecification,
DefinitionsService,
@@ -111,7 +112,7 @@ export const readComposeFile = async (compose: Compose) => {
};
export const writeDomainsToCompose = async (
compose: Compose,
compose: ComposeWithMetadata,
domains: Domain[],
) => {
try {
@@ -196,8 +197,73 @@ const removeDomainLabels = (
return labels;
};
type ComposeWithMetadata = Compose & {
environment?: {
environmentId: string;
name: string;
projectId: string;
project?: { name: string; organizationId: string };
};
};
const applyDokployLabelsToCompose = (
result: ComposeSpecification,
compose: ComposeWithMetadata,
) => {
const { environment } = compose;
if (!environment?.project) return;
for (const [serviceName, service] of Object.entries(result.services ?? {})) {
if (!service) continue;
const metadataLabels = buildDokployLabels({
organizationId: environment.project.organizationId,
projectId: environment.projectId,
projectName: environment.project.name,
environmentId: environment.environmentId,
environmentName: environment.name,
applicationId: compose.composeId,
applicationName: compose.name,
serviceName,
});
// docker compose interpolates `$` in the file, so user-typed names need `$$` to stay literal.
const dokployLabels = Object.fromEntries(
Object.entries(metadataLabels).map(([key, value]) => [
key,
value.replaceAll("$", () => "$$"),
]),
);
service.labels = mergeDokployLabels(service.labels, dokployLabels);
if (compose.composeType === "docker-compose") {
continue;
}
if (!service.deploy) {
service.deploy = {};
}
service.deploy.labels = mergeDokployLabels(
service.deploy.labels,
dokployLabels,
);
}
};
const mergeDokployLabels = (
current: DefinitionsService["labels"],
dokployLabels: Record<string, string>,
): DefinitionsService["labels"] => {
if (current && !Array.isArray(current)) {
return { ...current, ...dokployLabels };
}
const entries = Object.entries(dokployLabels);
const keys = new Set(entries.map(([key]) => key));
const kept = (current ?? []).filter(
(label) => !keys.has(label.slice(0, label.indexOf("="))),
);
return [...kept, ...entries.map(([key, value]) => `${key}=${value}`)];
};
export const addDomainToCompose = async (
compose: Compose,
compose: ComposeWithMetadata,
domains: Domain[],
) => {
const { appName } = compose;
@@ -326,6 +392,8 @@ export const addDomainToCompose = async (
}
}
applyDokployLabelsToCompose(result, compose);
const injectedNetworkNames = await applyServiceNetworks(result, compose);
if (!compose.isolatedDeployment) {
+303 -16
View File
@@ -117,6 +117,12 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@aws-sdk/client-cloudwatch':
specifier: ^3.1108.0
version: 3.1127.0
'@aws-sdk/client-cloudwatch-logs':
specifier: ^3.1108.0
version: 3.1127.0
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
@@ -596,6 +602,12 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@aws-sdk/client-cloudwatch':
specifier: ^3.1108.0
version: 3.1127.0
'@aws-sdk/client-cloudwatch-logs':
specifier: ^3.1108.0
version: 3.1127.0
'@aws-sdk/client-route-53':
specifier: ^3.1108.0
version: 3.1108.0
@@ -743,6 +755,9 @@ importers:
toml:
specifier: 3.0.0
version: 3.0.0
undici:
specifier: ^6.21.3
version: 6.23.0
ws:
specifier: 8.16.0
version: 8.16.0
@@ -894,6 +909,14 @@ packages:
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
engines: {node: '>=12'}
'@aws-sdk/client-cloudwatch-logs@3.1127.0':
resolution: {integrity: sha512-BhRRVPLSEAQ3Yep8izRyVaMfHk1inNHm1C4txBWoJQYDVE/EX/SAIxtnpaF9KZYZzgBn8l7CBXC616435E2vlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-cloudwatch@3.1127.0':
resolution: {integrity: sha512-pbXY+ppzlZWLZyZEcDAT5KDqyNUbWmuK66bbg681Fl31x2rZ3PKkSh8po4oHqHrG9dcdv9AYi/9TQIg+B2Hfag==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-route-53@3.1108.0':
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
engines: {node: '>=20.0.0'}
@@ -910,38 +933,74 @@ packages:
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.9':
resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.68':
resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.70':
resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.70':
resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.72':
resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.13':
resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.15':
resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.75':
resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.77':
resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.79':
resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.82':
resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.68':
resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.70':
resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.12':
resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.14':
resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.74':
resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.76':
resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-route53@3.972.24':
resolution: {integrity: sha512-+z3OqGhqLA46vF5KbnjuygzSQo9A2cWsbuDy1rdrbWSPDCNT5CCRC+veIxRnqvxwpj/BY8P8XNz+WwCmX0e8rg==}
engines: {node: '>=20.0.0'}
@@ -950,22 +1009,42 @@ packages:
resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.44':
resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.44':
resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.46':
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1108.0':
resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1116.0':
resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.3':
resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.5':
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.38':
resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.40':
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
@@ -3944,6 +4023,10 @@ packages:
resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.33.3':
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.0':
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
engines: {node: '>=18.0.0'}
@@ -3952,10 +4035,22 @@ packages:
resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.8.0':
resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
engines: {node: '>=18.0.0'}
'@smithy/middleware-compression@4.6.2':
resolution: {integrity: sha512-Q9d+luiRjyHT6kCL/9NyGpdZJgodh4vtvfHC6H8SqoVKrV2k9RoyI9/IloVfdCYks3/2DIi7BsYYLcda5KZS0A==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.10.0':
resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.12.1':
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.0':
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
engines: {node: '>=18.0.0'}
@@ -3964,6 +4059,10 @@ packages:
resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.18.0':
resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==}
engines: {node: '>=18.0.0'}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
@@ -6039,6 +6138,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -9315,6 +9417,29 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
'@aws-sdk/client-cloudwatch-logs@3.1127.0':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/credential-provider-node': 3.972.82
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/client-cloudwatch@3.1127.0':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/credential-provider-node': 3.972.82
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/middleware-compression': 4.6.2
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/client-route-53@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9360,6 +9485,17 @@ snapshots:
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/core@3.977.9':
dependencies:
'@aws-sdk/types': 3.974.5
'@aws-sdk/xml-builder': 3.972.40
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.33.3
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.18.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.68':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9368,6 +9504,14 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9378,6 +9522,16 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.72':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.13':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9394,13 +9548,38 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.15':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/credential-provider-env': 3.972.70
'@aws-sdk/credential-provider-http': 3.972.72
'@aws-sdk/credential-provider-login': 3.972.77
'@aws-sdk/credential-provider-process': 3.972.70
'@aws-sdk/credential-provider-sso': 3.973.14
'@aws-sdk/credential-provider-web-identity': 3.972.76
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.75':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.77':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.79':
@@ -9417,6 +9596,20 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.82':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.70
'@aws-sdk/credential-provider-http': 3.972.72
'@aws-sdk/credential-provider-ini': 3.973.15
'@aws-sdk/credential-provider-process': 3.972.70
'@aws-sdk/credential-provider-sso': 3.973.14
'@aws-sdk/credential-provider-web-identity': 3.972.76
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/credential-provider-imds': 4.5.0
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.68':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9425,6 +9618,14 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.70':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.12':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9435,6 +9636,16 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.14':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/token-providers': 3.1116.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.74':
dependencies:
'@aws-sdk/core': 3.977.7
@@ -9444,6 +9655,15 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.76':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/middleware-sdk-route53@3.972.24':
dependencies:
'@aws-sdk/types': 3.974.3
@@ -9452,29 +9672,56 @@ snapshots:
'@aws-sdk/nested-clients@3.997.42':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/core': 3.977.9
'@aws-sdk/signature-v4-multi-region': 3.996.44
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.44':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/signature-v4-multi-region': 3.996.46
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.44':
dependencies:
'@aws-sdk/types': 3.974.3
'@aws-sdk/types': 3.974.5
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.17.0
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.46':
dependencies:
'@aws-sdk/types': 3.974.5
'@smithy/signature-v4': 5.7.0
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.42
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1116.0':
dependencies:
'@aws-sdk/core': 3.977.9
'@aws-sdk/nested-clients': 3.997.44
'@aws-sdk/types': 3.974.5
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/types@3.974.3':
@@ -9482,11 +9729,21 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/types@3.974.5':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.38':
dependencies:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.40':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.0':
@@ -12820,6 +13077,11 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/core@3.33.3':
dependencies:
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.0':
dependencies:
'@smithy/core': 3.32.0
@@ -12832,12 +13094,31 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/middleware-compression@4.6.2':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
fflate: 0.8.3
tslib: 2.8.1
'@smithy/node-http-handler@4.10.0':
dependencies:
'@smithy/core': 3.32.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
'@smithy/core': 3.33.3
'@smithy/types': 4.18.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.0':
dependencies:
'@smithy/core': 3.32.0
@@ -12848,6 +13129,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@smithy/types@4.18.0':
dependencies:
tslib: 2.8.1
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
@@ -15067,6 +15352,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0