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