refactor: clean up log-management code and rebuild migrations

This commit is contained in:
Narciso
2026-09-10 12:26:30 -04:00
parent 88118cdb3f
commit d803fe6123
57 changed files with 14464 additions and 23 deletions
@@ -0,0 +1,356 @@
import {
getLogProviderAdapter,
logProviderAdapters,
} from "@dokploy/server/services/log-management/providers/registry";
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
import { describe, expect, it } from "vitest";
const baseConfig: LogProviderRuntimeConfig = {
logProviderId: "log-provider-1",
name: "test",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getLogProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getLogProviderAdapter("loki").type).toBe("loki");
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getLogProviderAdapter("unknown")).toThrow(
/No LogProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
);
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_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
});
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 = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
);
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 = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("has toVectorTransform to build .ddtags from the scoping fields — confirmado que datadog_logs no tiene un campo `tags` de config (vector generate-schema)", () => {
const adapter = logProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = logProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = logProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
);
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 = logProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
);
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 = logProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
);
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 = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
);
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 = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
);
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 = logProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
);
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",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = logProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
);
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",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
logProviderAdapters.aws_cloudwatch.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
);
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 = logProviderAdapters.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",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: mocks.lookup,
}));
const { logProviderFetch } = await import(
"@dokploy/server/services/log-management/types"
);
describe("logProviderFetch — metadata 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 () => {
await expect(
logProviderFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
await expect(
logProviderFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
logProviderFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
await expect(
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
await expect(
logProviderFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a private-network endpoint (self-hosted Loki behind a VPN, say)", async () => {
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
await logProviderFetch("http://loki.internal.example.com:3100/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("allows a normal public endpoint", async () => {
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch(
"https://api.us-east-1.datadoghq.com/api/v1/validate",
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
await logProviderFetch("http://does-not-exist.invalid/");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,280 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
logProvider: {
findFirst: mocks.findFirst,
},
},
},
}));
const { createLogProvider, updateLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
describe("createLogProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createLogProvider(
{ name: "loki-prod", providerType: "loki" } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createLogProvider(
{ name: "dd", providerType: "datadog" } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createLogProvider(
{
name: "bs",
providerType: "betterstack",
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createLogProvider(
{
name: "splunk",
providerType: "splunk_hec",
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createLogProvider(
{
name: "dd",
providerType: "datadog",
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createLogProvider(
{
name: "es",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
]);
const created = await createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createLogProvider(
{
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateLogProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "old-name",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ logProviderId: "lp-1", name: "new-name" },
]);
const updated = await updateLogProvider("lp-1", { name: "new-name" });
expect(updated.name).toBe("new-name");
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(updateLogProvider("lp-1", { endpoint: null })).rejects.toThrow(
/endpoint/i,
);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: { username: "new-user" } });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateLogProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
@@ -0,0 +1,314 @@
import { describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
const mocks = vi.hoisted(() => ({
findManyProjects: vi.fn(),
findEnabledLogProvidersByOrganization: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
projects: {
findMany: mocks.findManyProjects,
},
},
},
}));
vi.mock(
"@dokploy/server/services/log-management/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/log-management/service")
>()),
findEnabledLogProvidersByOrganization:
mocks.findEnabledLogProvidersByOrganization,
}),
);
const { buildVectorConfigYaml } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("buildVectorConfigYaml", () => {
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
mocks.findManyProjects.mockResolvedValue([
{
projectId: "project-1",
name: "My Project",
environments: [
{
environmentId: "env-1",
name: "production",
applications: [
{
appName: "app-my-app-abc123",
applicationId: "application-1",
name: "my-app",
},
],
compose: [],
},
],
},
]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([
{
logProviderId: "log-provider-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
},
]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
expect(config.transforms.dokploy_scope.source).toContain(
'.dokploy_organization = "org-1"',
);
expect(config.transforms.dokploy_scope.source).toContain(
'app_name == "app-my-app-abc123"',
);
expect(config.transforms.dokploy_scope.source).toContain(
'.dokploy_project = "My Project"',
);
expect(config.transforms.dokploy_scope.source).toContain(
'.dokploy_environment = "production"',
);
expect(config.transforms.dokploy_scope.source).toContain(
'.dokploy_application = "my-app"',
);
const sinkIds = Object.keys(config.sinks);
expect(sinkIds).toEqual(["sink_log-provider-1"]);
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
"dokploy_scope",
]);
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
mocks.findManyProjects.mockResolvedValue([]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([
{
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
},
{
logProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
apiSecret: null,
extraConfig: null,
},
{
logProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: null,
},
]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"]).toBeDefined();
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"]).toBeDefined();
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("escapes double quotes in project/application names to avoid breaking the VRL string literal", async () => {
mocks.findManyProjects.mockResolvedValue([
{
projectId: "project-1",
name: 'My "Project"',
environments: [
{
environmentId: "env-1",
name: "production",
applications: [
{
appName: "app-abc123",
applicationId: "application-1",
name: "app",
},
],
compose: [],
},
],
},
]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope.source).toContain(
'.dokploy_project = "My \\"Project\\""',
);
});
it("replaces every control character in a project name, not just newlines, before embedding it in VRL", async () => {
mocks.findManyProjects.mockResolvedValue([
{
projectId: "project-1",
name: "Weird\r\x00Name",
environments: [
{
environmentId: "env-1",
name: "production",
applications: [
{
appName: "app-abc123",
applicationId: "application-1",
name: "app",
},
],
compose: [],
},
],
},
]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
const source = config.transforms.dokploy_scope.source as string;
expect(source).toContain('.dokploy_project = "Weird Name"');
});
it("keeps events without a matching appName unscoped (no crash, no filtering), with the scoping fields defaulted to empty string", async () => {
mocks.findManyProjects.mockResolvedValue([]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_organization = "org-1"',
'.dokploy_project = ""',
'.dokploy_project_id = ""',
'.dokploy_environment = ""',
'.dokploy_environment_id = ""',
'.dokploy_application = ""',
'.dokploy_application_id = ""',
].join("\n"),
);
});
it("uses preloaded org data instead of querying, when given (fan-out reuse)", async () => {
mocks.findManyProjects.mockClear();
mocks.findEnabledLogProvidersByOrganization.mockClear();
const yamlStr = await buildVectorConfigYaml("org-1", {
providers: [],
lookup: {},
});
expect(mocks.findManyProjects).not.toHaveBeenCalled();
expect(mocks.findEnabledLogProvidersByOrganization).not.toHaveBeenCalled();
const config = parse(yamlStr) as any;
expect(config.sinks).toEqual({});
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", async () => {
const yamlStr = await buildVectorConfigYaml("org-1", {
providers: [
{
logProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
},
{
logProviderId: "good-loki",
name: "loki-ok",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
},
] as any,
lookup: {},
});
const config = parse(yamlStr) as any;
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
});
it("dropUnmatched (local host only) filters out events with no matching Dokploy app before any sink", async () => {
const yamlStr = await buildVectorConfigYaml(
"org-1",
{
providers: [
{
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
},
] as any,
lookup: {},
},
{ dropUnmatched: true },
);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_project != ""',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the drop-unmatched filter for the per-server (non-local) path", async () => {
mocks.findManyProjects.mockResolvedValue([]);
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
const yamlStr = await buildVectorConfigYaml("org-1");
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
reserve: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { projects: { findMany: vi.fn() } },
},
}));
vi.mock("postgres", () => ({
default: vi.fn(() => ({ reserve: mocks.reserve })),
}));
const makeReservedMock = (locked: boolean) => {
const tag: any = vi
.fn()
.mockImplementation((strings: TemplateStringsArray) => {
const text = strings.join("");
if (text.includes("pg_try_advisory_lock")) {
return Promise.resolve([{ locked }]);
}
return Promise.resolve([]);
});
tag.release = vi.fn();
return tag;
};
const {
withConfigWriteLock,
collectSecretValues,
redactSecrets,
stripImageDigest,
vectorServiceSpecUnchanged,
buildServiceSettings,
} = await import("@dokploy/server/setup/vector-setup");
describe("withConfigWriteLock — cross-process guard (session scoped pg_advisory_lock)", () => {
beforeEach(() => {
mocks.reserve.mockReset();
});
it("runs fn and returns its result when the advisory lock is acquired", async () => {
const reserved = makeReservedMock(true);
mocks.reserve.mockResolvedValue(reserved);
const fn = vi.fn().mockResolvedValue("ok");
await expect(withConfigWriteLock("server-1", fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
const joinedCalls = reserved.mock.calls.map((call: any[]) =>
(call[0] as string[]).join(""),
);
expect(
joinedCalls.some((text: string) => text.includes("pg_advisory_unlock")),
).toBe(true);
expect(reserved.release).toHaveBeenCalledTimes(1);
});
it("retries, then throws without ever calling fn, when another process keeps holding the lock", async () => {
mocks.reserve.mockImplementation(async () => makeReservedMock(false));
const fn = vi.fn();
await expect(withConfigWriteLock("server-1", fn)).rejects.toThrow(
/already in progress/i,
);
expect(fn).not.toHaveBeenCalled();
expect(mocks.reserve).toHaveBeenCalledTimes(3);
}, 10_000);
it("still serializes two calls for the same serverId within this process", async () => {
mocks.reserve.mockImplementation(async () => makeReservedMock(true));
const order: string[] = [];
const slow = async (label: string, ms: number) => {
order.push(`${label}:start`);
await new Promise((resolve) => setTimeout(resolve, ms));
order.push(`${label}:end`);
return label;
};
const first = withConfigWriteLock("server-a", () => slow("first", 20));
const second = withConfigWriteLock("server-a", () => slow("second", 1));
await Promise.all([first, second]);
expect(order).toEqual([
"first:start",
"first:end",
"second:start",
"second:end",
]);
});
});
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
const providers = [
{
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a" },
},
{
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
] as any;
expect(collectSecretValues(providers)).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"tenant-a",
"dd-key",
"datadoghq.eu",
]),
);
});
it("ignores non-string extraConfig values and null/empty credentials", () => {
const providers = [
{
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: null,
extraConfig: { other: "hunter2" },
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
describe("stripImageDigest", () => {
it("drops an @sha256 digest suffix", () => {
expect(
stripImageDigest("timberio/vector:latest-alpine@sha256:abc123"),
).toBe("timberio/vector:latest-alpine");
});
it("returns the image unchanged when there's no digest", () => {
expect(stripImageDigest("timberio/vector:latest-alpine")).toBe(
"timberio/vector:latest-alpine",
);
});
});
describe("vectorServiceSpecUnchanged", () => {
const settings = {
TaskTemplate: {
ContainerSpec: {
Image: "timberio/vector:latest-alpine",
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
Mounts: [{ Type: "bind", Source: "/a", Target: "/b" }],
},
Networks: [{ Target: "host" }],
},
Mode: { Replicated: { Replicas: 1 } },
} as any;
it("is unchanged when inspect matches settings modulo an image digest suffix and a resolved network ID", () => {
const inspect = {
Spec: {
TaskTemplate: {
ContainerSpec: {
Image: "timberio/vector:latest-alpine@sha256:deadbeef",
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
Mounts: [{ Type: "bind", Source: "/a", Target: "/b" }],
},
Networks: [{ Target: "k1vw5h40twado5nnk9n2hi8nt" }],
},
Mode: { Replicated: { Replicas: 1 } },
},
} as any;
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(true);
});
it("is changed when a bind mount source differs", () => {
const inspect = {
Spec: {
TaskTemplate: {
ContainerSpec: {
Image: "timberio/vector:latest-alpine",
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
Mounts: [{ Type: "bind", Source: "/old-path", Target: "/b" }],
},
Networks: [{ Target: "host" }],
},
Mode: { Replicated: { Replicas: 1 } },
},
} as any;
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(false);
});
it("is changed when Mode differs", () => {
const inspect = {
Spec: {
TaskTemplate: settings.TaskTemplate,
Mode: { Global: {} },
},
} as any;
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(false);
});
});
describe("buildServiceSettings", () => {
it("bind-mounts the config directory, not the vector.yaml file itself", () => {
const settings = buildServiceSettings("/etc/dokploy/vector") as any;
const mounts = settings.TaskTemplate.ContainerSpec.Mounts as Array<{
Source: string;
Target: string;
}>;
const configMount = mounts.find((m) => m.Target === "/etc/vector");
expect(configMount?.Source).toBe("/etc/dokploy/vector");
expect(mounts.some((m) => m.Target === "/etc/vector/vector.yaml")).toBe(
false,
);
expect(settings.TaskTemplate.ContainerSpec.Args).toContain(
"/etc/vector/vector.yaml",
);
});
});
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getWebServerSettings: vi.fn(),
hasEnabledLogProvider: vi.fn(),
getService: vi.fn(),
remove: vi.fn(),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock(
"@dokploy/server/services/log-management/service",
async (importOriginal) => ({
...(await importOriginal<
typeof import("@dokploy/server/services/log-management/service")
>()),
hasEnabledLogProvider: mocks.hasEnabledLogProvider,
}),
);
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: vi.fn().mockResolvedValue({
getService: mocks.getService,
}),
}));
const { syncWebVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("syncWebVectorAgent — install condition", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("tears down (never installs) when the local toggle is off", async () => {
mocks.getWebServerSettings.mockResolvedValue({
enableLogManagement: false,
logManagementOrganizationId: "org-1",
});
mocks.remove.mockResolvedValue(undefined);
mocks.getService.mockReturnValue({ remove: mocks.remove });
const result = await syncWebVectorAgent();
expect(result.installed).toBe(false);
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
expect(mocks.remove).toHaveBeenCalled();
});
it("tears down when no organization has claimed the local agent yet", async () => {
mocks.getWebServerSettings.mockResolvedValue({
enableLogManagement: true,
logManagementOrganizationId: null,
});
mocks.remove.mockResolvedValue(undefined);
mocks.getService.mockReturnValue({ remove: mocks.remove });
const result = await syncWebVectorAgent();
expect(result.installed).toBe(false);
expect(mocks.hasEnabledLogProvider).not.toHaveBeenCalled();
});
it("tears down when the owning organization has no enabled log provider", async () => {
mocks.getWebServerSettings.mockResolvedValue({
enableLogManagement: true,
logManagementOrganizationId: "org-1",
});
mocks.hasEnabledLogProvider.mockResolvedValue(false);
mocks.remove.mockResolvedValue(undefined);
mocks.getService.mockReturnValue({ remove: mocks.remove });
const result = await syncWebVectorAgent();
expect(result.installed).toBe(false);
expect(mocks.hasEnabledLogProvider).toHaveBeenCalledWith("org-1");
});
it("skips the hasEnabledLogProvider query when providers are already preloaded", async () => {
mocks.getWebServerSettings.mockResolvedValue({
enableLogManagement: true,
logManagementOrganizationId: "org-1",
});
mocks.remove.mockResolvedValue(undefined);
mocks.getService.mockReturnValue({ remove: mocks.remove });
const result = await syncWebVectorAgent({ providers: [], lookup: {} });
expect(result.installed).toBe(false);
expect(mocks.hasEnabledLogProvider).not.toHaveBeenCalled();
});
});
@@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses logProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses logProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { logProvider: ["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 logProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied logProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
];
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.logProvider.read).toBe(false);
});
});
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
enableLogManagement: false,
logManagementOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -40,21 +40,29 @@ describe("pipeBetweenServers", () => {
await fs.rm(dir, { recursive: true, force: true });
}, 30_000);
it("reports a target failure with its stderr", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
it(
"reports a target failure with its stderr",
async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
},
30_000,
);
it("reports a source failure", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
it(
"reports a source failure",
async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
},
30_000,
);
});
@@ -0,0 +1,347 @@
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
interface Props {
logProviderId?: string;
}
export const HandleLogProvider = ({ logProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
const [providerType, setProviderType] = useState("");
const [enabled, setEnabled] = useState(true);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [initialFieldValues, setInitialFieldValues] = useState<
Record<string, string>
>({});
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: provider } = api.logProvider.one.useQuery(
{ logProviderId: logProviderId || "" },
{ enabled: !!logProviderId },
);
const {
mutateAsync,
isPending: isSaving,
error,
isError,
} = logProviderId
? api.logProvider.update.useMutation()
: api.logProvider.create.useMutation();
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.logProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.logProvider.testConnectionById.useMutation();
const selectedType = availableTypes?.find((t) => t.type === providerType);
const handleProviderTypeChange = (value: string) => {
setProviderType(value);
setFieldValues({});
};
useEffect(() => {
if (provider) {
setName(provider.name);
setProviderType(provider.providerType);
setEnabled(provider.enabled);
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
setFieldValues(extraConfig);
setInitialFieldValues(extraConfig);
} else if (isOpen) {
setName("");
setProviderType("");
setEnabled(true);
setFieldValues({});
setInitialFieldValues({});
}
}, [provider, isOpen]);
const buildPayload = () => {
const payload: Record<string, unknown> = { name, providerType, enabled };
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of selectedType?.credentialFields ?? []) {
const value = fieldValues[field.key];
if (TOP_LEVEL_KEYS.has(field.key)) {
if (value) {
payload[field.key] = value;
}
} else {
hasExtraConfigFields = true;
extraConfig[field.key] = value ?? "";
}
}
if (hasExtraConfigFields) {
payload.extraConfig = extraConfig;
}
return payload;
};
const topLevelKeys = (selectedType?.credentialFields ?? [])
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter((key) => !!fieldValues[key]);
const extraConfigKeys = (selectedType?.credentialFields ?? [])
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedExtraConfigKeys = extraConfigKeys.filter(
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
);
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!logProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const onTest = async () => {
if (logProviderId && !touchedAnyCredentialField) {
await testConnectionById({ logProviderId })
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
const payload = buildPayload();
await testConnection({
name: payload.name as string,
providerType: payload.providerType as LogProviderType,
enabled: payload.enabled as boolean,
endpoint: (payload.endpoint as string) ?? null,
apiKey: (payload.apiKey as string) ?? null,
apiSecret: (payload.apiSecret as string) ?? null,
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
})
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
const payload = buildPayload();
await mutateAsync({
...(logProviderId ? { logProviderId } : {}),
...payload,
} as any)
.then((result: any) => {
utils.logProvider.all.invalidate();
toast.success(
logProviderId ? "Log provider updated" : "Log provider added",
);
if (result?.syncErrors && result.syncErrors.length > 0) {
toast.error(
`Failed to sync ${result.syncErrors.length} server(s) — they may still be shipping with the old config`,
);
}
setIsOpen(false);
})
.catch(() => {
toast.error(
logProviderId
? "Error updating log provider"
: "Error adding log provider",
);
});
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{logProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Log Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
</DialogTitle>
<DialogDescription>
Vector will ship container logs from every server with Log
Management enabled to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testError?.message ||
testByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 w-full gap-4">
<div className="flex flex-col gap-2">
<Label>Name</Label>
<Input
placeholder="e.g. Production Loki"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Provider</Label>
<Select
value={providerType}
onValueChange={handleProviderTypeChange}
disabled={!!logProviderId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{availableTypes?.map((type) => (
<SelectItem key={type.type} value={type.type}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedType?.credentialFields.map((field) => (
<div
key={field.key}
className={
field.type === "url" || field.fullWidth
? "flex flex-col gap-2 col-span-2"
: "flex flex-col gap-2"
}
>
<Label>
{field.label}
{!field.required && " (Optional)"}
</Label>
{field.helpText && (
<span className="text-xs text-muted-foreground">
{field.helpText}
</span>
)}
<Input
type={field.type === "password" ? "password" : "text"}
placeholder={
logProviderId && TOP_LEVEL_KEYS.has(field.key)
? "Leave blank to keep existing"
: field.placeholder
}
autoComplete={
field.type === "password" ? "one-time-code" : "off"
}
value={fieldValues[field.key] ?? ""}
onChange={(e) =>
setFieldValues((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
/>
</div>
))}
<div className="flex flex-row items-center gap-2 col-span-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>Enabled</Label>
</div>
</div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
{isPartiallyTouched && (
<span className="text-xs text-muted-foreground">
You changed something that needs a fresh credential to test — fill
in every credential field to test with the new values, or revert
it to test with what's already saved.
</span>
)}
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
disabled={!providerType || isPartiallyTouched}
onClick={onTest}
>
Test connection
</Button>
<Button
onClick={onSubmit}
isLoading={isSaving}
disabled={!providerType || !name || isSaving}
>
{logProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,247 @@
import {
AlertTriangle,
CheckCircle2,
Loader2,
ScrollText,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
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 { HandleLogProvider } from "./handle-log-provider";
interface LogProviderRowProps {
provider: {
logProviderId: string;
name: string;
enabled: boolean;
providerType: string;
};
index: number;
typeLabel: string;
canEdit: boolean;
canDelete: boolean;
onDeleted: () => void;
}
const LogProviderRow = ({
provider,
index,
typeLabel,
canEdit,
canDelete,
onDeleted,
}: LogProviderRowProps) => {
const { mutateAsync, isPending: isRemoving } =
api.logProvider.remove.useMutation();
const { mutateAsync: testConnectionById, isPending: isTesting } =
api.logProvider.testConnectionById.useMutation();
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 items-center justify-between">
<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>
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<Button
variant="ghost"
size="sm"
isLoading={isTesting}
onClick={async () => {
await testConnectionById({
logProviderId: provider.logProviderId,
})
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Connection test failed",
);
});
}}
>
<CheckCircle2 className="size-4" />
Test
</Button>
)}
{canEdit && (
<HandleLogProvider logProviderId={provider.logProviderId} />
)}
{canDelete && (
<DialogAction
title="Delete Log Provider"
description="Are you sure you want to delete this log provider? Vector will stop shipping logs to it."
type="destructive"
onClick={async () => {
await mutateAsync({
logProviderId: provider.logProviderId,
})
.then((result) => {
toast.success("Log provider deleted successfully");
if (result.syncErrors && result.syncErrors.length > 0) {
toast.error(
`Failed to sync ${result.syncErrors.length} server(s) — they may still be shipping with the old config`,
);
}
onDeleted();
})
.catch(() => {
toast.error("Error deleting log 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 ShowLogProviders = () => {
const utils = api.useUtils();
const { data, isPending, refetch } = api.logProvider.all.useQuery();
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data: servers } = api.server.all.useQuery();
const providerListLabel = availableTypes?.length
? ` (${availableTypes.map((t) => t.label).join(", ")})`
: "";
const hasAnyProvider = (data?.length ?? 0) > 0;
const knowsAboutServers = servers !== undefined;
const hasActiveTarget =
!!webServerSettings?.enableLogManagement ||
!!servers?.some((s) => s.enableLogManagement);
const showNoActiveTargetBanner =
hasAnyProvider && knowsAboutServers && !hasActiveTarget;
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" />
Log Management
</CardTitle>
<CardDescription>
{`Ship container logs to an external provider${providerListLabel}. Needs at least one provider here and the toggle enabled on each server's settings.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{showNoActiveTargetBanner && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 mb-4">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
<div className="flex flex-col gap-2 text-sm">
<span className="text-yellow-700 dark:text-yellow-400">
Nothing will ship yet — no server has Log Management turned
on.
</span>
<Button asChild variant="outline" size="sm" className="w-fit">
<Link
href={
isCloud
? "/dashboard/settings/servers"
: "/dashboard/settings/server"
}
>
{isCloud ? "Go to Servers" : "Go to Web Server settings"}
</Link>
</Button>
</div>
</div>
)}
{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 log provider configured
</span>
{permissions?.logProvider.create && <HandleLogProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((provider, index) => (
<LogProviderRow
key={provider.logProviderId}
provider={provider}
index={index}
typeLabel={
availableTypes?.find(
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.logProvider.create}
canDelete={!!permissions?.logProvider.delete}
onDeleted={() => {
refetch();
utils.server.all?.invalidate?.();
}}
/>
))}
</div>
{permissions?.logProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleLogProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -12,6 +12,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowStorageActions } from "./show-storage-actions";
import { ShowTraefikActions } from "./show-traefik-actions";
import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
import { ToggleLogManagement } from "./toggle-log-management";
interface Props {
serverId: string;
@@ -49,6 +50,7 @@ export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
<ShowTraefikActions serverId={serverId} />
<ShowStorageActions serverId={serverId} />
<ToggleDockerCleanup serverId={serverId} />
<ToggleLogManagement serverId={serverId} />
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,125 @@
import { HelpCircle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
export const ToggleLogManagement = ({ serverId }: Props) => {
const utils = api.useUtils();
const { data: server } = api.server.one.useQuery(
{ serverId: serverId || "" },
{ enabled: !!serverId },
);
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const { mutateAsync: updateServer, isPending: isServerPending } =
api.server.updateLogManagement.useMutation();
const { mutateAsync: updateLocal, isPending: isLocalPending } =
api.settings.updateLogManagement.useMutation();
const isPending = serverId ? isServerPending : isLocalPending;
const enabled = serverId
? !!server?.enableLogManagement
: !!webServerSettings?.enableLogManagement;
const handleToggle = async (checked: boolean) => {
if (serverId) {
await updateServer({ serverId, enableLogManagement: checked })
.then((result) => {
utils.server.one.setData({ serverId }, (old) =>
old
? { ...old, enableLogManagement: result.enableLogManagement }
: old,
);
if (checked && !result.installed) {
toast.message(
"Log Management enabled, but no log provider is configured yet — add one under Settings > Log Management.",
);
} else {
toast.success("Log Management updated");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Error updating Log Management",
);
});
return;
}
await updateLocal({ enableLogManagement: checked })
.then((result) => {
utils.settings.getWebServerSettings.setData(undefined, (old) =>
old
? { ...old, enableLogManagement: result.enableLogManagement }
: old,
);
if (checked && !result.installed) {
toast.message(
"Log Management enabled, but no log provider is configured yet — add one under Settings > Log Management.",
);
} else {
toast.success("Log Management updated");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Error updating Log Management",
);
});
};
return (
<div className="flex items-center gap-4">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isPending}
/>
{isPending && (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
)}
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Log Management
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm flex-col items-start">
<p>
Ships {serverId ? "this server's" : "this machine's"} container
logs to the log providers configured under Settings &gt; Log
Management, via a Vector agent. May take a few seconds to install
or remove.
</p>
<p className="mt-1">
Has no effect (and installs nothing) unless at least one log
provider is enabled for this organization.
</p>
{!serverId && (
<p className="mt-1">
Ships every container on this machine, not just this
organization's — if another organization on this instance also
deploys here, its logs ship too (unscoped, no project/app tags).
</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</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 remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
);
}
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
@@ -126,10 +126,13 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async (_data) => {
.then(async (data) => {
await utils.server.all.invalidate();
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
if ("vectorAgentWarning" in data && data.vectorAgentWarning) {
toast.error(data.vectorAgentWarning);
}
setIsOpen(false);
})
.catch(() => {
@@ -13,6 +13,7 @@ import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
import { ToggleLogManagement } from "./servers/actions/toggle-log-management";
import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
@@ -68,6 +69,7 @@ export const WebServer = () => {
</span>
<ToggleDockerCleanup />
<ToggleLogManagement />
</div>
</CardContent>
</div>
+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: "Log Management",
url: "/dashboard/settings/log-providers",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
},
{
isSingle: true,
title: "Secrets",
@@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
logProvider: "Log Provider",
};
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",
},
logProvider: {
label: "Log Providers",
description:
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
},
};
/** Descriptions for each action within a resource */
@@ -475,6 +480,14 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
logProvider: {
read: { label: "Read", description: "View configured log providers" },
create: {
label: "Create",
description: "Add new log providers and test their connection",
},
delete: { label: "Delete", description: "Remove log providers" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -0,0 +1,19 @@
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
CREATE TABLE "logProvider" (
"logProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "LogProviderType" NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "enableLogManagement" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "enableLogManagement" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
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": 1789042957674,
"tag": "0197_past_omega_red",
"breakpoints": true
}
]
}
+1
View File
@@ -48,6 +48,7 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch-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,55 @@
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 { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowLogProviders />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Log Management">{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
@@ -21,6 +21,7 @@ import { giteaRouter } from "./routers/gitea";
import { githubRouter } from "./routers/github";
import { gitlabRouter } from "./routers/gitlab";
import { libsqlRouter } from "./routers/libsql";
import { logProviderRouter } from "./routers/log-provider";
import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount";
@@ -85,6 +86,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
logProvider: logProviderRouter,
mariadb: mariadbRouter,
mongo: mongoRouter,
mounts: mountRouter,
@@ -0,0 +1,141 @@
import {
createLogProvider,
findLogProviderById,
findLogProvidersByOrganization,
logProviderAdapters,
removeLogProvider,
sanitizeLogProvider,
testLogProviderConnection,
updateLogProvider,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateLogProvider,
apiFindOneLogProvider,
apiRemoveLogProvider,
apiTestLogProvider,
apiUpdateLogProvider,
} from "@/server/db/schema";
import { safeSyncVectorAgentsForOrganization } from "@/server/utils/vector-resync";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const logProviderRouter = createTRPCRouter({
create: withPermission("logProvider", "create")
.input(apiCreateLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await createLogProvider(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
const syncErrors = await safeSyncVectorAgentsForOrganization(
ctx.session.activeOrganizationId,
);
return { ...sanitizeLogProvider(provider), syncErrors };
}),
update: withPermission("logProvider", "create")
.input(apiUpdateLogProvider)
.mutation(async ({ ctx, input }) => {
const { logProviderId, ...rest } = input;
const provider = await findLogProviderById(logProviderId);
if (provider.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to update this log provider",
});
}
const updated = await updateLogProvider(logProviderId, rest);
await audit(ctx, {
action: "update",
resourceType: "logProvider",
resourceId: logProviderId,
resourceName: provider.name,
});
const syncErrors = await safeSyncVectorAgentsForOrganization(
ctx.session.activeOrganizationId,
);
return { ...sanitizeLogProvider(updated), syncErrors };
}),
remove: withPermission("logProvider", "delete")
.input(apiRemoveLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findLogProviderById(input.logProviderId);
if (provider.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to delete this log provider",
});
}
const removed = await removeLogProvider(input.logProviderId);
await audit(ctx, {
action: "delete",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
const syncErrors = await safeSyncVectorAgentsForOrganization(
ctx.session.activeOrganizationId,
);
return { ...sanitizeLogProvider(removed), syncErrors };
}),
all: withPermission("logProvider", "read").query(async ({ ctx }) => {
return await findLogProvidersByOrganization(
ctx.session.activeOrganizationId,
);
}),
one: withPermission("logProvider", "read")
.input(apiFindOneLogProvider)
.query(async ({ ctx, input }) => {
const provider = await findLogProviderById(input.logProviderId);
if (provider.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this log provider",
});
}
return provider;
}),
testConnection: withPermission("logProvider", "create")
.input(apiTestLogProvider)
.mutation(async ({ input }) => {
return await testLogProviderConnection({
providerType: input.providerType,
config: {
logProviderId: "test",
name: input.name ?? "test",
endpoint: input.endpoint ?? null,
apiKey: input.apiKey ?? null,
apiSecret: input.apiSecret ?? null,
extraConfig: input.extraConfig ?? null,
},
});
}),
testConnectionById: withPermission("logProvider", "create")
.input(apiFindOneLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findLogProviderById(input.logProviderId);
if (provider.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this log provider",
});
}
return await testLogProviderConnection({
logProviderId: input.logProviderId,
});
}),
availableTypes: protectedProcedure.query(() => {
return Object.values(logProviderAdapters).map((adapter) => ({
type: adapter.type,
label: adapter.label,
docsUrl: adapter.docsUrl,
credentialFields: adapter.credentialFields,
}));
}),
});
@@ -20,6 +20,7 @@ import {
organizationRole,
user,
} from "@/server/db/schema";
import { teardownVectorForOrganizationDeletion } from "@/server/utils/vector-resync";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const organizationRouter = createTRPCRouter({
create: protectedProcedure
@@ -285,6 +286,8 @@ export const organizationRouter = createTRPCRouter({
});
}
await teardownVectorForOrganizationDeletion(input.organizationId);
const result = await db
.delete(organization)
.where(eq(organization.id, input.organizationId));
+146 -3
View File
@@ -12,6 +12,7 @@ import {
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
removeVectorAgent,
serverAudit,
serverSetup,
serverValidate,
@@ -50,6 +51,10 @@ import {
server,
} from "@/server/db/schema";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
import {
applyVectorResyncSchedule,
syncVectorAgentAndSchedule,
} from "@/server/utils/vector-resync";
export const serverRouter = createTRPCRouter({
create: withPermission("server", "create")
@@ -453,6 +458,73 @@ export const serverRouter = createTRPCRouter({
throw error;
}
}),
setupLogManagement: withPermission("server", "create")
.input(apiFindOneServer)
.mutation(async ({ input, ctx }) => {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup this server",
});
}
const result = await syncVectorAgentAndSchedule(input.serverId);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
});
return result;
}),
updateLogManagement: withPermission("server", "create")
.input(
z.object({
serverId: z.string().min(1),
enableLogManagement: z.boolean(),
}),
)
.mutation(async ({ input, ctx }) => {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this server",
});
}
await updateServerById(input.serverId, {
enableLogManagement: input.enableLogManagement,
});
try {
const result = await syncVectorAgentAndSchedule(input.serverId);
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
});
return { ...result, enableLogManagement: input.enableLogManagement };
} catch (error) {
await updateServerById(input.serverId, {
enableLogManagement: server.enableLogManagement,
});
await syncVectorAgentAndSchedule(input.serverId).catch(
(teardownError) => {
console.error(
`[Vector] Failed to tear down agent for server ${input.serverId} after a failed sync — it may still be running:`,
teardownError,
);
},
);
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? `Failed to sync Vector agent: ${error.message}`
: "Failed to sync Vector agent",
});
}
}),
remove: withPermission("server", "delete")
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
@@ -473,11 +545,28 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
let vectorAgentRemovalWarning: string | undefined;
if (currentServer.enableLogManagement) {
await removeVectorAgent({ serverId: input.serverId }).catch(
(error) => {
vectorAgentRemovalWarning =
error instanceof Error ? error.message : String(error);
console.error(
`[Vector] Failed to remove agent for server ${input.serverId} before deletion:`,
error,
);
},
);
await applyVectorResyncSchedule(input.serverId, false);
}
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 +577,10 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return redactServerSshKey(currentServer);
return {
...redactServerSshKey(currentServer),
vectorAgentRemovalWarning,
};
} catch (error) {
throw error;
}
@@ -511,7 +603,34 @@ export const serverRouter = createTRPCRouter({
message: "Server is inactive",
});
}
const currentServer = await updateServerById(input.serverId, {
const connectionChanged =
server.ipAddress !== input.ipAddress ||
server.port !== input.port ||
server.username !== input.username ||
server.sshKeyId !== input.sshKeyId;
let vectorAgentWarning: string | undefined;
const recordVectorWarning = (prefix: string, error: unknown) => {
const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
vectorAgentWarning = vectorAgentWarning
? `${vectorAgentWarning} ${message}`
: message;
console.error(`[Vector] ${message}`);
};
if (connectionChanged && server.enableLogManagement) {
await removeVectorAgent({ serverId: input.serverId }).catch(
(error) => {
recordVectorWarning(
"Failed to remove the agent from the old host",
error,
);
},
);
}
let currentServer = await updateServerById(input.serverId, {
...input,
});
@@ -521,13 +640,37 @@ export const serverRouter = createTRPCRouter({
input.enableDockerCleanup,
);
if (connectionChanged && server.enableLogManagement) {
try {
await syncVectorAgentAndSchedule(input.serverId);
} catch (error) {
currentServer =
(await updateServerById(input.serverId, {
enableLogManagement: false,
})) ?? currentServer;
await syncVectorAgentAndSchedule(input.serverId).catch(
(teardownError) => {
console.error(
`[Vector] Failed to tear down agent for server ${input.serverId} after a failed sync — it may still be running:`,
teardownError,
);
},
);
recordVectorWarning(
"Failed to install the agent on the new host, log management has been turned off for this server",
error,
);
}
}
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId,
resourceName: server.name,
...(vectorAgentWarning ? { metadata: { vectorAgentWarning } } : {}),
});
return currentServer;
return { ...currentServer, vectorAgentWarning };
} catch (error) {
throw error;
}
@@ -4,6 +4,7 @@ import {
checkPortInUse,
checkPostgresHealth,
checkTraefikHealth,
claimWebServerLogManagement,
cleanupAll,
cleanupAllBackground,
cleanupBuilders,
@@ -71,6 +72,7 @@ import {
} from "@/server/db/schema";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup";
import { syncWebVectorAgentAndSchedule } from "@/server/utils/vector-resync";
import packageInfo from "../../../package.json";
import { appRouter } from "../root";
import {
@@ -410,6 +412,75 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
updateLogManagement: adminProcedure
.input(z.object({ enableLogManagement: z.boolean() }))
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available for self-hosted instances",
});
}
const activeOrganizationId = ctx.session.activeOrganizationId;
const current = await getWebServerSettings();
const currentOwner = current?.logManagementOrganizationId ?? null;
if (currentOwner && currentOwner !== activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: input.enableLogManagement
? "The local Vector agent is already used by another organization on this instance — disable it there first."
: "You are not authorized to disable the local Vector agent — it belongs to another organization.",
});
}
const previous = {
enableLogManagement: !!current?.enableLogManagement,
logManagementOrganizationId: currentOwner,
};
const claimed = await claimWebServerLogManagement(
activeOrganizationId,
input.enableLogManagement,
);
if (!claimed) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"The local Vector agent was just claimed by another organization — try again.",
});
}
try {
const result =
await syncWebVectorAgentAndSchedule(activeOrganizationId);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "log-management",
});
return { ...result, enableLogManagement: input.enableLogManagement };
} catch (error) {
await updateWebServerSettings(previous);
await syncWebVectorAgentAndSchedule(activeOrganizationId).catch(
(teardownError) => {
console.error(
"[Vector] Failed to reconcile local agent after a failed sync:",
teardownError,
);
},
);
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? `Failed to sync local Vector agent: ${error.message}`
: "Failed to sync local Vector agent",
});
}
}),
updateRemoteServersOnly: enterpriseProcedure
.input(z.object({ remoteServersOnly: z.boolean() }))
.mutation(async ({ input, ctx }) => {
+4
View File
@@ -16,6 +16,7 @@ import {
import { config } from "dotenv";
import next from "next";
import packageInfo from "../package.json";
import { initVectorResyncSchedules } from "./utils/vector-resync";
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
@@ -65,7 +66,10 @@ void app.prepare().then(async () => {
await initSchedules();
await initCancelDeployments();
await initVolumeBackupsCronJobs();
await initVectorResyncSchedules();
await sendDokployRestartNotifications();
} else if (IS_CLOUD) {
await initVectorResyncSchedules();
}
await initEnterpriseBackupCronJobs();
+249
View File
@@ -0,0 +1,249 @@
import {
findAllServersWithLogManagementEnabled,
findServersWithLogManagementEnabled,
getWebServerSettings,
hasEnabledLogProvider,
IS_CLOUD,
loadVectorOrgData,
removeVectorAgent,
removeWebVectorAgent,
syncVectorAgent,
syncVectorConfig,
syncWebVectorAgent,
syncWebVectorConfig,
updateWebServerSettings,
VECTOR_RESYNC_CRON_JOB,
type VectorOrgData,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { scheduledJobs, scheduleJob } from "node-schedule";
export const applyVectorResyncSchedule = async (
serverId: string,
enable: boolean,
) => {
const jobName = `vector-resync:${serverId}`;
if (IS_CLOUD) {
console.warn(
`[Vector] Periodic resync for server ${serverId} is running in-process, not via the Cloud Jobs service (no handler for "vector-resync" yet).`,
);
}
if (enable) {
scheduleJob(jobName, VECTOR_RESYNC_CRON_JOB, async () => {
try {
await syncVectorConfig({ serverId });
} catch (error) {
console.error(`[Vector] Resync failed for server ${serverId}:`, error);
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
scheduledJobs[jobName]?.cancel();
}
}
});
} else {
scheduledJobs[jobName]?.cancel();
}
};
const WEB_VECTOR_RESYNC_JOB_NAME = "vector-resync-web";
export const applyWebVectorResyncSchedule = async (
organizationId: string,
enable: boolean,
) => {
if (enable) {
scheduleJob(
WEB_VECTOR_RESYNC_JOB_NAME,
VECTOR_RESYNC_CRON_JOB,
async () => {
try {
await syncWebVectorConfig(organizationId);
} catch (error) {
console.error("[Vector] Local resync failed:", error);
}
},
);
} else {
scheduledJobs[WEB_VECTOR_RESYNC_JOB_NAME]?.cancel();
}
};
export const initVectorResyncSchedules = async () => {
let servers: Awaited<
ReturnType<typeof findAllServersWithLogManagementEnabled>
>;
try {
servers = await findAllServersWithLogManagementEnabled();
} catch (error) {
console.error(
"[Vector] Failed to load servers for resync schedules:",
error,
);
return;
}
const hasProviderByOrg = new Map<
string,
ReturnType<typeof hasEnabledLogProvider>
>();
const hasProviderCached = (organizationId: string) => {
let result = hasProviderByOrg.get(organizationId);
if (!result) {
result = hasEnabledLogProvider(organizationId);
hasProviderByOrg.set(organizationId, result);
}
return result;
};
await Promise.all(
servers.map(async (s) => {
try {
if (await hasProviderCached(s.organizationId)) {
await applyVectorResyncSchedule(s.serverId, true);
}
} catch (error) {
console.error(
`[Vector] Failed to re-register resync schedule for server ${s.serverId}:`,
error,
);
}
}),
);
if (!IS_CLOUD) {
try {
const settings = await getWebServerSettings();
const organizationId = settings?.logManagementOrganizationId;
if (
settings?.enableLogManagement &&
organizationId &&
(await hasEnabledLogProvider(organizationId))
) {
await applyWebVectorResyncSchedule(organizationId, true);
}
} catch (error) {
console.error(
"[Vector] Failed to re-register local resync schedule:",
error,
);
}
}
};
export const syncVectorAgentAndSchedule = async (
serverId: string,
preloaded?: VectorOrgData,
) => {
const { installed } = await syncVectorAgent({ serverId, preloaded });
await applyVectorResyncSchedule(serverId, installed);
return { installed };
};
export const syncWebVectorAgentAndSchedule = async (
organizationId: string,
preloaded?: VectorOrgData,
) => {
const { installed } = await syncWebVectorAgent(preloaded);
await applyWebVectorResyncSchedule(organizationId, installed);
return { installed };
};
export const syncVectorAgentsForOrganization = async (
organizationId: string,
): Promise<Array<{ serverId: string; error: string }>> => {
const servers = await findServersWithLogManagementEnabled(organizationId);
const webOwnsThisOrg =
!IS_CLOUD &&
(await getWebServerSettings())?.logManagementOrganizationId ===
organizationId;
if (servers.length === 0 && !webOwnsThisOrg) {
return [];
}
const preloaded = await loadVectorOrgData(organizationId);
const targets: Array<{
id: string;
run: () => Promise<{ installed: boolean }>;
}> = [
...servers.map((s) => ({
id: s.serverId,
run: () => syncVectorAgentAndSchedule(s.serverId, preloaded),
})),
...(webOwnsThisOrg
? [
{
id: "web",
run: () => syncWebVectorAgentAndSchedule(organizationId, preloaded),
},
]
: []),
];
const results = await Promise.allSettled(targets.map((t) => t.run()));
const errors: Array<{ serverId: string; error: string }> = [];
results.forEach((result, index) => {
if (result.status === "rejected") {
const target = targets[index];
if (target) {
errors.push({
serverId: target.id,
error:
result.reason instanceof Error
? result.reason.message
: String(result.reason),
});
}
}
});
return errors;
};
export const safeSyncVectorAgentsForOrganization = async (
organizationId: string,
) => {
try {
return await syncVectorAgentsForOrganization(organizationId);
} catch (error) {
return [
{
serverId: "unknown",
error: error instanceof Error ? error.message : String(error),
},
];
}
};
export const teardownVectorForOrganizationDeletion = async (
organizationId: string,
): Promise<void> => {
const servers = await findServersWithLogManagementEnabled(organizationId);
await Promise.all(
servers.map(async (s) => {
await removeVectorAgent({ serverId: s.serverId }).catch((error) => {
console.error(
`[Vector] Failed to remove agent for server ${s.serverId} before deleting organization ${organizationId}:`,
error,
);
});
await applyVectorResyncSchedule(s.serverId, false);
}),
);
if (!IS_CLOUD) {
const settings = await getWebServerSettings();
if (settings?.logManagementOrganizationId === organizationId) {
await removeWebVectorAgent().catch((error) => {
console.error(
`[Vector] Failed to remove local agent before deleting organization ${organizationId}:`,
error,
);
});
await applyWebVectorResyncSchedule(organizationId, false);
await updateWebServerSettings({
enableLogManagement: false,
logManagementOrganizationId: null,
});
}
}
};