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

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.
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, 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.).

View File

@ -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 }}");
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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",
);
});
});

View File

@ -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();
});
});

View File

@ -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", () => {

View File

@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
];
const ENTERPRISE_RESOURCES = [

View File

@ -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);
});
});

View File

@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
enableLogManagement: false,
logManagementOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {

View File

@ -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,
);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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) {

View File

@ -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(() => {

View File

@ -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>

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",

View File

@ -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 }) {

View File

@ -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) */

View File

@ -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

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
}
]
}

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",

View File

@ -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(),
},
};
}

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,

View File

@ -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,
}));
}),
});

View File

@ -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));

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;
}

View File

@ -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 }) => {

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();

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,
});
}
}
};

138
docs/log-management.md Normal file
View File

@ -0,0 +1,138 @@
# Log Management
> **Nota para quien mergee esto:** este contenido vive acá porque no había un checkout local del
> repo de docs públicas (`docs.dokploy.com`) disponible al escribir esta feature. Migrar este
> archivo a ese repo (sección "Core" o similar, junto a Registry/Monitoring) y borrarlo de acá.
## Qué es
Dokploy puede shippear los logs de los containers de un server a un proveedor externo de log
management — hoy: **Grafana Loki**, **Datadog Logs**, **Better Stack** (ex-Logtail),
**Elasticsearch / OpenSearch**, **Splunk (HTTP Event Collector)** y **AWS CloudWatch Logs**. Antes de
esta feature, la única forma de ver logs era el stream en vivo por WebSocket en el dashboard, sin
historial ni persistencia. Esta feature no reemplaza ese viewer en vivo — sigue funcionando igual
— agrega la posibilidad de mandar una copia de los logs a un sistema externo para
retención/búsqueda/alerting.
Por debajo corre [Vector](https://vector.dev) (Apache-2.0), un único binario, como un agente por
server registrado. Dokploy no implementa clientes HTTP para cada proveedor — solo traduce
credenciales guardadas en el dashboard a un bloque de configuración de Vector (un "sink").
## Cómo activarla
Hacen falta **dos cosas**, ambas apagadas por default:
1. **Al menos un log provider configurado**, en Settings → Log Management. Elegís el tipo
(Loki/Datadog/Better Stack/Elasticsearch/OpenSearch/Splunk/AWS CloudWatch), completás las
credenciales que pida ese tipo, y podés probar la
conexión antes de guardar.
2. **El toggle "Log Management" prendido** en cada lugar donde quieras shippear logs:
- Por server registrado: en el diálogo de acciones del server ("Server Actions"), al lado del
toggle de Docker Cleanup.
- **En la máquina donde corre Dokploy** (self-hosted únicamente — no existe en Dokploy Cloud,
donde esa máquina es infraestructura de Dokploy, no del cliente): en Settings → Web Server,
al lado del toggle de Docker Cleanup local. La primera organización que lo prende "reclama"
ese host — otra organización de la misma instancia no puede prenderlo hasta que la primera lo
apague (rechazo explícito, no se pisan silenciosamente).
Si cualquiera de las dos condiciones no se cumple, el agente Vector **no corre en absoluto** en
ese host — no consume CPU/memoria/disco, y nada más del dashboard se ve afectado (el streaming en
vivo de logs sigue funcionando igual).
**Host local en una instancia self-hosted multi-organización:** Vector lee el socket de Docker
completo, sin distinguir de qué organización es cada container — pero solo el agente local (el
registrado por `server` siempre pertenece a una sola organización, sin este problema). Para
evitar que le lleguen logs de otra organización a los providers del dueño del host, el pipeline
del agente local descarta todo evento que no matchee una app de la organización dueña (filtro
`dokploy_scope_local_only` en `vector-setup.ts`) antes de que llegue a cualquier sink — esto
también implica que, a diferencia del agente por-server, containers de terceros (no gestionados
por Dokploy) corriendo en la misma máquina ya no shippean via el agente local.
Un log provider es a nivel de organización: si tenés varios servers con el toggle prendido, todos
ellos shippean a todos los providers habilitados. No hay (todavía) una asociación
provider-por-server — es una mejora futura posible si hace falta más granularidad.
### Scoping por proyecto/entorno/aplicación
Aunque Vector ve **todos** los containers del host (no solo los de Dokploy), cada evento que sí
corresponde a una app gestionada por Dokploy llega taggeado con:
- `dokploy_organization` — siempre presente.
- `dokploy_project` / `dokploy_project_id`
- `dokploy_environment` / `dokploy_environment_id`
- `dokploy_application` / `dokploy_application_id`
Un container de terceros (no gestionado por Dokploy) corriendo en el mismo host sigue
shippeando igual, simplemente sin esos 6 campos.
Esta tabla de scoping se recalcula cada vez que se toca algo de log management (activar/crear/
editar un provider, prender el toggle de un server) y además cada 15 minutos como red de
seguridad, para que una app creada después de la configuración inicial no quede sin estos tags
indefinidamente.
## Cómo agregar un provider nuevo (guía para contribuidores)
Toda la lógica de un provider vive en un solo archivo:
`packages/server/src/services/log-management/providers/<nombre>.ts`, implementando la interfaz
`LogProviderAdapter` (`packages/server/src/services/log-management/types.ts`). **No hace falta
tocar el router tRPC ni la UI** — la UI (`logProvider.availableTypes`) lista los providers
disponibles dinámicamente a partir del registry de adapters, y el orquestador de Vector
(`packages/server/src/setup/vector-setup.ts`) resuelve el resto de forma genérica.
Hay dos caminos, según si Vector ya tiene un sink nativo para tu backend:
### Camino 1: tu backend ya tiene un sink en Vector
Es el caso de Loki, Datadog, Elasticsearch/OpenSearch, Splunk HEC y AWS CloudWatch Logs. Mirá la
[lista de sinks de Vector](https://vector.dev/docs/reference/configuration/sinks/)
— si tu backend está ahí, este es tu camino.
1. Agregar el tipo al enum de Drizzle: `logProviderType` en
`packages/server/src/db/schema/log-provider.ts`, + correr
`pnpm --filter dokploy migration:generate` y commitear el SQL generado.
2. Ampliar el union `LogProviderType` en `services/log-management/types.ts`.
3. Crear `providers/<nombre>.ts`:
- `credentialFields`: qué campos pedirle al usuario. `key` tiene que ser exactamente
`"endpoint"` | `"apiKey"` | `"apiSecret"` (las 3 únicas columnas con espacio propio en la
tabla) o, si tu backend necesita algo que no encaja en esas 3, cualquier otro nombre — ese
valor termina en `extraConfig` (jsonb). **Este `key` no es solo para mostrar en la UI: la UI
genérica lo usa para decidir dónde guardar el valor** (columna directa vs. `extraConfig`) —
ver `providers/loki.ts` para el caso simple (`endpoint`) y `providers/betterstack.ts` para
el comentario completo sobre este punto (ahí hubo un bug real por esto durante el desarrollo:
usar un `key` "bonito" que no coincidía con la columna real).
- `toVectorSink(config, sinkId, inputId)`: devolvé el bloque de sink de Vector con tus
credenciales mapeadas. **Siempre incluir `buffer: { type: "disk", max_size, when_full }`**
(ver `DEFAULT_DISK_BUFFER` en `types.ts` para el default) — el default de Vector es buffer
en memoria, que no protege contra un backend caído.
- `testConnection(config)` (opcional pero recomendado): un ping barato para validar
credenciales antes de guardar.
4. Registrar en `providers/registry.ts` (`logProviderAdapters`) — una línea.
5. Tests unitarios junto a los de `loki`/`datadog`/`betterstack`
(`apps/dokploy/__test__/log-management/log-provider-adapters.test.ts`) verificando la forma
del sink generado.
### Camino 2: tu backend NO tiene sink nativo en Vector
Es el caso de Better Stack — usar `providers/betterstack.ts` como plantilla. Mismos pasos que
arriba, con dos diferencias:
- El sink usa el genérico `http` (o `socket`/`file` si tu backend expone algo distinto a HTTP).
- Si tu backend espera el evento en una forma distinta a la que produce el scoping (ej. un
nombre de campo distinto para el timestamp), implementar `toVectorTransform(config,
transformId, scopeTransformId)` — un paso VRL intermedio entre el scoping y tu sink. El
orquestador (`vector-setup.ts`) ya sabe encadenar `scope → tu transform → tu sink` cuando este
método existe, y `scope → sink` directo cuando no — no hay que tocar el orquestador.
## Decisiones y límites conocidos
- **Un server = un nodo Swarm de un solo nodo, típicamente.** Dokploy no modela clusters Swarm
multi-nodo (cada `server` registrado es una máquina SSH independiente). Si tu server participa
de un swarm con workers que no están registrados como su propio `server` en Dokploy, el agente
Vector puede terminar programado en un nodo sin la config — fuera de alcance del MVP actual.
- **No hay viewer de logs históricos embebido en el dashboard** — esta feature es solo shipping.
Para ver los logs, usar la UI del provider externo (Grafana, Datadog, Better Stack, Kibana/
OpenSearch Dashboards, Splunk, CloudWatch Logs Insights).
- **`logProvider` es free tier**, no enterprise-only — a diferencia de `registry`/`server`/
`domain`/`monitoring`, disponible para cualquier organización sin importar el plan.
- **Deployment en la misma máquina de Dokploy**: soportado self-hosted (Settings → Web Server),
nunca en Dokploy Cloud — ver "Cómo activarla" arriba para el caveat multi-organización.

View File

@ -37,6 +37,7 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch-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",

View File

@ -13,6 +13,8 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const VECTOR_RESYNC_CRON_JOB = "*/15 * * * *";
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
const parseByteSize = (envVar: string, fallback: number): number => {
const raw = process.env[envVar];
@ -134,6 +136,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`,

View File

@ -94,4 +94,5 @@ export type AuditResourceType =
| "compose"
| "network"
| "vaultProvider"
| "dnsProvider";
| "dnsProvider"
| "logProvider";

View File

@ -17,6 +17,7 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./log-provider";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";

View File

@ -0,0 +1,87 @@
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 logProviderType = pgEnum("LogProviderType", [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
]);
export const logProvider = pgTable("logProvider", {
logProviderId: text("logProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: logProviderType("providerType").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(logProvider, {
logProviderId: z.string().min(1),
name: z.string().min(1),
providerType: z.enum([
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch",
]),
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 apiCreateLogProvider = createSchema
.pick({
name: true,
providerType: true,
endpoint: true,
apiKey: true,
apiSecret: true,
extraConfig: true,
enabled: true,
})
.required({ name: true, providerType: true });
export const apiUpdateLogProvider = apiCreateLogProvider.partial().extend({
logProviderId: z.string().min(1),
});
export const apiRemoveLogProvider = z.object({
logProviderId: z.string().min(1),
});
export const apiFindOneLogProvider = z.object({
logProviderId: z.string().min(1),
});
export const apiTestLogProvider = apiCreateLogProvider.partial({ name: true });

View File

@ -42,6 +42,7 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogManagement: boolean("enableLogManagement").notNull().default(false),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")

View File

@ -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,11 @@ export const webServerSettings = pgTable("webServerSettings", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
enableLogManagement: boolean("enableLogManagement").notNull().default(false),
logManagementOrganizationId: text("logManagementOrganizationId").references(
() => organization.id,
{ onDelete: "set null" },
),
// Metrics Configuration
metricsConfig: jsonb("metricsConfig")
.$type<{

View File

@ -27,6 +27,9 @@ export * from "./services/gitea";
export * from "./services/github";
export * from "./services/gitlab";
export * from "./services/libsql";
export * from "./services/log-management/providers/registry";
export * from "./services/log-management/service";
export * from "./services/log-management/types";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
@ -68,6 +71,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 {

View File

@ -28,6 +28,7 @@ export const statements = {
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
logProvider: ["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"],
logProvider: ["read", "create", "delete"],
});
/**
@ -158,6 +160,7 @@ export const adminRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
logProvider: ["read", "create", "delete"],
});
/**
@ -198,6 +201,7 @@ export const memberRole = ac.newRole({
certificate: [],
destination: [],
notification: [],
logProvider: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed

View File

@ -0,0 +1,114 @@
import {
CloudWatchLogsClient,
DescribeLogGroupsCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, LOG_PROVIDER_REQUEST_TIMEOUT_MS } from "../types";
const DEFAULT_STREAM_TEMPLATE = "{{ container_name }}";
export const awsCloudwatchAdapter: LogProviderAdapter = {
type: "aws_cloudwatch",
label: "AWS CloudWatch Logs",
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,
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,
placeholder: DEFAULT_STREAM_TEMPLATE,
helpText:
"Vector event template. Defaults to one stream per container name if left blank.",
fullWidth: true,
},
],
toVectorSink(
config: LogProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const logGroup = config.extraConfig?.logGroup;
const logStream = config.extraConfig?.logStream;
const region = config.extraConfig?.region;
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: typeof region === "string" ? region : "",
encoding: { codec: "json" },
auth: {
access_key_id: config.apiKey ?? "",
secret_access_key: config.apiSecret ?? "",
},
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
const region = config.extraConfig?.region;
const logGroup = config.extraConfig?.logGroup;
if (
!config.apiKey ||
!config.apiSecret ||
typeof region !== "string" ||
!region ||
typeof logGroup !== "string" ||
!logGroup
) {
throw new Error(
"AWS access key, secret key, region and log group are required",
);
}
const client = new CloudWatchLogsClient({
region,
credentials: {
accessKeyId: config.apiKey,
secretAccessKey: config.apiSecret,
},
});
await client.send(
new DescribeLogGroupsCommand({
logGroupNamePrefix: logGroup,
limit: 1,
}),
{ abortSignal: AbortSignal.timeout(LOG_PROVIDER_REQUEST_TIMEOUT_MS) },
);
},
};

View File

@ -0,0 +1,88 @@
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
logProviderFetch,
normalizeEndpointUrl,
} from "../types";
export const betterStackAdapter: LogProviderAdapter = {
type: "betterstack",
label: "Better Stack (Logtail)",
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 to your account.",
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: LogProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
): VectorTransformConfig {
return {
type: "remap",
inputs: [scopeTransformId],
source: ".dt = del(.timestamp)",
};
},
toVectorSink(
config: LogProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
return {
type: "http",
inputs: [inputId],
method: "post",
uri: `${normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "")}/`,
encoding: { codec: "json" },
compression: "gzip",
auth: { strategy: "bearer", token: config.apiKey ?? "" },
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error(
"Better Stack ingestingHost and sourceToken are required",
);
}
const uri = `${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/`;
const response = await logProviderFetch(uri, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
message: "dokploy-log-provider-test",
dokploy_test: true,
}),
});
if (!response.ok) {
throw new Error(
`Better Stack test event failed with status ${response.status}`,
);
}
},
};

View File

@ -0,0 +1,90 @@
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import { DEFAULT_DISK_BUFFER, logProviderFetch } 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), ",", "_")) }
.ddtags = join!(tags, ",")
`.trim();
const resolveSite = (config: LogProviderRuntimeConfig): 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: LogProviderAdapter = {
type: "datadog",
label: "Datadog Logs",
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: LogProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
): VectorTransformConfig {
return {
type: "remap",
inputs: [scopeTransformId],
source: DDTAGS_VRL,
};
},
toVectorSink(
config: LogProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
return {
type: "datadog_logs",
inputs: [inputId],
default_api_key: config.apiKey ?? "",
site: resolveSite(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
if (!config.apiKey) {
throw new Error("Datadog API key is required");
}
const site = resolveSite(config);
const response = await logProviderFetch(
`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}`,
);
}
},
};

View File

@ -0,0 +1,153 @@
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
VectorTransformConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
logProviderFetch,
normalizeEndpointUrl,
} from "../types";
const LABEL_TO_JSON_VRL = `
if exists(.label) { .label = encode_json(.label) }
`.trim();
type AuthMode =
| { kind: "basic"; username: string; password: string }
| { kind: "apiKey"; apiKey: string }
| { kind: "none" };
const resolveAuthMode = (config: LogProviderRuntimeConfig): 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: LogProviderRuntimeConfig,
): 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: LogProviderAdapter = {
type: "elasticsearch",
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,
placeholder: "vector-%Y.%m.%d",
helpText: "Defaults to vector-%Y.%m.%d, a daily index.",
fullWidth: true,
},
],
toVectorTransform(
_config: LogProviderRuntimeConfig,
_transformId: string,
scopeTransformId: string,
): VectorTransformConfig {
return {
type: "remap",
inputs: [scopeTransformId],
source: LABEL_TO_JSON_VRL,
};
},
toVectorSink(
config: LogProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
const index = config.extraConfig?.index;
return {
type: "elasticsearch",
inputs: [inputId],
endpoints: [normalizeEndpointUrl(config.endpoint ?? "")],
...(typeof index === "string" && index.length > 0
? { bulk: { index } }
: {}),
...buildAuthAndHeaders(config),
buffer: DEFAULT_DISK_BUFFER,
};
},
async testConnection(config: LogProviderRuntimeConfig): 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 logProviderFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/_cluster/health`,
{ headers },
);
if (!response.ok) {
throw new Error(
`Elasticsearch/OpenSearch health check failed with status ${response.status}`,
);
}
},
validateConfig(config: LogProviderRuntimeConfig): void {
resolveAuthMode(config);
},
};

View File

@ -0,0 +1,119 @@
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
logProviderFetch,
normalizeEndpointUrl,
} from "../types";
export const lokiAdapter: LogProviderAdapter = {
type: "loki",
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).",
},
{
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: LogProviderRuntimeConfig,
_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_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
},
...(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: LogProviderRuntimeConfig): 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 logProviderFetch(
`${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-log-provider-test"]],
},
],
}),
},
);
if (!response.ok) {
throw new Error(`Loki push test failed with status ${response.status}`);
}
},
};

View File

@ -0,0 +1,27 @@
import type { LogProviderAdapter, LogProviderType } from "../types";
import { awsCloudwatchAdapter } from "./aws-cloudwatch";
import { betterStackAdapter } from "./betterstack";
import { datadogAdapter } from "./datadog";
import { elasticsearchAdapter } from "./elasticsearch";
import { lokiAdapter } from "./loki";
import { splunkAdapter } from "./splunk";
export const logProviderAdapters: Record<LogProviderType, LogProviderAdapter> =
{
loki: lokiAdapter,
datadog: datadogAdapter,
betterstack: betterStackAdapter,
elasticsearch: elasticsearchAdapter,
splunk_hec: splunkAdapter,
aws_cloudwatch: awsCloudwatchAdapter,
};
export function getLogProviderAdapter(
type: LogProviderType,
): LogProviderAdapter {
const adapter = logProviderAdapters[type];
if (!adapter) {
throw new Error(`No LogProviderAdapter registered for type "${type}"`);
}
return adapter;
}

View File

@ -0,0 +1,80 @@
import type {
LogProviderAdapter,
LogProviderRuntimeConfig,
VectorSinkConfig,
} from "../types";
import {
DEFAULT_DISK_BUFFER,
logProviderFetch,
normalizeEndpointUrl,
} from "../types";
export const splunkAdapter: LogProviderAdapter = {
type: "splunk_hec",
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,
},
{
key: "sourcetype",
label: "Source Type",
type: "text",
required: false,
placeholder: "httpevent",
},
],
toVectorSink(
config: LogProviderRuntimeConfig,
_sinkId: string,
inputId: string,
): VectorSinkConfig {
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: LogProviderRuntimeConfig): Promise<void> {
if (!config.endpoint || !config.apiKey) {
throw new Error("Splunk endpoint and HEC token are required");
}
const response = await logProviderFetch(
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/services/collector/health`,
{ headers: { Authorization: `Splunk ${config.apiKey}` } },
);
if (!response.ok) {
throw new Error(
`Splunk HEC health check failed with status ${response.status}`,
);
}
},
};

View File

@ -0,0 +1,269 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateLogProvider,
logProvider,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import type { z } from "zod";
import { getLogProviderAdapter } from "./providers/registry";
import type { LogProviderRuntimeConfig, LogProviderType } from "./types";
export type LogProvider = typeof logProvider.$inferSelect;
const CREDENTIAL_COLUMNS = {
endpoint: false,
apiKey: false,
apiSecret: false,
} as const;
const assertRequiredCredentialFields = (
providerType: LogProviderType,
values: {
endpoint?: string | null;
apiKey?: string | null;
apiSecret?: string | null;
extraConfig?: Record<string, unknown> | null;
},
) => {
const adapter = getLogProviderAdapter(providerType);
const fieldValues: Record<string, unknown> = {
...(values.extraConfig ?? {}),
endpoint: values.endpoint,
apiKey: values.apiKey,
apiSecret: values.apiSecret,
};
const missing = adapter.credentialFields
.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({
logProviderId: "validate",
name: "",
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 createLogProvider = async (
input: z.infer<typeof apiCreateLogProvider>,
organizationId: string,
) => {
assertRequiredCredentialFields(input.providerType, input);
const created = await db
.insert(logProvider)
.values({
...input,
organizationId,
})
.returning()
.then((rows) => rows[0]);
if (!created) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating log provider",
});
}
return created;
};
export const updateLogProvider = async (
logProviderId: string,
data: Partial<z.infer<typeof apiCreateLogProvider>>,
) => {
const existing = await findLogProviderByIdWithCredentials(logProviderId);
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 };
assertRequiredCredentialFields(merged.providerType, merged);
const updated = await db
.update(logProvider)
.set(dataToApply)
.where(eq(logProvider.logProviderId, logProviderId))
.returning()
.then((rows) => rows[0]);
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Log provider not found",
});
}
return updated;
};
export const removeLogProvider = async (logProviderId: string) => {
const removed = await db
.delete(logProvider)
.where(eq(logProvider.logProviderId, logProviderId))
.returning()
.then((rows) => rows[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Log provider not found",
});
}
return removed;
};
export const findLogProviderById = async (logProviderId: string) => {
const found = await db.query.logProvider.findFirst({
where: eq(logProvider.logProviderId, logProviderId),
columns: CREDENTIAL_COLUMNS,
});
if (!found) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Log provider not found",
});
}
return found;
};
export const findLogProviderByIdWithCredentials = async (
logProviderId: string,
) => {
const found = await db.query.logProvider.findFirst({
where: eq(logProvider.logProviderId, logProviderId),
});
if (!found) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Log provider not found",
});
}
return found;
};
export const findLogProvidersByOrganization = async (
organizationId: string,
) => {
return await db.query.logProvider.findMany({
where: eq(logProvider.organizationId, organizationId),
columns: CREDENTIAL_COLUMNS,
});
};
export const findEnabledLogProvidersByOrganization = async (
organizationId: string,
) => {
return await db.query.logProvider.findMany({
where: and(
eq(logProvider.organizationId, organizationId),
eq(logProvider.enabled, true),
),
});
};
export const hasEnabledLogProvider = async (organizationId: string) => {
const found = await db.query.logProvider.findFirst({
where: and(
eq(logProvider.organizationId, organizationId),
eq(logProvider.enabled, true),
),
columns: { logProviderId: true },
});
return !!found;
};
export const toRuntimeConfig = (
provider: LogProvider,
): LogProviderRuntimeConfig => ({
logProviderId: provider.logProviderId,
name: provider.name,
endpoint: provider.endpoint,
apiKey: provider.apiKey,
apiSecret: provider.apiSecret,
extraConfig: provider.extraConfig,
});
export const testLogProviderConnection = async (
params:
| { logProviderId: string }
| { providerType: LogProviderType; config: LogProviderRuntimeConfig },
) => {
let providerType: LogProviderType;
let runtimeConfig: LogProviderRuntimeConfig;
if ("logProviderId" in params) {
const provider = await findLogProviderByIdWithCredentials(
params.logProviderId,
);
providerType = provider.providerType;
runtimeConfig = toRuntimeConfig(provider);
} else {
providerType = params.providerType;
runtimeConfig = params.config;
}
const adapter = getLogProviderAdapter(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 sanitizeLogProvider = <
T extends { endpoint?: unknown; apiKey?: unknown; apiSecret?: unknown },
>(
provider: T,
) => {
const { endpoint, apiKey, apiSecret, ...rest } = provider;
return rest;
};

View File

@ -0,0 +1,124 @@
export const LOG_PROVIDER_REQUEST_TIMEOUT_MS = 15_000;
const isMetadataAddress = (address: string): boolean => {
const lower = address.toLowerCase().replace(/^\[|\]$/g, "");
if (lower === "fd00:ec2::254") return true;
if (lower.startsWith("169.254.")) return true;
if (/^fe[89ab]/.test(lower)) return true;
return false;
};
const METADATA_HOSTNAMES = new Set([
"metadata.google.internal",
"metadata.goog",
]);
const assertNotMetadataEndpoint = async (rawUrl: string): Promise<void> => {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return;
}
const blocked = () =>
new Error(
"This endpoint resolves to a cloud metadata address and can't be used here.",
);
if (METADATA_HOSTNAMES.has(url.hostname.toLowerCase())) {
throw blocked();
}
if (isMetadataAddress(url.hostname)) {
throw blocked();
}
try {
const { lookup } = await import("node:dns/promises");
const results = await lookup(url.hostname, { all: true });
if (results.some((r) => isMetadataAddress(r.address))) {
throw blocked();
}
} catch (error) {
if (error instanceof Error && error.message.includes("metadata")) {
throw error;
}
}
};
export const logProviderFetch = async (url: string, init: RequestInit = {}) => {
await assertNotMetadataEndpoint(url);
return await fetch(url, {
...init,
signal: AbortSignal.timeout(LOG_PROVIDER_REQUEST_TIMEOUT_MS),
});
};
export interface LogProviderCredentialField {
key: string;
label: string;
type: "text" | "password" | "url";
required: boolean;
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 LogProviderRuntimeConfig {
logProviderId: string;
name: string;
endpoint: string | null;
apiKey: string | null;
apiSecret: string | null;
extraConfig: Record<string, unknown> | null;
}
export type LogProviderType =
| "loki"
| "datadog"
| "betterstack"
| "elasticsearch"
| "splunk_hec"
| "aws_cloudwatch";
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 interface LogProviderAdapter {
type: LogProviderType;
label: string;
docsUrl?: string;
credentialFields: LogProviderCredentialField[];
toVectorTransform?(
config: LogProviderRuntimeConfig,
transformId: string,
scopeTransformId: string,
): VectorTransformConfig;
toVectorSink(
config: LogProviderRuntimeConfig,
sinkId: string,
inputId: string,
): VectorSinkConfig;
testConnection?(config: LogProviderRuntimeConfig): Promise<void>;
validateConfig?(config: LogProviderRuntimeConfig): void;
}

View File

@ -87,6 +87,25 @@ export const findServersByUserId = async (userId: string) => {
return servers;
};
export const findServersWithLogManagementEnabled = async (
organizationId: string,
) => {
return await db.query.server.findMany({
where: and(
eq(server.organizationId, organizationId),
eq(server.enableLogManagement, true),
),
columns: { serverId: true },
});
};
export const findAllServersWithLogManagementEnabled = async () => {
return await db.query.server.findMany({
where: eq(server.enableLogManagement, true),
columns: { serverId: true, organizationId: true },
});
};
export const deleteServer = async (serverId: string) => {
const currentServer = await db
.delete(server)

View File

@ -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,33 @@ export const updateWebServerSettings = async (
return updated;
};
export const claimWebServerLogManagement = async (
organizationId: string,
enableLogManagement: boolean,
) => {
const current = await getWebServerSettings();
if (!current) {
return null;
}
const [updated] = await db
.update(webServerSettings)
.set({
enableLogManagement,
logManagementOrganizationId: enableLogManagement ? organizationId : null,
updatedAt: new Date(),
})
.where(
and(
eq(webServerSettings.id, current.id),
or(
isNull(webServerSettings.logManagementOrganizationId),
eq(webServerSettings.logManagementOrganizationId, organizationId),
),
),
)
.returning();
return updated ?? null;
};

View File

@ -0,0 +1,656 @@
import {
chmodSync as fsChmodSync,
mkdirSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { dbUrl } from "@dokploy/server/db/constants";
import { projects } from "@dokploy/server/db/schema";
import {
findEnabledLogProvidersByOrganization,
hasEnabledLogProvider,
type LogProvider,
toRuntimeConfig,
} from "@dokploy/server/services/log-management/service";
import { findServerById } from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { pullImage, pullRemoteImage } from "@dokploy/server/utils/docker/utils";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
import {
execAsync,
execAsyncRemote,
writeFileRemote,
} from "@dokploy/server/utils/process/execAsync";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import type { CreateServiceOptions } from "dockerode";
import { eq } from "drizzle-orm";
import postgres from "postgres";
import { stringify } from "yaml";
import { getLogProviderAdapter } from "../services/log-management/providers/registry";
import type {
VectorSinkConfig,
VectorTransformConfig,
} from "../services/log-management/types";
const VECTOR_IMAGE = "timberio/vector:latest-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";
interface AppNameLookupEntry {
projectId: string;
projectName: string;
environmentId: string;
environmentName: string;
applicationId: string;
applicationName: string;
}
const escapeVrlString = (value: string): string =>
value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
// biome-ignore lint/suspicious/noControlCharactersInRegex: replacing them is the point
.replace(/[\u0000-\u001f\u007f]/g, " ");
const buildAppNameLookup = async (
organizationId: string,
): Promise<Record<string, AppNameLookupEntry>> => {
const projectRows = await db.query.projects.findMany({
where: eq(projects.organizationId, organizationId),
columns: { projectId: true, name: true },
with: {
environments: {
columns: { environmentId: true, name: true },
with: {
applications: {
columns: { applicationId: true, appName: true, name: true },
},
compose: {
columns: { composeId: true, appName: true, name: true },
},
},
},
},
});
const lookup: Record<string, AppNameLookupEntry> = {};
for (const project of projectRows) {
for (const environment of project.environments) {
for (const application of environment.applications) {
lookup[application.appName] = {
projectId: project.projectId,
projectName: project.name,
environmentId: environment.environmentId,
environmentName: environment.name,
applicationId: application.applicationId,
applicationName: application.name,
};
}
for (const composeService of environment.compose) {
lookup[composeService.appName] = {
projectId: project.projectId,
projectName: project.name,
environmentId: environment.environmentId,
environmentName: environment.name,
applicationId: composeService.composeId,
applicationName: composeService.name,
};
}
}
}
return lookup;
};
const buildScopeTransformSource = (
organizationId: string,
lookup: Record<string, AppNameLookupEntry>,
): string => {
const lines: string[] = [
`.dokploy_organization = "${escapeVrlString(organizationId)}"`,
'.dokploy_project = ""',
'.dokploy_project_id = ""',
'.dokploy_environment = ""',
'.dokploy_environment_id = ""',
'.dokploy_application = ""',
'.dokploy_application_id = ""',
];
const entries = Object.entries(lookup);
if (entries.length === 0) {
return lines.join("\n");
}
lines.push('app_name = .label."com.docker.compose.project"');
lines.push("if app_name == null {");
lines.push(' app_name = .label."com.docker.stack.namespace"');
lines.push("}");
lines.push("if app_name == null {");
lines.push(' app_name = .label."com.docker.swarm.service.name"');
lines.push("}");
lines.push("if app_name != null {");
entries.forEach(([appName, entry], index) => {
const branch = index === 0 ? " if" : " } else if";
lines.push(`${branch} app_name == "${escapeVrlString(appName)}" {`);
lines.push(
` .dokploy_project = "${escapeVrlString(entry.projectName)}"`,
);
lines.push(
` .dokploy_project_id = "${escapeVrlString(entry.projectId)}"`,
);
lines.push(
` .dokploy_environment = "${escapeVrlString(entry.environmentName)}"`,
);
lines.push(
` .dokploy_environment_id = "${escapeVrlString(entry.environmentId)}"`,
);
lines.push(
` .dokploy_application = "${escapeVrlString(entry.applicationName)}"`,
);
lines.push(
` .dokploy_application_id = "${escapeVrlString(entry.applicationId)}"`,
);
});
lines.push(" }");
lines.push("}");
return lines.join("\n");
};
const buildSinksAndTransforms = (
providers: Array<LogProvider>,
baseTransformId: string,
): {
sinks: Record<string, VectorSinkConfig>;
transforms: Record<string, VectorTransformConfig>;
} => {
const sinks: Record<string, VectorSinkConfig> = {};
const transforms: Record<string, VectorTransformConfig> = {};
for (const provider of providers) {
try {
const adapter = getLogProviderAdapter(provider.providerType);
const runtimeConfig = toRuntimeConfig(provider);
const sinkId = `sink_${provider.logProviderId}`;
let inputId = baseTransformId;
let transform: { id: string; config: VectorTransformConfig } | null =
null;
if (adapter.toVectorTransform) {
const transformId = `transform_${provider.logProviderId}`;
transform = {
id: transformId,
config: adapter.toVectorTransform(
runtimeConfig,
transformId,
baseTransformId,
),
};
inputId = transformId;
}
const sink = adapter.toVectorSink(runtimeConfig, sinkId, inputId);
if (transform) {
transforms[transform.id] = transform.config;
}
sinks[sinkId] = sink;
} catch (error) {
console.error(
`[Vector] Skipping log provider "${provider.name}" (${provider.logProviderId}) — invalid config:`,
error,
);
}
}
return { sinks, transforms };
};
export interface VectorOrgData {
providers: Array<LogProvider>;
lookup: Record<string, AppNameLookupEntry>;
}
export const loadVectorOrgData = async (
organizationId: string,
): Promise<VectorOrgData> => {
const [providers, lookup] = await Promise.all([
findEnabledLogProvidersByOrganization(organizationId),
buildAppNameLookup(organizationId),
]);
return { providers, lookup };
};
export const buildVectorConfigYaml = async (
organizationId: string,
preloaded?: VectorOrgData,
options?: { dropUnmatched?: boolean },
) => {
const { providers, lookup } =
preloaded ?? (await loadVectorOrgData(organizationId));
const baseTransformId = options?.dropUnmatched
? "dokploy_scope_local_only"
: "dokploy_scope";
const { sinks, transforms } = buildSinksAndTransforms(
providers,
baseTransformId,
);
const config = {
data_dir: VECTOR_DATA_DIR_CONTAINER,
sources: {
docker_logs_source: {
type: "docker_logs",
docker_host: "unix:///var/run/docker.sock",
},
},
transforms: {
dokploy_scope: {
type: "remap",
inputs: ["docker_logs_source"],
source: buildScopeTransformSource(organizationId, lookup),
},
...(options?.dropUnmatched
? {
dokploy_scope_local_only: {
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_project != ""',
},
}
: {}),
...transforms,
},
sinks,
};
return stringify(config);
};
export const collectSecretValues = (
providers: Array<LogProvider>,
): 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);
for (const value of Object.values(provider.extraConfig ?? {})) {
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 VECTOR_CONFIG_LOCK_NAMESPACE = 87_234_501;
const hashServerIdToInt32 = (serverId: string): number => {
let hash = 0;
for (let i = 0; i < serverId.length; i++) {
hash = (Math.imul(31, hash) + serverId.charCodeAt(i)) | 0;
}
return hash;
};
let lockPool: ReturnType<typeof postgres> | undefined;
const getLockPool = () => {
if (!lockPool) {
lockPool = postgres(dbUrl, { max: 10 });
}
return lockPool;
};
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 1000;
const configWriteLocks = new Map<string, Promise<void>>();
export const withConfigWriteLock = async <T>(
serverId: string,
fn: () => Promise<T>,
): Promise<T> => {
const previous = configWriteLocks.get(serverId) ?? Promise.resolve();
let release: () => void = () => {};
const current = new Promise<void>((resolve) => {
release = resolve;
});
configWriteLocks.set(
serverId,
previous.then(() => current),
);
await previous;
try {
return await runWithCrossProcessLock(serverId, fn);
} finally {
release();
}
};
const runWithCrossProcessLock = async <T>(
serverId: string,
fn: () => Promise<T>,
): Promise<T> => {
const key1 = VECTOR_CONFIG_LOCK_NAMESPACE;
const key2 = hashServerIdToInt32(serverId);
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
const reserved = await getLockPool().reserve();
try {
const rows =
await reserved`select pg_try_advisory_lock(${key1}, ${key2}) as locked`;
if (rows[0]?.locked) {
try {
return await fn();
} finally {
await reserved`select pg_advisory_unlock(${key1}, ${key2})`;
}
}
} finally {
reserved.release();
}
if (attempt < LOCK_RETRY_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
}
}
throw new Error(
`Vector config sync for server ${serverId} is already in progress on another Dokploy instance, skipped`,
);
};
export const syncVectorConfig = async ({
serverId,
preloaded,
}: {
serverId: string;
preloaded?: VectorOrgData;
}) =>
withConfigWriteLock(serverId, async () => {
const server = await findServerById(serverId);
const { VECTOR_PATH } = paths(true);
const configPath = `${VECTOR_PATH}/vector.yaml`;
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate`;
const orgData =
preloaded ?? (await loadVectorOrgData(server.organizationId));
const yamlStr = await buildVectorConfigYaml(server.organizationId, orgData);
await execAsyncRemote(serverId, `mkdir -p ${VECTOR_PATH}/data`);
await writeFileRemote(serverId, candidatePath, yamlStr);
await execAsyncRemote(
serverId,
`chmod 600 ${candidatePath} && chmod 700 ${VECTOR_PATH} && chmod 700 ${VECTOR_PATH}/data`,
);
try {
await execAsyncRemote(
serverId,
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${candidatePath}:/etc/vector/vector.yaml:ro ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
);
} catch (error) {
const detail =
error instanceof ExecError
? error.stderr || error.stdout || error.message
: error instanceof Error
? error.message
: String(error);
const sanitizedDetail = redactSecrets(
detail,
collectSecretValues(orgData.providers),
);
await execAsyncRemote(serverId, `rm -f ${candidatePath}`).catch(
(cleanupError) => {
console.error(
`[Vector] Failed to remove invalid config candidate ${candidatePath} on server ${serverId}:`,
cleanupError,
);
},
);
throw new Error(
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
);
}
await execAsyncRemote(serverId, `mv ${candidatePath} ${configPath}`);
});
export const buildServiceSettings = (
vectorPath: string,
): CreateServiceOptions => {
const VECTOR_PATH = vectorPath;
return {
Name: VECTOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: VECTOR_IMAGE,
Args: ["--config", VECTOR_CONFIG_CONTAINER_PATH, "--watch-config"],
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,
},
],
},
Networks: [{ Target: "host" }],
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
};
export const stripImageDigest = (image: string): string =>
image.split("@")[0] ?? image;
export const vectorServiceSpecUnchanged = (
inspect: { Spec: { TaskTemplate: any; Mode?: unknown } },
settings: CreateServiceOptions,
): boolean => {
const desiredTaskTemplate = settings.TaskTemplate as any;
const existing = inspect.Spec.TaskTemplate?.ContainerSpec ?? {};
const desired = desiredTaskTemplate?.ContainerSpec ?? {};
return (
stripImageDigest(existing.Image ?? "") ===
stripImageDigest(desired.Image ?? "") &&
JSON.stringify(existing.Args ?? []) ===
JSON.stringify(desired.Args ?? []) &&
JSON.stringify(existing.Mounts ?? []) ===
JSON.stringify(desired.Mounts ?? []) &&
JSON.stringify(inspect.Spec.Mode ?? {}) ===
JSON.stringify(settings.Mode ?? {})
);
};
const deployVectorService = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
settings: CreateServiceOptions,
) => {
try {
const service = docker.getService(VECTOR_SERVICE_NAME);
const inspect = await service.inspect();
if (vectorServiceSpecUnchanged(inspect, settings)) {
return;
}
await service.update({
version: Number.parseInt(inspect.Version.Index, 10),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1,
},
});
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
await docker.createService(settings);
}
};
export const setupVectorAgent = async ({
serverId,
preloaded,
}: {
serverId: string;
preloaded?: VectorOrgData;
}) => {
await syncVectorConfig({ serverId, preloaded });
const { VECTOR_PATH } = paths(true);
await pullRemoteImage(VECTOR_IMAGE, serverId);
const docker = await getRemoteDocker(serverId);
await deployVectorService(docker, buildServiceSettings(VECTOR_PATH));
};
export const removeVectorAgent = async ({ serverId }: { serverId: string }) => {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`No SSH key configured for server ${serverId}`);
}
const docker = await getRemoteDocker(serverId);
try {
await docker.getService(VECTOR_SERVICE_NAME).remove();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
const { VECTOR_PATH } = paths(true);
await execAsyncRemote(serverId, `rm -rf ${VECTOR_PATH}`);
};
export const syncVectorAgent = async ({
serverId,
preloaded,
}: {
serverId: string;
preloaded?: VectorOrgData;
}): Promise<{ installed: boolean }> => {
const server = await findServerById(serverId);
const hasProvider = preloaded
? preloaded.providers.length > 0
: await hasEnabledLogProvider(server.organizationId);
const shouldRun = server.enableLogManagement && hasProvider;
if (shouldRun) {
await setupVectorAgent({ serverId, preloaded });
} else {
await removeVectorAgent({ serverId });
}
return { installed: shouldRun };
};
export const syncWebVectorConfig = async (
organizationId: string,
preloaded?: VectorOrgData,
) =>
withConfigWriteLock("web", async () => {
const { VECTOR_PATH } = paths();
const configPath = `${VECTOR_PATH}/vector.yaml`;
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate`;
const orgData = preloaded ?? (await loadVectorOrgData(organizationId));
const yamlStr = await buildVectorConfigYaml(organizationId, orgData, {
dropUnmatched: true,
});
mkdirSync(`${VECTOR_PATH}/data`, { recursive: true });
writeFileSync(candidatePath, yamlStr, "utf8");
fsChmodSync(candidatePath, 0o600);
fsChmodSync(VECTOR_PATH, 0o700);
fsChmodSync(`${VECTOR_PATH}/data`, 0o700);
try {
await execAsync(
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${candidatePath}:/etc/vector/vector.yaml:ro ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
);
} catch (error) {
const detail =
error instanceof ExecError
? error.stderr || error.stdout || error.message
: error instanceof Error
? error.message
: String(error);
const sanitizedDetail = redactSecrets(
detail,
collectSecretValues(orgData.providers),
);
try {
rmSync(candidatePath, { force: true });
} catch (cleanupError) {
console.error(
`[Vector] Failed to remove invalid local config candidate ${candidatePath}:`,
cleanupError,
);
}
throw new Error(
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
);
}
renameSync(candidatePath, configPath);
});
export const setupWebVectorAgent = async (
organizationId: string,
preloaded?: VectorOrgData,
) => {
await syncWebVectorConfig(organizationId, preloaded);
const { VECTOR_PATH } = paths();
await pullImage(VECTOR_IMAGE);
const docker = await getRemoteDocker();
await deployVectorService(docker, buildServiceSettings(VECTOR_PATH));
};
export const removeWebVectorAgent = async () => {
const docker = await getRemoteDocker();
try {
await docker.getService(VECTOR_SERVICE_NAME).remove();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
const { VECTOR_PATH } = paths();
rmSync(VECTOR_PATH, { recursive: true, force: true });
};
export const syncWebVectorAgent = async (
preloaded?: VectorOrgData,
): Promise<{ installed: boolean }> => {
const settings = await getWebServerSettings();
const organizationId = settings?.logManagementOrganizationId ?? null;
const hasProvider = organizationId
? preloaded
? preloaded.providers.length > 0
: await hasEnabledLogProvider(organizationId)
: false;
const shouldRun =
!!settings?.enableLogManagement && !!organizationId && hasProvider;
if (shouldRun && organizationId) {
await setupWebVectorAgent(organizationId, preloaded);
} else {
await removeWebVectorAgent();
}
return { installed: shouldRun };
};

View File

@ -117,6 +117,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@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 +599,9 @@ importers:
'@ai-sdk/openai-compatible':
specifier: ^2.0.30
version: 2.0.30(zod@4.3.6)
'@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
@ -894,6 +900,10 @@ 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-route-53@3.1108.0':
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
engines: {node: '>=20.0.0'}
@ -910,38 +920,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 +996,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 +4010,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 +4022,18 @@ 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/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 +4042,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==}
@ -9315,6 +9397,17 @@ 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-route-53@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
@ -9360,6 +9453,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 +9472,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 +9490,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,6 +9516,22 @@ 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
@ -9403,6 +9541,15 @@ snapshots:
'@smithy/types': 4.17.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':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.68
@ -9417,6 +9564,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 +9586,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 +9604,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 +9623,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
@ -9461,6 +9649,17 @@ snapshots:
'@smithy/types': 4.17.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
@ -9468,6 +9667,13 @@ snapshots:
'@smithy/types': 4.17.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
@ -9477,16 +9683,35 @@ snapshots:
'@smithy/types': 4.17.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':
dependencies:
'@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 +13045,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 +13062,24 @@ 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/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 +13090,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': {}