Compare commits

..

1 Commits

Author SHA1 Message Date
Narciso 165746ca46 fix(server): allow members to list servers on cloud 2026-10-07 11:54:03 -04:00
68 changed files with 33 additions and 14674 deletions
-1
View File
@@ -26,7 +26,6 @@ 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.).
@@ -33,16 +33,8 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -62,16 +54,6 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,356 +0,0 @@
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 }}");
});
});
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: mocks.lookup,
}));
const loadFetch = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
const { logProviderFetch } = await import(
"@dokploy/server/services/log-management/types"
);
return logProviderFetch;
};
describe("logProviderFetch — blocked endpoint guard", () => {
const originalFetch = global.fetch;
beforeEach(() => {
mocks.lookup.mockReset();
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
const logProviderFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
logProviderFetch(`http://${address}/latest/meta-data/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
const logProviderFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
logProviderFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not block a hostname just because it starts like a link-local prefix", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
await expect(
logProviderFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a private-network endpoint when self-hosted (Loki behind a VPN, say)", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
await logProviderFetch("http://loki.internal.example.com:3100/ready");
await logProviderFetch("http://10.0.0.5:3100/ready");
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("rejects loopback and private-network endpoints in cloud, literal or resolved", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
for (const url of [
"http://10.0.0.5:3100/ready",
"http://127.0.0.1:3100/ready",
"http://localhost:3100/ready",
"http://[::1]:3100/ready",
"http://0.0.0.0:3100/ready",
"http://100.100.100.200/latest/meta-data/",
"http://[::]:3100/ready",
"http://loki.internal.example.com:3100/ready",
]) {
await expect(
logProviderFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a normal public endpoint", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch(
"https://api.us-east-1.datadoghq.com/api/v1/validate",
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("does not follow redirects", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
await logProviderFetch("http://does-not-exist.invalid/");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -1,315 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
logProvider: {
findFirst: mocks.findFirst,
},
},
},
}));
const { createLogProvider, updateLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
describe("createLogProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createLogProvider(
{ name: "loki-prod", providerType: "loki" } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createLogProvider(
{ name: "dd", providerType: "datadog" } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createLogProvider(
{
name: "bs",
providerType: "betterstack",
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createLogProvider(
{
name: "splunk",
providerType: "splunk_hec",
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createLogProvider(
{
name: "dd",
providerType: "datadog",
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createLogProvider(
{
name: "es",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
]);
const created = await createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createLogProvider(
{
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateLogProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "old-name",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ logProviderId: "lp-1", name: "new-name" },
]);
const updated = await updateLogProvider("lp-1", { name: "new-name" });
expect(updated.name).toBe("new-name");
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(updateLogProvider("lp-1", { endpoint: null })).rejects.toThrow(
/endpoint/i,
);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: { username: "new-user" } });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("clears an optional credential when it is explicitly set to null", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { apiKey: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: null }),
);
});
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(updateLogProvider("lp-1", { apiKey: null })).rejects.toThrow(
/Password/,
);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateLogProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
@@ -1,71 +0,0 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleted: [] as unknown[],
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
mocks.updates.push({ table, values });
return Promise.resolve();
},
}),
}),
};
return {
db: { transaction: (fn: (tx: unknown) => unknown) => fn(tx) },
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeLogProvider", () => {
beforeEach(() => {
mocks.updates = [];
});
it("drops the provider id from every server and from the local host", async () => {
mocks.deleted = [{ logProviderId: "lp-1" }];
await removeLogProvider("lp-1");
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.logProviderIds)).toBe(
'array_remove("server"."logProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.logProviderIds)).toBe(
'array_remove("webServerSettings"."logProviderIds", $1)',
);
expect(mocks.updates[1]?.values).not.toHaveProperty(
"logManagementOrganizationId",
);
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeLogProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -1,306 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
claimWebServerLogManagement: vi.fn(),
releaseWebServerLogManagement: vi.fn(),
getWebServerSettings: vi.fn(),
updateServerLogProviders: vi.fn(),
getAccessibleServerIds: vi.fn(),
removeVectorAgent: vi.fn(),
setupVectorAgent: vi.fn(),
assertServerBelongsToOrg: vi.fn(),
assertLogProvidersBelongToOrg: vi.fn(),
filterExistingLogProviderIds: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
updateServerLogProviders: mocks.updateServerLogProviders,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
claimWebServerLogManagement: mocks.claimWebServerLogManagement,
releaseWebServerLogManagement: mocks.releaseWebServerLogManagement,
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
setupVectorAgent: mocks.setupVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
vi.doMock("@dokploy/server/services/log-management/service", () => ({
assertServerBelongsToOrg: mocks.assertServerBelongsToOrg,
assertLogProvidersBelongToOrg: mocks.assertLogProvidersBelongToOrg,
filterExistingLogProviderIds: mocks.filterExistingLogProviderIds,
}));
return await import("@dokploy/server/services/log-management/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
describe("local Vector agent guards", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("does not remove the local agent when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionB, undefined),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes the local agent and only then releases the claim", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
expect(mocks.removeVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.releaseWebServerLogManagement.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the claim when removing the local agent fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeLogManagement } = await loadAgent(false);
await expect(removeLogManagement(sessionA, undefined)).rejects.toThrow(
"docker down",
);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes an unclaimed local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
});
it("rejects deploying the local agent in cloud", async () => {
const { deployLogManagement } = await loadAgent(true);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.claimWebServerLogManagement).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing the local agent in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await expect(
removeLogManagement(sessionA, undefined),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.getWebServerSettings).not.toHaveBeenCalled();
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("still manages remote servers in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await removeLogManagement(sessionA, "server-1");
expect(mocks.assertServerBelongsToOrg).toHaveBeenCalledWith(
"server-1",
"org-a",
);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", []);
});
});
describe("remote server access", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("rejects deploying on a server the member cannot access", async () => {
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing from a server the member cannot access", async () => {
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionA, "server-2"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("deploys on an accessible server", async () => {
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1"]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
expect(mocks.setupVectorAgent).toHaveBeenCalledWith("org-a", "server-1", [
"lp-1",
]);
});
it("does not save a provider deleted while the server deploy ran", async () => {
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1", "lp-2"]);
expect(mocks.filterExistingLogProviderIds).toHaveBeenCalledWith([
"lp-1",
"lp-2",
]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
});
it("keeps the saved providers when the server deploy fails", async () => {
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
});
it("removes the agent on server deletion even with no providers left", async () => {
const { removeServerLogManagement } = await loadAgent(false);
await removeServerLogManagement({
serverId: "server-1",
sshKeyId: "key-1",
logProviderIds: [],
} as never);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
});
describe("local agent deploy", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.claimWebServerLogManagement.mockResolvedValue({});
});
it("saves the new providers only after the deploy succeeds", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
1,
"org-a",
["lp-1"],
);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
2,
"org-a",
["lp-2"],
);
expect(mocks.setupVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.claimWebServerLogManagement.mock.invocationCallOrder[1] ?? 0,
);
});
it("does not save a provider deleted while the local deploy ran", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-1", "lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenLastCalledWith(
"org-a",
["lp-1"],
);
});
it("keeps the previous providers when a redeploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledTimes(1);
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("releases the claim when the first local deploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
logProviderIds: [],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toThrow("validation failed");
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
});
it("rejects deploying when another organization owns the local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.claimWebServerLogManagement.mockResolvedValue(null);
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionB, undefined, ["lp-9"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
});
@@ -1,145 +0,0 @@
import { buildVectorConfigYaml } from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
...overrides,
}) as any;
describe("buildVectorConfigYaml", () => {
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "log-provider-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
const sinkIds = Object.keys(config.sinks);
expect(sinkIds).toEqual(["sink_log-provider-1"]);
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
"dokploy_scope",
]);
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
provider({
logProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
provider({
logProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
apiKey: "dd-key",
}),
]);
const config = parse(yamlStr) as any;
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n"),
);
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
provider({
logProviderId: "good-loki",
name: "loki-ok",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", async () => {
const yamlStr = await buildVectorConfigYaml(
[
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
],
{ organizationId: "org-a" },
);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the organization filter for the per-server (non-local) path", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
@@ -1,37 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getRemoteDocker: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForLogManagement: vi.fn(),
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: mocks.getRemoteDocker,
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: mocks.execAsyncRemote,
}));
const { removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("removeVectorAgent on a server without SSH key", () => {
it("refuses instead of falling back to the local docker socket", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getRemoteDocker).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { projects: { findMany: vi.fn() } },
},
}));
const { collectSecretValues, redactSecrets } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
const providers = [
{
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a" },
},
{
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
] as any;
expect(collectSecretValues(providers)).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"tenant-a",
"dd-key",
"datadoghq.eu",
]),
);
});
it("ignores non-string extraConfig values and null/empty credentials", () => {
const providers = [
{
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: null,
extraConfig: { other: "hunter2" },
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForLogManagement: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
findServersByOrganizationForLogManagement:
mocks.findServersByOrganizationForLogManagement,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getLogManagementServerStatus, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getLogManagementServerStatus", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-1",
logProviderIds: ["lp-1"],
});
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledWith({
filters: JSON.stringify({
service: ["dokploy-vector"],
"desired-state": ["running"],
}),
});
});
it("reports not-running when the service has no running task", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("not-running");
});
it("reports stopped when the service does not exist", async () => {
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("not found"), { statusCode: 404 }),
);
mocks.listTasks.mockResolvedValue([]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("stopped");
expect(mocks.listTasks).not.toHaveBeenCalled();
});
it("reports unknown on any other docker error", async () => {
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("unknown");
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([
{ serverId: "server-1", name: "one", ipAddress: "1.1.1.1" },
{ serverId: "server-2", name: "two", ipAddress: "2.2.2.2" },
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getLogManagementServerStatus(session);
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
});
});
describe("withVectorTargetLock", () => {
it("runs operations on the same target one after another", async () => {
const events: string[] = [];
let releaseFirst = () => {};
const first = withVectorTargetLock("server-1", async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
throw new Error("first failed");
});
const second = withVectorTargetLock("server-1", async () => {
events.push("second");
});
const other = withVectorTargetLock("server-2", async () => {
events.push("other");
});
await other;
expect(events).toEqual(["first:start", "other"]);
releaseFirst();
await expect(first).rejects.toThrow("first failed");
await second;
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
});
});
@@ -73,20 +73,6 @@ 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(
@@ -153,20 +139,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied logProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied logProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,7 +18,6 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,7 +111,6 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.logProvider.read).toBe(false);
});
});
@@ -25,8 +25,6 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logProviderIds: [],
logManagementOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
}, 30_000);
});
it("reports a source failure", async () => {
await expect(
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
}, 30_000);
});
});
@@ -1,402 +0,0 @@
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
interface Props {
logProviderId?: string;
}
export const agentsUsingProvider = (
targets: Array<{ name: string; logProviderIds: string[] }> | undefined,
logProviderId: string,
) =>
(targets ?? [])
.filter((target) => target.logProviderIds.includes(logProviderId))
.map((target) => target.name)
.join(", ");
export const HandleLogProvider = ({ logProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
const [providerType, setProviderType] = useState("");
const [enabled, setEnabled] = useState(true);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [initialFieldValues, setInitialFieldValues] = useState<
Record<string, string>
>({});
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: provider } = api.logProvider.one.useQuery(
{ logProviderId: logProviderId || "" },
{ enabled: !!logProviderId },
);
const {
mutateAsync,
isPending: isSaving,
error,
isError,
} = logProviderId
? api.logProvider.update.useMutation()
: api.logProvider.create.useMutation();
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.logProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.logProvider.testConnectionById.useMutation();
const selectedType = availableTypes?.find((t) => t.type === providerType);
const handleProviderTypeChange = (value: string) => {
setProviderType(value);
setFieldValues({});
};
useEffect(() => {
if (provider) {
setName(provider.name);
setProviderType(provider.providerType);
setEnabled(provider.enabled);
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
setFieldValues(extraConfig);
setInitialFieldValues(extraConfig);
setClearedKeys({});
} else if (isOpen) {
setName("");
setProviderType("");
setEnabled(true);
setFieldValues({});
setInitialFieldValues({});
setClearedKeys({});
}
}, [provider, isOpen]);
const buildPayload = () => {
const payload: Record<string, unknown> = { name, providerType, enabled };
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of selectedType?.credentialFields ?? []) {
const value = fieldValues[field.key];
if (TOP_LEVEL_KEYS.has(field.key)) {
if (value) {
payload[field.key] = value;
} else if (clearedKeys[field.key]) {
payload[field.key] = null;
}
} else {
hasExtraConfigFields = true;
extraConfig[field.key] = value ?? "";
}
}
if (hasExtraConfigFields) {
payload.extraConfig = extraConfig;
}
return payload;
};
const topLevelKeys = (selectedType?.credentialFields ?? [])
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter(
(key) => !!fieldValues[key] || clearedKeys[key],
);
const extraConfigKeys = (selectedType?.credentialFields ?? [])
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedExtraConfigKeys = extraConfigKeys.filter(
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
);
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!logProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const onTest = async () => {
if (logProviderId && !touchedAnyCredentialField) {
await testConnectionById({ logProviderId })
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
const payload = buildPayload();
await testConnection({
name: payload.name as string,
providerType: payload.providerType as LogProviderType,
enabled: payload.enabled as boolean,
endpoint: (payload.endpoint as string) ?? null,
apiKey: (payload.apiKey as string) ?? null,
apiSecret: (payload.apiSecret as string) ?? null,
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
})
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
const payload = buildPayload();
await mutateAsync({
...(logProviderId ? { logProviderId } : {}),
...payload,
} as any)
.then(() => {
utils.logProvider.all.invalidate();
if (logProviderId) {
utils.logProvider.one.invalidate({ logProviderId });
}
const agents =
logProviderId &&
agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
logProviderId,
);
toast.success(
logProviderId ? "Log provider updated" : "Log provider added",
agents
? {
description: `Redeploy the Vector agent on ${agents} to apply the changes.`,
}
: undefined,
);
setIsOpen(false);
})
.catch(() => {
toast.error(
logProviderId
? "Error updating log provider"
: "Error adding log provider",
);
});
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{logProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Log Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
</DialogTitle>
<DialogDescription>
Vector will ship container logs from every server with Log
Management enabled to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testError?.message ||
testByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
<div className="flex flex-col w-full gap-4">
<div className="flex flex-col gap-2">
<Label>Name</Label>
<Input
placeholder="e.g. Production Loki"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Provider</Label>
<Select
value={providerType}
onValueChange={handleProviderTypeChange}
disabled={!!logProviderId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{availableTypes?.map((type) => {
const ProviderIcon =
logProviderIcons[
type.type as keyof typeof logProviderIcons
];
return (
<SelectItem key={type.type} value={type.type}>
<div className="flex flex-row items-center gap-2">
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
{type.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedType?.credentialFields.map((field) => (
<div key={field.key} className="flex flex-col gap-2">
<Label>
{field.label}
{!field.required && " (Optional)"}
</Label>
{field.helpText && (
<span className="text-xs text-muted-foreground">
{field.helpText}
</span>
)}
<div className="flex flex-row gap-2">
<Input
type={field.type === "password" ? "password" : "text"}
disabled={!!clearedKeys[field.key]}
placeholder={
clearedKeys[field.key]
? "Will be cleared on update"
: logProviderId && TOP_LEVEL_KEYS.has(field.key)
? "Leave blank to keep existing"
: field.placeholder
}
autoComplete={
field.type === "password" ? "one-time-code" : "off"
}
value={fieldValues[field.key] ?? ""}
onChange={(e) =>
setFieldValues((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
/>
{logProviderId &&
TOP_LEVEL_KEYS.has(field.key) &&
!field.required && (
<Button
type="button"
variant="outline"
onClick={() => {
setClearedKeys((prev) => ({
...prev,
[field.key]: !prev[field.key],
}));
setFieldValues((prev) => ({
...prev,
[field.key]: "",
}));
}}
>
{clearedKeys[field.key] ? "Keep" : "Clear"}
</Button>
)}
</div>
</div>
))}
<div className="flex flex-row items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>Enabled</Label>
</div>
</div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
{isPartiallyTouched && (
<span className="text-xs text-muted-foreground">
You changed something that needs a fresh credential to test — fill
in every credential field to test with the new values, or revert
it to test with what's already saved.
</span>
)}
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
disabled={!providerType || isPartiallyTouched}
onClick={onTest}
>
Test connection
</Button>
<Button
onClick={onSubmit}
isLoading={isSaving}
disabled={!providerType || !name || isSaving}
>
{logProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,324 +0,0 @@
import { Copy, Loader2, ScrollText, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
type Status = "running" | "not-running" | "stopped" | "unknown";
const statusBadge = (status: Status) => {
if (status === "running") {
return <Badge variant="green">Running</Badge>;
}
if (status === "not-running") {
return <Badge variant="yellow">Not running</Badge>;
}
if (status === "stopped") {
return <Badge variant="secondary">Not deployed</Badge>;
}
return <Badge variant="destructive">Unreachable</Badge>;
};
export const ShowLogManagementServers = () => {
const utils = api.useUtils();
const [selection, setSelection] = useState<Record<string, string[]>>({});
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: providers } = api.logProvider.all.useQuery();
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 30_000,
trpc: { context: { skipBatch: true } },
},
);
const { mutateAsync: deployOnServer } =
api.logProvider.deployOnServer.useMutation();
const { mutateAsync: removeOnServer } =
api.logProvider.removeOnServer.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
const idsFor = (serverId: string | null, saved: string[]) =>
selection[keyOf(serverId)] ?? saved;
const toggleProvider = (
serverId: string | null,
saved: string[],
logProviderId: string,
) => {
const key = keyOf(serverId);
const current = selection[key] ?? saved;
setSelection({
...selection,
[key]: current.includes(logProviderId)
? current.filter((id) => id !== logProviderId)
: [...current, logProviderId],
});
};
const setError = (serverId: string | null, message?: string) =>
setErrors((prev) => {
const next = { ...prev };
if (message) {
next[keyOf(serverId)] = message;
} else {
delete next[keyOf(serverId)];
}
return next;
});
const handleDeploy = async (serverId: string | null, ids: string[]) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await deployOnServer({ serverId, logProviderIds: ids })
.then(() => {
toast.success("Vector agent deployed");
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to deploy the agent",
);
})
.finally(() => setPendingTarget(null));
};
const handleRemove = async (serverId: string | null) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await removeOnServer({ serverId })
.then(() => {
toast.success("Vector agent removed");
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to remove the agent",
);
})
.finally(() => setPendingTarget(null));
};
const hasProviders = (providers?.length ?? 0) > 0;
const visibleTargets = hasProviders
? targets
: targets?.filter((target) => target.status !== "stopped");
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
Pick which providers each server ships to, then deploy its Vector
agent. Changing a provider only takes effect on a server after you
redeploy it. Services deployed before Log Management need a
redeploy to get the labels it uses; until then their logs are not
shipped from the Dokploy server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Checking servers...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a log provider first — there's nowhere to ship logs to yet
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{visibleTargets?.map((target) => {
const key = keyOf(target.serverId);
const ids = idsFor(target.serverId, target.logProviderIds);
const isBusy = pendingTarget === key;
const isDeployed = target.status !== "stopped";
const hasEnabledSelected = ids.some(
(id) =>
providers?.find((p) => p.logProviderId === id)?.enabled,
);
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{target.name}
</span>
{target.ipAddress && (
<span className="text-xs text-muted-foreground">
{target.ipAddress}
</span>
)}
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{isDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server will stop shipping container logs until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isBusy}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">
Log providers
</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
logProviderIcons[
provider.providerType as keyof typeof logProviderIcons
];
return (
<div
key={provider.logProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.logProviderId}`}
disabled={!provider.enabled}
checked={ids.includes(provider.logProviderId)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
target.logProviderIds,
provider.logProviderId,
)
}
/>
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.logProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{provider.name}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
);
})}
</div>
</div>
{errors[key] && (
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
Deploy failed — the config was not applied, this
server keeps shipping with its previous one.
</span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
{errors[key]}
</pre>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key] ?? "",
);
toast.success("Error copied");
}}
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</AlertBlock>
)}
{hasProviders && (
<div className="flex items-center justify-end gap-3">
{ids.length > 0 && !hasEnabledSelected && (
<span className="text-xs text-muted-foreground">
Every selected provider is disabled — nothing
would ship.
</span>
)}
<Button
size="sm"
isLoading={isBusy}
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{isDeployed ? "Redeploy" : "Deploy"}
</Button>
</div>
)}
</div>
);
})}
{visibleTargets?.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any server yet
</span>
</div>
)}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,186 +0,0 @@
import { Loader2, ScrollText, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { agentsUsingProvider, 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 utils = api.useUtils();
const { mutateAsync, isPending: isRemoving } =
api.logProvider.remove.useMutation();
const ProviderIcon =
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
return (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium flex items-center gap-2">
{index + 1}. {provider.name}
<Badge variant={provider.enabled ? "green" : "secondary"}>
{provider.enabled ? "Enabled" : "Disabled"}
</Badge>
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<HandleLogProvider logProviderId={provider.logProviderId} />
)}
{canDelete && (
<DialogAction
title="Delete Log Provider"
description="Are you sure you want to delete this log provider? Agents already deployed keep shipping logs to it until you redeploy or remove them."
type="destructive"
onClick={async () => {
const agents = agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
provider.logProviderId,
);
await mutateAsync({
logProviderId: provider.logProviderId,
})
.then(() => {
toast.success(
"Log provider deleted successfully",
agents
? {
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
}
: undefined,
);
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 providerListLabel = availableTypes?.length
? ` (${availableTypes.map((t) => t.label).join(", ")})`
: "";
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Log Management
</CardTitle>
<CardDescription>
{`Ship container logs to an external provider${providerListLabel}. Add one here, then deploy the Vector agent on the servers below.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any 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.logProvider.serverStatus.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>
);
};
@@ -109,13 +109,8 @@ export const DeleteServerModal = ({
const handleDeleteServer = async () => {
try {
const result = await deleteServer({ serverId });
await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
if (result?.vectorAgentRemovalWarning) {
toast.error(
`Couldn't remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
);
}
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async () => {
.then(async (_data) => {
await utils.server.all.invalidate();
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
@@ -1,69 +0,0 @@
import { AwsIcon } from "@/components/icons/vault-provider-icons";
interface Props {
className?: string;
}
export const GrafanaLokiIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#F46800"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
</svg>
);
export const DatadogIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#632CA6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
</svg>
);
export const BetterStackIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
</svg>
);
export const ElasticsearchIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#00BFB3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
</svg>
);
export const SplunkIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
</svg>
);
export const logProviderIcons = {
loki: GrafanaLokiIcon,
datadog: DatadogIcon,
betterstack: BetterStackIcon,
elasticsearch: ElasticsearchIcon,
splunk_hec: SplunkIcon,
aws_cloudwatch: AwsIcon,
} as const;
-8
View File
@@ -29,7 +29,6 @@ import {
type LucideIcon,
Package,
Palette,
ScrollText,
Server,
ShieldCheck,
Smartphone,
@@ -375,13 +374,6 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Log Management",
url: "/dashboard/settings/log-providers",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
},
{
isSingle: true,
title: "Secrets",
@@ -96,7 +96,6 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
logProvider: "Log Provider",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,11 +180,6 @@ 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 */
@@ -480,14 +475,6 @@ 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) */
@@ -1,19 +0,0 @@
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
CREATE TABLE "logProvider" (
"logProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "LogProviderType" NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1380,13 +1380,6 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1791377838843,
"tag": "0197_smiling_madrox",
"breakpoints": true
}
]
}
-1
View File
@@ -48,7 +48,6 @@
"@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",
@@ -1,57 +0,0 @@
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 { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
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 />
<ShowLogManagementServers />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
-2
View File
@@ -21,7 +21,6 @@ import { giteaRouter } from "./routers/gitea";
import { githubRouter } from "./routers/github";
import { gitlabRouter } from "./routers/gitlab";
import { libsqlRouter } from "./routers/libsql";
import { logProviderRouter } from "./routers/log-provider";
import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount";
@@ -86,7 +85,6 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
logProvider: logProviderRouter,
mariadb: mariadbRouter,
mongo: mongoRouter,
mounts: mountRouter,
@@ -1,157 +0,0 @@
import {
createLogProvider,
deployLogManagement,
findLogProviderForOrganization,
findLogProvidersByOrganization,
getLogManagementServerStatus,
logProviderAdapters,
removeLogManagement,
removeLogProvider,
sanitizeLogProvider,
testLogProviderConnection,
updateLogProvider,
} from "@dokploy/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateLogProvider,
apiFindOneLogProvider,
apiRemoveLogProvider,
apiTestLogProvider,
apiUpdateLogProvider,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const logProviderRouter = createTRPCRouter({
create: withPermission("logProvider", "create")
.input(apiCreateLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await createLogProvider(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(provider);
}),
update: withPermission("logProvider", "create")
.input(apiUpdateLogProvider)
.mutation(async ({ ctx, input }) => {
const { logProviderId, ...rest } = input;
const provider = await findLogProviderForOrganization(
logProviderId,
ctx.session.activeOrganizationId,
);
const updated = await updateLogProvider(logProviderId, rest);
await audit(ctx, {
action: "update",
resourceType: "logProvider",
resourceId: logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(updated);
}),
remove: withPermission("logProvider", "delete")
.input(apiRemoveLogProvider)
.mutation(async ({ ctx, input }) => {
const provider = await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
const removed = await removeLogProvider(input.logProviderId);
await audit(ctx, {
action: "delete",
resourceType: "logProvider",
resourceId: provider.logProviderId,
resourceName: provider.name,
});
return sanitizeLogProvider(removed);
}),
all: withPermission("logProvider", "read").query(async ({ ctx }) => {
return await findLogProvidersByOrganization(
ctx.session.activeOrganizationId,
);
}),
one: withPermission("logProvider", "read")
.input(apiFindOneLogProvider)
.query(async ({ ctx, input }) => {
return await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
}),
testConnection: withPermission("logProvider", "create")
.input(apiTestLogProvider)
.mutation(async ({ input }) => {
return await testLogProviderConnection({
providerType: input.providerType,
config: {
logProviderId: "test",
name: input.name ?? "test",
endpoint: input.endpoint ?? null,
apiKey: input.apiKey ?? null,
apiSecret: input.apiSecret ?? null,
extraConfig: input.extraConfig ?? null,
},
});
}),
testConnectionById: withPermission("logProvider", "create")
.input(apiFindOneLogProvider)
.mutation(async ({ ctx, input }) => {
await findLogProviderForOrganization(
input.logProviderId,
ctx.session.activeOrganizationId,
);
return await testLogProviderConnection({
logProviderId: input.logProviderId,
});
}),
serverStatus: withPermission("logProvider", "read").query(({ ctx }) =>
getLogManagementServerStatus(ctx.session),
),
deployOnServer: withPermission("logProvider", "create")
.input(
z.object({
serverId: z.string().nullable().optional(),
logProviderIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
await deployLogManagement(
ctx.session,
input.serverId ?? undefined,
input.logProviderIds,
);
await audit(ctx, {
action: "create",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "log-management",
});
return { installed: true };
}),
removeOnServer: withPermission("logProvider", "create")
.input(z.object({ serverId: z.string().nullable().optional() }))
.mutation(async ({ ctx, input }) => {
await removeLogManagement(ctx.session, input.serverId ?? undefined);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "log-management",
});
return { installed: false };
}),
availableTypes: protectedProcedure.query(() => {
return Object.values(logProviderAdapters).map((adapter) => ({
type: adapter.type,
label: adapter.label,
docsUrl: adapter.docsUrl,
credentialFields: adapter.credentialFields,
}));
}),
});
@@ -1,6 +1,5 @@
import { db } from "@dokploy/server/db";
import {
deleteOrganization,
hasValidLicense,
IS_CLOUD,
sendInvitationEmail,
@@ -286,7 +285,9 @@ export const organizationRouter = createTRPCRouter({
});
}
const result = await deleteOrganization(input.organizationId);
const result = await db
.delete(organization)
.where(eq(organization.id, input.organizationId));
await audit(ctx, {
action: "delete",
+11 -13
View File
@@ -12,7 +12,6 @@ import {
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
removeServerLogManagement,
serverAudit,
serverSetup,
serverValidate,
@@ -20,7 +19,10 @@ import {
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { findMemberByUserId } from "@dokploy/server/services/permission";
import {
checkPermission,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
@@ -217,10 +219,15 @@ export const serverRouter = createTRPCRouter({
return servers.length ?? 0;
}),
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
await checkPermission(
ctx,
IS_CLOUD ? { service: ["create"] } : { server: ["read"] },
);
const accessibleIds = await getAccessibleServerIds(ctx.session);
const result = await db.query.server.findMany({
columns: { serverId: true, name: true, ipAddress: true },
orderBy: desc(server.createdAt),
where: IS_CLOUD
? and(
@@ -474,16 +481,11 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
const vectorAgentRemovalWarning =
await removeServerLogManagement(currentServer);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: currentServer.serverId,
resourceName: currentServer.name,
...(vectorAgentRemovalWarning
? { metadata: { vectorAgentRemovalWarning } }
: {}),
});
await removeDeploymentsByServerId(currentServer);
await deleteServer(input.serverId);
@@ -494,10 +496,7 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return {
...redactServerSshKey(currentServer),
vectorAgentRemovalWarning,
};
return redactServerSshKey(currentServer);
} catch (error) {
throw error;
}
@@ -520,7 +519,6 @@ export const serverRouter = createTRPCRouter({
message: "Server is inactive",
});
}
const currentServer = await updateServerById(input.serverId, {
...input,
});
+1 -2
View File
@@ -52,8 +52,7 @@ const links =
transformer: superjson,
}),
false: splitLink({
condition: (op) =>
op.input instanceof FormData || op.context.skipBatch === true,
condition: (op) => op.input instanceof FormData,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
-1
View File
@@ -37,7 +37,6 @@
"@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",
-1
View File
@@ -134,7 +134,6 @@ 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`,
+1 -2
View File
@@ -94,5 +94,4 @@ export type AuditResourceType =
| "compose"
| "network"
| "vaultProvider"
| "dnsProvider"
| "logProvider";
| "dnsProvider";
-1
View File
@@ -17,7 +17,6 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./log-provider";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
@@ -1,87 +0,0 @@
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 });
+1 -5
View File
@@ -1,4 +1,4 @@
import { relations, sql } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
boolean,
integer,
@@ -42,10 +42,6 @@ export const server = pgTable("server", {
.notNull()
.$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
logProviderIds: text("logProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(),
organizationId: text("organizationId")
@@ -1,4 +1,4 @@
import { relations, sql } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
boolean,
integer,
@@ -10,7 +10,6 @@ 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", {
@@ -27,14 +26,6 @@ export const webServerSettings = pgTable("webServerSettings", {
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
logProviderIds: text("logProviderIds")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
logManagementOrganizationId: text("logManagementOrganizationId").references(
() => organization.id,
{ onDelete: "set null" },
),
// Metrics Configuration
metricsConfig: jsonb("metricsConfig")
.$type<{
-6
View File
@@ -28,17 +28,12 @@ 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/log-management/vector-agent";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/organization";
export * from "./services/overview";
export * from "./services/patch";
export * from "./services/patch-repo";
@@ -74,7 +69,6 @@ export * from "./setup/server-setup";
export * from "./setup/server-validate";
export * from "./setup/setup";
export * from "./setup/traefik-setup";
export * from "./setup/vector-setup";
export * from "./templates/processors";
export * from "./utils/access-log/handler";
export {
@@ -28,7 +28,6 @@ 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"],
@@ -120,7 +119,6 @@ export const ownerRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
logProvider: ["read", "create", "delete"],
});
/**
@@ -160,7 +158,6 @@ export const adminRole = ac.newRole({
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
logProvider: ["read", "create", "delete"],
});
/**
@@ -201,7 +198,6 @@ 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
@@ -1,114 +0,0 @@
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) },
);
},
};
@@ -1,88 +0,0 @@
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}`,
);
}
},
};
@@ -1,90 +0,0 @@
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}`,
);
}
},
};
@@ -1,154 +0,0 @@
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 ?? "")],
api_version: "v8",
...(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);
},
};
@@ -1,119 +0,0 @@
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}`);
}
},
};
@@ -1,27 +0,0 @@
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;
}
@@ -1,80 +0,0 @@
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}`,
);
}
},
};
@@ -1,354 +0,0 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateLogProvider,
logProvider,
server,
webServerSettings,
} from "@dokploy/server/db/schema";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server";
import { and, arrayContains, eq, inArray, sql } 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) => {
return await db.transaction(async (tx) => {
const removed = await tx
.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",
});
}
await tx
.update(server)
.set({
logProviderIds: sql`array_remove(${server.logProviderIds}, ${logProviderId})`,
})
.where(arrayContains(server.logProviderIds, [logProviderId]));
// Keeps logManagementOrganizationId: the local agent keeps running until it is explicitly removed.
await tx
.update(webServerSettings)
.set({
logProviderIds: sql`array_remove(${webServerSettings.logProviderIds}, ${logProviderId})`,
})
.where(arrayContains(webServerSettings.logProviderIds, [logProviderId]));
return removed;
});
};
export const findLogProviderForOrganization = async (
logProviderId: string,
organizationId: string,
) => {
const provider = await findLogProviderById(logProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this log provider",
});
}
return provider;
};
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;
};
export const assertServerBelongsToOrg = async (
serverId: string | undefined,
organizationId: string,
) => {
if (!serverId) return;
const server = await findServerById(serverId);
if (server.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
};
export const filterExistingLogProviderIds = async (
logProviderIds: string[],
): Promise<string[]> => {
if (logProviderIds.length === 0) return [];
const rows = await db
.select({ logProviderId: logProvider.logProviderId })
.from(logProvider)
.where(inArray(logProvider.logProviderId, logProviderIds));
const existing = new Set(rows.map((row) => row.logProviderId));
return logProviderIds.filter((id) => existing.has(id));
};
export const assertLogProvidersBelongToOrg = async (
logProviderIds: string[],
organizationId: string,
) => {
const providers = await findLogProvidersByOrganization(organizationId);
const byId = new Map(providers.map((p) => [p.logProviderId, p]));
for (const id of logProviderIds) {
if (!byId.has(id)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"One of the selected log providers is not in this organization",
});
}
}
if (!logProviderIds.some((id) => byId.get(id)?.enabled)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Select at least one enabled log provider — a disabled one ships nothing.",
});
}
};
@@ -1,148 +0,0 @@
import { BlockList, isIP } from "node:net";
import { IS_CLOUD } from "@dokploy/server/constants";
export const LOG_PROVIDER_REQUEST_TIMEOUT_MS = 15_000;
const metadataAddresses = new BlockList();
metadataAddresses.addSubnet("169.254.0.0", 16, "ipv4");
metadataAddresses.addSubnet("fe80::", 10, "ipv6");
metadataAddresses.addAddress("fd00:ec2::254", "ipv6");
const privateAddresses = new BlockList();
privateAddresses.addSubnet("0.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("100.64.0.0", 10, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addAddress("::", "ipv6");
privateAddresses.addAddress("::1", "ipv6");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
const isBlockedAddress = (address: string): boolean => {
const ip = address.replace(/^\[|\]$/g, "");
const family = isIP(ip);
if (family === 0) {
return IS_CLOUD && ip.toLowerCase() === "localhost";
}
const type = family === 4 ? "ipv4" : "ipv6";
return (
metadataAddresses.check(ip, type) ||
(IS_CLOUD && privateAddresses.check(ip, type))
);
};
const METADATA_HOSTNAMES = new Set([
"metadata.google.internal",
"metadata.goog",
]);
const assertNotBlockedEndpoint = async (rawUrl: string): Promise<void> => {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return;
}
const blocked = () =>
new Error(
"This endpoint resolves to a blocked address (cloud metadata or private network) and can't be used here.",
);
if (METADATA_HOSTNAMES.has(url.hostname.toLowerCase())) {
throw blocked();
}
if (isBlockedAddress(url.hostname)) {
throw blocked();
}
let results: Array<{ address: string }>;
try {
const { lookup } = await import("node:dns/promises");
results = await lookup(url.hostname, { all: true });
} catch {
return;
}
if (results.some((r) => isBlockedAddress(r.address))) {
throw blocked();
}
};
export const logProviderFetch = async (url: string, init: RequestInit = {}) => {
await assertNotBlockedEndpoint(url);
return await fetch(url, {
...init,
redirect: "error",
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;
}
@@ -1,145 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import {
getAccessibleServerIds,
type Server,
updateServerLogProviders,
} from "@dokploy/server/services/server";
import {
claimWebServerLogManagement,
getWebServerSettings,
releaseWebServerLogManagement,
} from "@dokploy/server/services/web-server-settings";
import {
removeVectorAgent,
setupVectorAgent,
withVectorTargetLock,
} from "@dokploy/server/setup/vector-setup";
import { TRPCError } from "@trpc/server";
import {
assertLogProvidersBelongToOrg,
assertServerBelongsToOrg,
filterExistingLogProviderIds,
} from "./service";
const assertLocalAgentAllowed = (serverId: string | undefined) => {
if (!serverId && IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Log management on the Dokploy host is not available in Dokploy Cloud",
});
}
};
const assertServerAccessible = async (
serverId: string,
session: { userId: string; activeOrganizationId: string },
) => {
await assertServerBelongsToOrg(serverId, session.activeOrganizationId);
const accessibleIds = await getAccessibleServerIds(session);
if (!accessibleIds.has(serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
};
export const deployLogManagement = async (
session: { userId: string; activeOrganizationId: string },
serverId: string | undefined,
logProviderIds: string[],
) => {
const organizationId = session.activeOrganizationId;
assertLocalAgentAllowed(serverId);
if (serverId) {
await assertServerAccessible(serverId, session);
}
await assertLogProvidersBelongToOrg(logProviderIds, organizationId);
await withVectorTargetLock(serverId, async () => {
if (serverId) {
await setupVectorAgent(organizationId, serverId, logProviderIds);
// A provider deleted during the deploy must not be written back.
await updateServerLogProviders(
serverId,
await filterExistingLogProviderIds(logProviderIds),
);
return;
}
const settings = await getWebServerSettings();
const previousOwner = settings?.logManagementOrganizationId ?? null;
const previousIds = settings?.logProviderIds ?? [];
const claimed = await claimWebServerLogManagement(
organizationId,
previousOwner ? previousIds : [],
);
if (!claimed) {
throw new TRPCError({
code: "CONFLICT",
message:
"The local Vector agent is already claimed by another organization",
});
}
try {
await setupVectorAgent(organizationId, undefined, logProviderIds);
} catch (error) {
if (!previousOwner) {
await releaseWebServerLogManagement(organizationId);
}
throw error;
}
await claimWebServerLogManagement(
organizationId,
await filterExistingLogProviderIds(logProviderIds),
);
});
};
export const removeLogManagement = async (
session: { userId: string; activeOrganizationId: string },
serverId: string | undefined,
) => {
const organizationId = session.activeOrganizationId;
assertLocalAgentAllowed(serverId);
if (serverId) {
await assertServerAccessible(serverId, session);
await withVectorTargetLock(serverId, async () => {
await removeVectorAgent(serverId);
await updateServerLogProviders(serverId, []);
});
return;
}
await withVectorTargetLock(undefined, async () => {
const settings = await getWebServerSettings();
const owner = settings?.logManagementOrganizationId;
if (owner && owner !== organizationId) {
throw new TRPCError({
code: "CONFLICT",
message: "The local Vector agent is claimed by another organization",
});
}
await removeVectorAgent();
await releaseWebServerLogManagement(organizationId);
});
};
// Runs even with no providers assigned: deleting the last provider leaves the agent deployed.
export const removeServerLogManagement = async (server: Server) => {
if (!server.sshKeyId) return;
try {
await withVectorTargetLock(server.serverId, () =>
removeVectorAgent(server.serverId),
);
} catch (error) {
console.error(
`[Vector] Failed to remove agent for server ${server.serverId} before deletion:`,
error,
);
return error instanceof Error ? error.message : String(error);
}
};
@@ -1,51 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { organization } from "@dokploy/server/db/schema";
import { findServersByOrganizationForLogManagement } from "@dokploy/server/services/server";
import {
getWebServerSettings,
releaseWebServerLogManagement,
} from "@dokploy/server/services/web-server-settings";
import {
removeVectorAgent,
withVectorTargetLock,
} from "@dokploy/server/setup/vector-setup";
import { eq } from "drizzle-orm";
const removeVectorAgents = async (organizationId: string) => {
// Every server, not only those with providers: deleting the last provider leaves the agent deployed.
const servers =
await findServersByOrganizationForLogManagement(organizationId);
await Promise.all(
servers.map((server) =>
withVectorTargetLock(server.serverId, () =>
removeVectorAgent(server.serverId),
).catch((error) => {
console.error(
`[Vector] Failed to remove the agent on server ${server.serverId} before deleting organization ${organizationId}:`,
error,
);
}),
),
);
if (IS_CLOUD) return;
await withVectorTargetLock(undefined, async () => {
const settings = await getWebServerSettings();
if (settings?.logManagementOrganizationId !== organizationId) return;
await removeVectorAgent().catch((error) => {
console.error(
`[Vector] Failed to remove the local agent before deleting organization ${organizationId}:`,
error,
);
});
await releaseWebServerLogManagement(organizationId);
});
};
export const deleteOrganization = async (organizationId: string) => {
await removeVectorAgents(organizationId);
return await db
.delete(organization)
.where(eq(organization.id, organizationId));
};
+1 -32
View File
@@ -7,7 +7,7 @@ import {
} from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNotNull } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { z } from "zod";
export type Server = typeof server.$inferSelect;
@@ -87,37 +87,6 @@ export const findServersByUserId = async (userId: string) => {
return servers;
};
export const findServersByOrganizationForLogManagement = async (
organizationId: string,
) => {
return await db.query.server.findMany({
where: and(
eq(server.organizationId, organizationId),
isNotNull(server.sshKeyId),
eq(server.serverType, "deploy"),
),
columns: {
serverId: true,
name: true,
ipAddress: true,
logProviderIds: true,
},
orderBy: [desc(server.createdAt)],
});
};
export const updateServerLogProviders = async (
serverId: string,
logProviderIds: string[],
) => {
const [updated] = await db
.update(server)
.set({ logProviderIds })
.where(eq(server.serverId, serverId))
.returning();
return updated ?? null;
};
export const deleteServer = async (serverId: string) => {
const currentServer = await db
.delete(server)
@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db";
import { webServerSettings } from "@dokploy/server/db/schema";
import { and, eq, isNull, or } from "drizzle-orm";
import { eq } from "drizzle-orm";
/**
* Get the web server settings (singleton - only one row should exist)
@@ -42,44 +42,3 @@ export const updateWebServerSettings = async (
return updated;
};
export const claimWebServerLogManagement = async (
organizationId: string,
logProviderIds: string[],
) => {
const current = await getWebServerSettings();
if (!current) {
return null;
}
const [updated] = await db
.update(webServerSettings)
.set({
logProviderIds,
logManagementOrganizationId: organizationId,
updatedAt: new Date(),
})
.where(
and(
eq(webServerSettings.id, current.id),
or(
isNull(webServerSettings.logManagementOrganizationId),
eq(webServerSettings.logManagementOrganizationId, organizationId),
),
),
)
.returning();
return updated ?? null;
};
export const releaseWebServerLogManagement = async (organizationId: string) => {
await db
.update(webServerSettings)
.set({
logProviderIds: [],
logManagementOrganizationId: null,
updatedAt: new Date(),
})
.where(eq(webServerSettings.logManagementOrganizationId, organizationId));
};
-460
View File
@@ -1,460 +0,0 @@
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import {
findEnabledLogProvidersByOrganization,
type LogProvider,
toRuntimeConfig,
} from "@dokploy/server/services/log-management/service";
import {
findServerById,
findServersByOrganizationForLogManagement,
getAccessibleServerIds,
} from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { encodeBase64 } from "@dokploy/server/utils/docker/utils";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import type { CreateServiceOptions } from "dockerode";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
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";
const buildScopeTransformSource = (): string =>
[
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n");
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 };
};
const loadEnabledLogProviders = async (
organizationId: string,
logProviderIds?: string[],
): Promise<Array<LogProvider>> => {
const providers = await findEnabledLogProvidersByOrganization(organizationId);
return logProviderIds
? providers.filter((provider) =>
logProviderIds.includes(provider.logProviderId),
)
: providers;
};
export const buildVectorConfigYaml = async (
providers: Array<LogProvider>,
options?: { organizationId?: string },
) => {
const organizationId = options?.organizationId;
const baseTransformId = organizationId
? "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
? {
dokploy_scope_local_only: {
type: "filter",
inputs: ["dokploy_scope"],
condition: `.dokploy_organization == ${JSON.stringify(organizationId)}`,
},
}
: {}),
...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 getServerLogProviderIds = async (
serverId?: string,
): Promise<string[]> => {
if (serverId) {
const server = await findServerById(serverId);
return server.logProviderIds ?? [];
}
const settings = await getWebServerSettings();
return settings?.logProviderIds ?? [];
};
const syncVectorConfig = async (
organizationId: string,
serverId?: string,
logProviderIds?: string[],
) => {
const { VECTOR_PATH } = paths(!!serverId);
const configPath = `${VECTOR_PATH}/vector.yaml`;
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate.${nanoid(10)}`;
const ids = logProviderIds ?? (await getServerLogProviderIds(serverId));
const providers = await loadEnabledLogProviders(organizationId, ids);
const yamlStr = await buildVectorConfigYaml(providers, {
organizationId: serverId ? undefined : organizationId,
});
const writeCommand = `umask 077 && mkdir -p ${quote([`${VECTOR_PATH}/data`])} && chmod 700 ${quote([VECTOR_PATH])} ${quote([`${VECTOR_PATH}/data`])} && echo "${encodeBase64(yamlStr)}" | base64 -d > ${quote([candidatePath])}`;
if (serverId) {
await execAsyncRemote(serverId, writeCommand);
} else {
await execAsync(writeCommand);
}
const validateCommand = `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${quote([`${candidatePath}:/etc/vector/vector.yaml:ro`])} ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`;
try {
if (serverId) {
await execAsyncRemote(serverId, validateCommand);
} else {
await execAsync(validateCommand);
}
} 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(providers),
);
const cleanupCommand = `rm -f ${quote([candidatePath])}`;
try {
if (serverId) {
await execAsyncRemote(serverId, cleanupCommand);
} else {
await execAsync(cleanupCommand);
}
} catch (cleanupError) {
console.error(
`[Vector] Failed to remove invalid config candidate ${candidatePath}${
serverId ? ` on server ${serverId}` : ""
}:`,
cleanupError,
);
}
throw new Error(
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
);
}
const applyCommand = `mv ${quote([candidatePath])} ${quote([configPath])}`;
if (serverId) {
await execAsyncRemote(serverId, applyCommand);
} else {
await execAsync(applyCommand);
}
};
const deployVectorService = async (serverId?: string) => {
const { VECTOR_PATH } = paths(!!serverId);
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: VECTOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: VECTOR_IMAGE,
Args: ["--config", VECTOR_CONFIG_CONTAINER_PATH, "--watch-config"],
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" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
try {
const service = docker.getService(VECTOR_SERVICE_NAME);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index, 10),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1,
},
});
console.log("Vector Updated ✅");
} catch (_) {
try {
await docker.createService(settings);
console.log("Vector Started ✅");
} catch (error: any) {
if (error?.statusCode !== 409) {
throw error;
}
console.log("Vector service already exists, continuing...");
}
}
};
const vectorTargetLocks = new Map<string, Promise<unknown>>();
// In-process only: Dokploy runs a single dashboard process per host.
export const withVectorTargetLock = async <T>(
serverId: string | undefined,
fn: () => Promise<T>,
): Promise<T> => {
const key = serverId ?? "local";
const previous = vectorTargetLocks.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(fn);
vectorTargetLocks.set(key, current);
try {
return await current;
} finally {
if (vectorTargetLocks.get(key) === current) {
vectorTargetLocks.delete(key);
}
}
};
export const setupVectorAgent = async (
organizationId: string,
serverId?: string,
logProviderIds?: string[],
) => {
await syncVectorConfig(organizationId, serverId, logProviderIds);
await deployVectorService(serverId);
};
export const removeVectorAgent = async (serverId?: string) => {
if (serverId) {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`Server ${serverId} has no SSH key`);
}
}
const docker = await getRemoteDocker(serverId);
try {
await docker.getService(VECTOR_SERVICE_NAME).remove();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
const { VECTOR_PATH } = paths(!!serverId);
const cleanupCommand = `rm -rf ${quote([VECTOR_PATH])}`;
if (serverId) {
await execAsyncRemote(serverId, cleanupCommand);
} else {
await execAsync(cleanupCommand);
}
};
type VectorStatus = "running" | "not-running" | "stopped" | "unknown";
const inspectVectorStatus = async (
serverId?: string,
): Promise<VectorStatus> => {
const docker = await getRemoteDocker(serverId);
// listTasks returns [] instead of 404 for a missing service, so inspect first.
try {
await docker.getService(VECTOR_SERVICE_NAME).inspect();
} catch (error: any) {
if (error?.statusCode === 404) {
return "stopped";
}
throw error;
}
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [VECTOR_SERVICE_NAME],
"desired-state": ["running"],
}),
});
return tasks.some((task) => task.Status?.State === "running")
? "running"
: "not-running";
};
const VECTOR_CHECK_TIMEOUT_MS = 5000;
const vectorStatus = async (serverId: string | null): Promise<VectorStatus> => {
try {
return await Promise.race([
inspectVectorStatus(serverId ?? undefined),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timeout")), VECTOR_CHECK_TIMEOUT_MS),
),
]);
} catch {
return "unknown";
}
};
export const getLogManagementServerStatus = async (session: {
userId: string;
activeOrganizationId: string;
}) => {
const organizationId = session.activeOrganizationId;
const [allServers, settings, accessibleIds] = await Promise.all([
findServersByOrganizationForLogManagement(organizationId),
IS_CLOUD ? Promise.resolve(null) : getWebServerSettings(),
getAccessibleServerIds(session),
]);
const servers = allServers.filter((s) => accessibleIds.has(s.serverId));
const webOwnsThisOrg =
!!settings &&
(settings.logManagementOrganizationId === null ||
settings.logManagementOrganizationId === organizationId);
const targets: Array<{
serverId: string | null;
name: string;
ipAddress: string | null;
logProviderIds: string[];
}> = [
...(settings && webOwnsThisOrg
? [
{
serverId: null,
name: "Dokploy Server (local)",
ipAddress: null,
logProviderIds: settings.logProviderIds ?? [],
},
]
: []),
...servers.map((s) => ({
serverId: s.serverId,
name: s.name,
ipAddress: s.ipAddress,
logProviderIds: s.logProviderIds ?? [],
})),
];
return Promise.all(
targets.map(async (target) => ({
...target,
status: await vectorStatus(target.serverId),
})),
);
};
+1 -13
View File
@@ -3,7 +3,6 @@ import { findRegistryByIdWithCredentials } from "@dokploy/server/services/regist
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -151,18 +150,7 @@ export const mechanizeDockerContainer = async (
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: application.environment.project.organizationId,
projectId: application.environment.projectId,
projectName: application.environment.project.name,
environmentId: application.environmentId,
environmentName: application.environment.name,
applicationId: application.applicationId,
applicationName: application.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions, PortConfig } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -99,18 +98,7 @@ export const buildLibsql = async (rawLibsql: LibsqlNested) => {
Args: ["-c", finalCommand],
}
: {}),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: libsql.environment.project.organizationId,
projectId: libsql.environment.projectId,
projectName: libsql.environment.project.name,
environmentId: libsql.environmentId,
environmentName: libsql.environment.name,
applicationId: libsql.libsqlId,
applicationName: libsql.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -90,18 +89,7 @@ export const buildMariadb = async (rawMariadb: MariadbNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mariadb.environment.project.organizationId,
projectId: mariadb.environment.projectId,
projectName: mariadb.environment.project.name,
environmentId: mariadb.environmentId,
environmentName: mariadb.environment.name,
applicationId: mariadb.mariadbId,
applicationName: mariadb.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -146,18 +145,7 @@ ${command ?? "wait $MONGOD_PID"}`;
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mongo.environment.project.organizationId,
projectId: mongo.environment.projectId,
projectName: mongo.environment.project.name,
environmentId: mongo.environmentId,
environmentName: mongo.environment.name,
applicationId: mongo.mongoId,
applicationName: mongo.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -96,18 +95,7 @@ export const buildMysql = async (rawMysql: MysqlNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: mysql.environment.project.organizationId,
projectId: mysql.environment.projectId,
projectName: mysql.environment.project.name,
environmentId: mysql.environmentId,
environmentName: mysql.environment.name,
applicationId: mysql.mysqlId,
applicationName: mysql.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -88,18 +87,7 @@ export const buildPostgres = async (rawPostgres: PostgresNested) => {
Args: args,
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: postgres.environment.project.organizationId,
projectId: postgres.environment.projectId,
projectName: postgres.environment.project.name,
environmentId: postgres.environmentId,
environmentName: postgres.environment.name,
applicationId: postgres.postgresId,
applicationName: postgres.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
+1 -13
View File
@@ -1,7 +1,6 @@
import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode";
import { resolveServiceNetworks } from "../../services/network";
import { buildDokployLabels } from "../docker/dokploy-labels";
import {
calculateResources,
generateBindMounts,
@@ -94,18 +93,7 @@ export const buildRedis = async (rawRedis: RedisNested) => {
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
...(Ulimits && { Ulimits }),
Labels: {
...Labels,
...buildDokployLabels({
organizationId: redis.environment.project.organizationId,
projectId: redis.environment.projectId,
projectName: redis.environment.project.name,
environmentId: redis.environmentId,
environmentName: redis.environment.name,
applicationId: redis.redisId,
applicationName: redis.name,
}),
},
Labels,
},
Networks: resolvedNetworks,
RestartPolicy,
@@ -1,34 +0,0 @@
export interface DokployResourceMetadata {
organizationId: string;
projectId: string;
projectName: string;
environmentId: string;
environmentName: string;
applicationId: string;
applicationName: string;
serviceName?: string;
}
export const DOKPLOY_LABEL_PREFIX = "dokploy";
export const buildDokployLabels = (
metadata: DokployResourceMetadata,
): Record<string, string> => ({
[`${DOKPLOY_LABEL_PREFIX}.organization.id`]: metadata.organizationId,
[`${DOKPLOY_LABEL_PREFIX}.project.id`]: metadata.projectId,
[`${DOKPLOY_LABEL_PREFIX}.project`]: metadata.projectName,
[`${DOKPLOY_LABEL_PREFIX}.environment.id`]: metadata.environmentId,
[`${DOKPLOY_LABEL_PREFIX}.environment`]: metadata.environmentName,
[`${DOKPLOY_LABEL_PREFIX}.application.id`]: metadata.applicationId,
[`${DOKPLOY_LABEL_PREFIX}.application`]: metadata.applicationName,
...(metadata.serviceName
? { [`${DOKPLOY_LABEL_PREFIX}.service`]: metadata.serviceName }
: {}),
});
export const dokployLabelsAsList = (
metadata: DokployResourceMetadata,
): string[] =>
Object.entries(buildDokployLabels(metadata)).map(
([key, value]) => `${key}=${value}`,
);
+2 -63
View File
@@ -17,7 +17,6 @@ import { cloneGitlabRepository } from "../providers/gitlab";
import { getCreateComposeFileCommand } from "../providers/raw";
import { randomizeDeployableSpecificationFile } from "./collision";
import { randomizeSpecificationFile } from "./compose";
import { buildDokployLabels } from "./dokploy-labels";
import type {
ComposeSpecification,
DefinitionsService,
@@ -112,7 +111,7 @@ export const readComposeFile = async (compose: Compose) => {
};
export const writeDomainsToCompose = async (
compose: ComposeWithMetadata,
compose: Compose,
domains: Domain[],
) => {
try {
@@ -197,66 +196,8 @@ const removeDomainLabels = (
return labels;
};
type ComposeWithMetadata = Compose & {
environment?: {
environmentId: string;
name: string;
projectId: string;
project?: { name: string; organizationId: string };
};
};
const applyDokployLabelsToCompose = (
result: ComposeSpecification,
compose: ComposeWithMetadata,
) => {
const { environment } = compose;
if (!environment?.project) return;
for (const [serviceName, service] of Object.entries(result.services ?? {})) {
if (!service) continue;
const dokployLabels = buildDokployLabels({
organizationId: environment.project.organizationId,
projectId: environment.projectId,
projectName: environment.project.name,
environmentId: environment.environmentId,
environmentName: environment.name,
applicationId: compose.composeId,
applicationName: compose.name,
serviceName,
});
service.labels = mergeDokployLabels(service.labels, dokployLabels);
if (compose.composeType === "docker-compose") {
continue;
}
if (!service.deploy) {
service.deploy = {};
}
service.deploy.labels = mergeDokployLabels(
service.deploy.labels,
dokployLabels,
);
}
};
const mergeDokployLabels = (
current: DefinitionsService["labels"],
dokployLabels: Record<string, string>,
): DefinitionsService["labels"] => {
if (current && !Array.isArray(current)) {
return { ...current, ...dokployLabels };
}
const entries = Object.entries(dokployLabels);
const keys = new Set(entries.map(([key]) => key));
const kept = (current ?? []).filter(
(label) => !keys.has(label.slice(0, label.indexOf("="))),
);
return [...kept, ...entries.map(([key, value]) => `${key}=${value}`)];
};
export const addDomainToCompose = async (
compose: ComposeWithMetadata,
compose: Compose,
domains: Domain[],
) => {
const { appName } = compose;
@@ -385,8 +326,6 @@ export const addDomainToCompose = async (
}
}
applyDokployLabelsToCompose(result, compose);
const injectedNetworkNames = await applyServiceNetworks(result, compose);
if (!compose.isolatedDeployment) {
-246
View File
@@ -117,9 +117,6 @@ 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
@@ -599,9 +596,6 @@ 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
@@ -900,10 +894,6 @@ 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'}
@@ -920,74 +910,38 @@ 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'}
@@ -996,42 +950,22 @@ 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'}
@@ -4010,10 +3944,6 @@ 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'}
@@ -4022,18 +3952,10 @@ 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'}
@@ -4042,10 +3964,6 @@ 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==}
@@ -9397,17 +9315,6 @@ 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
@@ -9453,17 +9360,6 @@ 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
@@ -9472,14 +9368,6 @@ 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
@@ -9490,16 +9378,6 @@ 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
@@ -9516,22 +9394,6 @@ 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
@@ -9541,15 +9403,6 @@ 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
@@ -9564,20 +9417,6 @@ 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
@@ -9586,14 +9425,6 @@ 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
@@ -9604,16 +9435,6 @@ 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
@@ -9623,15 +9444,6 @@ 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
@@ -9649,17 +9461,6 @@ 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
@@ -9667,13 +9468,6 @@ 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
@@ -9683,35 +9477,16 @@ 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':
@@ -13045,11 +12820,6 @@ 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
@@ -13062,24 +12832,12 @@ 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
@@ -13090,10 +12848,6 @@ 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': {}