mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-18 06:31:35 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c8d80cde | |||
| 49d250aaa9 | |||
| bef77ac84f | |||
| a67d9e3522 | |||
| e38a980d9f | |||
| 829f8af63f | |||
| 41cecde735 | |||
| 3e75dc64f3 | |||
| 7430afd701 | |||
| 837730038c | |||
| 53464e9096 | |||
| 136a09d016 | |||
| c867a3c4c9 | |||
| 471e91caeb | |||
| 16c22c74fd | |||
| 37df4e484e | |||
| 5ff8d05ff7 | |||
| a9871f3a8d | |||
| 0049421095 | |||
| 3d67665c33 | |||
| 2bfcac58b0 | |||
| a7d8378f52 | |||
| bdefc02e77 | |||
| 293e19703a | |||
| cd9edb290b | |||
| b5473e63fd | |||
| e0006758c0 | |||
| d9fe046396 | |||
| bfe24f89f9 | |||
| 502d6c8a18 | |||
| 9e87b9e401 | |||
| a7d8dff360 | |||
| 94f3815eb4 | |||
| 931f5c1335 | |||
| aae2dd9377 | |||
| fe3912dc4b | |||
| ccd26e946b | |||
| 29107d88fc | |||
| c7ee0430f7 | |||
| 3cc8158fdc | |||
| 726664ab3c | |||
| 20701ba272 | |||
| 412516d47b | |||
| b4681b318f | |||
| 0898642e32 | |||
| d2779136c5 | |||
| 0b537a815d | |||
| b329595370 | |||
| 2674eb200d | |||
| e8f236c9db | |||
| 749bb2c1ce | |||
| 9992659f24 | |||
| 88cb057e06 | |||
| 7289a3f68e | |||
| 949ac8e0a2 | |||
| 5ccc15f00d | |||
| 5e0b021ec2 | |||
| 1f1acacf58 |
@@ -26,7 +26,7 @@ Dokploy includes multiple features to make your life easier.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
||||
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, tagged by project/environment/application.
|
||||
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
|
||||
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
||||
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inngest": "3.40.1",
|
||||
"inngest": "3.54.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.11.7",
|
||||
"hono": "^4.13.5",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "19.2.7",
|
||||
|
||||
@@ -74,4 +74,60 @@ volumes:
|
||||
);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
|
||||
const compose = {
|
||||
appName: "dollar-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
mapped:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
custom: keep
|
||||
deploy:
|
||||
labels:
|
||||
custom: keep
|
||||
listed:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
- custom=keep
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "app $DB_PASSWORD",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "env ${HOME}",
|
||||
projectId: "project-1",
|
||||
project: { name: "Budget $100", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: null,
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
const escaped = {
|
||||
"dokploy.project": "Budget $$100",
|
||||
"dokploy.environment": "env $${HOME}",
|
||||
"dokploy.application": "app $$DB_PASSWORD",
|
||||
};
|
||||
|
||||
const mapped = converted?.services?.mapped;
|
||||
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
|
||||
expect(mapped?.deploy?.labels).toMatchObject({
|
||||
custom: "keep",
|
||||
...escaped,
|
||||
});
|
||||
const listed = converted?.services?.listed;
|
||||
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
|
||||
expect(listed?.labels).toEqual(
|
||||
expect.arrayContaining(["custom=keep", ...asList]),
|
||||
);
|
||||
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
|
||||
it("returns only the configured zone without listing every zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the configured zone instead of listing all zones", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess({ name: "example.com" }));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient with a configured zone", () => {
|
||||
const record = {
|
||||
type: "A" as const,
|
||||
name: "app.other.org",
|
||||
content: "1.2.3.4",
|
||||
};
|
||||
|
||||
it("refuses record operations on any other zone without calling OVH", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const message = /restricted to the zone "example.com"/;
|
||||
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
|
||||
).rejects.toThrow(message);
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "other.org", "1", record),
|
||||
).rejects.toThrow(message);
|
||||
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still allows operations on the configured zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(apiCalls()[0]?.[0]).toBe(
|
||||
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("decodes octal escapes such as \\052 for wildcard names", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "\\052.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]).toMatchObject({
|
||||
id: "A:*.example.com",
|
||||
name: "*.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps escaped dots so they stay distinct from label separators", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "a\\056b.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
{
|
||||
Name: "a.b.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "5.6.7.8" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records.map((r) => r.id)).toEqual([
|
||||
"A:a\\056b.example.com",
|
||||
"A:a.b.example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
@@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a wildcard record set that Route53 returns escaped", async () => {
|
||||
const existingSet = {
|
||||
Name: "\\052.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
};
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.deleteRecord(config, "Z123", "A:*.example.com");
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the record no longer exists", async () => {
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
provider: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitlab", () => ({
|
||||
findGitlabById: vi.fn(async () => ({ ...store.provider })),
|
||||
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
|
||||
store.provider = { ...store.provider, ...values };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { refreshGitlabToken } = await import(
|
||||
"@dokploy/server/utils/providers/gitlab"
|
||||
);
|
||||
|
||||
const GITLAB_ID = "gitlab-1";
|
||||
|
||||
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
|
||||
const createGitlab = () => {
|
||||
let validRefreshToken = "refresh-1";
|
||||
let issued = 1;
|
||||
let failNext = false;
|
||||
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
// Keeps the request open, so concurrent callers overlap as two deployments do
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.get("refresh_token") !== validRefreshToken) {
|
||||
return new Response(null, { status: 400, statusText: "Bad Request" });
|
||||
}
|
||||
|
||||
issued++;
|
||||
validRefreshToken = `refresh-${issued}`;
|
||||
return Response.json({
|
||||
access_token: `access-${issued}`,
|
||||
refresh_token: validRefreshToken,
|
||||
expires_in: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fetch,
|
||||
failNextRequest: () => {
|
||||
failNext = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("refreshGitlabToken", () => {
|
||||
let gitlab: ReturnType<typeof createGitlab>;
|
||||
|
||||
beforeEach(() => {
|
||||
store.provider = {
|
||||
gitlabId: GITLAB_ID,
|
||||
gitlabUrl: "https://gitlab.example.com",
|
||||
gitlabInternalUrl: null,
|
||||
applicationId: "app-id",
|
||||
secret: "app-secret",
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 0,
|
||||
};
|
||||
gitlab = createGitlab();
|
||||
vi.stubGlobal("fetch", gitlab.fetch);
|
||||
});
|
||||
|
||||
it("sends one refresh request when two callers find the token expired at the same time", async () => {
|
||||
await Promise.all([
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
]);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
|
||||
it("refreshes again after a failed refresh", async () => {
|
||||
gitlab.failNextRequest();
|
||||
|
||||
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
|
||||
"Failed to refresh token: Internal Server Error",
|
||||
);
|
||||
await refreshGitlabToken(GITLAB_ID);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: mocks.lookup,
|
||||
}));
|
||||
|
||||
const loadFetch = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
const { logProviderFetch } = await import(
|
||||
"@dokploy/server/services/log-management/types"
|
||||
);
|
||||
return logProviderFetch;
|
||||
};
|
||||
|
||||
describe("logProviderFetch — blocked endpoint guard", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.lookup.mockReset();
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
await expect(
|
||||
logProviderFetch("http://169.254.169.254/latest/meta-data/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
|
||||
await expect(
|
||||
logProviderFetch(`http://${address}/latest/meta-data/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the AWS ECS metadata IPv6 address", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
await expect(
|
||||
logProviderFetch("http://[fd00:ec2::254]/v2/credentials"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
for (const address of [
|
||||
"[fe80::1]",
|
||||
"[fe90::1]",
|
||||
"[fea0::1]",
|
||||
"[febf::1]",
|
||||
]) {
|
||||
await expect(
|
||||
logProviderFetch(`http://${address}/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block a hostname just because it starts like a link-local prefix", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||
|
||||
await logProviderFetch("https://feature-logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
await expect(
|
||||
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
|
||||
|
||||
await expect(
|
||||
logProviderFetch("http://attacker-controlled.example.com/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a private-network endpoint when self-hosted (Loki behind a VPN, say)", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
|
||||
|
||||
await logProviderFetch("http://loki.internal.example.com:3100/ready");
|
||||
await logProviderFetch("http://10.0.0.5:3100/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects loopback and private-network endpoints in cloud, literal or resolved", async () => {
|
||||
const logProviderFetch = await loadFetch(true);
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
|
||||
|
||||
for (const url of [
|
||||
"http://10.0.0.5:3100/ready",
|
||||
"http://127.0.0.1:3100/ready",
|
||||
"http://localhost:3100/ready",
|
||||
"http://[::1]:3100/ready",
|
||||
"http://0.0.0.0:3100/ready",
|
||||
"http://100.100.100.200/latest/meta-data/",
|
||||
"http://[::]:3100/ready",
|
||||
"http://loki.internal.example.com:3100/ready",
|
||||
]) {
|
||||
await expect(
|
||||
logProviderFetch(url),
|
||||
`expected ${url} to be rejected`,
|
||||
).rejects.toThrow(/blocked/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a normal public endpoint", async () => {
|
||||
const logProviderFetch = await loadFetch(true);
|
||||
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||
|
||||
await logProviderFetch(
|
||||
"https://api.us-east-1.datadoghq.com/api/v1/validate",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not follow redirects", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||
|
||||
await logProviderFetch("https://logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://logs.example.com/ready",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
|
||||
const logProviderFetch = await loadFetch(false);
|
||||
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
|
||||
await logProviderFetch("http://does-not-exist.invalid/");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertValues: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
insert: () => ({
|
||||
values: (v: any) => ({
|
||||
returning: () => Promise.resolve(mocks.insertValues(v)),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (v: any) => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.updateSet(v)),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
logProvider: {
|
||||
findFirst: mocks.findFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { createLogProvider, updateLogProvider } = await import(
|
||||
"@dokploy/server/services/log-management/service"
|
||||
);
|
||||
|
||||
describe("createLogProvider — required credential fields per adapter", () => {
|
||||
it("rejects a loki provider without endpoint", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{ name: "loki-prod", providerType: "loki" } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider without apiKey", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{ name: "dd", providerType: "datadog" } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a betterstack provider missing either field", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
apiKey: "token-only",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a splunk_hec provider without a HEC token", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/token/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/region|log group/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
extraConfig: { apiKey: "sneaky-value" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/api key/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "es",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/password.*required/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
|
||||
]);
|
||||
const created = await createLogProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.logProviderId).toBe("lp-2");
|
||||
});
|
||||
|
||||
it("accepts a loki provider with endpoint", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
|
||||
]);
|
||||
const created = await createLogProvider(
|
||||
{
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.logProviderId).toBe("lp-1");
|
||||
expect(mocks.insertValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLogProvider — merges partial update with existing row before validating", () => {
|
||||
it("allows updating just the name without re-sending existing credentials", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "old-name",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([
|
||||
{ logProviderId: "lp-1", name: "new-name" },
|
||||
]);
|
||||
|
||||
const updated = await updateLogProvider("lp-1", { name: "new-name" });
|
||||
expect(updated.name).toBe("new-name");
|
||||
});
|
||||
|
||||
it("rejects clearing the only required field via update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(updateLogProvider("lp-1", { endpoint: null })).rejects.toThrow(
|
||||
/endpoint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.com" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", {
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic", index: "custom-index" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", { extraConfig: { username: "new-user" } });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraConfig: { username: "new-user", index: "custom-index" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", { extraConfig: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extraConfig: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an optional credential when it is explicitly set to null", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old-token",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "user" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", { apiKey: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "old-password",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
|
||||
await expect(updateLogProvider("lp-1", { apiKey: null })).rejects.toThrow(
|
||||
/Password/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateLogProvider("lp-1", { providerType: "loki" }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleted: [] as unknown[],
|
||||
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const tx = {
|
||||
delete: () => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.deleted),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
mocks.updates.push({ table, values });
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: { transaction: (fn: (tx: unknown) => unknown) => fn(tx) },
|
||||
};
|
||||
});
|
||||
|
||||
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
|
||||
const { removeLogProvider } = await import(
|
||||
"@dokploy/server/services/log-management/service"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
describe("removeLogProvider", () => {
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
});
|
||||
|
||||
it("drops the provider id from every server and from the local host", async () => {
|
||||
mocks.deleted = [{ logProviderId: "lp-1" }];
|
||||
|
||||
await removeLogProvider("lp-1");
|
||||
|
||||
expect(mocks.updates.map((u) => u.table)).toEqual([
|
||||
server,
|
||||
webServerSettings,
|
||||
]);
|
||||
expect(render(mocks.updates[0]?.values.logProviderIds)).toBe(
|
||||
'array_remove("server"."logProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.logProviderIds)).toBe(
|
||||
'array_remove("webServerSettings"."logProviderIds", $1)',
|
||||
);
|
||||
expect(mocks.updates[1]?.values).not.toHaveProperty(
|
||||
"logManagementOrganizationId",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch assignments when the provider does not exist", async () => {
|
||||
mocks.deleted = [];
|
||||
|
||||
await expect(removeLogProvider("missing")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.updates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
claimWebServerLogManagement: vi.fn(),
|
||||
releaseWebServerLogManagement: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
updateServerLogProviders: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
setupVectorAgent: vi.fn(),
|
||||
assertServerBelongsToOrg: vi.fn(),
|
||||
assertLogProvidersBelongToOrg: vi.fn(),
|
||||
filterExistingLogProviderIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const loadAgent = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
vi.doMock("@dokploy/server/services/server", () => ({
|
||||
updateServerLogProviders: mocks.updateServerLogProviders,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
|
||||
claimWebServerLogManagement: mocks.claimWebServerLogManagement,
|
||||
releaseWebServerLogManagement: mocks.releaseWebServerLogManagement,
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
setupVectorAgent: mocks.setupVectorAgent,
|
||||
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
|
||||
fn(),
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/log-management/service", () => ({
|
||||
assertServerBelongsToOrg: mocks.assertServerBelongsToOrg,
|
||||
assertLogProvidersBelongToOrg: mocks.assertLogProvidersBelongToOrg,
|
||||
filterExistingLogProviderIds: mocks.filterExistingLogProviderIds,
|
||||
}));
|
||||
return await import("@dokploy/server/services/log-management/vector-agent");
|
||||
};
|
||||
|
||||
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
|
||||
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
|
||||
|
||||
describe("local Vector agent guards", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.filterExistingLogProviderIds.mockImplementation(
|
||||
async (ids: string[]) => ids,
|
||||
);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
|
||||
});
|
||||
|
||||
it("does not remove the local agent when another organization owns it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
});
|
||||
const { removeLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeLogManagement(sessionB, undefined),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the local agent and only then releases the claim", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
});
|
||||
const { removeLogManagement } = await loadAgent(false);
|
||||
|
||||
await removeLogManagement(sessionA, undefined);
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
|
||||
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
|
||||
expect(mocks.removeVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.releaseWebServerLogManagement.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the claim when removing the local agent fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
});
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
const { removeLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(removeLogManagement(sessionA, undefined)).rejects.toThrow(
|
||||
"docker down",
|
||||
);
|
||||
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes an unclaimed local agent", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: null,
|
||||
});
|
||||
const { removeLogManagement } = await loadAgent(false);
|
||||
|
||||
await removeLogManagement(sessionA, undefined);
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("rejects deploying the local agent in cloud", async () => {
|
||||
const { deployLogManagement } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionA, undefined, ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.claimWebServerLogManagement).not.toHaveBeenCalled();
|
||||
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects removing the local agent in cloud", async () => {
|
||||
const { removeLogManagement } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
removeLogManagement(sessionA, undefined),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.getWebServerSettings).not.toHaveBeenCalled();
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still manages remote servers in cloud", async () => {
|
||||
const { removeLogManagement } = await loadAgent(true);
|
||||
|
||||
await removeLogManagement(sessionA, "server-1");
|
||||
expect(mocks.assertServerBelongsToOrg).toHaveBeenCalledWith(
|
||||
"server-1",
|
||||
"org-a",
|
||||
);
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote server access", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.filterExistingLogProviderIds.mockImplementation(
|
||||
async (ids: string[]) => ids,
|
||||
);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
|
||||
});
|
||||
|
||||
it("rejects deploying on a server the member cannot access", async () => {
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionA, "server-2", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
|
||||
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
|
||||
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects removing from a server the member cannot access", async () => {
|
||||
const { removeLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeLogManagement(sessionA, "server-2"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deploys on an accessible server", async () => {
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await deployLogManagement(sessionA, "server-1", ["lp-1"]);
|
||||
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
|
||||
"lp-1",
|
||||
]);
|
||||
expect(mocks.setupVectorAgent).toHaveBeenCalledWith("org-a", "server-1", [
|
||||
"lp-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not save a provider deleted while the server deploy ran", async () => {
|
||||
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await deployLogManagement(sessionA, "server-1", ["lp-1", "lp-2"]);
|
||||
expect(mocks.filterExistingLogProviderIds).toHaveBeenCalledWith([
|
||||
"lp-1",
|
||||
"lp-2",
|
||||
]);
|
||||
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
|
||||
"lp-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the saved providers when the server deploy fails", async () => {
|
||||
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionA, "server-1", ["lp-2"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent on server deletion even with no providers left", async () => {
|
||||
const { removeServerLogManagement } = await loadAgent(false);
|
||||
|
||||
await removeServerLogManagement({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
logProviderIds: [],
|
||||
} as never);
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local agent deploy", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.filterExistingLogProviderIds.mockImplementation(
|
||||
async (ids: string[]) => ids,
|
||||
);
|
||||
mocks.claimWebServerLogManagement.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("saves the new providers only after the deploy succeeds", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
logProviderIds: ["lp-1"],
|
||||
});
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await deployLogManagement(sessionA, undefined, ["lp-2"]);
|
||||
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"org-a",
|
||||
["lp-1"],
|
||||
);
|
||||
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"org-a",
|
||||
["lp-2"],
|
||||
);
|
||||
expect(mocks.setupVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.claimWebServerLogManagement.mock.invocationCallOrder[1] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not save a provider deleted while the local deploy ran", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
logProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await deployLogManagement(sessionA, undefined, ["lp-1", "lp-2"]);
|
||||
expect(mocks.claimWebServerLogManagement).toHaveBeenLastCalledWith(
|
||||
"org-a",
|
||||
["lp-1"],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the previous providers when a redeploy fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
logProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionA, undefined, ["lp-2"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledWith("org-a", [
|
||||
"lp-1",
|
||||
]);
|
||||
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the claim when the first local deploy fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: null,
|
||||
logProviderIds: [],
|
||||
});
|
||||
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionA, undefined, ["lp-1"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
|
||||
});
|
||||
|
||||
it("rejects deploying when another organization owns the local agent", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-a",
|
||||
logProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.claimWebServerLogManagement.mockResolvedValue(null);
|
||||
const { deployLogManagement } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
deployLogManagement(sessionB, undefined, ["lp-9"]),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { buildVectorConfigYaml } from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
describe("buildVectorConfigYaml", () => {
|
||||
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml([
|
||||
provider({
|
||||
logProviderId: "log-provider-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
]);
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.data_dir).toBe("/var/lib/vector");
|
||||
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
|
||||
expect(config.transforms.dokploy_scope.type).toBe("remap");
|
||||
expect(config.transforms.dokploy_scope.inputs).toEqual([
|
||||
"docker_logs_source",
|
||||
]);
|
||||
|
||||
const sinkIds = Object.keys(config.sinks);
|
||||
expect(sinkIds).toEqual(["sink_log-provider-1"]);
|
||||
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
|
||||
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
});
|
||||
|
||||
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml([
|
||||
provider({
|
||||
logProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
provider({
|
||||
logProviderId: "bs-1",
|
||||
name: "betterstack",
|
||||
providerType: "betterstack",
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
provider({
|
||||
logProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
apiKey: "dd-key",
|
||||
}),
|
||||
]);
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
expect(config.transforms["transform_bs-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
|
||||
expect(config.transforms["transform_dd-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
|
||||
});
|
||||
|
||||
it("reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml([]);
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toBe(
|
||||
[
|
||||
'.dokploy_organization = .label."dokploy.organization.id" || ""',
|
||||
'.dokploy_project = .label."dokploy.project" || ""',
|
||||
'.dokploy_project_id = .label."dokploy.project.id" || ""',
|
||||
'.dokploy_environment = .label."dokploy.environment" || ""',
|
||||
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
|
||||
'.dokploy_application = .label."dokploy.application" || ""',
|
||||
'.dokploy_application_id = .label."dokploy.application.id" || ""',
|
||||
'.dokploy_service = .label."dokploy.service" || ""',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml([
|
||||
provider({
|
||||
logProviderId: "broken-es",
|
||||
name: "es-broken",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
provider({
|
||||
logProviderId: "good-loki",
|
||||
name: "loki-ok",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
]);
|
||||
|
||||
const config = parse(yamlStr) as any;
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
|
||||
});
|
||||
|
||||
it("only keeps containers of the owning organization on the local host, which is shared by every organization", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml(
|
||||
[
|
||||
provider({
|
||||
logProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
],
|
||||
{ organizationId: "org-a" },
|
||||
);
|
||||
|
||||
const config = parse(yamlStr) as any;
|
||||
expect(config.transforms.dokploy_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add the organization filter for the per-server (non-local) path", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml([]);
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
getRemoteDocker: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
findServersByOrganizationForLogManagement: vi.fn(),
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: mocks.getRemoteDocker,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
const { removeVectorAgent } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
describe("removeVectorAgent on a server without SSH key", () => {
|
||||
it("refuses instead of falling back to the local docker socket", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
});
|
||||
|
||||
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
|
||||
expect(mocks.getRemoteDocker).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { projects: { findMany: vi.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const { collectSecretValues, redactSecrets } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
describe("collectSecretValues / redactSecrets", () => {
|
||||
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "loki-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { tenantId: "tenant-a" },
|
||||
},
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
expect(collectSecretValues(providers)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://loki.example.com",
|
||||
"loki-key",
|
||||
"tenant-a",
|
||||
"dd-key",
|
||||
"datadoghq.eu",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-string extraConfig values and null/empty credentials", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { limit: 10, enabled: true, empty: "" },
|
||||
},
|
||||
] as any;
|
||||
expect(collectSecretValues(providers)).toEqual([]);
|
||||
});
|
||||
|
||||
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: "hunter2-long-suffix",
|
||||
apiSecret: null,
|
||||
extraConfig: { other: "hunter2" },
|
||||
},
|
||||
] as any;
|
||||
const text =
|
||||
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
|
||||
|
||||
const redacted = redactSecrets(text, collectSecretValues(providers));
|
||||
|
||||
expect(redacted).not.toContain("hunter2");
|
||||
expect(redacted).toBe(
|
||||
"sink validation error near apiKey=[redacted] and [redacted]",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTasks: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
findServersByOrganizationForLogManagement: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
findServersByOrganizationForLogManagement:
|
||||
mocks.findServersByOrganizationForLogManagement,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
listTasks: mocks.listTasks,
|
||||
getService: () => ({ inspect: mocks.inspectService }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getLogManagementServerStatus, withVectorTargetLock } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const session = { userId: "user-1", activeOrganizationId: "org-1" };
|
||||
|
||||
describe("getLogManagementServerStatus", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([]);
|
||||
mocks.inspectService.mockResolvedValue({});
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
logManagementOrganizationId: "org-1",
|
||||
logProviderIds: ["lp-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports running only when a task of the service is running", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getLogManagementServerStatus(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
service: ["dokploy-vector"],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not-running when the service has no running task", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
|
||||
|
||||
const [local] = await getLogManagementServerStatus(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
});
|
||||
|
||||
it("reports stopped when the service does not exist", async () => {
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("not found"), { statusCode: 404 }),
|
||||
);
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getLogManagementServerStatus(session);
|
||||
expect(local?.status).toBe("stopped");
|
||||
expect(mocks.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown on any other docker error", async () => {
|
||||
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
|
||||
const [local] = await getLogManagementServerStatus(session);
|
||||
expect(local?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("hides servers the member cannot access", async () => {
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([
|
||||
{ serverId: "server-1", name: "one", ipAddress: "1.1.1.1" },
|
||||
{ serverId: "server-2", name: "two", ipAddress: "2.2.2.2" },
|
||||
]);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
|
||||
|
||||
const targets = await getLogManagementServerStatus(session);
|
||||
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withVectorTargetLock", () => {
|
||||
it("runs operations on the same target one after another", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const first = withVectorTargetLock("server-1", async () => {
|
||||
events.push("first:start");
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
events.push("first:end");
|
||||
throw new Error("first failed");
|
||||
});
|
||||
const second = withVectorTargetLock("server-1", async () => {
|
||||
events.push("second");
|
||||
});
|
||||
const other = withVectorTargetLock("server-2", async () => {
|
||||
events.push("other");
|
||||
});
|
||||
|
||||
await other;
|
||||
expect(events).toEqual(["first:start", "other"]);
|
||||
releaseFirst();
|
||||
await expect(first).rejects.toThrow("first failed");
|
||||
await second;
|
||||
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findOwnedOrganizations: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
removeVectorAgents: vi.fn(),
|
||||
calls: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { organization: { findMany: mocks.findOwnedOrganizations } },
|
||||
transaction: mocks.transaction,
|
||||
},
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/organization", () => ({
|
||||
removeVectorAgents: mocks.removeVectorAgents,
|
||||
}));
|
||||
|
||||
const { deleteUserAccountData } = await import(
|
||||
"@dokploy/server/services/account-deletion"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.calls.length = 0;
|
||||
mocks.findOwnedOrganizations.mockReset();
|
||||
mocks.removeVectorAgents
|
||||
.mockReset()
|
||||
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
|
||||
mocks.transaction.mockReset().mockImplementation(async () => {
|
||||
mocks.calls.push("delete");
|
||||
return { email: "a@b.c", organizationsDeleted: 2 };
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUserAccountData", () => {
|
||||
it("removes the Vector agents of every owned organization before deleting the user", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([
|
||||
{ id: "org-a" },
|
||||
{ id: "org-b" },
|
||||
]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
|
||||
});
|
||||
|
||||
it("deletes the user without touching agents when it owns no organization", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
|
||||
expect(mocks.calls).toEqual(["delete"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import {
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const aws = vi.hoisted(() => ({
|
||||
describeLogGroups: vi.fn(),
|
||||
putMetricData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: async () => [{ address: "93.184.216.34" }],
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
|
||||
CloudWatchLogsClient: class {
|
||||
send = aws.describeLogGroups;
|
||||
},
|
||||
DescribeLogGroupsCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch", () => ({
|
||||
CloudWatchClient: class {
|
||||
send = aws.putMetricData;
|
||||
},
|
||||
PutMetricDataCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["metrics"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const respond = (status: number, body: unknown = "") =>
|
||||
new Response(
|
||||
status === 204
|
||||
? null
|
||||
: typeof body === "string"
|
||||
? body
|
||||
: JSON.stringify(body),
|
||||
{ status },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
aws.describeLogGroups.mockReset();
|
||||
aws.putMetricData.mockReset();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
describe("registry", () => {
|
||||
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
|
||||
const types = [
|
||||
"loki",
|
||||
"prometheus_remote_write",
|
||||
"new_relic",
|
||||
"influxdb",
|
||||
"datadog",
|
||||
"aws_cloudwatch",
|
||||
"splunk_hec",
|
||||
"elasticsearch",
|
||||
"betterstack",
|
||||
] as const;
|
||||
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
|
||||
[...types].sort(),
|
||||
);
|
||||
for (const type of types) {
|
||||
const adapter = getTelemetryProviderAdapter(type);
|
||||
expect(adapter.type).toBe(type);
|
||||
expect(adapter.signals.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
adapter.signals.every((s) => s === "logs" || s === "metrics"),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
|
||||
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
|
||||
"metrics",
|
||||
]);
|
||||
expect(telemetryProviderAdapters.datadog.signals).toEqual([
|
||||
"logs",
|
||||
"metrics",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prometheusRemoteWriteAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.prometheus_remote_write;
|
||||
const endpoint = "http://prom.example.com:9090/api/v1/write";
|
||||
|
||||
it("builds a sink without auth, with the healthcheck disabled", () => {
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint },
|
||||
"sink_1",
|
||||
"dokploy_metrics_scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(sink).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
endpoint,
|
||||
healthcheck: { enabled: false },
|
||||
});
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
|
||||
const basic = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "12345", tenantId: "team-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(basic.auth).toEqual({
|
||||
strategy: "basic",
|
||||
user: "12345",
|
||||
password: "pw",
|
||||
});
|
||||
expect(basic.tenant_id).toBe("team-a");
|
||||
|
||||
const bearer = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint, apiKey: "tok" },
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
|
||||
});
|
||||
|
||||
it("rejects an endpoint that is not an http(s) URL", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
});
|
||||
|
||||
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(400));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
|
||||
).rejects.toThrow(/rejected the credentials \(status 401\)/);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).rejects.toThrow(/not found at this URL \(status 404\)/);
|
||||
});
|
||||
|
||||
it("sends the auth header and the tenant header on the test request", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await adapter.testConnection?.({
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "tok",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
});
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Scope-OrgID": "team-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("newRelicAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.new_relic;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123" },
|
||||
};
|
||||
|
||||
it("defaults to the us region and accepts eu", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "new_relic",
|
||||
api: "metrics",
|
||||
license_key: "license",
|
||||
account_id: "123",
|
||||
region: "us",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).region,
|
||||
).toBe("eu");
|
||||
});
|
||||
|
||||
it("rejects an unknown region", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "jp" },
|
||||
}),
|
||||
).toThrow(/"us" or "eu"/);
|
||||
});
|
||||
|
||||
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(202));
|
||||
await expect(
|
||||
adapter.testConnection?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"https://metric-api.eu.newrelic.com/metric/v1",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(403));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the license key/,
|
||||
);
|
||||
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("influxdbAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.influxdb;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
};
|
||||
|
||||
it("builds an influxdb_metrics v2 sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
|
||||
type: "influxdb_metrics",
|
||||
inputs: ["scope"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
org: "dokploy",
|
||||
bucket: "metrics",
|
||||
token: "token",
|
||||
default_namespace: "dokploy",
|
||||
version: "2",
|
||||
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the token/,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/organization or bucket not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
signals: ["logs", "metrics"] as const,
|
||||
apiKey: "dd",
|
||||
};
|
||||
|
||||
it("ships metrics through datadog_metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
|
||||
).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["scope"],
|
||||
default_api_key: "dd",
|
||||
site: "datadoghq.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the logs sink and ddtags transform unchanged", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
|
||||
).toMatchObject({ type: "remap", inputs: ["scope"] });
|
||||
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
|
||||
"datadog_logs",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
};
|
||||
|
||||
it("posts metrics to /metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "http",
|
||||
uri: "https://in.logs.betterstack.com/metrics",
|
||||
auth: { strategy: "bearer", token: "token" },
|
||||
},
|
||||
);
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
|
||||
"https://in.logs.betterstack.com/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
|
||||
fetchMock.mockResolvedValue(respond(202));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://in.logs.betterstack.com/",
|
||||
"https://in.logs.betterstack.com/metrics",
|
||||
]);
|
||||
|
||||
fetchMock.mockClear();
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
};
|
||||
|
||||
it("scopes the log fields to logs and the namespace to metrics", () => {
|
||||
const bySignal = Object.fromEntries(
|
||||
adapter.credentialFields.map((f) => [f.key, f.signal]),
|
||||
);
|
||||
expect(bySignal).toMatchObject({
|
||||
logGroup: "logs",
|
||||
logStream: "logs",
|
||||
metricsNamespace: "metrics",
|
||||
});
|
||||
expect(bySignal.region).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
|
||||
const bad = {
|
||||
...config,
|
||||
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
|
||||
};
|
||||
expect(() => adapter.validateConfig?.(bad)).toThrow(
|
||||
/cannot start with AWS\//,
|
||||
);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
config,
|
||||
"t",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(transform).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["scope"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(transform?.source).toContain(
|
||||
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
|
||||
);
|
||||
expect(transform?.source).toContain('.namespace = "Dokploy"');
|
||||
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
|
||||
type: "aws_cloudwatch_metrics",
|
||||
inputs: ["t"],
|
||||
default_namespace: "Dokploy",
|
||||
region: "us-east-1",
|
||||
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
|
||||
aws.putMetricData.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
|
||||
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
|
||||
expect(aws.describeLogGroups).not.toHaveBeenCalled();
|
||||
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
|
||||
input: { Namespace: "Dokploy" },
|
||||
});
|
||||
|
||||
aws.putMetricData.mockRejectedValue(
|
||||
Object.assign(new Error("denied"), { name: "AccessDenied" }),
|
||||
);
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
|
||||
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
|
||||
});
|
||||
|
||||
it("tests logs with DescribeLogGroups only", async () => {
|
||||
aws.describeLogGroups.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
|
||||
expect(aws.putMetricData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { metricsIndex: "dokploy_metrics" },
|
||||
};
|
||||
|
||||
it("requires the metrics index only for metrics", () => {
|
||||
const field = adapter.credentialFields.find(
|
||||
(f) => f.key === "metricsIndex",
|
||||
);
|
||||
expect(field).toMatchObject({ required: true, signal: "metrics" });
|
||||
expect(
|
||||
adapter.credentialFields.find((f) => f.key === "index")?.signal,
|
||||
).toBe("logs");
|
||||
});
|
||||
|
||||
it("builds a splunk_hec_metrics sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "splunk_hec_metrics",
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
default_token: "hec",
|
||||
index: "dokploy_metrics",
|
||||
source: "dokploy",
|
||||
sourcetype: "dokploy:metrics",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a test metric and translates HEC code 7", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector",
|
||||
]);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
respond(400, { text: "Incorrect index", code: 7 }),
|
||||
);
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/can't write to the metrics index "dokploy_metrics"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("only checks the health endpoint for logs", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector/health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
|
||||
|
||||
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "elasticsearch",
|
||||
api_version: "v8",
|
||||
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).bulk,
|
||||
).toEqual({ index: "metrics-custom" });
|
||||
});
|
||||
|
||||
it("keeps the logs sink without a bulk index unless one is set", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
releaseWebServerAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { delete: () => ({ where: mocks.deleteWhere }) },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
releaseWebServerAgent: mocks.releaseWebServerAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/setup/vector-setup", () => ({
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
|
||||
fn(),
|
||||
}));
|
||||
|
||||
const { deleteOrganization } = await import(
|
||||
"@dokploy/server/services/organization"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.deleteWhere.mockResolvedValue({ count: 1 });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("deleteOrganization", () => {
|
||||
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-3", telemetryProviderIds: [] },
|
||||
]);
|
||||
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-1") throw new Error("ssh down");
|
||||
});
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the local agent alone when another organization owns it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-b",
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the local agent even when removing it fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
|
||||
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
lookup: mocks.lookup,
|
||||
}));
|
||||
|
||||
const loadModule = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
return await import(
|
||||
"@dokploy/server/services/logs-and-metrics/provider-fetch"
|
||||
);
|
||||
};
|
||||
|
||||
const loadFetch = async (isCloud: boolean) =>
|
||||
(await loadModule(isCloud)).providerFetch;
|
||||
|
||||
const resolvesTo = (...addresses: string[]) =>
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(
|
||||
null,
|
||||
addresses.map((address) => ({
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
describe("providerFetch — blocked endpoint guard", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.lookup.mockReset();
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://169.254.169.254/latest/meta-data/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/latest/meta-data/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the AWS ECS metadata IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://[fd00:ec2::254]/v2/credentials"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of [
|
||||
"[fe80::1]",
|
||||
"[fe90::1]",
|
||||
"[fea0::1]",
|
||||
"[febf::1]",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block a hostname just because it starts like a link-local prefix", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://feature-logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects loopback and private-network endpoints in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
|
||||
for (const url of [
|
||||
"http://10.0.0.5:3100/ready",
|
||||
"http://127.0.0.1:3100/ready",
|
||||
"http://localhost:3100/ready",
|
||||
"http://[::1]:3100/ready",
|
||||
"http://0.0.0.0:3100/ready",
|
||||
"http://100.100.100.200/latest/meta-data/",
|
||||
"http://[::]:3100/ready",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(url),
|
||||
`expected ${url} to be rejected`,
|
||||
).rejects.toThrow(/blocked/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not follow redirects", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://logs.example.com/ready",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerFetch — address checked at connect time", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let hostHeader: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.lookup.mockReset();
|
||||
hostHeader = undefined;
|
||||
server = createServer((req, res) => {
|
||||
hostHeader = req.headers.host;
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("rejects a hostname whose lookup returns a metadata address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("203.0.113.10", "169.254.169.254");
|
||||
|
||||
await expect(
|
||||
providerFetch("http://attacker-controlled.example.com/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(mocks.lookup).toHaveBeenCalledWith(
|
||||
"attacker-controlled.example.com",
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to a private address in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
await expect(
|
||||
providerFetch(`http://loki.internal.example.com:${port}/ready`),
|
||||
).rejects.toThrow(/blocked/i);
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when DNS resolution fails", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://does-not-exist.invalid/"),
|
||||
).rejects.toThrow();
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
const response = await providerFetch(
|
||||
`http://loki.internal.example.com:${port}/ready`,
|
||||
);
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
|
||||
});
|
||||
|
||||
it("passes a public address through in cloud", async () => {
|
||||
const { guardedLookup } = await loadModule(true);
|
||||
resolvesTo("203.0.113.10");
|
||||
|
||||
const result = await new Promise((resolve, reject) =>
|
||||
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
|
||||
error ? reject(error) : resolve(addresses),
|
||||
),
|
||||
);
|
||||
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
|
||||
|
||||
describe("providerLabel", () => {
|
||||
it("names what the provider sends next to its name", () => {
|
||||
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
|
||||
"Loki prod (logs)",
|
||||
);
|
||||
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
|
||||
"Splunk (metrics)",
|
||||
);
|
||||
expect(
|
||||
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
|
||||
).toBe("Datadog (logs and metrics)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleted: [] as unknown[],
|
||||
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
|
||||
updatedServers: [] as Array<{ serverId: string }>,
|
||||
updatedLocal: [] as Array<{ id: string }>,
|
||||
queried: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const tx = {
|
||||
delete: () => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.deleted),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
mocks.updates.push({ table, values });
|
||||
return Promise.resolve(
|
||||
mocks.updates.length === 1
|
||||
? mocks.updatedServers
|
||||
: mocks.updatedLocal,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
|
||||
query: {
|
||||
server: { findMany: mocks.queried },
|
||||
webServerSettings: { findFirst: mocks.queried },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
|
||||
const { removeTelemetryProvider } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/service"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
describe("removeTelemetryProvider", () => {
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }];
|
||||
mocks.updatedLocal = [{ id: "ws-1" }];
|
||||
mocks.queried.mockReset();
|
||||
});
|
||||
|
||||
it("drops the provider id from the selection of every server and of the local host", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
|
||||
const { provider, targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
|
||||
expect(mocks.updates.map((u) => u.table)).toEqual([
|
||||
server,
|
||||
webServerSettings,
|
||||
]);
|
||||
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("server"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
|
||||
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
|
||||
mocks.updatedLocal = [];
|
||||
|
||||
const { targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(targets).toEqual(["server-1", "server-2"]);
|
||||
expect(mocks.queried).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch assignments when the provider does not exist", async () => {
|
||||
mocks.deleted = [];
|
||||
|
||||
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.updates).toEqual([]);
|
||||
});
|
||||
});
|
||||
+75
-39
@@ -1,52 +1,60 @@
|
||||
import {
|
||||
getLogProviderAdapter,
|
||||
logProviderAdapters,
|
||||
} from "@dokploy/server/services/log-management/providers/registry";
|
||||
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseConfig: LogProviderRuntimeConfig = {
|
||||
logProviderId: "log-provider-1",
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
describe("getLogProviderAdapter", () => {
|
||||
describe("getTelemetryProviderAdapter", () => {
|
||||
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");
|
||||
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
|
||||
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
|
||||
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
|
||||
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
|
||||
"elasticsearch",
|
||||
);
|
||||
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
|
||||
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
|
||||
"aws_cloudwatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an explicit error for an unregistered type", () => {
|
||||
// @ts-expect-error: intentionally an unregistered type
|
||||
expect(() => getLogProviderAdapter("unknown")).toThrow(
|
||||
/No LogProviderAdapter registered/,
|
||||
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
|
||||
/No TelemetryProviderAdapter registered/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lokiAdapter.toVectorSink", () => {
|
||||
it("maps endpoint + labels, without tenant_id if not set", () => {
|
||||
const adapter = logProviderAdapters.loki;
|
||||
const adapter = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("loki");
|
||||
expect(sink.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(sink.endpoint).toBe("https://loki.example.com");
|
||||
expect(sink.labels).toMatchObject({
|
||||
dokploy_project: "{{ dokploy_project }}",
|
||||
dokploy_environment: "{{ dokploy_environment }}",
|
||||
dokploy_application: "{{ dokploy_application }}",
|
||||
dokploy_organization: "{{ dokploy_organization }}",
|
||||
dokploy_server: "{{ dokploy_server }}",
|
||||
});
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
expect(sink.buffer).toEqual({
|
||||
@@ -57,7 +65,7 @@ describe("lokiAdapter.toVectorSink", () => {
|
||||
});
|
||||
|
||||
it("includes tenant_id when present in extraConfig", () => {
|
||||
const adapter = logProviderAdapters.loki;
|
||||
const adapter = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -66,22 +74,24 @@ describe("lokiAdapter.toVectorSink", () => {
|
||||
},
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.tenant_id).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
||||
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter.toVectorSink", () => {
|
||||
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, apiKey: "dd-key" },
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("datadog_logs");
|
||||
expect(sink.default_api_key).toBe("dd-key");
|
||||
@@ -91,7 +101,7 @@ describe("datadogAdapter.toVectorSink", () => {
|
||||
});
|
||||
|
||||
it("uses extraConfig.site when present", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -100,12 +110,13 @@ describe("datadogAdapter.toVectorSink", () => {
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -114,29 +125,35 @@ describe("datadogAdapter.toVectorSink", () => {
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
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;
|
||||
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain(".ddtags");
|
||||
expect(transform?.source).toContain(
|
||||
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.source).toContain(
|
||||
'replace(to_string!(.dokploy_project), ",", "_")',
|
||||
@@ -146,12 +163,13 @@ describe("datadogAdapter.toVectorSink", () => {
|
||||
|
||||
describe("betterStackAdapter", () => {
|
||||
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
||||
const adapter = logProviderAdapters.betterstack;
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform).toEqual({
|
||||
type: "remap",
|
||||
@@ -161,7 +179,7 @@ describe("betterStackAdapter", () => {
|
||||
});
|
||||
|
||||
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
|
||||
const adapter = logProviderAdapters.betterstack;
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -170,6 +188,7 @@ describe("betterStackAdapter", () => {
|
||||
},
|
||||
"sink_3",
|
||||
"transform_1",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("http");
|
||||
expect(sink.inputs).toEqual(["transform_1"]);
|
||||
@@ -181,12 +200,13 @@ describe("betterStackAdapter", () => {
|
||||
|
||||
describe("elasticsearchAdapter", () => {
|
||||
it("has a toVectorTransform that flattens .label to a JSON string", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
@@ -194,7 +214,7 @@ describe("elasticsearchAdapter", () => {
|
||||
});
|
||||
|
||||
it("uses basic auth when a username is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -204,6 +224,7 @@ describe("elasticsearchAdapter", () => {
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("elasticsearch");
|
||||
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
|
||||
@@ -216,7 +237,7 @@ describe("elasticsearchAdapter", () => {
|
||||
});
|
||||
|
||||
it("falls back to an ApiKey header when no username is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -225,6 +246,7 @@ describe("elasticsearchAdapter", () => {
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.request).toEqual({
|
||||
@@ -233,7 +255,7 @@ describe("elasticsearchAdapter", () => {
|
||||
});
|
||||
|
||||
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
expect(() =>
|
||||
adapter.toVectorSink(
|
||||
{
|
||||
@@ -243,16 +265,18 @@ describe("elasticsearchAdapter", () => {
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toThrow(/Password.*required/i);
|
||||
});
|
||||
|
||||
it("includes bulk.index only when extraConfig.index is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const withoutIndex = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withoutIndex.bulk).toBeUndefined();
|
||||
const withIndex = adapter.toVectorSink(
|
||||
@@ -263,6 +287,7 @@ describe("elasticsearchAdapter", () => {
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
||||
});
|
||||
@@ -270,11 +295,13 @@ describe("elasticsearchAdapter", () => {
|
||||
|
||||
describe("splunkAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
|
||||
expect(
|
||||
telemetryProviderAdapters.splunk_hec.toVectorTransform,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
|
||||
const adapter = logProviderAdapters.splunk_hec;
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -283,6 +310,7 @@ describe("splunkAdapter", () => {
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("splunk_hec_logs");
|
||||
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
|
||||
@@ -300,6 +328,7 @@ describe("splunkAdapter", () => {
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withExtras.index).toBe("dokploy");
|
||||
expect(withExtras.sourcetype).toBe("docker");
|
||||
@@ -307,14 +336,19 @@ describe("splunkAdapter", () => {
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
logProviderAdapters.aws_cloudwatch.toVectorTransform,
|
||||
).toBeUndefined();
|
||||
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("toVectorSink maps access key/secret, region and log group/stream", () => {
|
||||
const adapter = logProviderAdapters.aws_cloudwatch;
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -324,6 +358,7 @@ describe("awsCloudwatchAdapter", () => {
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("aws_cloudwatch_logs");
|
||||
expect(sink.group_name).toBe("/dokploy/logs");
|
||||
@@ -336,7 +371,7 @@ describe("awsCloudwatchAdapter", () => {
|
||||
});
|
||||
|
||||
it("uses extraConfig.logStream as the stream template when set", () => {
|
||||
const adapter = logProviderAdapters.aws_cloudwatch;
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
@@ -350,6 +385,7 @@ describe("awsCloudwatchAdapter", () => {
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.stream_name).toBe("{{ dokploy_application }}");
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertValues: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
serverFindFirst: vi.fn(),
|
||||
serverFindMany: vi.fn(),
|
||||
webServerFindFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
insert: () => ({
|
||||
values: (v: any) => ({
|
||||
returning: () => Promise.resolve(mocks.insertValues(v)),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (v: any) => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.updateSet(v)),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
telemetryProvider: {
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
server: {
|
||||
findFirst: mocks.serverFindFirst,
|
||||
findMany: mocks.serverFindMany,
|
||||
},
|
||||
webServerSettings: { findFirst: mocks.webServerFindFirst },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
assertProvidersBelongToOrg,
|
||||
assertSignalsSupported,
|
||||
createTelemetryProvider,
|
||||
testTelemetryProviderConnection,
|
||||
updateTelemetryProvider,
|
||||
} = await import("@dokploy/server/services/logs-and-metrics/service");
|
||||
const { telemetryProviderAdapters } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/providers/registry"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.serverFindMany.mockResolvedValue([]);
|
||||
mocks.webServerFindFirst.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — targets to re-apply", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
});
|
||||
|
||||
it("returns the servers and the local host that ship with it when a credential changes", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
apiKey: "new",
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns the same targets when the provider is disabled", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
enabled: false,
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
|
||||
expect(
|
||||
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
endpoint: "https://loki.example.com",
|
||||
})
|
||||
).targets,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — required credential fields per adapter", () => {
|
||||
it("rejects a loki provider without endpoint", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider without apiKey", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a betterstack provider missing either field", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
apiKey: "token-only",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a splunk_hec provider without a HEC token", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/token/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/region|log group/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
extraConfig: { apiKey: "sneaky-value" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/api key/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "es",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/password.*required/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{
|
||||
telemetryProviderId: "lp-2",
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-2");
|
||||
});
|
||||
|
||||
it("accepts a loki provider with endpoint", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-1");
|
||||
expect(mocks.insertValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
|
||||
it("allows updating just the name without re-sending existing credentials", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "old-name",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "new-name" },
|
||||
]);
|
||||
|
||||
const { provider: updated, targets } = await updateTelemetryProvider(
|
||||
"lp-1",
|
||||
{ name: "new-name" },
|
||||
);
|
||||
expect(updated.name).toBe("new-name");
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects clearing the only required field via update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { endpoint: null }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
|
||||
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.com" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic", index: "custom-index" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
extraConfig: { username: "new-user" },
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraConfig: { username: "new-user", index: "custom-index" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { extraConfig: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extraConfig: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an optional credential when it is explicitly set to null", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old-token",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "user" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { apiKey: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "old-password",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { apiKey: null }),
|
||||
).rejects.toThrow(/Password/);
|
||||
});
|
||||
|
||||
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { providerType: "loki" }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSignalsSupported", () => {
|
||||
it("rejects a repeated signal", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
|
||||
/only be selected once/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a signal the type cannot send", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
|
||||
/Grafana Loki cannot send metrics/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a supported subset", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — signals", () => {
|
||||
it("rejects a provider whose type cannot send the selected signal", async () => {
|
||||
mocks.insertValues.mockClear();
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["metrics"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/cannot send metrics/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvidersBelongToOrg", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: true,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a provider of the organization regardless of what it sends", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-1"], "org-1"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an id that is not in the organization", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-9"], "org-1"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("rejects a selection where every provider is disabled", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: false,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
|
||||
/at least one enabled provider/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required fields scoped to a signal", () => {
|
||||
beforeEach(() => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
|
||||
});
|
||||
|
||||
it("does not require the log group when only metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("does not require the metrics namespace when only logs are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("requires the metrics namespace when metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/Metrics namespace/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signals are stored in a fixed order", () => {
|
||||
it("normalizes the order on create", async () => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["metrics", "logs"],
|
||||
apiKey: "dd-key",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes the order on update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testTelemetryProviderConnection by id", () => {
|
||||
it("tests the saved provider with the signals override from the form", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await testTelemetryProviderConnection({
|
||||
telemetryProviderId: "bs-1",
|
||||
signals: ["logs", "metrics"],
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "token",
|
||||
signals: ["logs", "metrics"],
|
||||
}),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — signals", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updateSet.mockReset();
|
||||
mocks.serverFindFirst.mockReset();
|
||||
mocks.webServerFindFirst.mockReset();
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
|
||||
});
|
||||
|
||||
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
|
||||
).resolves.toMatchObject({
|
||||
provider: { signals: ["metrics"] },
|
||||
targets: ["server-1", null],
|
||||
});
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a type change that cannot send the saved signals", async () => {
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", {
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
).rejects.toThrow(/Grafana Loki cannot send metrics/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setWebServerProviderIds: vi.fn(),
|
||||
setServerProviderIds: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
reconcileVectorAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
assertProvidersBelongToOrg: vi.fn(),
|
||||
}));
|
||||
|
||||
const loadAgent = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
vi.doMock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
setServerProviderIds: mocks.setServerProviderIds,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
|
||||
setWebServerProviderIds: mocks.setWebServerProviderIds,
|
||||
}));
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
|
||||
LOCAL_SERVER_NAME: "Dokploy Server (local)",
|
||||
reconcileVectorAgent: mocks.reconcileVectorAgent,
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (
|
||||
serverId: string | undefined,
|
||||
fn: () => Promise<unknown>,
|
||||
) => {
|
||||
const key = serverId ?? "local";
|
||||
const current = (locks.get(key) ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(fn);
|
||||
locks.set(key, current);
|
||||
return current;
|
||||
},
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
|
||||
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
|
||||
}));
|
||||
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
|
||||
};
|
||||
|
||||
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
|
||||
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
name: `name-of-${serverId}`,
|
||||
organizationId: "org-a",
|
||||
sshKeyId: "key-1",
|
||||
}));
|
||||
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
|
||||
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on the local host", () => {
|
||||
it("rejects the local host in cloud without touching the database", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
|
||||
mocks.setWebServerProviderIds.mockResolvedValue(null);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the selection and then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
|
||||
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
|
||||
["lp-1"],
|
||||
"org-a",
|
||||
);
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
|
||||
"lp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
expect(
|
||||
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
|
||||
});
|
||||
|
||||
it("removing the agent saves an empty selection without checking providers", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, []);
|
||||
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on a remote server", () => {
|
||||
it("still manages remote servers in cloud", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
|
||||
expect(result).toEqual({ serverName: "name-of-server-1" });
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("rejects a server of another organization", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-b",
|
||||
sshKeyId: "key-1",
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server without an SSH key before saving anything", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
sshKeyId: null,
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server the member cannot access", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the array and only then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the saved selection when the reconcile fails", async () => {
|
||||
mocks.reconcileVectorAgent.mockRejectedValue(
|
||||
new Error("validation failed"),
|
||||
);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"lp-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
|
||||
const saved: string[][] = [];
|
||||
const seen: string[][] = [];
|
||||
mocks.setServerProviderIds.mockImplementation(
|
||||
async (_serverId: string, ids: string[]) => {
|
||||
saved.push(ids);
|
||||
return { serverId: "server-1" };
|
||||
},
|
||||
);
|
||||
mocks.reconcileVectorAgent.mockImplementation(async () => {
|
||||
seen.push(saved.at(-1) ?? []);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await Promise.all([
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
]);
|
||||
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeServerVectorAgent", () => {
|
||||
it("removes the agent on server deletion when providers are assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("skips servers without an SSH key, warning when providers were assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toMatch(/no SSH key/);
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the failure as a message instead of throwing", async () => {
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "k",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBe("docker down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileVectorTargets", () => {
|
||||
it("re-applies every target under its lock and reports failures as a warning", async () => {
|
||||
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-2") throw new Error("ssh down");
|
||||
});
|
||||
const { reconcileVectorTargets } = await loadAgent(false);
|
||||
|
||||
const warning = await reconcileVectorTargets([
|
||||
"server-1",
|
||||
"server-2",
|
||||
null,
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
|
||||
"server-1",
|
||||
"server-2",
|
||||
undefined,
|
||||
]);
|
||||
expect(warning).toBe(
|
||||
"The Vector agent could not be re-applied on server-2: ssh down",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
|
||||
const { reconcileVectorTargets } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
reconcileVectorTargets(["server-1", null]),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import {
|
||||
buildMetricsScopeTransformSource,
|
||||
buildVectorConfigYaml,
|
||||
collectSecretValues,
|
||||
redactSecrets,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
signals: ["logs"],
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const loki = provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
const prom = provider({
|
||||
telemetryProviderId: "prom-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
});
|
||||
|
||||
const datadog = provider({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
});
|
||||
|
||||
const build = (
|
||||
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
|
||||
) =>
|
||||
parse(
|
||||
buildVectorConfigYaml({
|
||||
logProviders: [],
|
||||
metricsProviders: [],
|
||||
serverName: "my server",
|
||||
...overrides,
|
||||
}),
|
||||
) as any;
|
||||
|
||||
describe("buildVectorConfigYaml — logs", () => {
|
||||
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
expect(config.data_dir).toBe("/var/lib/vector");
|
||||
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
|
||||
expect(config.transforms.dokploy_scope.type).toBe("remap");
|
||||
expect(config.transforms.dokploy_scope.inputs).toEqual([
|
||||
"docker_logs_source",
|
||||
]);
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
expect(config.sinks["sink_loki-1"].type).toBe("loki");
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
loki,
|
||||
provider({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "betterstack",
|
||||
providerType: "betterstack",
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
datadog,
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
expect(config.transforms["transform_bs-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
|
||||
expect(config.transforms["transform_dd-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
|
||||
});
|
||||
|
||||
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
|
||||
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toBe(
|
||||
[
|
||||
'.dokploy_server = "srv \\"quoted\\""',
|
||||
'.dokploy_organization = .label."dokploy.organization.id" || ""',
|
||||
'.dokploy_project = .label."dokploy.project" || ""',
|
||||
'.dokploy_project_id = .label."dokploy.project.id" || ""',
|
||||
'.dokploy_environment = .label."dokploy.environment" || ""',
|
||||
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
|
||||
'.dokploy_application = .label."dokploy.application" || ""',
|
||||
'.dokploy_application_id = .label."dokploy.application.id" || ""',
|
||||
'.dokploy_service = .label."dokploy.service" || ""',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "broken-es",
|
||||
name: "es-broken",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
loki,
|
||||
],
|
||||
});
|
||||
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
});
|
||||
|
||||
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
|
||||
const config = build({ logProviders: [loki], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add the organization filter for the per-server (non-local) path", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — metrics", () => {
|
||||
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
|
||||
const config = build({ metricsProviders: [prom] });
|
||||
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_scope).toBeUndefined();
|
||||
expect(config.sources.host_metrics_source).toMatchObject({
|
||||
type: "host_metrics",
|
||||
scrape_interval_secs: 30,
|
||||
collectors: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"filesystem",
|
||||
"network",
|
||||
"load",
|
||||
"host",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
config.sources.host_metrics_source.filesystem.filesystems.excludes,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"overlay",
|
||||
"tmpfs",
|
||||
"squashfs",
|
||||
"devtmpfs",
|
||||
"nsfs",
|
||||
"proc",
|
||||
"sysfs",
|
||||
"devpts",
|
||||
"cgroup2",
|
||||
]),
|
||||
);
|
||||
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
|
||||
["veth*", "br-*", "docker*"],
|
||||
);
|
||||
expect(config.sources.cadvisor_source).toEqual({
|
||||
type: "prometheus_scrape",
|
||||
endpoints: ["http://127.0.0.1:4510/metrics"],
|
||||
scrape_interval_secs: 30,
|
||||
scrape_timeout_secs: 10,
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["host_metrics_source", "cadvisor_source"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
|
||||
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
|
||||
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
condition:
|
||||
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
|
||||
"dokploy_metrics_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
|
||||
const config = build({
|
||||
logProviders: [datadog],
|
||||
metricsProviders: [datadog],
|
||||
organizationId: "org-a",
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_dd-1"]).toMatchObject({
|
||||
type: "datadog_logs",
|
||||
inputs: ["transform_dd-1"],
|
||||
});
|
||||
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["dokploy_metrics_scope_local_only"],
|
||||
});
|
||||
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
|
||||
const source = buildMetricsScopeTransformSource('srv "quoted"');
|
||||
|
||||
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
|
||||
expect(source).toContain(
|
||||
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
|
||||
);
|
||||
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
|
||||
expect(
|
||||
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
|
||||
.source,
|
||||
).toBe(buildMetricsScopeTransformSource("my server"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — dollar signs", () => {
|
||||
it("keeps every $ literal because the agent runs without env var interpolation", () => {
|
||||
const yamlStr = buildVectorConfigYaml({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
],
|
||||
metricsProviders: [prom],
|
||||
serverName: "srv $ONE",
|
||||
});
|
||||
|
||||
expect(yamlStr).not.toContain("$$");
|
||||
expect(yamlStr).toContain("srv $ONE");
|
||||
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
|
||||
"Pa$5w0rd$HOME",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSecretValues / redactSecrets", () => {
|
||||
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "loki-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { tenantId: "tenant-a", username: "12345" },
|
||||
},
|
||||
{
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
{
|
||||
providerType: "aws_cloudwatch",
|
||||
endpoint: null,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
const values = collectSecretValues(providers);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://loki.example.com",
|
||||
"loki-key",
|
||||
"dd-key",
|
||||
"AKIA",
|
||||
"shh",
|
||||
]),
|
||||
);
|
||||
for (const plain of [
|
||||
"tenant-a",
|
||||
"12345",
|
||||
"datadoghq.eu",
|
||||
"us-east-1",
|
||||
"Dokploy",
|
||||
]) {
|
||||
expect(values).not.toContain(plain);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores null/empty credentials", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "elasticsearch",
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { limit: 10, enabled: true, empty: "" },
|
||||
},
|
||||
] as any;
|
||||
expect(collectSecretValues(providers)).toEqual([]);
|
||||
});
|
||||
|
||||
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: null,
|
||||
apiKey: "hunter2-long-suffix",
|
||||
apiSecret: "hunter2",
|
||||
extraConfig: null,
|
||||
},
|
||||
] as any;
|
||||
const text =
|
||||
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
|
||||
|
||||
const redacted = redactSecrets(text, collectSecretValues(providers));
|
||||
|
||||
expect(redacted).not.toContain("hunter2");
|
||||
expect(redacted).toBe(
|
||||
"sink validation error near apiKey=[redacted] and [redacted]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vrlString", () => {
|
||||
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
|
||||
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
|
||||
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
|
||||
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findEnabledTelemetryProvidersByOrganization: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
updateService: vi.fn(),
|
||||
removeService: vi.fn(),
|
||||
createService: vi.fn(),
|
||||
getService: vi.fn(),
|
||||
listContainers: vi.fn(),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: (isServer: boolean) => ({
|
||||
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findEnabledTelemetryProvidersByOrganization:
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
getService: mocks.getService,
|
||||
createService: mocks.createService,
|
||||
listContainers: mocks.listContainers,
|
||||
getContainer: () => ({ kill: mocks.killContainer }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { reconcileVectorAgent, removeVectorAgent } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const lokiProvider = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "sekret-token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
organizationId: "org-a",
|
||||
};
|
||||
|
||||
const promProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "mp-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: null,
|
||||
};
|
||||
|
||||
const notFound = () =>
|
||||
Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
|
||||
const writtenYaml = (command: string) => {
|
||||
const match = command.match(/echo "([^"]+)" \| base64 -d/);
|
||||
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
|
||||
};
|
||||
|
||||
const commandsOf = (mock: typeof mocks.execAsync) =>
|
||||
mock.mock.calls.map((call) => String(call[call.length - 1]));
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getService.mockImplementation(() => ({
|
||||
inspect: mocks.inspectService,
|
||||
update: mocks.updateService,
|
||||
remove: mocks.removeService,
|
||||
}));
|
||||
mocks.inspectService.mockRejectedValue(notFound());
|
||||
mocks.removeService.mockRejectedValue(notFound());
|
||||
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
|
||||
mocks.killContainer.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("reconcileVectorAgent", () => {
|
||||
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands[0]).toContain(
|
||||
"/local/dokploy/vector/vector.yaml.candidate.",
|
||||
);
|
||||
expect(commands[1]).toMatch(
|
||||
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
|
||||
);
|
||||
expect(commands[2]).toMatch(
|
||||
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
|
||||
const config = writtenYaml(commands[0] ?? "");
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
|
||||
expect(mocks.createService).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
|
||||
Name: "dokploy-vector",
|
||||
});
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
|
||||
const datadogProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
};
|
||||
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
promProvider,
|
||||
datadogProvider,
|
||||
unselected,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
const sinkIds = Object.keys(config.sinks);
|
||||
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
|
||||
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
|
||||
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
|
||||
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
|
||||
expect(config.sources.docker_logs_source).toBeDefined();
|
||||
expect(config.sources.cadvisor_source).toBeDefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
|
||||
).toBe(true);
|
||||
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
});
|
||||
|
||||
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.includes("validate")) {
|
||||
throw new Error("bad sink: password sekret-token rejected");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow(
|
||||
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
|
||||
);
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
|
||||
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when no enabled provider is left in the selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["deleted-or-disabled"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when the selection is empty, without an owner", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
promProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source.endpoints).toEqual([
|
||||
"http://127.0.0.1:4510/metrics",
|
||||
]);
|
||||
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
const cadvisor = mocks.createService.mock.calls[1]?.[0];
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
|
||||
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--listen_ip=127.0.0.1",
|
||||
"--port=4510",
|
||||
"--docker_only=true",
|
||||
"--store_container_labels=false",
|
||||
]),
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
|
||||
Test: ["NONE"],
|
||||
});
|
||||
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
|
||||
expect(mocks.removeService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent on a remote server when the selection is empty", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
|
||||
"rm -rf /etc/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
const echoCommand = async (command: string) => {
|
||||
if (command.includes("base64 -d") || command.startsWith("mv ")) {
|
||||
throw new Error(`Command failed: ${command} (token sekret-token)`);
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
mocks.execAsync.mockImplementation(echoCommand);
|
||||
|
||||
const writeError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(writeError).toMatch(/^Failed to write the Vector config: /);
|
||||
expect(writeError).not.toContain("sekret-token");
|
||||
expect(writeError).not.toContain(
|
||||
Buffer.from("data_dir").toString("base64").slice(0, 8),
|
||||
);
|
||||
expect(writeError).toContain("[redacted]");
|
||||
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.startsWith("mv ")) return echoCommand(command);
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
const applyError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
|
||||
expect(applyError).not.toContain("sekret-token");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
mocks.updateService.mockRejectedValue(
|
||||
new Error("rpc error: invalid mount"),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an inspect failure that is not a 404", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart Vector on a config-only redeploy", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockImplementation(async () => ({
|
||||
Version: { Index: "7" },
|
||||
Spec: {
|
||||
TaskTemplate: {
|
||||
ForceUpdate: 2,
|
||||
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
|
||||
? undefined
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
|
||||
expect(mocks.listContainers).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
label: ["com.docker.swarm.service.name=dokploy-vector"],
|
||||
}),
|
||||
});
|
||||
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "8" },
|
||||
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
|
||||
});
|
||||
mocks.updateService.mockClear();
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 8,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an existing Vector service instead of creating it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 7,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVectorAgent", () => {
|
||||
it("removes cAdvisor, then Vector, then the config directory", async () => {
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
|
||||
"dokploy-cadvisor",
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps going when cAdvisor cannot be removed", async () => {
|
||||
mocks.removeService
|
||||
.mockRejectedValueOnce(new Error("docker hiccup"))
|
||||
.mockRejectedValueOnce(notFound());
|
||||
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
});
|
||||
|
||||
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
|
||||
expect(mocks.getService).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTasks: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findTelemetryProvidersByOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findTelemetryProvidersByOrganization:
|
||||
mocks.findTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
listTasks: mocks.listTasks,
|
||||
getService: () => ({ inspect: mocks.inspectService }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getVectorAgentTargets, withVectorTargetLock } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const session = { userId: "user-1", activeOrganizationId: "org-1" };
|
||||
|
||||
describe("getVectorAgentTargets", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.inspectService.mockResolvedValue({});
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
|
||||
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
|
||||
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
|
||||
]);
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a running cAdvisor task when the target ships metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockImplementation(
|
||||
async ({ filters }: { filters: string }) =>
|
||||
JSON.parse(filters).service[0] === "dokploy-vector"
|
||||
? [{ Status: { State: "running" } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports running when both Vector and cAdvisor have a running task", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor for a logs-only target", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
await getVectorAgentTargets(session);
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1", "mp-off"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports running only when a task of the service is running", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
service: ["dokploy-vector"],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not-running when the service has no running task", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
});
|
||||
|
||||
it("reports stopped when the service does not exist", async () => {
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("not found"), { statusCode: 404 }),
|
||||
);
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("stopped");
|
||||
expect(mocks.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown on any other docker error", async () => {
|
||||
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local).toMatchObject({
|
||||
serverId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("hides servers the member cannot access", async () => {
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{
|
||||
serverId: "server-1",
|
||||
name: "one",
|
||||
ipAddress: "1.1.1.1",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
{
|
||||
serverId: "server-2",
|
||||
name: "two",
|
||||
ipAddress: "2.2.2.2",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
]);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
|
||||
|
||||
const targets = await getVectorAgentTargets(session);
|
||||
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withVectorTargetLock", () => {
|
||||
it("runs operations on the same target one after another", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const first = withVectorTargetLock("server-1", async () => {
|
||||
events.push("first:start");
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
events.push("first:end");
|
||||
throw new Error("first failed");
|
||||
});
|
||||
const second = withVectorTargetLock("server-1", async () => {
|
||||
events.push("second");
|
||||
});
|
||||
const other = withVectorTargetLock("server-2", async () => {
|
||||
events.push("other");
|
||||
});
|
||||
|
||||
await other;
|
||||
expect(events).toEqual(["first:start", "other"]);
|
||||
releaseFirst();
|
||||
await expect(first).rejects.toThrow("first failed");
|
||||
await second;
|
||||
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildVectorConfigYaml,
|
||||
VECTOR_IMAGE,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dockerAvailable = (() => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const logsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "loki1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "http://loki.example.com:3100",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
];
|
||||
|
||||
const metricsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "prom1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: "token",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "nr1",
|
||||
name: "new relic",
|
||||
providerType: "new_relic",
|
||||
signals: ["metrics"],
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "influx1",
|
||||
name: "influx",
|
||||
providerType: "influxdb",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
}),
|
||||
];
|
||||
|
||||
const both = [
|
||||
provider({
|
||||
telemetryProviderId: "dd1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "cw1",
|
||||
name: "cloudwatch",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIAFAKE",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "splunk1",
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "es1",
|
||||
name: "elastic",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "http://es.example.com:9200",
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "bs1",
|
||||
name: "better stack",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
];
|
||||
|
||||
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
|
||||
[
|
||||
"local logs only",
|
||||
{
|
||||
logProviders: logsOnly,
|
||||
metricsProviders: [],
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local metrics only",
|
||||
{
|
||||
logProviders: [],
|
||||
metricsProviders: metricsOnly,
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
organizationId: "org_a",
|
||||
serverName: 'Dokploy "Server" $HOSTNAME (local)',
|
||||
},
|
||||
],
|
||||
[
|
||||
"remote metrics only",
|
||||
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
|
||||
],
|
||||
[
|
||||
"remote every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
serverName: "edge-1",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe.skipIf(!dockerAvailable)(
|
||||
"vector validate on generated configs",
|
||||
() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
|
||||
|
||||
it.each(cases)(
|
||||
"%s",
|
||||
(name, input) => {
|
||||
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
|
||||
writeFileSync(file, buildVectorConfigYaml(input));
|
||||
const output = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
||||
"-v",
|
||||
`${file}:/etc/vector/vector.yaml:ro`,
|
||||
VECTOR_IMAGE,
|
||||
"validate",
|
||||
"--skip-healthchecks",
|
||||
"/etc/vector/vector.yaml",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
expect(output).toContain("Validated");
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
|
||||
returning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { webServerSettings: { findFirst: mocks.findFirst } },
|
||||
update: () => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: (where: unknown) => {
|
||||
mocks.updates.push({ values, where });
|
||||
return { returning: () => Promise.resolve(mocks.returning()) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
|
||||
"@dokploy/server/services/web-server-settings"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
id: "ws-1",
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
|
||||
});
|
||||
|
||||
describe("setWebServerProviderIds", () => {
|
||||
it("claims the local agent for the organization when assigning providers", async () => {
|
||||
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
|
||||
|
||||
expect(row).toEqual({ id: "ws-1" });
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the owner when the selection is cleared", async () => {
|
||||
await setWebServerProviderIds("org-a", []);
|
||||
|
||||
expect(mocks.updates[0]?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when another organization owns the local agent", async () => {
|
||||
mocks.returning.mockReturnValue([]);
|
||||
|
||||
await expect(
|
||||
setWebServerProviderIds("org-b", ["lp-9"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseWebServerAgent", () => {
|
||||
it("clears the selection and the owner only for the owning organization", async () => {
|
||||
await releaseWebServerAgent("org-a");
|
||||
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'"webServerSettings"."vectorAgentOrganizationId" = $1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -73,17 +73,17 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses logProvider.read", async () => {
|
||||
it("owner bypasses telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["read"] }),
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses logProvider.create", async () => {
|
||||
it("admin bypasses telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["create"] }),
|
||||
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -154,17 +154,17 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied logProvider.read", async () => {
|
||||
it("member is denied telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["read"] }),
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied logProvider.create", async () => {
|
||||
it("member is denied telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["create"] }),
|
||||
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const FREE_TIER_RESOURCES = [
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
"logProvider",
|
||||
"telemetryProvider",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -111,7 +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);
|
||||
expect(perms.telemetryProvider.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ const baseSettings: WebServerSettings = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logProviderIds: [],
|
||||
logManagementOrganizationId: null,
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
buildsConcurrency: 1,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
@@ -64,6 +64,10 @@ const baseSettings: WebServerSettings = {
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// apiReadTraefikConfig validates against the real local paths(), which outside
|
||||
// production live under the working directory.
|
||||
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
readDirectory: vi.fn(),
|
||||
readConfigInPath: vi.fn(),
|
||||
writeTraefikConfigInPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
|
||||
...mocks,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
cleanAllDeploymentQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
}));
|
||||
|
||||
const { settingsRouter } = await import("@/server/api/routers/settings");
|
||||
|
||||
const caller = settingsRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "member" },
|
||||
} as Parameters<typeof settingsRouter.createCaller>[0]);
|
||||
|
||||
const filePath = path.join(traefikPath, "dynamic/app.yml");
|
||||
|
||||
describe("traefik file endpoints scope remote servers to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
organizationId: serverId === "server-a" ? "org-a" : "org-b",
|
||||
}));
|
||||
mocks.readDirectory.mockResolvedValue([]);
|
||||
mocks.readConfigInPath.mockResolvedValue("http: {}");
|
||||
});
|
||||
|
||||
it("rejects listing a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readDirectories({ serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects writing to a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-b",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reading from a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows servers of the active organization", async () => {
|
||||
await caller.readDirectories({ serverId: "server-a" });
|
||||
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-a",
|
||||
});
|
||||
|
||||
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
|
||||
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
"server-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not look up a server when none is given", async () => {
|
||||
await caller.readDirectories({});
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
});
|
||||
|
||||
expect(mocks.findServerById).not.toHaveBeenCalled();
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
|
||||
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
|
||||
value={field.value}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
field.onChange(value);
|
||||
form.setValue("metadata", {});
|
||||
form.setValue("metadata", {
|
||||
serviceName:
|
||||
form.getValues("metadata.serviceName") ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
value={field.value ?? ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -45,13 +45,19 @@ function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
const content = (
|
||||
<div
|
||||
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
|
||||
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
@@ -63,6 +69,16 @@ function StatCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block h-full">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
@@ -182,6 +198,7 @@ export const ShowHome = () => {
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
href="/dashboard/projects"
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
|
||||
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
|
||||
@@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||
: null;
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template?.tags?.map((tag) => (
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||
<HandleAi />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -53,7 +57,7 @@ export const AiForm = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any AI configurations
|
||||
</span>
|
||||
<HandleAi />
|
||||
{permissions?.ai.create && <HandleAi />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
||||
<CardDescription>{config.model}</CardDescription>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<HandleAi aiId={config.aiId} />
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
{permissions?.ai.update && (
|
||||
<HandleAi aiId={config.aiId} />
|
||||
)}
|
||||
{permissions?.ai.delete && (
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<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>
|
||||
|
||||
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||
: null;
|
||||
|
||||
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
|
||||
export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
|
||||
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
|
||||
applicationKey: z.string(),
|
||||
applicationSecret: z.string(),
|
||||
consumerKey: z.string(),
|
||||
zone: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
|
||||
applicationKey: "",
|
||||
applicationSecret: "",
|
||||
consumerKey: "",
|
||||
zone: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
applicationKey: data.applicationKey,
|
||||
applicationSecret: data.applicationSecret,
|
||||
consumerKey: data.consumerKey,
|
||||
zone: data.zone.trim() || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
applicationKey: provider.config.applicationKey,
|
||||
applicationSecret: provider.config.applicationSecret,
|
||||
consumerKey: provider.config.consumerKey,
|
||||
zone: provider.config.zone ?? "",
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Zone (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Restrict this provider to a single DNS zone: Dokploy
|
||||
will only list and change records in it. Leave empty to
|
||||
use every zone the credentials can access. When set, the{" "}
|
||||
<code>GET /domain/zone</code> right is not needed. This
|
||||
is enforced by Dokploy only, so also limit the OVH token
|
||||
to that zone if you need a hard guarantee.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
{
|
||||
apiUrl: apiUrl ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
...(aiId ? { aiId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
aiId={aiId}
|
||||
/>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
aiId,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
aiId?: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
onClick={() =>
|
||||
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
|
||||
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { logProviderIcons } from "@/components/icons/log-provider-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
|
||||
|
||||
interface Props {
|
||||
logProviderId?: string;
|
||||
}
|
||||
|
||||
export const agentsUsingProvider = (
|
||||
targets: Array<{ name: string; logProviderIds: string[] }> | undefined,
|
||||
logProviderId: string,
|
||||
) =>
|
||||
(targets ?? [])
|
||||
.filter((target) => target.logProviderIds.includes(logProviderId))
|
||||
.map((target) => target.name)
|
||||
.join(", ");
|
||||
|
||||
export const HandleLogProvider = ({ logProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [providerType, setProviderType] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [initialFieldValues, setInitialFieldValues] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
|
||||
const { data: provider } = api.logProvider.one.useQuery(
|
||||
{ logProviderId: logProviderId || "" },
|
||||
{ enabled: !!logProviderId },
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync,
|
||||
isPending: isSaving,
|
||||
error,
|
||||
isError,
|
||||
} = logProviderId
|
||||
? api.logProvider.update.useMutation()
|
||||
: api.logProvider.create.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnection,
|
||||
isPending: isTestingRaw,
|
||||
error: testError,
|
||||
isError: testIsError,
|
||||
} = api.logProvider.testConnection.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnectionById,
|
||||
isPending: isTestingById,
|
||||
error: testByIdError,
|
||||
isError: testByIdIsError,
|
||||
} = api.logProvider.testConnectionById.useMutation();
|
||||
|
||||
const selectedType = availableTypes?.find((t) => t.type === providerType);
|
||||
|
||||
const handleProviderTypeChange = (value: string) => {
|
||||
setProviderType(value);
|
||||
setFieldValues({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
setName(provider.name);
|
||||
setProviderType(provider.providerType);
|
||||
setEnabled(provider.enabled);
|
||||
const extraConfig = (provider.extraConfig ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
setFieldValues(extraConfig);
|
||||
setInitialFieldValues(extraConfig);
|
||||
setClearedKeys({});
|
||||
} else if (isOpen) {
|
||||
setName("");
|
||||
setProviderType("");
|
||||
setEnabled(true);
|
||||
setFieldValues({});
|
||||
setInitialFieldValues({});
|
||||
setClearedKeys({});
|
||||
}
|
||||
}, [provider, isOpen]);
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload: Record<string, unknown> = { name, providerType, enabled };
|
||||
const extraConfig: Record<string, string> = {};
|
||||
let hasExtraConfigFields = false;
|
||||
for (const field of selectedType?.credentialFields ?? []) {
|
||||
const value = fieldValues[field.key];
|
||||
if (TOP_LEVEL_KEYS.has(field.key)) {
|
||||
if (value) {
|
||||
payload[field.key] = value;
|
||||
} else if (clearedKeys[field.key]) {
|
||||
payload[field.key] = null;
|
||||
}
|
||||
} else {
|
||||
hasExtraConfigFields = true;
|
||||
extraConfig[field.key] = value ?? "";
|
||||
}
|
||||
}
|
||||
if (hasExtraConfigFields) {
|
||||
payload.extraConfig = extraConfig;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const topLevelKeys = (selectedType?.credentialFields ?? [])
|
||||
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedTopLevelKeys = topLevelKeys.filter(
|
||||
(key) => !!fieldValues[key] || clearedKeys[key],
|
||||
);
|
||||
const extraConfigKeys = (selectedType?.credentialFields ?? [])
|
||||
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedExtraConfigKeys = extraConfigKeys.filter(
|
||||
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
|
||||
);
|
||||
const touchedAnyCredentialField =
|
||||
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
|
||||
const isPartiallyTouched =
|
||||
!!logProviderId &&
|
||||
touchedAnyCredentialField &&
|
||||
touchedTopLevelKeys.length < topLevelKeys.length;
|
||||
|
||||
const onTest = async () => {
|
||||
if (logProviderId && !touchedAnyCredentialField) {
|
||||
await testConnectionById({ logProviderId })
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Connection test failed",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload();
|
||||
await testConnection({
|
||||
name: payload.name as string,
|
||||
providerType: payload.providerType as LogProviderType,
|
||||
enabled: payload.enabled as boolean,
|
||||
endpoint: (payload.endpoint as string) ?? null,
|
||||
apiKey: (payload.apiKey as string) ?? null,
|
||||
apiSecret: (payload.apiSecret as string) ?? null,
|
||||
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Connection test failed");
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const payload = buildPayload();
|
||||
await mutateAsync({
|
||||
...(logProviderId ? { logProviderId } : {}),
|
||||
...payload,
|
||||
} as any)
|
||||
.then(() => {
|
||||
utils.logProvider.all.invalidate();
|
||||
if (logProviderId) {
|
||||
utils.logProvider.one.invalidate({ logProviderId });
|
||||
}
|
||||
const agents =
|
||||
logProviderId &&
|
||||
agentsUsingProvider(
|
||||
utils.logProvider.serverStatus.getData(),
|
||||
logProviderId,
|
||||
);
|
||||
toast.success(
|
||||
logProviderId ? "Log provider updated" : "Log provider added",
|
||||
agents
|
||||
? {
|
||||
description: `Redeploy the Vector agent on ${agents} to apply the changes.`,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
logProviderId
|
||||
? "Error updating log provider"
|
||||
: "Error adding log provider",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isTesting = isTestingRaw || isTestingById;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{logProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Log Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vector will ship container logs from every server with Log
|
||||
Management enabled to this provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testIsError || testByIdIsError) && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{testError?.message ||
|
||||
testByIdError?.message ||
|
||||
error?.message ||
|
||||
""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Production Loki"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={handleProviderTypeChange}
|
||||
disabled={!!logProviderId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes?.map((type) => {
|
||||
const ProviderIcon =
|
||||
logProviderIcons[
|
||||
type.type as keyof typeof logProviderIcons
|
||||
];
|
||||
return (
|
||||
<SelectItem key={type.type} value={type.type}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{ProviderIcon && (
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedType?.credentialFields.map((field) => (
|
||||
<div key={field.key} className="flex flex-col gap-2">
|
||||
<Label>
|
||||
{field.label}
|
||||
{!field.required && " (Optional)"}
|
||||
</Label>
|
||||
{field.helpText && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{field.helpText}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
disabled={!!clearedKeys[field.key]}
|
||||
placeholder={
|
||||
clearedKeys[field.key]
|
||||
? "Will be cleared on update"
|
||||
: logProviderId && TOP_LEVEL_KEYS.has(field.key)
|
||||
? "Leave blank to keep existing"
|
||||
: field.placeholder
|
||||
}
|
||||
autoComplete={
|
||||
field.type === "password" ? "one-time-code" : "off"
|
||||
}
|
||||
value={fieldValues[field.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{logProviderId &&
|
||||
TOP_LEVEL_KEYS.has(field.key) &&
|
||||
!field.required && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setClearedKeys((prev) => ({
|
||||
...prev,
|
||||
[field.key]: !prev[field.key],
|
||||
}));
|
||||
setFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.key]: "",
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{clearedKeys[field.key] ? "Keep" : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label>Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
|
||||
{isPartiallyTouched && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
You changed something that needs a fresh credential to test — fill
|
||||
in every credential field to test with the new values, or revert
|
||||
it to test with what's already saved.
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
disabled={!providerType || isPartiallyTouched}
|
||||
onClick={onTest}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
isLoading={isSaving}
|
||||
disabled={!providerType || !name || isSaving}
|
||||
>
|
||||
{logProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
import type {
|
||||
TelemetryProviderType,
|
||||
TelemetrySignal,
|
||||
} from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
|
||||
|
||||
const SIGNAL_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
signals: TelemetrySignal[];
|
||||
}> = [
|
||||
{ value: "logs", label: "Logs", signals: ["logs"] },
|
||||
{ value: "metrics", label: "Metrics", signals: ["metrics"] },
|
||||
{
|
||||
value: "logs,metrics",
|
||||
label: "Logs & Metrics",
|
||||
signals: ["logs", "metrics"],
|
||||
},
|
||||
];
|
||||
|
||||
const providerForm = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
providerType: z.string().min(1, "Provider is required"),
|
||||
signals: z.string().min(1, "Select what this provider sends"),
|
||||
enabled: z.boolean(),
|
||||
fields: z.record(z.string(), z.string().optional()),
|
||||
});
|
||||
|
||||
type ProviderForm = z.infer<typeof providerForm>;
|
||||
|
||||
const toSignals = (value: string) =>
|
||||
SIGNAL_OPTIONS.find((option) => option.value === value)?.signals ?? [];
|
||||
|
||||
interface Props {
|
||||
telemetryProviderId?: string;
|
||||
}
|
||||
|
||||
export const agentsUsingProvider = (
|
||||
targets:
|
||||
| Array<{
|
||||
name: string;
|
||||
telemetryProviderIds: string[];
|
||||
}>
|
||||
| undefined,
|
||||
telemetryProviderId: string,
|
||||
) =>
|
||||
(targets ?? [])
|
||||
.filter((target) =>
|
||||
target.telemetryProviderIds.includes(telemetryProviderId),
|
||||
)
|
||||
.map((target) => target.name)
|
||||
.join(", ");
|
||||
|
||||
export const HandleTelemetryProvider = ({ telemetryProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialFields, setInitialFields] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { data: availableTypes } =
|
||||
api.telemetryProvider.availableTypes.useQuery();
|
||||
const { data: provider } = api.telemetryProvider.one.useQuery(
|
||||
{ telemetryProviderId: telemetryProviderId || "" },
|
||||
{ enabled: !!telemetryProviderId, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const createMutation = api.telemetryProvider.create.useMutation();
|
||||
const updateMutation = api.telemetryProvider.update.useMutation();
|
||||
const {
|
||||
isPending: isSaving,
|
||||
error,
|
||||
isError,
|
||||
} = telemetryProviderId ? updateMutation : createMutation;
|
||||
const {
|
||||
mutateAsync: testConnection,
|
||||
isPending: isTestingRaw,
|
||||
error: testError,
|
||||
isError: testIsError,
|
||||
} = api.telemetryProvider.testConnection.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnectionById,
|
||||
isPending: isTestingById,
|
||||
error: testByIdError,
|
||||
isError: testByIdIsError,
|
||||
} = api.telemetryProvider.testConnectionById.useMutation();
|
||||
|
||||
const form = useForm<ProviderForm>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
providerType: "",
|
||||
signals: "",
|
||||
enabled: true,
|
||||
fields: {},
|
||||
},
|
||||
resolver: zodResolver(providerForm),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
const signalsValue = form.watch("signals");
|
||||
const fieldValues = form.watch("fields");
|
||||
const signals = toSignals(signalsValue);
|
||||
|
||||
const selectedType = availableTypes?.find((t) => t.type === providerType);
|
||||
const signalOptions = SIGNAL_OPTIONS.filter((option) =>
|
||||
option.signals.every((s) => selectedType?.signals.includes(s)),
|
||||
);
|
||||
const visibleFields = (selectedType?.credentialFields ?? []).filter(
|
||||
(field) => !field.signal || signals.includes(field.signal),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
const extraConfig = (provider.extraConfig ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
form.reset({
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
signals: provider.signals.join(","),
|
||||
enabled: provider.enabled,
|
||||
fields: extraConfig,
|
||||
});
|
||||
setInitialFields(extraConfig);
|
||||
} else {
|
||||
form.reset();
|
||||
setInitialFields({});
|
||||
}
|
||||
setClearedKeys({});
|
||||
}, [form, provider, isOpen]);
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload: {
|
||||
name: string;
|
||||
providerType: TelemetryProviderType;
|
||||
signals: TelemetrySignal[];
|
||||
enabled: boolean;
|
||||
endpoint?: string | null;
|
||||
apiKey?: string | null;
|
||||
apiSecret?: string | null;
|
||||
extraConfig?: Record<string, string>;
|
||||
} = {
|
||||
name: form.getValues("name"),
|
||||
providerType: providerType as TelemetryProviderType,
|
||||
signals,
|
||||
enabled: form.getValues("enabled"),
|
||||
};
|
||||
const extraConfig: Record<string, string> = {};
|
||||
let hasExtraConfigFields = false;
|
||||
for (const field of visibleFields) {
|
||||
const value = fieldValues[field.key] ?? "";
|
||||
if (
|
||||
field.key === "endpoint" ||
|
||||
field.key === "apiKey" ||
|
||||
field.key === "apiSecret"
|
||||
) {
|
||||
if (value) {
|
||||
payload[field.key] = value;
|
||||
} else if (clearedKeys[field.key]) {
|
||||
payload[field.key] = null;
|
||||
}
|
||||
} else {
|
||||
hasExtraConfigFields = true;
|
||||
extraConfig[field.key] = value;
|
||||
}
|
||||
}
|
||||
if (hasExtraConfigFields) {
|
||||
payload.extraConfig = extraConfig;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
// Required fields are per type and signal, so zod can't know them up front.
|
||||
const validateRequiredFields = () => {
|
||||
let valid = true;
|
||||
for (const field of visibleFields) {
|
||||
const value = fieldValues[field.key] ?? "";
|
||||
const keepsStored =
|
||||
!!telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(field.key) &&
|
||||
!clearedKeys[field.key];
|
||||
if (field.required && !value && !keepsStored) {
|
||||
form.setError(`fields.${field.key}`, {
|
||||
message: `${field.label} is required`,
|
||||
});
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
};
|
||||
|
||||
const topLevelKeys = visibleFields
|
||||
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedTopLevelKeys = topLevelKeys.filter(
|
||||
(key) => !!fieldValues[key] || clearedKeys[key],
|
||||
);
|
||||
const touchedExtraConfigKeys = visibleFields
|
||||
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key)
|
||||
.filter((key) => (fieldValues[key] ?? "") !== (initialFields[key] ?? ""));
|
||||
const touchedAnyCredentialField =
|
||||
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
|
||||
const isPartiallyTouched =
|
||||
!!telemetryProviderId &&
|
||||
touchedAnyCredentialField &&
|
||||
touchedTopLevelKeys.length < topLevelKeys.length;
|
||||
const signalsChanged =
|
||||
!!provider && signalsValue !== provider.signals.join(",");
|
||||
|
||||
const showTestResult = (result: { warning?: string }) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
if (!(await form.trigger(["providerType", "signals"]))) return;
|
||||
if (telemetryProviderId && !touchedAnyCredentialField) {
|
||||
await testConnectionById({
|
||||
telemetryProviderId,
|
||||
...(signalsChanged ? { signals } : {}),
|
||||
})
|
||||
.then(showTestResult)
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Connection test failed",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!validateRequiredFields()) return;
|
||||
await testConnection(buildPayload())
|
||||
.then(showTestResult)
|
||||
.catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Connection test failed");
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!validateRequiredFields()) return;
|
||||
const payload = buildPayload();
|
||||
let warning: string | undefined;
|
||||
let reapplied = false;
|
||||
try {
|
||||
if (telemetryProviderId) {
|
||||
const result = await updateMutation.mutateAsync({
|
||||
telemetryProviderId,
|
||||
...payload,
|
||||
});
|
||||
warning = result.warning;
|
||||
reapplied = result.reapplied;
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
telemetryProviderId
|
||||
? "Error updating provider"
|
||||
: "Error adding provider",
|
||||
);
|
||||
return;
|
||||
}
|
||||
utils.telemetryProvider.all.invalidate();
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
if (telemetryProviderId) {
|
||||
utils.telemetryProvider.one.invalidate({ telemetryProviderId });
|
||||
}
|
||||
const agents =
|
||||
telemetryProviderId &&
|
||||
agentsUsingProvider(
|
||||
utils.telemetryProvider.serverStatus.getData(),
|
||||
telemetryProviderId,
|
||||
);
|
||||
if (warning) {
|
||||
toast.warning("Provider updated", { description: warning });
|
||||
} else {
|
||||
toast.success(
|
||||
telemetryProviderId ? "Provider updated" : "Provider added",
|
||||
reapplied && agents
|
||||
? { description: `The Vector agent on ${agents} was re-applied.` }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const isTesting = isTestingRaw || isTestingById;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{telemetryProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{telemetryProviderId ? "Edit provider" : "Add a provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vector ships container logs and host/container metrics from every
|
||||
server you deploy it on to this provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testIsError || testByIdIsError) && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{testError?.message || testByIdError?.message || error?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-telemetry-provider"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Production Loki" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue("signals", "");
|
||||
form.setValue("fields", {});
|
||||
form.clearErrors();
|
||||
}}
|
||||
disabled={!!telemetryProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableTypes?.map((type) => {
|
||||
const ProviderIcon =
|
||||
telemetryProviderIcons[
|
||||
type.type as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
return (
|
||||
<SelectItem key={type.type} value={type.type}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{ProviderIcon && (
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedType && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signals"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sends</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select what this provider sends" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{signalOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleFields.map((credentialField) => (
|
||||
<FormField
|
||||
key={credentialField.key}
|
||||
control={form.control}
|
||||
name={`fields.${credentialField.key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{credentialField.label}
|
||||
{!credentialField.required && " (Optional)"}
|
||||
</FormLabel>
|
||||
{credentialField.helpText && (
|
||||
<FormDescription>
|
||||
{credentialField.helpText}
|
||||
</FormDescription>
|
||||
)}
|
||||
<div className="flex flex-row gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type={
|
||||
credentialField.type === "password"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
disabled={!!clearedKeys[credentialField.key]}
|
||||
placeholder={
|
||||
clearedKeys[credentialField.key]
|
||||
? "Will be cleared on update"
|
||||
: telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(credentialField.key)
|
||||
? "Leave blank to keep existing"
|
||||
: credentialField.placeholder
|
||||
}
|
||||
autoComplete={
|
||||
credentialField.type === "password"
|
||||
? "new-password"
|
||||
: "off"
|
||||
}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
{telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(credentialField.key) &&
|
||||
!credentialField.required && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setClearedKeys((prev) => ({
|
||||
...prev,
|
||||
[credentialField.key]:
|
||||
!prev[credentialField.key],
|
||||
}));
|
||||
field.onChange("");
|
||||
}}
|
||||
>
|
||||
{clearedKeys[credentialField.key]
|
||||
? "Keep"
|
||||
: "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
|
||||
{isPartiallyTouched && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
You changed something that needs a fresh credential to test — fill
|
||||
in every credential field to test with the new values, or revert
|
||||
it to test with what's already saved.
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
disabled={!providerType || isPartiallyTouched}
|
||||
onClick={onTest}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="hook-form-telemetry-provider"
|
||||
isLoading={isSaving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{telemetryProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+100
-63
@@ -1,7 +1,7 @@
|
||||
import { Copy, Loader2, ScrollText, Server } from "lucide-react";
|
||||
import { Copy, Loader2, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { logProviderIcons } from "@/components/icons/log-provider-icons";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -32,71 +32,92 @@ const statusBadge = (status: Status) => {
|
||||
return <Badge variant="destructive">Unreachable</Badge>;
|
||||
};
|
||||
|
||||
export const ShowLogManagementServers = () => {
|
||||
export const providerLabel = (provider: { name: string; signals: string[] }) =>
|
||||
`${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`;
|
||||
|
||||
export const ShowServers = () => {
|
||||
const utils = api.useUtils();
|
||||
const [selection, setSelection] = useState<Record<string, string[]>>({});
|
||||
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [errors, setErrors] = useState<
|
||||
Record<string, { title: string; message: string }>
|
||||
>({});
|
||||
|
||||
const { data: providers } = api.logProvider.all.useQuery();
|
||||
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
|
||||
undefined,
|
||||
{
|
||||
const { data: providers } = api.telemetryProvider.all.useQuery();
|
||||
const { data: targets, isPending } =
|
||||
api.telemetryProvider.serverStatus.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { mutateAsync: deployOnServer } =
|
||||
api.logProvider.deployOnServer.useMutation();
|
||||
api.telemetryProvider.deployOnServer.useMutation();
|
||||
const { mutateAsync: removeOnServer } =
|
||||
api.logProvider.removeOnServer.useMutation();
|
||||
api.telemetryProvider.removeOnServer.useMutation();
|
||||
|
||||
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||
|
||||
const idsFor = (serverId: string | null, saved: string[]) =>
|
||||
selection[keyOf(serverId)] ?? saved;
|
||||
// The selection can still hold providers deleted since it was made.
|
||||
const idsFor = (serverId: string | null, saved: string[]) => {
|
||||
const ids = selection[keyOf(serverId)] ?? saved;
|
||||
return providers
|
||||
? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id))
|
||||
: ids;
|
||||
};
|
||||
|
||||
const toggleProvider = (
|
||||
serverId: string | null,
|
||||
saved: string[],
|
||||
logProviderId: string,
|
||||
telemetryProviderId: string,
|
||||
) => {
|
||||
const key = keyOf(serverId);
|
||||
const current = selection[key] ?? saved;
|
||||
const current = idsFor(serverId, saved);
|
||||
setSelection({
|
||||
...selection,
|
||||
[key]: current.includes(logProviderId)
|
||||
? current.filter((id) => id !== logProviderId)
|
||||
: [...current, logProviderId],
|
||||
[key]: current.includes(telemetryProviderId)
|
||||
? current.filter((id) => id !== telemetryProviderId)
|
||||
: [...current, telemetryProviderId],
|
||||
});
|
||||
};
|
||||
|
||||
const setError = (serverId: string | null, message?: string) =>
|
||||
const setError = (
|
||||
serverId: string | null,
|
||||
error?: { title: string; message: string },
|
||||
) =>
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (message) {
|
||||
next[keyOf(serverId)] = message;
|
||||
if (error) {
|
||||
next[keyOf(serverId)] = error;
|
||||
} else {
|
||||
delete next[keyOf(serverId)];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// The task is still starting right after a deploy, so look again once it had time to come up.
|
||||
const refreshStatus = () => {
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000);
|
||||
};
|
||||
|
||||
const handleDeploy = async (serverId: string | null, ids: string[]) => {
|
||||
setPendingTarget(keyOf(serverId));
|
||||
setError(serverId);
|
||||
await deployOnServer({ serverId, logProviderIds: ids })
|
||||
await deployOnServer({ serverId, providerIds: ids })
|
||||
.then(() => {
|
||||
toast.success("Vector agent deployed");
|
||||
utils.logProvider.serverStatus.invalidate();
|
||||
refreshStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(
|
||||
serverId,
|
||||
error instanceof Error ? error.message : "Failed to deploy the agent",
|
||||
);
|
||||
setError(serverId, {
|
||||
title: "Deploy failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to deploy the agent",
|
||||
});
|
||||
refreshStatus();
|
||||
})
|
||||
.finally(() => setPendingTarget(null));
|
||||
};
|
||||
@@ -108,13 +129,17 @@ export const ShowLogManagementServers = () => {
|
||||
.then(() => {
|
||||
toast.success("Vector agent removed");
|
||||
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
|
||||
utils.logProvider.serverStatus.invalidate();
|
||||
refreshStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(
|
||||
serverId,
|
||||
error instanceof Error ? error.message : "Failed to remove the agent",
|
||||
);
|
||||
setError(serverId, {
|
||||
title: "Remove failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to remove the agent",
|
||||
});
|
||||
refreshStatus();
|
||||
})
|
||||
.finally(() => setPendingTarget(null));
|
||||
};
|
||||
@@ -122,7 +147,12 @@ export const ShowLogManagementServers = () => {
|
||||
const hasProviders = (providers?.length ?? 0) > 0;
|
||||
const visibleTargets = hasProviders
|
||||
? targets
|
||||
: targets?.filter((target) => target.status !== "stopped");
|
||||
: targets?.filter(
|
||||
(target) =>
|
||||
target.telemetryProviderIds.length > 0 ||
|
||||
target.status === "running" ||
|
||||
target.status === "not-running",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -135,10 +165,11 @@ export const ShowLogManagementServers = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pick which providers each server ships to, then deploy its Vector
|
||||
agent. Changing a provider only takes effect on a server after you
|
||||
redeploy it. Services deployed before Log Management need a
|
||||
redeploy to get the labels it uses; until then their logs are not
|
||||
shipped from the Dokploy server.
|
||||
agent; a provider receives every signal it sends, and cAdvisor
|
||||
runs next to the agent on 127.0.0.1:4510 when metrics are shipped.
|
||||
Services deployed before Logs & Metrics need a redeploy to get the
|
||||
labels it uses; until then their logs and container metrics are
|
||||
not shipped from the Dokploy server.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -149,21 +180,23 @@ export const ShowLogManagementServers = () => {
|
||||
</div>
|
||||
) : !hasProviders && !visibleTargets?.length ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<ScrollText className="size-8 self-center text-muted-foreground" />
|
||||
<Server className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
Add a log provider first — there's nowhere to ship logs to yet
|
||||
Add a provider first — there's nowhere to ship to yet
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{visibleTargets?.map((target) => {
|
||||
const key = keyOf(target.serverId);
|
||||
const ids = idsFor(target.serverId, target.logProviderIds);
|
||||
const saved = target.telemetryProviderIds;
|
||||
const ids = idsFor(target.serverId, saved);
|
||||
const isBusy = pendingTarget === key;
|
||||
const isDeployed = target.status !== "stopped";
|
||||
const agentDeployed = target.status !== "stopped";
|
||||
const hasEnabledSelected = ids.some(
|
||||
(id) =>
|
||||
providers?.find((p) => p.logProviderId === id)?.enabled,
|
||||
providers?.find((p) => p.telemetryProviderId === id)
|
||||
?.enabled,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -184,10 +217,10 @@ export const ShowLogManagementServers = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(target.status as Status)}
|
||||
{isDeployed && (
|
||||
{agentDeployed && (
|
||||
<DialogAction
|
||||
title="Remove the Vector agent"
|
||||
description="This server will stop shipping container logs until you deploy it again. Continue?"
|
||||
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
|
||||
type="destructive"
|
||||
onClick={() => handleRemove(target.serverId)}
|
||||
>
|
||||
@@ -205,32 +238,35 @@ export const ShowLogManagementServers = () => {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasProviders && (
|
||||
<span className="text-xs font-medium">
|
||||
Log providers
|
||||
</span>
|
||||
<span className="text-xs font-medium">Providers</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
logProviderIcons[
|
||||
provider.providerType as keyof typeof logProviderIcons
|
||||
telemetryProviderIcons[
|
||||
provider.providerType as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={provider.logProviderId}
|
||||
key={provider.telemetryProviderId}
|
||||
className={`flex flex-row items-center gap-2 text-sm ${
|
||||
provider.enabled ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${key}-${provider.logProviderId}`}
|
||||
disabled={!provider.enabled}
|
||||
checked={ids.includes(provider.logProviderId)}
|
||||
id={`${key}-${provider.telemetryProviderId}`}
|
||||
disabled={
|
||||
!provider.enabled &&
|
||||
!ids.includes(provider.telemetryProviderId)
|
||||
}
|
||||
checked={ids.includes(
|
||||
provider.telemetryProviderId,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleProvider(
|
||||
target.serverId,
|
||||
target.logProviderIds,
|
||||
provider.logProviderId,
|
||||
saved,
|
||||
provider.telemetryProviderId,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -238,14 +274,14 @@ export const ShowLogManagementServers = () => {
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<Label
|
||||
htmlFor={`${key}-${provider.logProviderId}`}
|
||||
htmlFor={`${key}-${provider.telemetryProviderId}`}
|
||||
className={`font-normal ${
|
||||
provider.enabled
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{provider.name}
|
||||
{providerLabel(provider)}
|
||||
</Label>
|
||||
{!provider.enabled && (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
@@ -260,11 +296,10 @@ export const ShowLogManagementServers = () => {
|
||||
<AlertBlock type="error" className="items-start">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<span className="font-medium">
|
||||
Deploy failed — the config was not applied, this
|
||||
server keeps shipping with its previous one.
|
||||
{errors[key].title}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
|
||||
{errors[key]}
|
||||
{errors[key].message}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -272,7 +307,7 @@ export const ShowLogManagementServers = () => {
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
errors[key] ?? "",
|
||||
errors[key]?.message ?? "",
|
||||
);
|
||||
toast.success("Error copied");
|
||||
}}
|
||||
@@ -298,7 +333,9 @@ export const ShowLogManagementServers = () => {
|
||||
disabled={!hasEnabledSelected}
|
||||
onClick={() => handleDeploy(target.serverId, ids)}
|
||||
>
|
||||
{isDeployed ? "Redeploy" : "Deploy"}
|
||||
{agentDeployed || saved.length > 0
|
||||
? "Redeploy"
|
||||
: "Deploy"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
+51
-41
@@ -1,6 +1,6 @@
|
||||
import { Loader2, ScrollText, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { logProviderIcons } from "@/components/icons/log-provider-icons";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { agentsUsingProvider, HandleLogProvider } from "./handle-log-provider";
|
||||
import { HandleTelemetryProvider } from "./handle-telemetry-provider";
|
||||
|
||||
interface LogProviderRowProps {
|
||||
interface TelemetryProviderRowProps {
|
||||
provider: {
|
||||
logProviderId: string;
|
||||
telemetryProviderId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
providerType: string;
|
||||
signals: string[];
|
||||
};
|
||||
index: number;
|
||||
typeLabel: string;
|
||||
@@ -28,19 +29,20 @@ interface LogProviderRowProps {
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const LogProviderRow = ({
|
||||
const TelemetryProviderRow = ({
|
||||
provider,
|
||||
index,
|
||||
typeLabel,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onDeleted,
|
||||
}: LogProviderRowProps) => {
|
||||
const utils = api.useUtils();
|
||||
}: TelemetryProviderRowProps) => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.logProvider.remove.useMutation();
|
||||
api.telemetryProvider.remove.useMutation();
|
||||
const ProviderIcon =
|
||||
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
|
||||
telemetryProviderIcons[
|
||||
provider.providerType as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
|
||||
@@ -53,6 +55,12 @@ const LogProviderRow = ({
|
||||
<Badge variant={provider.enabled ? "green" : "secondary"}>
|
||||
{provider.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
{provider.signals.includes("logs") && (
|
||||
<Badge variant="blue">Logs</Badge>
|
||||
)}
|
||||
{provider.signals.includes("metrics") && (
|
||||
<Badge variant="orange">Metrics</Badge>
|
||||
)}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||
</div>
|
||||
@@ -60,35 +68,34 @@ const LogProviderRow = ({
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
{canEdit && (
|
||||
<HandleLogProvider logProviderId={provider.logProviderId} />
|
||||
<HandleTelemetryProvider
|
||||
telemetryProviderId={provider.telemetryProviderId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Log Provider"
|
||||
description="Are you sure you want to delete this log provider? Agents already deployed keep shipping logs to it until you redeploy or remove them."
|
||||
title="Delete provider"
|
||||
description="Are you sure you want to delete this provider? Every agent shipping to it is re-applied without it, and removed if nothing is left."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
const agents = agentsUsingProvider(
|
||||
utils.logProvider.serverStatus.getData(),
|
||||
provider.logProviderId,
|
||||
);
|
||||
await mutateAsync({
|
||||
logProviderId: provider.logProviderId,
|
||||
telemetryProviderId: provider.telemetryProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Log provider deleted successfully",
|
||||
agents
|
||||
? {
|
||||
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.warning("Provider deleted", {
|
||||
description: result.warning,
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
"Provider deleted — the agents that used it were re-applied",
|
||||
);
|
||||
}
|
||||
onDeleted();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting log provider");
|
||||
toast.error("Error deleting provider");
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -108,10 +115,11 @@ const LogProviderRow = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowLogProviders = () => {
|
||||
export const ShowTelemetryProviders = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isPending, refetch } = api.logProvider.all.useQuery();
|
||||
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
|
||||
const { data, isPending, refetch } = api.telemetryProvider.all.useQuery();
|
||||
const { data: availableTypes } =
|
||||
api.telemetryProvider.availableTypes.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const providerListLabel = availableTypes?.length
|
||||
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
||||
@@ -124,10 +132,10 @@ export const ShowLogProviders = () => {
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<ScrollText className="size-6 text-muted-foreground self-center" />
|
||||
Log Management
|
||||
Logs & Metrics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{`Ship container logs to an external provider${providerListLabel}. Add one here, then deploy the Vector agent on the servers below.`}
|
||||
{`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -142,16 +150,18 @@ export const ShowLogProviders = () => {
|
||||
<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
|
||||
You don't have any provider configured
|
||||
</span>
|
||||
{permissions?.logProvider.create && <HandleLogProvider />}
|
||||
{permissions?.telemetryProvider.create && (
|
||||
<HandleTelemetryProvider />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
{data?.map((provider, index) => (
|
||||
<LogProviderRow
|
||||
key={provider.logProviderId}
|
||||
<TelemetryProviderRow
|
||||
key={provider.telemetryProviderId}
|
||||
provider={provider}
|
||||
index={index}
|
||||
typeLabel={
|
||||
@@ -159,19 +169,19 @@ export const ShowLogProviders = () => {
|
||||
(t) => t.type === provider.providerType,
|
||||
)?.label ?? provider.providerType
|
||||
}
|
||||
canEdit={!!permissions?.logProvider.create}
|
||||
canDelete={!!permissions?.logProvider.delete}
|
||||
canEdit={!!permissions?.telemetryProvider.create}
|
||||
canDelete={!!permissions?.telemetryProvider.delete}
|
||||
onDeleted={() => {
|
||||
refetch();
|
||||
utils.logProvider.serverStatus.invalidate();
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{permissions?.logProvider.create && (
|
||||
{permissions?.telemetryProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleLogProvider />
|
||||
<HandleTelemetryProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ export const DeleteServerModal = ({
|
||||
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}`,
|
||||
`Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`,
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
|
||||
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
||||
enableDockerCleanup: data.enableDockerCleanup,
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
.then(async (_data) => {
|
||||
await utils.server.all.invalidate();
|
||||
refetchServer();
|
||||
toast.success(serverId ? "Server Updated" : "Server Created");
|
||||
|
||||
+40
-4
@@ -59,11 +59,47 @@ export const SplunkIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const logProviderIcons = {
|
||||
export const PrometheusIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#E6522C"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 0C5.373 0 0 5.372 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12c0-6.628-5.373-12-12-12zm0 22.46c-1.885 0-3.414-1.26-3.414-2.814h6.828c0 1.553-1.528 2.813-3.414 2.813zm5.64-3.745H6.36v-2.046h11.28v2.046zm-.04-3.098H6.391c-.037-.043-.075-.086-.111-.13-1.155-1.401-1.427-2.133-1.69-2.879-.005-.025 1.4.287 2.395.511 0 0 .513.119 1.262.255-.72-.843-1.147-1.915-1.147-3.01 0-2.406 1.845-4.508 1.18-6.207.648.053 1.34 1.367 1.387 3.422.689-.951.977-2.69.977-3.755 0-1.103.727-2.385 1.454-2.429-.648 1.069.168 1.984.894 4.256.272.854.237 2.29.447 3.201.07-1.892.395-4.652 1.595-5.605-.529 1.2.079 2.702.494 3.424.671 1.164 1.078 2.047 1.078 3.716a4.642 4.642 0 01-1.11 2.996c.792-.149 1.34-.283 1.34-.283l2.573-.502s-.374 1.538-1.81 3.019z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NewRelicIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#1CE783"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.0015 14.3091v7.384L12.0008 24V12.0008L1.6078 5.9996v4.6167ZM12.0008 0 2.8232 5.2976 6.8209 7.606l5.1799-2.9893 6.3936 3.6913v7.384l-5.1783 2.9908v4.6167l9.176-5.2991V5.9996Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfluxDbIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#22ADF6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64zM10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26zm4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 01.026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 01-.364-.675l1.378-4.421a.547.547 0 01.675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 01-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 01-.65-.39l-1.922-8.372a.534.534 0 01.39-.65L19.1 5.335a.534.534 0 01.649.39l1.923 8.371c.079.31-.102.596-.39.65Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const telemetryProviderIcons = {
|
||||
loki: GrafanaLokiIcon,
|
||||
prometheus_remote_write: PrometheusIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
influxdb: InfluxDbIcon,
|
||||
datadog: DatadogIcon,
|
||||
betterstack: BetterStackIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
splunk_hec: SplunkIcon,
|
||||
aws_cloudwatch: AwsIcon,
|
||||
splunk_hec: SplunkIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
betterstack: BetterStackIcon,
|
||||
} as const;
|
||||
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||
const logoUrl =
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||
const logoLightUrl =
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
href="/"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" logoUrl={logoUrl} />
|
||||
<Logo
|
||||
className="size-10"
|
||||
logoUrl={logoUrl}
|
||||
logoLightUrl={logoLightUrl}
|
||||
/>
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
|
||||
@@ -351,7 +351,13 @@ const MENU: Menu = {
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
isEnabled: ({ permissions }) =>
|
||||
!!(
|
||||
permissions?.ai.read ||
|
||||
permissions?.ai.create ||
|
||||
permissions?.ai.update ||
|
||||
permissions?.ai.delete
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -377,10 +383,10 @@ const MENU: Menu = {
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Log Management",
|
||||
url: "/dashboard/settings/log-providers",
|
||||
title: "Logs & Metrics",
|
||||
url: "/dashboard/settings/logs-and-metrics",
|
||||
icon: ScrollText,
|
||||
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
|
||||
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -96,7 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
||||
settings: "Settings",
|
||||
session: "Session",
|
||||
network: "Network",
|
||||
logProvider: "Log Provider",
|
||||
telemetryProvider: "Logs & Metrics",
|
||||
};
|
||||
|
||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||
|
||||
@@ -180,10 +180,15 @@ 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",
|
||||
telemetryProvider: {
|
||||
label: "Logs & Metrics",
|
||||
description:
|
||||
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
|
||||
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||
},
|
||||
ai: {
|
||||
label: "AI Settings",
|
||||
description:
|
||||
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -480,13 +485,39 @@ const ACTION_META: Record<
|
||||
description: "Remove DNS providers and delete their records",
|
||||
},
|
||||
},
|
||||
logProvider: {
|
||||
read: { label: "Read", description: "View configured log providers" },
|
||||
telemetryProvider: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View configured logs and metrics providers",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new log providers and test their connection",
|
||||
description:
|
||||
"Add new providers, test their connection and deploy the Vector agent",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove logs and metrics providers",
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description:
|
||||
"View AI providers and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new AI provider configurations",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit AI provider settings and custom providers",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove AI provider configurations",
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove log providers" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -599,6 +630,7 @@ const ROLE_PRESETS: {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
|
||||
config: {
|
||||
appName?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
dualModeBranding?: boolean;
|
||||
footerText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
const appName = config.appName || "Dokploy";
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveLogoUrl =
|
||||
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
|
||||
? config.logoLightUrl
|
||||
: config.logoUrl;
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{/* Simulated sidebar header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||
{config.logoUrl ? (
|
||||
{effectiveLogoUrl ? (
|
||||
<img
|
||||
src={config.logoUrl}
|
||||
src={effectiveLogoUrl}
|
||||
alt="Preview Logo"
|
||||
className="size-8 rounded-sm object-contain"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||
@@ -36,20 +37,42 @@ const safeUrlField = z
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
logoLightUrl: z.string(),
|
||||
loginLogoLightUrl: z.string(),
|
||||
faviconLightUrl: z.string(),
|
||||
dualModeBranding: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.dualModeBranding) return;
|
||||
for (const field of [
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
] as const) {
|
||||
const val = data[field];
|
||||
if (val !== "" && !/^https?:\/\//i.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
logoLightUrl: "",
|
||||
loginLogoLightUrl: "",
|
||||
faviconLightUrl: "",
|
||||
dualModeBranding: false,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
logoLightUrl: data.logoLightUrl ?? "",
|
||||
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
|
||||
faviconLightUrl: data.faviconLightUrl ?? "",
|
||||
dualModeBranding: data.dualModeBranding ?? false,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
logoLightUrl: values.logoLightUrl || null,
|
||||
loginLogoLightUrl: values.loginLogoLightUrl || null,
|
||||
faviconLightUrl: values.faviconLightUrl || null,
|
||||
dualModeBranding: values.dualModeBranding,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Light Mode Logos Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>Light Mode Logos</CardTitle>
|
||||
<CardDescription>
|
||||
Use separate logos for light and dark themes. When enabled,
|
||||
the logos in Branding are used for dark mode.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dualModeBranding"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch
|
||||
aria-label="Enable light mode logos"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.clearErrors([
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="grid transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
gridTemplateRows: form.watch("dualModeBranding")
|
||||
? "1fr"
|
||||
: "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="loginLogoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login Page Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon-light.ico"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Follows the browser/OS theme, not the website theme.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
|
||||
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
<FolderInput className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
<span
|
||||
className="font-medium max-w-[50px] md:max-w-[150px] truncate"
|
||||
title={currentProject?.name}
|
||||
>
|
||||
{currentProject?.name || "Select Project"}
|
||||
</span>
|
||||
<ChevronDown className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
export const Logo = ({
|
||||
className = "size-14",
|
||||
logoUrl,
|
||||
logoLightUrl,
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveUrl =
|
||||
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
|
||||
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={effectiveUrl}
|
||||
alt="Organization Logo"
|
||||
className={cn(className, "object-contain rounded-sm")}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint
|
||||
CREATE TABLE "telemetryProvider" (
|
||||
"telemetryProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "TelemetryProviderType" NOT NULL,
|
||||
"signals" text[] NOT NULL,
|
||||
"endpoint" text,
|
||||
"apiKey" text,
|
||||
"apiSecret" text,
|
||||
"extraConfig" jsonb,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "server" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,19 +0,0 @@
|
||||
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
|
||||
CREATE TABLE "logProvider" (
|
||||
"logProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "LogProviderType" NOT NULL,
|
||||
"endpoint" text,
|
||||
"apiKey" text,
|
||||
"apiSecret" text,
|
||||
"extraConfig" jsonb,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "server" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "bcb4d155-4bad-4373-bbfa-73705d3a9714",
|
||||
"id": "e2f2cd03-52dd-457a-8854-1ef6d0b5d168",
|
||||
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -4310,95 +4310,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.logProvider": {
|
||||
"name": "logProvider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"logProviderId": {
|
||||
"name": "logProviderId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"providerType": {
|
||||
"name": "providerType",
|
||||
"type": "LogProviderType",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"apiKey": {
|
||||
"name": "apiKey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"apiSecret": {
|
||||
"name": "apiSecret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"extraConfig": {
|
||||
"name": "extraConfig",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organizationId": {
|
||||
"name": "organizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"logProvider_organizationId_organization_id_fk": {
|
||||
"name": "logProvider_organizationId_organization_id_fk",
|
||||
"tableFrom": "logProvider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mariadb": {
|
||||
"name": "mariadb",
|
||||
"schema": "",
|
||||
@@ -7765,8 +7676,8 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"logProviderIds": {
|
||||
"name": "logProviderIds",
|
||||
"telemetryProviderIds": {
|
||||
"name": "telemetryProviderIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -8277,6 +8188,101 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.telemetryProvider": {
|
||||
"name": "telemetryProvider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"telemetryProviderId": {
|
||||
"name": "telemetryProviderId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"providerType": {
|
||||
"name": "providerType",
|
||||
"type": "TelemetryProviderType",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"signals": {
|
||||
"name": "signals",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint": {
|
||||
"name": "endpoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"apiKey": {
|
||||
"name": "apiKey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"apiSecret": {
|
||||
"name": "apiSecret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"extraConfig": {
|
||||
"name": "extraConfig",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organizationId": {
|
||||
"name": "organizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"telemetryProvider_organizationId_organization_id_fk": {
|
||||
"name": "telemetryProvider_organizationId_organization_id_fk",
|
||||
"tableFrom": "telemetryProvider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
@@ -8901,15 +8907,15 @@
|
||||
"notNull": false,
|
||||
"default": "'0 0 * * *'"
|
||||
},
|
||||
"logProviderIds": {
|
||||
"name": "logProviderIds",
|
||||
"telemetryProviderIds": {
|
||||
"name": "telemetryProviderIds",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "ARRAY[]::text[]"
|
||||
},
|
||||
"logManagementOrganizationId": {
|
||||
"name": "logManagementOrganizationId",
|
||||
"vectorAgentOrganizationId": {
|
||||
"name": "vectorAgentOrganizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
@@ -8987,12 +8993,12 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"webServerSettings_logManagementOrganizationId_organization_id_fk": {
|
||||
"name": "webServerSettings_logManagementOrganizationId_organization_id_fk",
|
||||
"webServerSettings_vectorAgentOrganizationId_organization_id_fk": {
|
||||
"name": "webServerSettings_vectorAgentOrganizationId_organization_id_fk",
|
||||
"tableFrom": "webServerSettings",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"logManagementOrganizationId"
|
||||
"vectorAgentOrganizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -9114,18 +9120,6 @@
|
||||
"gitea"
|
||||
]
|
||||
},
|
||||
"public.LogProviderType": {
|
||||
"name": "LogProviderType",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch"
|
||||
]
|
||||
},
|
||||
"public.mountType": {
|
||||
"name": "mountType",
|
||||
"schema": "public",
|
||||
@@ -9277,6 +9271,21 @@
|
||||
"tag"
|
||||
]
|
||||
},
|
||||
"public.TelemetryProviderType": {
|
||||
"name": "TelemetryProviderType",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch",
|
||||
"prometheus_remote_write",
|
||||
"new_relic",
|
||||
"influxdb"
|
||||
]
|
||||
},
|
||||
"public.VaultProviderType": {
|
||||
"name": "VaultProviderType",
|
||||
"schema": "public",
|
||||
|
||||
@@ -1384,8 +1384,8 @@
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1791377838843,
|
||||
"tag": "0197_smiling_madrox",
|
||||
"when": 1791456930312,
|
||||
"tag": "0197_logs_and_metrics",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
+15
-17
@@ -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": "^3.1108.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
@@ -66,7 +67,7 @@
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.18",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^10.5.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/webhooks": "^13.9.0",
|
||||
@@ -86,7 +87,7 @@
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/addon-clipboard": "0.1.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.1",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
@@ -99,24 +100,24 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.13",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "4.17.21",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.4",
|
||||
"nanoid": "3.3.18",
|
||||
"next": "16.3.8",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.1.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"nodemailer": "10.0.15",
|
||||
"octokit": "3.1.2",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
@@ -134,7 +135,7 @@
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shadcn": "^4.11.0",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.11.0",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "~1.16.0",
|
||||
@@ -143,19 +144,18 @@
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"toml": "3.0.0",
|
||||
"toml": "4.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"undici": "^6.21.3",
|
||||
"undici": "^6.28.0",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"ws": "8.21.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -163,17 +163,15 @@
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
"simple-icons": "^16.14.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
faviconLightHref: string | null;
|
||||
customCss: string | null;
|
||||
baseUrl: string;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ export default function Document({
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
}: WhitelabelingDocumentProps) {
|
||||
@@ -44,7 +46,22 @@ export default function Document({
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
{faviconLightHref ? (
|
||||
<>
|
||||
<link
|
||||
rel="icon"
|
||||
href={faviconHref || "/icon.svg"}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href={faviconLightHref}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
)}
|
||||
{customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
@@ -69,6 +86,7 @@ declare global {
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
faviconLightHref: string | null;
|
||||
customCss: string | null;
|
||||
};
|
||||
expiresAt: number;
|
||||
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
|
||||
let appDescription: string | null = null;
|
||||
let ogImageUrl: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let faviconLightHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
|
||||
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
||||
: null;
|
||||
faviconHref = config.faviconUrl || null;
|
||||
faviconLightHref = config.faviconLightUrl || null;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
||||
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
},
|
||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
|
||||
const errorTitle = whitelabeling?.errorPageTitle;
|
||||
const errorDescription = whitelabeling?.errorPageDescription;
|
||||
|
||||
@@ -22,7 +23,7 @@ export default function ErrorPage({ statusCode }: Props) {
|
||||
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
||||
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo logoUrl={logoUrl} />
|
||||
<Logo logoUrl={logoUrl} logoLightUrl={logoLightUrl} />
|
||||
<span className="font-medium text-sm">{appName}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
@@ -1663,19 +1663,19 @@ const EnvironmentPage = (
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full min-w-0">
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap break-words">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<span className="text-sm font-medium text-muted-foreground wrap-anywhere line-clamp-3">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
<span className="text-sm font-medium text-muted-foreground self-start shrink-0">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
|
||||
+13
-9
@@ -123,9 +123,9 @@ const Service = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
serviceId={applicationId}
|
||||
@@ -136,20 +136,22 @@ const Service = (
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
</div>
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -165,7 +167,9 @@ const Service = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -114,9 +114,9 @@ const Service = (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<div className="flex flex-col gap-4">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||
<div className="relative flex flex-row gap-4 items-center">
|
||||
<ShowIconSettings
|
||||
serviceId={composeId}
|
||||
@@ -127,20 +127,22 @@ const Service = (
|
||||
<StatusTooltip status={data?.composeStatus} />
|
||||
</div>
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -156,7 +158,9 @@ const Service = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider>
|
||||
|
||||
+13
-9
@@ -80,9 +80,9 @@ const Libsql = (
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -90,20 +90,22 @@ const Libsql = (
|
||||
|
||||
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -119,7 +121,9 @@ const Libsql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -92,9 +92,9 @@ const Mariadb = (
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -102,20 +102,22 @@ const Mariadb = (
|
||||
|
||||
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -131,7 +133,9 @@ const Mariadb = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -91,9 +91,9 @@ const Mongo = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -101,20 +101,22 @@ const Mongo = (
|
||||
|
||||
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -130,7 +132,9 @@ const Mongo = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -91,9 +91,9 @@ const MySql = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -101,20 +101,22 @@ const MySql = (
|
||||
|
||||
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -130,7 +132,9 @@ const MySql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -90,9 +90,9 @@ const Postgresql = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -100,20 +100,22 @@ const Postgresql = (
|
||||
|
||||
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -129,7 +131,9 @@ const Postgresql = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+13
-9
@@ -90,9 +90,9 @@ const Redis = (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||
<div className="relative flex flex-row gap-4">
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={data?.applicationStatus} />
|
||||
@@ -100,20 +100,22 @@ const Redis = (
|
||||
|
||||
<RedisIcon className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
{data?.name}
|
||||
<span className="min-w-0 truncate" title={data?.name}>
|
||||
{data?.name}
|
||||
</span>
|
||||
</CardTitle>
|
||||
{data?.description && (
|
||||
<CardDescription>{data?.description}</CardDescription>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||
{data?.appName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col h-fit w-fit gap-2">
|
||||
<div className="flex flex-row h-fit w-fit gap-2">
|
||||
<div className="flex max-w-[45%] min-w-0 flex-col h-fit gap-2 md:max-w-none md:w-fit">
|
||||
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||
<Badge
|
||||
className="cursor-pointer"
|
||||
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||
onClick={() => {
|
||||
const ip = data?.server?.ipAddress || serverIp;
|
||||
if (ip) {
|
||||
@@ -129,7 +131,9 @@ const Redis = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
<span className="truncate">
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
</span>
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
+5
-5
@@ -3,16 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
|
||||
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
|
||||
import { ShowServers } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
|
||||
import { ShowTelemetryProviders } from "@/components/dashboard/settings/logs-and-metrics/show-telemetry-providers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowLogProviders />
|
||||
<ShowLogManagementServers />
|
||||
<ShowTelemetryProviders />
|
||||
<ShowServers />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ const Page = () => {
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
|
||||
return <DashboardLayout metaName="Logs & Metrics">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
Sign in
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
@@ -164,6 +164,11 @@ const Invitation = ({
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Invitation
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{isCloud ? "Sign Up" : "Setup the server"}
|
||||
|
||||
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Reset Password
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -92,6 +92,11 @@ export default function Home() {
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{whitelabeling?.appName || "Dokploy"}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { giteaRouter } from "./routers/gitea";
|
||||
import { githubRouter } from "./routers/github";
|
||||
import { gitlabRouter } from "./routers/gitlab";
|
||||
import { libsqlRouter } from "./routers/libsql";
|
||||
import { logProviderRouter } from "./routers/log-provider";
|
||||
import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
@@ -54,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key";
|
||||
import { stripeRouter } from "./routers/stripe";
|
||||
import { swarmRouter } from "./routers/swarm";
|
||||
import { tagRouter } from "./routers/tag";
|
||||
import { telemetryProviderRouter } from "./routers/telemetry-provider";
|
||||
import { transferRouter } from "./routers/transfer";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { vaultProviderRouter } from "./routers/vault-provider";
|
||||
@@ -86,7 +86,7 @@ export const appRouter = createTRPCRouter({
|
||||
github: githubRouter,
|
||||
gitlab: gitlabRouter,
|
||||
libsql: libsqlRouter,
|
||||
logProvider: logProviderRouter,
|
||||
telemetryProvider: telemetryProviderRouter,
|
||||
mariadb: mariadbRouter,
|
||||
mongo: mongoRouter,
|
||||
mounts: mountRouter,
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getAiSettingById,
|
||||
getAiSettingsByOrganizationId,
|
||||
getCustomAiProviders,
|
||||
maskAiApiKey,
|
||||
mergeAiApiKey,
|
||||
resolveAiApiKey,
|
||||
saveAiSettings,
|
||||
saveCustomAiProviders,
|
||||
suggestVariants,
|
||||
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
withAnyPermission,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
|
||||
const resolveInputApiKey = (
|
||||
input: { apiKey: string; apiUrl: string; aiId?: string },
|
||||
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
|
||||
) =>
|
||||
resolveAiApiKey({
|
||||
apiKey: input.apiKey,
|
||||
apiUrl: input.apiUrl,
|
||||
aiId: input.aiId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
authorize: () => hasPermission(ctx, { ai: ["update"] }),
|
||||
});
|
||||
|
||||
export const aiRouter = createTRPCRouter({
|
||||
one: adminProcedure
|
||||
one: withAnyPermission("ai", ["read", "update"])
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return maskAiApiKey(setting);
|
||||
}),
|
||||
|
||||
getModels: protectedProcedure
|
||||
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
getModels: withAnyPermission("ai", ["read", "create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const providerName = getProviderName(input.apiUrl);
|
||||
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
|
||||
const headers = getProviderHeaders(input.apiUrl, apiKey);
|
||||
let response = null;
|
||||
switch (providerName) {
|
||||
case "ollama":
|
||||
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
|
||||
break;
|
||||
case "gemini":
|
||||
response = await fetch(
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ headers: {} },
|
||||
);
|
||||
break;
|
||||
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API key must contain at least 1 character(s)",
|
||||
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
|
||||
get: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
create: withPermission("ai", "create")
|
||||
.input(apiCreateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "ai",
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await deleteAiSettings(input.aiId);
|
||||
update: withPermission("ai", "update")
|
||||
.input(apiUpdateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (input.apiKey) {
|
||||
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
|
||||
}
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
false,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
|
||||
async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return settings.map(({ apiKey, ...rest }) => rest);
|
||||
},
|
||||
),
|
||||
|
||||
delete: withPermission("ai", "delete")
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: setting.name,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
getCustomProviders: withAnyPermission("ai", [
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
]).query(async ({ ctx }) => {
|
||||
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
|
||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||
}),
|
||||
|
||||
analyzeLogs: protectedProcedure
|
||||
analyzeLogs: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string().min(1),
|
||||
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
const aiSettings = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
@@ -283,19 +351,21 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
testConnection: protectedProcedure
|
||||
testConnection: withAnyPermission("ai", ["create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
model: z.string().min(1),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const provider = selectAIProvider({
|
||||
apiUrl: input.apiUrl,
|
||||
apiKey: input.apiKey,
|
||||
apiKey,
|
||||
});
|
||||
const model = provider(input.model);
|
||||
const result = await generateText({
|
||||
@@ -317,7 +387,7 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
suggest: protectedProcedure
|
||||
suggest: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string(),
|
||||
@@ -338,7 +408,7 @@ ${input.logs}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
deploy: withPermission("ai", "read")
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
createLogProvider,
|
||||
deployLogManagement,
|
||||
findLogProviderForOrganization,
|
||||
findLogProvidersByOrganization,
|
||||
getLogManagementServerStatus,
|
||||
logProviderAdapters,
|
||||
removeLogManagement,
|
||||
removeLogProvider,
|
||||
sanitizeLogProvider,
|
||||
testLogProviderConnection,
|
||||
updateLogProvider,
|
||||
} from "@dokploy/server";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateLogProvider,
|
||||
apiFindOneLogProvider,
|
||||
apiRemoveLogProvider,
|
||||
apiTestLogProvider,
|
||||
apiUpdateLogProvider,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
export const logProviderRouter = createTRPCRouter({
|
||||
create: withPermission("logProvider", "create")
|
||||
.input(apiCreateLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await createLogProvider(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "logProvider",
|
||||
resourceId: provider.logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return sanitizeLogProvider(provider);
|
||||
}),
|
||||
update: withPermission("logProvider", "create")
|
||||
.input(apiUpdateLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { logProviderId, ...rest } = input;
|
||||
const provider = await findLogProviderForOrganization(
|
||||
logProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const updated = await updateLogProvider(logProviderId, rest);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "logProvider",
|
||||
resourceId: logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return sanitizeLogProvider(updated);
|
||||
}),
|
||||
remove: withPermission("logProvider", "delete")
|
||||
.input(apiRemoveLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findLogProviderForOrganization(
|
||||
input.logProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const removed = await removeLogProvider(input.logProviderId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "logProvider",
|
||||
resourceId: provider.logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return sanitizeLogProvider(removed);
|
||||
}),
|
||||
all: withPermission("logProvider", "read").query(async ({ ctx }) => {
|
||||
return await findLogProvidersByOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
one: withPermission("logProvider", "read")
|
||||
.input(apiFindOneLogProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await findLogProviderForOrganization(
|
||||
input.logProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
testConnection: withPermission("logProvider", "create")
|
||||
.input(apiTestLogProvider)
|
||||
.mutation(async ({ input }) => {
|
||||
return await testLogProviderConnection({
|
||||
providerType: input.providerType,
|
||||
config: {
|
||||
logProviderId: "test",
|
||||
name: input.name ?? "test",
|
||||
endpoint: input.endpoint ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
apiSecret: input.apiSecret ?? null,
|
||||
extraConfig: input.extraConfig ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
testConnectionById: withPermission("logProvider", "create")
|
||||
.input(apiFindOneLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await findLogProviderForOrganization(
|
||||
input.logProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return await testLogProviderConnection({
|
||||
logProviderId: input.logProviderId,
|
||||
});
|
||||
}),
|
||||
serverStatus: withPermission("logProvider", "read").query(({ ctx }) =>
|
||||
getLogManagementServerStatus(ctx.session),
|
||||
),
|
||||
deployOnServer: withPermission("logProvider", "create")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().nullable().optional(),
|
||||
logProviderIds: z.array(z.string()).min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await deployLogManagement(
|
||||
ctx.session,
|
||||
input.serverId ?? undefined,
|
||||
input.logProviderIds,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "log-management",
|
||||
});
|
||||
return { installed: true };
|
||||
}),
|
||||
removeOnServer: withPermission("logProvider", "create")
|
||||
.input(z.object({ serverId: z.string().nullable().optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await removeLogManagement(ctx.session, input.serverId ?? undefined);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "log-management",
|
||||
});
|
||||
return { installed: false };
|
||||
}),
|
||||
availableTypes: protectedProcedure.query(() => {
|
||||
return Object.values(logProviderAdapters).map((adapter) => ({
|
||||
type: adapter.type,
|
||||
label: adapter.label,
|
||||
docsUrl: adapter.docsUrl,
|
||||
credentialFields: adapter.credentialFields,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
IS_CLOUD,
|
||||
redactServerSshKey,
|
||||
removeDeploymentsByServerId,
|
||||
removeServerLogManagement,
|
||||
removeServerVectorAgent,
|
||||
serverAudit,
|
||||
serverSetup,
|
||||
serverValidate,
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
updateServerById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
findMemberByUserId,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
@@ -52,6 +55,22 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
|
||||
// Users without server.read only get what the server pickers render
|
||||
const toServerOptions = async (
|
||||
ctx: Parameters<typeof hasPermission>[0],
|
||||
servers: (typeof server.$inferSelect)[],
|
||||
) => {
|
||||
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
|
||||
if (await hasPermission(ctx, { server: ["read"] })) {
|
||||
return servers;
|
||||
}
|
||||
return servers.map(({ serverId, name, ipAddress }) => ({
|
||||
serverId,
|
||||
name,
|
||||
ipAddress,
|
||||
}));
|
||||
};
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
.input(apiCreateServer)
|
||||
@@ -217,7 +236,7 @@ export const serverRouter = createTRPCRouter({
|
||||
|
||||
return servers.length ?? 0;
|
||||
}),
|
||||
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -235,9 +254,12 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "deploy"),
|
||||
),
|
||||
});
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
}),
|
||||
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -255,7 +277,10 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "build"),
|
||||
),
|
||||
});
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
}),
|
||||
setup: withPermission("server", "create")
|
||||
.input(apiFindOneServer)
|
||||
@@ -475,7 +500,7 @@ export const serverRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
const vectorAgentRemovalWarning =
|
||||
await removeServerLogManagement(currentServer);
|
||||
await removeServerVectorAgent(currentServer);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
@@ -520,7 +545,6 @@ export const serverRouter = createTRPCRouter({
|
||||
message: "Server is inactive",
|
||||
});
|
||||
}
|
||||
|
||||
const currentServer = await updateServerById(input.serverId, {
|
||||
...input,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
checkGPUStatus,
|
||||
@@ -81,6 +82,32 @@ import {
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
|
||||
/**
|
||||
* Confines a caller-supplied Traefik file path to the Traefik configuration
|
||||
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
|
||||
* expose files listed under this directory, but the raw path was passed
|
||||
* straight to the filesystem helpers, letting a user with the `traefikFiles`
|
||||
* permission read or overwrite arbitrary files on the host (for example
|
||||
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
|
||||
* `../` traversal. Reject anything that escapes the directory.
|
||||
*/
|
||||
const resolveTraefikFilePath = (
|
||||
filePath: string,
|
||||
serverId?: string | null,
|
||||
): string => {
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
|
||||
const base = path.resolve(MAIN_TRAEFIK_PATH);
|
||||
const resolved = path.resolve(base, filePath);
|
||||
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Invalid path: file must be inside the Traefik configuration directory",
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
getWebServerSettings: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
await checkPermission(ctx, { traefikFiles: ["read"] });
|
||||
if (input?.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
|
||||
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
|
||||
return result || [];
|
||||
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
|
||||
.input(apiModifyTraefikConfig)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkPermission(ctx, { traefikFiles: ["write"] });
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
|
||||
await writeTraefikConfigInPath(
|
||||
input.path,
|
||||
safePath,
|
||||
input.traefikConfig,
|
||||
input?.serverId,
|
||||
);
|
||||
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
return readConfigInPath(input.path, input.serverId);
|
||||
const safePath = resolveTraefikFilePath(input.path, input.serverId);
|
||||
return readConfigInPath(safePath, input.serverId);
|
||||
}),
|
||||
getIp: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
applyVectorAgentSelection,
|
||||
createTelemetryProvider,
|
||||
findTelemetryProviderForOrganization,
|
||||
findTelemetryProvidersByOrganization,
|
||||
getVectorAgentTargets,
|
||||
reconcileVectorTargets,
|
||||
removeTelemetryProvider,
|
||||
sanitizeTelemetryProvider,
|
||||
telemetryProviderAdapters,
|
||||
testTelemetryProviderConnection,
|
||||
updateTelemetryProvider,
|
||||
} from "@dokploy/server";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateTelemetryProvider,
|
||||
apiFindOneTelemetryProvider,
|
||||
apiRemoveTelemetryProvider,
|
||||
apiTestTelemetryProvider,
|
||||
apiUpdateTelemetryProvider,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
export const telemetryProviderRouter = createTRPCRouter({
|
||||
create: withPermission("telemetryProvider", "create")
|
||||
.input(apiCreateTelemetryProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await createTelemetryProvider(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "telemetryProvider",
|
||||
resourceId: provider.telemetryProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return sanitizeTelemetryProvider(provider);
|
||||
}),
|
||||
update: withPermission("telemetryProvider", "create")
|
||||
.input(apiUpdateTelemetryProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { telemetryProviderId, ...rest } = input;
|
||||
const provider = await findTelemetryProviderForOrganization(
|
||||
telemetryProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const { provider: updated, targets } = await updateTelemetryProvider(
|
||||
telemetryProviderId,
|
||||
rest,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "telemetryProvider",
|
||||
resourceId: telemetryProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
const warning = await reconcileVectorTargets(targets);
|
||||
return {
|
||||
...sanitizeTelemetryProvider(updated),
|
||||
warning,
|
||||
reapplied: targets.length > 0,
|
||||
};
|
||||
}),
|
||||
remove: withPermission("telemetryProvider", "delete")
|
||||
.input(apiRemoveTelemetryProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findTelemetryProviderForOrganization(
|
||||
input.telemetryProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const { provider: removed, targets } = await removeTelemetryProvider(
|
||||
input.telemetryProviderId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "telemetryProvider",
|
||||
resourceId: provider.telemetryProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
const warning = await reconcileVectorTargets(targets);
|
||||
return { ...sanitizeTelemetryProvider(removed), warning };
|
||||
}),
|
||||
all: withPermission("telemetryProvider", "read").query(async ({ ctx }) => {
|
||||
return await findTelemetryProvidersByOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
one: withPermission("telemetryProvider", "read")
|
||||
.input(apiFindOneTelemetryProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await findTelemetryProviderForOrganization(
|
||||
input.telemetryProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
testConnection: withPermission("telemetryProvider", "create")
|
||||
.input(apiTestTelemetryProvider)
|
||||
.mutation(async ({ input }) => {
|
||||
return await testTelemetryProviderConnection({
|
||||
providerType: input.providerType,
|
||||
config: {
|
||||
telemetryProviderId: "test",
|
||||
name: input.name ?? "test",
|
||||
signals: input.signals,
|
||||
endpoint: input.endpoint ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
apiSecret: input.apiSecret ?? null,
|
||||
extraConfig: input.extraConfig ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
testConnectionById: withPermission("telemetryProvider", "create")
|
||||
.input(
|
||||
apiFindOneTelemetryProvider.extend({
|
||||
signals: z
|
||||
.array(z.enum(["logs", "metrics"]))
|
||||
.min(1)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await findTelemetryProviderForOrganization(
|
||||
input.telemetryProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return await testTelemetryProviderConnection({
|
||||
telemetryProviderId: input.telemetryProviderId,
|
||||
signals: input.signals,
|
||||
});
|
||||
}),
|
||||
serverStatus: withPermission("telemetryProvider", "read").query(({ ctx }) =>
|
||||
getVectorAgentTargets(ctx.session),
|
||||
),
|
||||
deployOnServer: withPermission("telemetryProvider", "create")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().min(1).nullable().optional(),
|
||||
providerIds: z.array(z.string()).min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { serverName } = await applyVectorAgentSelection(
|
||||
ctx.session,
|
||||
input.serverId ?? undefined,
|
||||
input.providerIds,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: serverName,
|
||||
metadata: { providerIds: input.providerIds },
|
||||
});
|
||||
return { installed: true };
|
||||
}),
|
||||
removeOnServer: withPermission("telemetryProvider", "create")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().min(1).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { serverName } = await applyVectorAgentSelection(
|
||||
ctx.session,
|
||||
input.serverId ?? undefined,
|
||||
[],
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: serverName,
|
||||
metadata: { providerIds: [] },
|
||||
});
|
||||
return { installed: false };
|
||||
}),
|
||||
availableTypes: protectedProcedure.query(() => {
|
||||
return Object.values(telemetryProviderAdapters).map((adapter) => ({
|
||||
type: adapter.type,
|
||||
label: adapter.label,
|
||||
docsUrl: adapter.docsUrl,
|
||||
credentialFields: adapter.credentialFields,
|
||||
signals: adapter.signals,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
|
||||
import { hasValidLicense } from "@dokploy/server/index";
|
||||
import type { statements } from "@dokploy/server/lib/access-control";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { checkPermission } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
checkPermission,
|
||||
resolvePermissions,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
return createInnerTRPCContext({
|
||||
req,
|
||||
res,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
session: session
|
||||
? {
|
||||
...session,
|
||||
activeOrganizationId: session.activeOrganizationId || "",
|
||||
}
|
||||
: null,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
|
||||
await checkPermission(ctx, { [resource]: [action] } as any);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const withAnyPermission = <R extends Resource>(
|
||||
resource: R,
|
||||
actions: ActionOf<R>[],
|
||||
) =>
|
||||
protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const permissions = await resolvePermissions(ctx);
|
||||
const resourcePerms = permissions[resource];
|
||||
|
||||
if (resourcePerms) {
|
||||
for (const action of actions) {
|
||||
if ((resourcePerms as Record<string, boolean>)[action as string]) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/mauriciogm/dokploy/apps/monitoring
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.11
|
||||
github.com/gofiber/fiber/v2 v2.52.14
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
|
||||
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
|
||||
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
|
||||
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"bullmq": "5.67.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"hono": "^4.11.7",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"hono": "^4.13.5",
|
||||
"ioredis": "5.4.1",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
|
||||
+23
-3
@@ -26,7 +26,7 @@
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@types/node": "^24.4.0",
|
||||
"dotenv": "16.4.5",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"tsx": "4.22.4"
|
||||
},
|
||||
@@ -46,11 +46,31 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"better-call": "1.3.7",
|
||||
"@better-fetch/fetch": "1.3.1",
|
||||
"tar": ">=7.5.19 <8.0.0",
|
||||
"protobufjs": ">=7.5.5 <8.0.0"
|
||||
"protobufjs": ">=7.5.5 <8.0.0",
|
||||
"js-yaml": ">=4.3.2 <5.0.0",
|
||||
"mysql2": ">=3.23.1 <4.0.0",
|
||||
"deepmerge-ts": ">=8.0.0 <9.0.0",
|
||||
"@ai-sdk/provider-utils": ">=4.0.33 <5.0.0",
|
||||
"hono": ">=4.13.5 <5.0.0",
|
||||
"@hono/node-server": ">=1.19.15 <2.0.0",
|
||||
"lodash": "4.18.1",
|
||||
"postcss": ">=8.5.23 <9.0.0",
|
||||
"effect": ">=3.20.0 <4.0.0",
|
||||
"h3": ">=1.15.9 <2.0.0",
|
||||
"tar-fs": ">=2.1.4 <3.0.0",
|
||||
"uuid": ">=11.1.1 <12.0.0",
|
||||
"valibot": ">=1.4.2 <2.0.0",
|
||||
"proxy-addr": ">=2.0.8 <3.0.0",
|
||||
"axios": ">=1.20.0 <2.0.0",
|
||||
"source-map-js": ">=1.2.2 <2.0.0",
|
||||
"ip-address": ">=10.7.1 <11.0.0",
|
||||
"sharp": ">=0.35.5 <0.36.0",
|
||||
"@modelcontextprotocol/sdk": ">=1.31.0 <2.0.0",
|
||||
"fast-copy": ">=3.1.0 <4.0.0"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1108.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
@@ -46,14 +47,14 @@
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^10.5.0",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@trpc/server": "11.10.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.1",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
@@ -66,13 +67,13 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.1.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"nodemailer": "10.0.15",
|
||||
"octokit": "3.1.2",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
@@ -83,34 +84,32 @@
|
||||
"react-dom": "19.2.7",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.11.0",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "~1.16.0",
|
||||
"toml": "3.0.0",
|
||||
"ws": "8.16.0",
|
||||
"yaml": "2.8.1",
|
||||
"toml": "4.2.0",
|
||||
"undici": "^6.21.3",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-alias": "0.2.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.23",
|
||||
"rimraf": "6.1.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsc-alias": "1.8.10",
|
||||
|
||||
@@ -95,4 +95,5 @@ export type AuditResourceType =
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider"
|
||||
| "logProvider";
|
||||
| "telemetryProvider"
|
||||
| "ai";
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
|
||||
applicationKey: z.string().trim().min(1),
|
||||
applicationSecret: z.string().trim().min(1),
|
||||
consumerKey: z.string().trim().min(1),
|
||||
zone: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
|
||||
@@ -17,7 +17,6 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./log-provider";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
@@ -42,6 +41,7 @@ export * from "./shared";
|
||||
export * from "./ssh-key";
|
||||
export * from "./sso";
|
||||
export * from "./tag";
|
||||
export * from "./telemetry-provider";
|
||||
export * from "./transfer";
|
||||
export * from "./user";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -42,7 +42,7 @@ export const server = pgTable("server", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("server")),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
logProviderIds: text("logProviderIds")
|
||||
telemetryProviderIds: text("telemetryProviderIds")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
|
||||
+27
-24
@@ -5,22 +5,26 @@ import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const logProviderType = pgEnum("LogProviderType", [
|
||||
export const telemetryProviderType = pgEnum("TelemetryProviderType", [
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch",
|
||||
"prometheus_remote_write",
|
||||
"new_relic",
|
||||
"influxdb",
|
||||
]);
|
||||
|
||||
export const logProvider = pgTable("logProvider", {
|
||||
logProviderId: text("logProviderId")
|
||||
export const telemetryProvider = pgTable("telemetryProvider", {
|
||||
telemetryProviderId: text("telemetryProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: logProviderType("providerType").notNull(),
|
||||
providerType: telemetryProviderType("providerType").notNull(),
|
||||
signals: text("signals").array().notNull(),
|
||||
endpoint: encryptedText("endpoint"),
|
||||
apiKey: encryptedText("apiKey"),
|
||||
apiSecret: encryptedText("apiSecret"),
|
||||
@@ -34,17 +38,11 @@ export const logProvider = pgTable("logProvider", {
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
const createSchema = createInsertSchema(logProvider, {
|
||||
logProviderId: z.string().min(1),
|
||||
const createSchema = createInsertSchema(telemetryProvider, {
|
||||
telemetryProviderId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
providerType: z.enum([
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch",
|
||||
]),
|
||||
providerType: z.enum(telemetryProviderType.enumValues),
|
||||
signals: z.array(z.enum(["logs", "metrics"])).min(1),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -60,28 +58,33 @@ const createSchema = createInsertSchema(logProvider, {
|
||||
organizationId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiCreateLogProvider = createSchema
|
||||
export const apiCreateTelemetryProvider = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
providerType: true,
|
||||
signals: true,
|
||||
endpoint: true,
|
||||
apiKey: true,
|
||||
apiSecret: true,
|
||||
extraConfig: true,
|
||||
enabled: true,
|
||||
})
|
||||
.required({ name: true, providerType: true });
|
||||
.required({ name: true, providerType: true, signals: true });
|
||||
|
||||
export const apiUpdateLogProvider = apiCreateLogProvider.partial().extend({
|
||||
logProviderId: z.string().min(1),
|
||||
export const apiUpdateTelemetryProvider = apiCreateTelemetryProvider
|
||||
.partial()
|
||||
.extend({
|
||||
telemetryProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveTelemetryProvider = z.object({
|
||||
telemetryProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveLogProvider = z.object({
|
||||
logProviderId: z.string().min(1),
|
||||
export const apiFindOneTelemetryProvider = z.object({
|
||||
telemetryProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneLogProvider = z.object({
|
||||
logProviderId: z.string().min(1),
|
||||
export const apiTestTelemetryProvider = apiCreateTelemetryProvider.partial({
|
||||
name: true,
|
||||
});
|
||||
|
||||
export const apiTestLogProvider = apiCreateLogProvider.partial({ name: true });
|
||||
@@ -27,11 +27,11 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||
logProviderIds: text("logProviderIds")
|
||||
telemetryProviderIds: text("telemetryProviderIds")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
logManagementOrganizationId: text("logManagementOrganizationId").references(
|
||||
vectorAgentOrganizationId: text("vectorAgentOrganizationId").references(
|
||||
() => organization.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
@@ -97,6 +97,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
errorPageDescription: string | null;
|
||||
footerText: string | null;
|
||||
ogImageUrl: string | null;
|
||||
logoLightUrl: string | null;
|
||||
loginLogoLightUrl: string | null;
|
||||
faviconLightUrl: string | null;
|
||||
dualModeBranding: boolean;
|
||||
}>()
|
||||
.default({
|
||||
appName: null,
|
||||
@@ -111,6 +115,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
errorPageDescription: null,
|
||||
footerText: null,
|
||||
ogImageUrl: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
}),
|
||||
// Deployment Configuration (self-hosted only)
|
||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||
@@ -141,46 +149,52 @@ const createSchema = createInsertSchema(webServerSettings, {
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
serverIp: z.string().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
https: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
letsEncryptEmail: z.string().email().optional().nullable(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
enableDockerCleanup: z.boolean().optional(),
|
||||
logCleanupCron: z.string().optional().nullable(),
|
||||
metricsConfig: z
|
||||
.object({
|
||||
server: z.object({
|
||||
type: z.enum(["Dokploy", "Remote"]),
|
||||
refreshRate: z.number(),
|
||||
port: z.number(),
|
||||
token: z.string(),
|
||||
urlCallback: z.string(),
|
||||
retentionDays: z.number(),
|
||||
cronJob: z.string(),
|
||||
thresholds: z.object({
|
||||
cpu: z.number(),
|
||||
memory: z.number(),
|
||||
export const apiUpdateWebServerSettings = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
serverIp: z.string().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
https: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
letsEncryptEmail: z.string().email().optional().nullable(),
|
||||
sshPrivateKey: z.string().optional(),
|
||||
enableDockerCleanup: z.boolean().optional(),
|
||||
logCleanupCron: z.string().optional().nullable(),
|
||||
metricsConfig: z
|
||||
.object({
|
||||
server: z.object({
|
||||
type: z.enum(["Dokploy", "Remote"]),
|
||||
refreshRate: z.number(),
|
||||
port: z.number(),
|
||||
token: z.string(),
|
||||
urlCallback: z.string(),
|
||||
retentionDays: z.number(),
|
||||
cronJob: z.string(),
|
||||
thresholds: z.object({
|
||||
cpu: z.number(),
|
||||
memory: z.number(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
containers: z.object({
|
||||
refreshRate: z.number(),
|
||||
services: z.object({
|
||||
include: z.array(z.string()),
|
||||
exclude: z.array(z.string()),
|
||||
containers: z.object({
|
||||
refreshRate: z.number(),
|
||||
services: z.object({
|
||||
include: z.array(z.string()),
|
||||
exclude: z.array(z.string()),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
cleanupCacheApplications: z.boolean().optional(),
|
||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
remoteServersOnly: z.boolean().optional(),
|
||||
enforceSSO: z.boolean().optional(),
|
||||
buildsConcurrency: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
})
|
||||
.optional(),
|
||||
cleanupCacheApplications: z.boolean().optional(),
|
||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
remoteServersOnly: z.boolean().optional(),
|
||||
enforceSSO: z.boolean().optional(),
|
||||
buildsConcurrency: z.number().int().min(1).max(100).optional(),
|
||||
})
|
||||
.omit({
|
||||
telemetryProviderIds: true,
|
||||
vectorAgentOrganizationId: true,
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerBuildsConcurrency = z.object({
|
||||
buildsConcurrency: z.number().int().min(1).max(100),
|
||||
@@ -234,6 +248,10 @@ export const whitelabelingConfigSchema = z.object({
|
||||
errorPageDescription: z.string().nullable(),
|
||||
footerText: z.string().nullable(),
|
||||
ogImageUrl: safeUrl,
|
||||
logoLightUrl: safeUrl,
|
||||
loginLogoLightUrl: safeUrl,
|
||||
faviconLightUrl: safeUrl,
|
||||
dualModeBranding: z.boolean(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabeling = z.object({
|
||||
|
||||
+8
-8
@@ -476,8 +476,8 @@ packages:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
chokidar@4.0.3:
|
||||
@@ -773,8 +773,8 @@ packages:
|
||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
@@ -1166,7 +1166,7 @@ snapshots:
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -1200,7 +1200,7 @@ snapshots:
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
@@ -1361,7 +1361,7 @@ snapshots:
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -1490,7 +1490,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user