mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-21 16:05:25 +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.
|
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
- **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.
|
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
- **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.).
|
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inngest": "3.40.1",
|
"inngest": "3.54.0",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.19.15",
|
||||||
"@hono/zod-validator": "0.7.6",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.13.5",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
|
|||||||
@@ -74,4 +74,60 @@ volumes:
|
|||||||
);
|
);
|
||||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
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");
|
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", () => {
|
describe("ovhClient.listRecords", () => {
|
||||||
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
|
|||||||
"Invalid signature",
|
"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 () => {
|
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||||
send.mockResolvedValueOnce({
|
send.mockResolvedValueOnce({
|
||||||
ResourceRecordSets: [
|
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 () => {
|
it("throws when the record no longer exists", async () => {
|
||||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
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 {
|
import {
|
||||||
getLogProviderAdapter,
|
getTelemetryProviderAdapter,
|
||||||
logProviderAdapters,
|
telemetryProviderAdapters,
|
||||||
} from "@dokploy/server/services/log-management/providers/registry";
|
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||||
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
|
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const baseConfig: LogProviderRuntimeConfig = {
|
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||||
logProviderId: "log-provider-1",
|
telemetryProviderId: "provider-1",
|
||||||
name: "test",
|
name: "test",
|
||||||
|
signals: ["logs"],
|
||||||
endpoint: null,
|
endpoint: null,
|
||||||
apiKey: null,
|
apiKey: null,
|
||||||
apiSecret: null,
|
apiSecret: null,
|
||||||
extraConfig: null,
|
extraConfig: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("getLogProviderAdapter", () => {
|
describe("getTelemetryProviderAdapter", () => {
|
||||||
it("returns the registered adapter for each known type", () => {
|
it("returns the registered adapter for each known type", () => {
|
||||||
expect(getLogProviderAdapter("loki").type).toBe("loki");
|
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
|
||||||
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
|
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
|
||||||
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
|
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
|
||||||
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
|
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
|
||||||
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
|
"elasticsearch",
|
||||||
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
|
);
|
||||||
|
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", () => {
|
it("throws an explicit error for an unregistered type", () => {
|
||||||
// @ts-expect-error: intentionally an unregistered type
|
// @ts-expect-error: intentionally an unregistered type
|
||||||
expect(() => getLogProviderAdapter("unknown")).toThrow(
|
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
|
||||||
/No LogProviderAdapter registered/,
|
/No TelemetryProviderAdapter registered/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lokiAdapter.toVectorSink", () => {
|
describe("lokiAdapter.toVectorSink", () => {
|
||||||
it("maps endpoint + labels, without tenant_id if not set", () => {
|
it("maps endpoint + labels, without tenant_id if not set", () => {
|
||||||
const adapter = logProviderAdapters.loki;
|
const adapter = telemetryProviderAdapters.loki;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
||||||
"sink_1",
|
"sink_1",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("loki");
|
expect(sink.type).toBe("loki");
|
||||||
expect(sink.inputs).toEqual(["dokploy_scope"]);
|
expect(sink.inputs).toEqual(["dokploy_scope"]);
|
||||||
expect(sink.endpoint).toBe("https://loki.example.com");
|
expect(sink.endpoint).toBe("https://loki.example.com");
|
||||||
expect(sink.labels).toMatchObject({
|
expect(sink.labels).toMatchObject({
|
||||||
dokploy_project: "{{ dokploy_project }}",
|
dokploy_project: "{{ dokploy_project }}",
|
||||||
|
dokploy_environment: "{{ dokploy_environment }}",
|
||||||
dokploy_application: "{{ dokploy_application }}",
|
dokploy_application: "{{ dokploy_application }}",
|
||||||
dokploy_organization: "{{ dokploy_organization }}",
|
dokploy_organization: "{{ dokploy_organization }}",
|
||||||
|
dokploy_server: "{{ dokploy_server }}",
|
||||||
});
|
});
|
||||||
expect(sink.tenant_id).toBeUndefined();
|
expect(sink.tenant_id).toBeUndefined();
|
||||||
expect(sink.buffer).toEqual({
|
expect(sink.buffer).toEqual({
|
||||||
@@ -57,7 +65,7 @@ describe("lokiAdapter.toVectorSink", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes tenant_id when present in extraConfig", () => {
|
it("includes tenant_id when present in extraConfig", () => {
|
||||||
const adapter = logProviderAdapters.loki;
|
const adapter = telemetryProviderAdapters.loki;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -66,22 +74,24 @@ describe("lokiAdapter.toVectorSink", () => {
|
|||||||
},
|
},
|
||||||
"sink_1",
|
"sink_1",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.tenant_id).toBe("tenant-a");
|
expect(sink.tenant_id).toBe("tenant-a");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
||||||
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("datadogAdapter.toVectorSink", () => {
|
describe("datadogAdapter.toVectorSink", () => {
|
||||||
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
|
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
|
||||||
const adapter = logProviderAdapters.datadog;
|
const adapter = telemetryProviderAdapters.datadog;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{ ...baseConfig, apiKey: "dd-key" },
|
{ ...baseConfig, apiKey: "dd-key" },
|
||||||
"sink_2",
|
"sink_2",
|
||||||
"transform_2",
|
"transform_2",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("datadog_logs");
|
expect(sink.type).toBe("datadog_logs");
|
||||||
expect(sink.default_api_key).toBe("dd-key");
|
expect(sink.default_api_key).toBe("dd-key");
|
||||||
@@ -91,7 +101,7 @@ describe("datadogAdapter.toVectorSink", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses extraConfig.site when present", () => {
|
it("uses extraConfig.site when present", () => {
|
||||||
const adapter = logProviderAdapters.datadog;
|
const adapter = telemetryProviderAdapters.datadog;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -100,12 +110,13 @@ describe("datadogAdapter.toVectorSink", () => {
|
|||||||
},
|
},
|
||||||
"sink_2",
|
"sink_2",
|
||||||
"transform_2",
|
"transform_2",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.site).toBe("datadoghq.eu");
|
expect(sink.site).toBe("datadoghq.eu");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
|
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(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -114,29 +125,35 @@ describe("datadogAdapter.toVectorSink", () => {
|
|||||||
},
|
},
|
||||||
"sink_2",
|
"sink_2",
|
||||||
"transform_2",
|
"transform_2",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.site).toBe("datadoghq.eu");
|
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)", () => {
|
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
|
||||||
const adapter = logProviderAdapters.datadog;
|
const adapter = telemetryProviderAdapters.datadog;
|
||||||
expect(adapter.toVectorTransform).toBeDefined();
|
expect(adapter.toVectorTransform).toBeDefined();
|
||||||
const transform = adapter.toVectorTransform?.(
|
const transform = adapter.toVectorTransform?.(
|
||||||
baseConfig,
|
baseConfig,
|
||||||
"transform_2",
|
"transform_2",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(transform?.type).toBe("remap");
|
expect(transform?.type).toBe("remap");
|
||||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||||
expect(transform?.source).toContain(".ddtags");
|
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", () => {
|
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?.(
|
const transform = adapter.toVectorTransform?.(
|
||||||
baseConfig,
|
baseConfig,
|
||||||
"transform_2",
|
"transform_2",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(transform?.source).toContain(
|
expect(transform?.source).toContain(
|
||||||
'replace(to_string!(.dokploy_project), ",", "_")',
|
'replace(to_string!(.dokploy_project), ",", "_")',
|
||||||
@@ -146,12 +163,13 @@ describe("datadogAdapter.toVectorSink", () => {
|
|||||||
|
|
||||||
describe("betterStackAdapter", () => {
|
describe("betterStackAdapter", () => {
|
||||||
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
||||||
const adapter = logProviderAdapters.betterstack;
|
const adapter = telemetryProviderAdapters.betterstack;
|
||||||
expect(adapter.toVectorTransform).toBeDefined();
|
expect(adapter.toVectorTransform).toBeDefined();
|
||||||
const transform = adapter.toVectorTransform?.(
|
const transform = adapter.toVectorTransform?.(
|
||||||
baseConfig,
|
baseConfig,
|
||||||
"transform_1",
|
"transform_1",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(transform).toEqual({
|
expect(transform).toEqual({
|
||||||
type: "remap",
|
type: "remap",
|
||||||
@@ -161,7 +179,7 @@ describe("betterStackAdapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
|
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(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -170,6 +188,7 @@ describe("betterStackAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_3",
|
"sink_3",
|
||||||
"transform_1",
|
"transform_1",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("http");
|
expect(sink.type).toBe("http");
|
||||||
expect(sink.inputs).toEqual(["transform_1"]);
|
expect(sink.inputs).toEqual(["transform_1"]);
|
||||||
@@ -181,12 +200,13 @@ describe("betterStackAdapter", () => {
|
|||||||
|
|
||||||
describe("elasticsearchAdapter", () => {
|
describe("elasticsearchAdapter", () => {
|
||||||
it("has a toVectorTransform that flattens .label to a JSON string", () => {
|
it("has a toVectorTransform that flattens .label to a JSON string", () => {
|
||||||
const adapter = logProviderAdapters.elasticsearch;
|
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||||
expect(adapter.toVectorTransform).toBeDefined();
|
expect(adapter.toVectorTransform).toBeDefined();
|
||||||
const transform = adapter.toVectorTransform?.(
|
const transform = adapter.toVectorTransform?.(
|
||||||
baseConfig,
|
baseConfig,
|
||||||
"transform_4",
|
"transform_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(transform?.type).toBe("remap");
|
expect(transform?.type).toBe("remap");
|
||||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||||
@@ -194,7 +214,7 @@ describe("elasticsearchAdapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses basic auth when a username is set", () => {
|
it("uses basic auth when a username is set", () => {
|
||||||
const adapter = logProviderAdapters.elasticsearch;
|
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -204,6 +224,7 @@ describe("elasticsearchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_4",
|
"sink_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("elasticsearch");
|
expect(sink.type).toBe("elasticsearch");
|
||||||
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
|
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", () => {
|
it("falls back to an ApiKey header when no username is set", () => {
|
||||||
const adapter = logProviderAdapters.elasticsearch;
|
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -225,6 +246,7 @@ describe("elasticsearchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_4",
|
"sink_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.auth).toBeUndefined();
|
expect(sink.auth).toBeUndefined();
|
||||||
expect(sink.request).toEqual({
|
expect(sink.request).toEqual({
|
||||||
@@ -233,7 +255,7 @@ describe("elasticsearchAdapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
|
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
|
||||||
const adapter = logProviderAdapters.elasticsearch;
|
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||||
expect(() =>
|
expect(() =>
|
||||||
adapter.toVectorSink(
|
adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
@@ -243,16 +265,18 @@ describe("elasticsearchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_4",
|
"sink_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
),
|
),
|
||||||
).toThrow(/Password.*required/i);
|
).toThrow(/Password.*required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes bulk.index only when extraConfig.index is set", () => {
|
it("includes bulk.index only when extraConfig.index is set", () => {
|
||||||
const adapter = logProviderAdapters.elasticsearch;
|
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||||
const withoutIndex = adapter.toVectorSink(
|
const withoutIndex = adapter.toVectorSink(
|
||||||
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
||||||
"sink_4",
|
"sink_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(withoutIndex.bulk).toBeUndefined();
|
expect(withoutIndex.bulk).toBeUndefined();
|
||||||
const withIndex = adapter.toVectorSink(
|
const withIndex = adapter.toVectorSink(
|
||||||
@@ -263,6 +287,7 @@ describe("elasticsearchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_4",
|
"sink_4",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
||||||
});
|
});
|
||||||
@@ -270,11 +295,13 @@ describe("elasticsearchAdapter", () => {
|
|||||||
|
|
||||||
describe("splunkAdapter", () => {
|
describe("splunkAdapter", () => {
|
||||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
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", () => {
|
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(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -283,6 +310,7 @@ describe("splunkAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_5",
|
"sink_5",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("splunk_hec_logs");
|
expect(sink.type).toBe("splunk_hec_logs");
|
||||||
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
|
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
|
||||||
@@ -300,6 +328,7 @@ describe("splunkAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_5",
|
"sink_5",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(withExtras.index).toBe("dokploy");
|
expect(withExtras.index).toBe("dokploy");
|
||||||
expect(withExtras.sourcetype).toBe("docker");
|
expect(withExtras.sourcetype).toBe("docker");
|
||||||
@@ -307,14 +336,19 @@ describe("splunkAdapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("awsCloudwatchAdapter", () => {
|
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(
|
expect(
|
||||||
logProviderAdapters.aws_cloudwatch.toVectorTransform,
|
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
|
||||||
).toBeUndefined();
|
baseConfig,
|
||||||
|
"transform_1",
|
||||||
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("toVectorSink maps access key/secret, region and log group/stream", () => {
|
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(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -324,6 +358,7 @@ describe("awsCloudwatchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_6",
|
"sink_6",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.type).toBe("aws_cloudwatch_logs");
|
expect(sink.type).toBe("aws_cloudwatch_logs");
|
||||||
expect(sink.group_name).toBe("/dokploy/logs");
|
expect(sink.group_name).toBe("/dokploy/logs");
|
||||||
@@ -336,7 +371,7 @@ describe("awsCloudwatchAdapter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses extraConfig.logStream as the stream template when set", () => {
|
it("uses extraConfig.logStream as the stream template when set", () => {
|
||||||
const adapter = logProviderAdapters.aws_cloudwatch;
|
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||||
const sink = adapter.toVectorSink(
|
const sink = adapter.toVectorSink(
|
||||||
{
|
{
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
@@ -350,6 +385,7 @@ describe("awsCloudwatchAdapter", () => {
|
|||||||
},
|
},
|
||||||
"sink_6",
|
"sink_6",
|
||||||
"dokploy_scope",
|
"dokploy_scope",
|
||||||
|
"logs",
|
||||||
);
|
);
|
||||||
expect(sink.stream_name).toBe("{{ dokploy_application }}");
|
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();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owner bypasses logProvider.read", async () => {
|
it("owner bypasses telemetryProvider.read", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
await expect(
|
await expect(
|
||||||
checkPermission(ctx, { logProvider: ["read"] }),
|
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("admin bypasses logProvider.create", async () => {
|
it("admin bypasses telemetryProvider.create", async () => {
|
||||||
memberToReturn = mockMemberData("admin");
|
memberToReturn = mockMemberData("admin");
|
||||||
await expect(
|
await expect(
|
||||||
checkPermission(ctx, { logProvider: ["create"] }),
|
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,17 +154,17 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
|||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("member is denied logProvider.read", async () => {
|
it("member is denied telemetryProvider.read", async () => {
|
||||||
memberToReturn = mockMemberData("member");
|
memberToReturn = mockMemberData("member");
|
||||||
await expect(
|
await expect(
|
||||||
checkPermission(ctx, { logProvider: ["read"] }),
|
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("member is denied logProvider.create", async () => {
|
it("member is denied telemetryProvider.create", async () => {
|
||||||
memberToReturn = mockMemberData("member");
|
memberToReturn = mockMemberData("member");
|
||||||
await expect(
|
await expect(
|
||||||
checkPermission(ctx, { logProvider: ["create"] }),
|
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const FREE_TIER_RESOURCES = [
|
|||||||
"gitProviders",
|
"gitProviders",
|
||||||
"traefikFiles",
|
"traefikFiles",
|
||||||
"api",
|
"api",
|
||||||
"logProvider",
|
"telemetryProvider",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTERPRISE_RESOURCES = [
|
const ENTERPRISE_RESOURCES = [
|
||||||
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
|||||||
"auditLog",
|
"auditLog",
|
||||||
"vaultProvider",
|
"vaultProvider",
|
||||||
"dnsProvider",
|
"dnsProvider",
|
||||||
|
"ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("enterpriseOnlyResources set", () => {
|
describe("enterpriseOnlyResources set", () => {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ describe("enterprise resources for static roles", () => {
|
|||||||
expect(perms.destination.read).toBe(false);
|
expect(perms.destination.read).toBe(false);
|
||||||
expect(perms.notification.read).toBe(false);
|
expect(perms.notification.read).toBe(false);
|
||||||
expect(perms.auditLog.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,
|
letsEncryptEmail: null,
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
enableDockerCleanup: false,
|
enableDockerCleanup: false,
|
||||||
logProviderIds: [],
|
telemetryProviderIds: [],
|
||||||
logManagementOrganizationId: null,
|
vectorAgentOrganizationId: null,
|
||||||
buildsConcurrency: 1,
|
buildsConcurrency: 1,
|
||||||
logCleanupCron: null,
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
@@ -64,6 +64,10 @@ const baseSettings: WebServerSettings = {
|
|||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
ogImageUrl: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
|
logoLightUrl: null,
|
||||||
|
loginLogoLightUrl: null,
|
||||||
|
faviconLightUrl: null,
|
||||||
|
dualModeBranding: false,
|
||||||
},
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: 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" },
|
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||||
});
|
});
|
||||||
expect(bytes).toBe(11);
|
expect(bytes).toBe(11);
|
||||||
});
|
}, 30_000);
|
||||||
|
|
||||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||||
|
|||||||
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={(value: DatabaseType) => {
|
onValueChange={(value: DatabaseType) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("metadata", {});
|
form.setValue("metadata", {
|
||||||
|
serviceName:
|
||||||
|
form.getValues("metadata.serviceName") ?? "",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value || undefined}
|
value={field.value ?? ""}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|||||||
@@ -45,13 +45,19 @@ function StatCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
delta,
|
delta,
|
||||||
|
href,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
delta?: string;
|
delta?: string;
|
||||||
|
href?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const content = (
|
||||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
<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">
|
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -63,6 +69,16 @@ function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="block h-full">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusListCard({
|
function StatusListCard({
|
||||||
@@ -182,6 +198,7 @@ export const ShowHome = () => {
|
|||||||
label="Projects"
|
label="Projects"
|
||||||
value={String(totals.projects)}
|
value={String(totals.projects)}
|
||||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||||
|
href="/dashboard/projects"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Services"
|
label="Services"
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
|
|||||||
<Logo
|
<Logo
|
||||||
className="w-10 h-10"
|
className="w-10 h-10"
|
||||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||||
|
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||||
/>
|
/>
|
||||||
{!isImpersonating ? (
|
{!isImpersonating ? (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<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 { api } from "@/utils/api";
|
||||||
import { displayFont } from "../font";
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
const stripePromise = loadStripe(
|
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||||
);
|
: null;
|
||||||
|
|
||||||
type Tier = "hobby" | "startup";
|
type Tier = "hobby" | "startup";
|
||||||
|
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
{viewMode === "detailed" &&
|
{viewMode === "detailed" &&
|
||||||
template?.tags?.length > 0 && (
|
template?.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{template?.tags?.map((tag) => (
|
{template.tags.slice(0, 6).map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="green"
|
variant="green"
|
||||||
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
|||||||
export const AiForm = () => {
|
export const AiForm = () => {
|
||||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { data: currentUser } = api.user.get.useQuery();
|
const { data: currentUser } = api.user.get.useQuery();
|
||||||
|
|
||||||
const isOrgAdmin =
|
const isOrgAdmin =
|
||||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||||
|
|
||||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{isOrgAdmin && <HandleAiProviders />}
|
{isOrgAdmin && <HandleAiProviders />}
|
||||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||||
|
<HandleAi />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<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">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
You don't have any AI configurations
|
You don't have any AI configurations
|
||||||
</span>
|
</span>
|
||||||
<HandleAi />
|
{permissions?.ai.create && <HandleAi />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
|||||||
<CardDescription>{config.model}</CardDescription>
|
<CardDescription>{config.model}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<HandleAi aiId={config.aiId} />
|
{permissions?.ai.update && (
|
||||||
<DialogAction
|
<HandleAi aiId={config.aiId} />
|
||||||
title="Delete AI"
|
)}
|
||||||
description="Are you sure you want to delete this AI?"
|
{permissions?.ai.delete && (
|
||||||
type="destructive"
|
<DialogAction
|
||||||
onClick={async () => {
|
title="Delete AI"
|
||||||
await mutateAsync({
|
description="Are you sure you want to delete this AI?"
|
||||||
aiId: config.aiId,
|
type="destructive"
|
||||||
})
|
onClick={async () => {
|
||||||
.then(() => {
|
await mutateAsync({
|
||||||
toast.success("AI deleted successfully");
|
aiId: config.aiId,
|
||||||
refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting AI");
|
toast.success("AI deleted successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error deleting AI");
|
||||||
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
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const stripePromise = loadStripe(
|
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
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. */
|
/** 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) => {
|
export const calculatePrice = (count: number, isAnnual = false) => {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
|
|||||||
applicationKey: z.string(),
|
applicationKey: z.string(),
|
||||||
applicationSecret: z.string(),
|
applicationSecret: z.string(),
|
||||||
consumerKey: z.string(),
|
consumerKey: z.string(),
|
||||||
|
zone: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||||
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
|
|||||||
applicationKey: "",
|
applicationKey: "",
|
||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
consumerKey: "",
|
consumerKey: "",
|
||||||
|
zone: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConfig = (data: DnsProviderForm) => {
|
const buildConfig = (data: DnsProviderForm) => {
|
||||||
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
|
|||||||
applicationKey: data.applicationKey,
|
applicationKey: data.applicationKey,
|
||||||
applicationSecret: data.applicationSecret,
|
applicationSecret: data.applicationSecret,
|
||||||
consumerKey: data.consumerKey,
|
consumerKey: data.consumerKey,
|
||||||
|
zone: data.zone.trim() || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
applicationKey: provider.config.applicationKey,
|
applicationKey: provider.config.applicationKey,
|
||||||
applicationSecret: provider.config.applicationSecret,
|
applicationSecret: provider.config.applicationSecret,
|
||||||
consumerKey: provider.config.consumerKey,
|
consumerKey: provider.config.consumerKey,
|
||||||
|
zone: provider.config.zone ?? "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else if (!dnsProviderId) {
|
} else if (!dnsProviderId) {
|
||||||
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
</FormItem>
|
</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 ?? "",
|
apiUrl: apiUrl ?? "",
|
||||||
apiKey: apiKey ?? "",
|
apiKey: apiKey ?? "",
|
||||||
|
...(aiId ? { aiId } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
apiUrl={apiUrl}
|
apiUrl={apiUrl}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
model={form.watch("model")}
|
model={form.watch("model")}
|
||||||
|
aiId={aiId}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending}>
|
||||||
{aiId ? "Update" : "Create"}
|
{aiId ? "Update" : "Create"}
|
||||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
|||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
model,
|
||||||
|
aiId,
|
||||||
}: {
|
}: {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
aiId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isDisabled || isPending}
|
disabled={isDisabled || isPending}
|
||||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
onClick={() =>
|
||||||
|
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -32,71 +32,92 @@ const statusBadge = (status: Status) => {
|
|||||||
return <Badge variant="destructive">Unreachable</Badge>;
|
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 utils = api.useUtils();
|
||||||
const [selection, setSelection] = useState<Record<string, string[]>>({});
|
const [selection, setSelection] = useState<Record<string, string[]>>({});
|
||||||
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
|
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: providers } = api.telemetryProvider.all.useQuery();
|
||||||
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
|
const { data: targets, isPending } =
|
||||||
undefined,
|
api.telemetryProvider.serverStatus.useQuery(undefined, {
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
trpc: { context: { skipBatch: true } },
|
trpc: { context: { skipBatch: true } },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: deployOnServer } =
|
const { mutateAsync: deployOnServer } =
|
||||||
api.logProvider.deployOnServer.useMutation();
|
api.telemetryProvider.deployOnServer.useMutation();
|
||||||
const { mutateAsync: removeOnServer } =
|
const { mutateAsync: removeOnServer } =
|
||||||
api.logProvider.removeOnServer.useMutation();
|
api.telemetryProvider.removeOnServer.useMutation();
|
||||||
|
|
||||||
const keyOf = (serverId: string | null) => serverId ?? "local";
|
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||||
|
|
||||||
const idsFor = (serverId: string | null, saved: string[]) =>
|
// The selection can still hold providers deleted since it was made.
|
||||||
selection[keyOf(serverId)] ?? saved;
|
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 = (
|
const toggleProvider = (
|
||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
saved: string[],
|
saved: string[],
|
||||||
logProviderId: string,
|
telemetryProviderId: string,
|
||||||
) => {
|
) => {
|
||||||
const key = keyOf(serverId);
|
const key = keyOf(serverId);
|
||||||
const current = selection[key] ?? saved;
|
const current = idsFor(serverId, saved);
|
||||||
setSelection({
|
setSelection({
|
||||||
...selection,
|
...selection,
|
||||||
[key]: current.includes(logProviderId)
|
[key]: current.includes(telemetryProviderId)
|
||||||
? current.filter((id) => id !== logProviderId)
|
? current.filter((id) => id !== telemetryProviderId)
|
||||||
: [...current, logProviderId],
|
: [...current, telemetryProviderId],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setError = (serverId: string | null, message?: string) =>
|
const setError = (
|
||||||
|
serverId: string | null,
|
||||||
|
error?: { title: string; message: string },
|
||||||
|
) =>
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (message) {
|
if (error) {
|
||||||
next[keyOf(serverId)] = message;
|
next[keyOf(serverId)] = error;
|
||||||
} else {
|
} else {
|
||||||
delete next[keyOf(serverId)];
|
delete next[keyOf(serverId)];
|
||||||
}
|
}
|
||||||
return next;
|
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[]) => {
|
const handleDeploy = async (serverId: string | null, ids: string[]) => {
|
||||||
setPendingTarget(keyOf(serverId));
|
setPendingTarget(keyOf(serverId));
|
||||||
setError(serverId);
|
setError(serverId);
|
||||||
await deployOnServer({ serverId, logProviderIds: ids })
|
await deployOnServer({ serverId, providerIds: ids })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Vector agent deployed");
|
toast.success("Vector agent deployed");
|
||||||
utils.logProvider.serverStatus.invalidate();
|
refreshStatus();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setError(
|
setError(serverId, {
|
||||||
serverId,
|
title: "Deploy failed",
|
||||||
error instanceof Error ? error.message : "Failed to deploy the agent",
|
message:
|
||||||
);
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to deploy the agent",
|
||||||
|
});
|
||||||
|
refreshStatus();
|
||||||
})
|
})
|
||||||
.finally(() => setPendingTarget(null));
|
.finally(() => setPendingTarget(null));
|
||||||
};
|
};
|
||||||
@@ -108,13 +129,17 @@ export const ShowLogManagementServers = () => {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Vector agent removed");
|
toast.success("Vector agent removed");
|
||||||
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
|
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
|
||||||
utils.logProvider.serverStatus.invalidate();
|
refreshStatus();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setError(
|
setError(serverId, {
|
||||||
serverId,
|
title: "Remove failed",
|
||||||
error instanceof Error ? error.message : "Failed to remove the agent",
|
message:
|
||||||
);
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to remove the agent",
|
||||||
|
});
|
||||||
|
refreshStatus();
|
||||||
})
|
})
|
||||||
.finally(() => setPendingTarget(null));
|
.finally(() => setPendingTarget(null));
|
||||||
};
|
};
|
||||||
@@ -122,7 +147,12 @@ export const ShowLogManagementServers = () => {
|
|||||||
const hasProviders = (providers?.length ?? 0) > 0;
|
const hasProviders = (providers?.length ?? 0) > 0;
|
||||||
const visibleTargets = hasProviders
|
const visibleTargets = hasProviders
|
||||||
? targets
|
? targets
|
||||||
: targets?.filter((target) => target.status !== "stopped");
|
: targets?.filter(
|
||||||
|
(target) =>
|
||||||
|
target.telemetryProviderIds.length > 0 ||
|
||||||
|
target.status === "running" ||
|
||||||
|
target.status === "not-running",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -135,10 +165,11 @@ export const ShowLogManagementServers = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Pick which providers each server ships to, then deploy its Vector
|
Pick which providers each server ships to, then deploy its Vector
|
||||||
agent. Changing a provider only takes effect on a server after you
|
agent; a provider receives every signal it sends, and cAdvisor
|
||||||
redeploy it. Services deployed before Log Management need a
|
runs next to the agent on 127.0.0.1:4510 when metrics are shipped.
|
||||||
redeploy to get the labels it uses; until then their logs are not
|
Services deployed before Logs & Metrics need a redeploy to get the
|
||||||
shipped from the Dokploy server.
|
labels it uses; until then their logs and container metrics are
|
||||||
|
not shipped from the Dokploy server.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -149,21 +180,23 @@ export const ShowLogManagementServers = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : !hasProviders && !visibleTargets?.length ? (
|
) : !hasProviders && !visibleTargets?.length ? (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{visibleTargets?.map((target) => {
|
{visibleTargets?.map((target) => {
|
||||||
const key = keyOf(target.serverId);
|
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 isBusy = pendingTarget === key;
|
||||||
const isDeployed = target.status !== "stopped";
|
const agentDeployed = target.status !== "stopped";
|
||||||
const hasEnabledSelected = ids.some(
|
const hasEnabledSelected = ids.some(
|
||||||
(id) =>
|
(id) =>
|
||||||
providers?.find((p) => p.logProviderId === id)?.enabled,
|
providers?.find((p) => p.telemetryProviderId === id)
|
||||||
|
?.enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,10 +217,10 @@ export const ShowLogManagementServers = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{statusBadge(target.status as Status)}
|
{statusBadge(target.status as Status)}
|
||||||
{isDeployed && (
|
{agentDeployed && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Remove the Vector agent"
|
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"
|
type="destructive"
|
||||||
onClick={() => handleRemove(target.serverId)}
|
onClick={() => handleRemove(target.serverId)}
|
||||||
>
|
>
|
||||||
@@ -205,32 +238,35 @@ export const ShowLogManagementServers = () => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{hasProviders && (
|
{hasProviders && (
|
||||||
<span className="text-xs font-medium">
|
<span className="text-xs font-medium">Providers</span>
|
||||||
Log providers
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{providers?.map((provider) => {
|
{providers?.map((provider) => {
|
||||||
const ProviderIcon =
|
const ProviderIcon =
|
||||||
logProviderIcons[
|
telemetryProviderIcons[
|
||||||
provider.providerType as keyof typeof logProviderIcons
|
provider.providerType as keyof typeof telemetryProviderIcons
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider.logProviderId}
|
key={provider.telemetryProviderId}
|
||||||
className={`flex flex-row items-center gap-2 text-sm ${
|
className={`flex flex-row items-center gap-2 text-sm ${
|
||||||
provider.enabled ? "" : "opacity-60"
|
provider.enabled ? "" : "opacity-60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`${key}-${provider.logProviderId}`}
|
id={`${key}-${provider.telemetryProviderId}`}
|
||||||
disabled={!provider.enabled}
|
disabled={
|
||||||
checked={ids.includes(provider.logProviderId)}
|
!provider.enabled &&
|
||||||
|
!ids.includes(provider.telemetryProviderId)
|
||||||
|
}
|
||||||
|
checked={ids.includes(
|
||||||
|
provider.telemetryProviderId,
|
||||||
|
)}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
toggleProvider(
|
toggleProvider(
|
||||||
target.serverId,
|
target.serverId,
|
||||||
target.logProviderIds,
|
saved,
|
||||||
provider.logProviderId,
|
provider.telemetryProviderId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -238,14 +274,14 @@ export const ShowLogManagementServers = () => {
|
|||||||
<ProviderIcon className="size-4 shrink-0" />
|
<ProviderIcon className="size-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<Label
|
<Label
|
||||||
htmlFor={`${key}-${provider.logProviderId}`}
|
htmlFor={`${key}-${provider.telemetryProviderId}`}
|
||||||
className={`font-normal ${
|
className={`font-normal ${
|
||||||
provider.enabled
|
provider.enabled
|
||||||
? "cursor-pointer"
|
? "cursor-pointer"
|
||||||
: "cursor-not-allowed"
|
: "cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{provider.name}
|
{providerLabel(provider)}
|
||||||
</Label>
|
</Label>
|
||||||
{!provider.enabled && (
|
{!provider.enabled && (
|
||||||
<Badge variant="secondary">Disabled</Badge>
|
<Badge variant="secondary">Disabled</Badge>
|
||||||
@@ -260,11 +296,10 @@ export const ShowLogManagementServers = () => {
|
|||||||
<AlertBlock type="error" className="items-start">
|
<AlertBlock type="error" className="items-start">
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
Deploy failed — the config was not applied, this
|
{errors[key].title}
|
||||||
server keeps shipping with its previous one.
|
|
||||||
</span>
|
</span>
|
||||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
|
<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>
|
</pre>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -272,7 +307,7 @@ export const ShowLogManagementServers = () => {
|
|||||||
className="w-fit"
|
className="w-fit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
errors[key] ?? "",
|
errors[key]?.message ?? "",
|
||||||
);
|
);
|
||||||
toast.success("Error copied");
|
toast.success("Error copied");
|
||||||
}}
|
}}
|
||||||
@@ -298,7 +333,9 @@ export const ShowLogManagementServers = () => {
|
|||||||
disabled={!hasEnabledSelected}
|
disabled={!hasEnabledSelected}
|
||||||
onClick={() => handleDeploy(target.serverId, ids)}
|
onClick={() => handleDeploy(target.serverId, ids)}
|
||||||
>
|
>
|
||||||
{isDeployed ? "Redeploy" : "Deploy"}
|
{agentDeployed || saved.length > 0
|
||||||
|
? "Redeploy"
|
||||||
|
: "Deploy"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
+51
-41
@@ -1,6 +1,6 @@
|
|||||||
import { Loader2, ScrollText, Trash2 } from "lucide-react";
|
import { Loader2, ScrollText, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -12,14 +12,15 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { agentsUsingProvider, HandleLogProvider } from "./handle-log-provider";
|
import { HandleTelemetryProvider } from "./handle-telemetry-provider";
|
||||||
|
|
||||||
interface LogProviderRowProps {
|
interface TelemetryProviderRowProps {
|
||||||
provider: {
|
provider: {
|
||||||
logProviderId: string;
|
telemetryProviderId: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
|
signals: string[];
|
||||||
};
|
};
|
||||||
index: number;
|
index: number;
|
||||||
typeLabel: string;
|
typeLabel: string;
|
||||||
@@ -28,19 +29,20 @@ interface LogProviderRowProps {
|
|||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LogProviderRow = ({
|
const TelemetryProviderRow = ({
|
||||||
provider,
|
provider,
|
||||||
index,
|
index,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
canEdit,
|
canEdit,
|
||||||
canDelete,
|
canDelete,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: LogProviderRowProps) => {
|
}: TelemetryProviderRowProps) => {
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, isPending: isRemoving } =
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
api.logProvider.remove.useMutation();
|
api.telemetryProvider.remove.useMutation();
|
||||||
const ProviderIcon =
|
const ProviderIcon =
|
||||||
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
|
telemetryProviderIcons[
|
||||||
|
provider.providerType as keyof typeof telemetryProviderIcons
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
|
<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"}>
|
<Badge variant={provider.enabled ? "green" : "secondary"}>
|
||||||
{provider.enabled ? "Enabled" : "Disabled"}
|
{provider.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{provider.signals.includes("logs") && (
|
||||||
|
<Badge variant="blue">Logs</Badge>
|
||||||
|
)}
|
||||||
|
{provider.signals.includes("metrics") && (
|
||||||
|
<Badge variant="orange">Metrics</Badge>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,35 +68,34 @@ const LogProviderRow = ({
|
|||||||
|
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<HandleLogProvider logProviderId={provider.logProviderId} />
|
<HandleTelemetryProvider
|
||||||
|
telemetryProviderId={provider.telemetryProviderId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Log Provider"
|
title="Delete 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."
|
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"
|
type="destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const agents = agentsUsingProvider(
|
|
||||||
utils.logProvider.serverStatus.getData(),
|
|
||||||
provider.logProviderId,
|
|
||||||
);
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
logProviderId: provider.logProviderId,
|
telemetryProviderId: provider.telemetryProviderId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((result) => {
|
||||||
toast.success(
|
if (result.warning) {
|
||||||
"Log provider deleted successfully",
|
toast.warning("Provider deleted", {
|
||||||
agents
|
description: result.warning,
|
||||||
? {
|
});
|
||||||
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
|
} else {
|
||||||
}
|
toast.success(
|
||||||
: undefined,
|
"Provider deleted — the agents that used it were re-applied",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
onDeleted();
|
onDeleted();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.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 utils = api.useUtils();
|
||||||
const { data, isPending, refetch } = api.logProvider.all.useQuery();
|
const { data, isPending, refetch } = api.telemetryProvider.all.useQuery();
|
||||||
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
|
const { data: availableTypes } =
|
||||||
|
api.telemetryProvider.availableTypes.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const providerListLabel = availableTypes?.length
|
const providerListLabel = availableTypes?.length
|
||||||
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
||||||
@@ -124,10 +132,10 @@ export const ShowLogProviders = () => {
|
|||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<ScrollText className="size-6 text-muted-foreground self-center" />
|
<ScrollText className="size-6 text-muted-foreground self-center" />
|
||||||
Log Management
|
Logs & Metrics
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<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">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<ScrollText className="size-8 self-center text-muted-foreground" />
|
<ScrollText className="size-8 self-center text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground text-center">
|
<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>
|
</span>
|
||||||
{permissions?.logProvider.create && <HandleLogProvider />}
|
{permissions?.telemetryProvider.create && (
|
||||||
|
<HandleTelemetryProvider />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<div className="flex flex-col gap-4 rounded-lg ">
|
<div className="flex flex-col gap-4 rounded-lg ">
|
||||||
{data?.map((provider, index) => (
|
{data?.map((provider, index) => (
|
||||||
<LogProviderRow
|
<TelemetryProviderRow
|
||||||
key={provider.logProviderId}
|
key={provider.telemetryProviderId}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
index={index}
|
index={index}
|
||||||
typeLabel={
|
typeLabel={
|
||||||
@@ -159,19 +169,19 @@ export const ShowLogProviders = () => {
|
|||||||
(t) => t.type === provider.providerType,
|
(t) => t.type === provider.providerType,
|
||||||
)?.label ?? provider.providerType
|
)?.label ?? provider.providerType
|
||||||
}
|
}
|
||||||
canEdit={!!permissions?.logProvider.create}
|
canEdit={!!permissions?.telemetryProvider.create}
|
||||||
canDelete={!!permissions?.logProvider.delete}
|
canDelete={!!permissions?.telemetryProvider.delete}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
refetch();
|
refetch();
|
||||||
utils.logProvider.serverStatus.invalidate();
|
utils.telemetryProvider.serverStatus.invalidate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{permissions?.logProvider.create && (
|
{permissions?.telemetryProvider.create && (
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
<HandleLogProvider />
|
<HandleTelemetryProvider />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +113,7 @@ export const DeleteServerModal = ({
|
|||||||
toast.success(`Server ${serverName} deleted successfully`);
|
toast.success(`Server ${serverName} deleted successfully`);
|
||||||
if (result?.vectorAgentRemovalWarning) {
|
if (result?.vectorAgentRemovalWarning) {
|
||||||
toast.error(
|
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);
|
setOpen(false);
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
|||||||
enableDockerCleanup: data.enableDockerCleanup,
|
enableDockerCleanup: data.enableDockerCleanup,
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async (_data) => {
|
||||||
await utils.server.all.invalidate();
|
await utils.server.all.invalidate();
|
||||||
refetchServer();
|
refetchServer();
|
||||||
toast.success(serverId ? "Server Updated" : "Server Created");
|
toast.success(serverId ? "Server Updated" : "Server Created");
|
||||||
|
|||||||
+40
-4
@@ -59,11 +59,47 @@ export const SplunkIcon = ({ className }: Props) => (
|
|||||||
</svg>
|
</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,
|
loki: GrafanaLokiIcon,
|
||||||
|
prometheus_remote_write: PrometheusIcon,
|
||||||
|
new_relic: NewRelicIcon,
|
||||||
|
influxdb: InfluxDbIcon,
|
||||||
datadog: DatadogIcon,
|
datadog: DatadogIcon,
|
||||||
betterstack: BetterStackIcon,
|
|
||||||
elasticsearch: ElasticsearchIcon,
|
|
||||||
splunk_hec: SplunkIcon,
|
|
||||||
aws_cloudwatch: AwsIcon,
|
aws_cloudwatch: AwsIcon,
|
||||||
|
splunk_hec: SplunkIcon,
|
||||||
|
elasticsearch: ElasticsearchIcon,
|
||||||
|
betterstack: BetterStackIcon,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
|||||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||||
const logoUrl =
|
const logoUrl =
|
||||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||||
|
const logoLightUrl =
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return (
|
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">
|
<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="/"
|
href="/"
|
||||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
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}
|
{appName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
|
|||||||
@@ -351,7 +351,13 @@ const MENU: Menu = {
|
|||||||
icon: BotIcon,
|
icon: BotIcon,
|
||||||
url: "/dashboard/settings/ai",
|
url: "/dashboard/settings/ai",
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
isEnabled: ({ permissions }) =>
|
||||||
|
!!(
|
||||||
|
permissions?.ai.read ||
|
||||||
|
permissions?.ai.create ||
|
||||||
|
permissions?.ai.update ||
|
||||||
|
permissions?.ai.delete
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -377,10 +383,10 @@ const MENU: Menu = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Log Management",
|
title: "Logs & Metrics",
|
||||||
url: "/dashboard/settings/log-providers",
|
url: "/dashboard/settings/logs-and-metrics",
|
||||||
icon: ScrollText,
|
icon: ScrollText,
|
||||||
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
|
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
|||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
network: "Network",
|
network: "Network",
|
||||||
logProvider: "Log Provider",
|
telemetryProvider: "Logs & Metrics",
|
||||||
};
|
};
|
||||||
|
|
||||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||||
|
|||||||
@@ -180,10 +180,15 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
||||||
},
|
},
|
||||||
logProvider: {
|
telemetryProvider: {
|
||||||
label: "Log Providers",
|
label: "Logs & Metrics",
|
||||||
description:
|
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",
|
description: "Remove DNS providers and delete their records",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logProvider: {
|
telemetryProvider: {
|
||||||
read: { label: "Read", description: "View configured log providers" },
|
read: {
|
||||||
|
label: "Read",
|
||||||
|
description: "View configured logs and metrics providers",
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
label: "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"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
|
ai: ["read", "create", "update", "delete"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
|
|||||||
config: {
|
config: {
|
||||||
appName?: string;
|
appName?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
logoLightUrl?: string;
|
||||||
|
dualModeBranding?: boolean;
|
||||||
footerText?: string;
|
footerText?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||||
const appName = config.appName || "Dokploy";
|
const appName = config.appName || "Dokploy";
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const effectiveLogoUrl =
|
||||||
|
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
|
||||||
|
? config.logoLightUrl
|
||||||
|
: config.logoUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
|||||||
<div className="rounded-lg border overflow-hidden">
|
<div className="rounded-lg border overflow-hidden">
|
||||||
{/* Simulated sidebar header */}
|
{/* Simulated sidebar header */}
|
||||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||||
{config.logoUrl ? (
|
{effectiveLogoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={config.logoUrl}
|
src={effectiveLogoUrl}
|
||||||
alt="Preview Logo"
|
alt="Preview Logo"
|
||||||
className="size-8 rounded-sm object-contain"
|
className="size-8 rounded-sm object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||||
@@ -36,20 +37,42 @@ const safeUrlField = z
|
|||||||
message: "Only http:// and https:// URLs are allowed",
|
message: "Only http:// and https:// URLs are allowed",
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
appName: z.string(),
|
.object({
|
||||||
appDescription: z.string(),
|
appName: z.string(),
|
||||||
logoUrl: safeUrlField,
|
appDescription: z.string(),
|
||||||
faviconUrl: safeUrlField,
|
logoUrl: safeUrlField,
|
||||||
customCss: z.string(),
|
faviconUrl: safeUrlField,
|
||||||
loginLogoUrl: safeUrlField,
|
customCss: z.string(),
|
||||||
supportUrl: safeUrlField,
|
loginLogoUrl: safeUrlField,
|
||||||
docsUrl: safeUrlField,
|
supportUrl: safeUrlField,
|
||||||
errorPageTitle: z.string(),
|
docsUrl: safeUrlField,
|
||||||
errorPageDescription: z.string(),
|
errorPageTitle: z.string(),
|
||||||
footerText: z.string(),
|
errorPageDescription: z.string(),
|
||||||
ogImageUrl: safeUrlField,
|
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>;
|
type FormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: "",
|
errorPageDescription: "",
|
||||||
footerText: "",
|
footerText: "",
|
||||||
ogImageUrl: "",
|
ogImageUrl: "",
|
||||||
|
logoLightUrl: "",
|
||||||
|
loginLogoLightUrl: "",
|
||||||
|
faviconLightUrl: "",
|
||||||
|
dualModeBranding: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: data.errorPageDescription ?? "",
|
errorPageDescription: data.errorPageDescription ?? "",
|
||||||
footerText: data.footerText ?? "",
|
footerText: data.footerText ?? "",
|
||||||
ogImageUrl: data.ogImageUrl ?? "",
|
ogImageUrl: data.ogImageUrl ?? "",
|
||||||
|
logoLightUrl: data.logoLightUrl ?? "",
|
||||||
|
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
|
||||||
|
faviconLightUrl: data.faviconLightUrl ?? "",
|
||||||
|
dualModeBranding: data.dualModeBranding ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: values.errorPageDescription || null,
|
errorPageDescription: values.errorPageDescription || null,
|
||||||
footerText: values.footerText || null,
|
footerText: values.footerText || null,
|
||||||
ogImageUrl: values.ogImageUrl || null,
|
ogImageUrl: values.ogImageUrl || null,
|
||||||
|
logoLightUrl: values.logoLightUrl || null,
|
||||||
|
loginLogoLightUrl: values.loginLogoLightUrl || null,
|
||||||
|
faviconLightUrl: values.faviconLightUrl || null,
|
||||||
|
dualModeBranding: values.dualModeBranding,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Appearance Section */}
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<FolderInput className="size-4 text-muted-foreground" />
|
<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"}
|
{currentProject?.name || "Select Project"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
logoLightUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
export const Logo = ({
|
||||||
if (logoUrl) {
|
className = "size-14",
|
||||||
|
logoUrl,
|
||||||
|
logoLightUrl,
|
||||||
|
}: Props) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const effectiveUrl =
|
||||||
|
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
|
||||||
|
|
||||||
|
if (effectiveUrl) {
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={effectiveUrl}
|
||||||
alt="Organization Logo"
|
alt="Organization Logo"
|
||||||
className={cn(className, "object-contain rounded-sm")}
|
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",
|
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -4310,95 +4310,6 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"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": {
|
"public.mariadb": {
|
||||||
"name": "mariadb",
|
"name": "mariadb",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
@@ -7765,8 +7676,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
"logProviderIds": {
|
"telemetryProviderIds": {
|
||||||
"name": "logProviderIds",
|
"name": "telemetryProviderIds",
|
||||||
"type": "text[]",
|
"type": "text[]",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -8277,6 +8188,101 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"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": {
|
"public.user": {
|
||||||
"name": "user",
|
"name": "user",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
@@ -8901,15 +8907,15 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "'0 0 * * *'"
|
"default": "'0 0 * * *'"
|
||||||
},
|
},
|
||||||
"logProviderIds": {
|
"telemetryProviderIds": {
|
||||||
"name": "logProviderIds",
|
"name": "telemetryProviderIds",
|
||||||
"type": "text[]",
|
"type": "text[]",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": "ARRAY[]::text[]"
|
"default": "ARRAY[]::text[]"
|
||||||
},
|
},
|
||||||
"logManagementOrganizationId": {
|
"vectorAgentOrganizationId": {
|
||||||
"name": "logManagementOrganizationId",
|
"name": "vectorAgentOrganizationId",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
@@ -8987,12 +8993,12 @@
|
|||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"webServerSettings_logManagementOrganizationId_organization_id_fk": {
|
"webServerSettings_vectorAgentOrganizationId_organization_id_fk": {
|
||||||
"name": "webServerSettings_logManagementOrganizationId_organization_id_fk",
|
"name": "webServerSettings_vectorAgentOrganizationId_organization_id_fk",
|
||||||
"tableFrom": "webServerSettings",
|
"tableFrom": "webServerSettings",
|
||||||
"tableTo": "organization",
|
"tableTo": "organization",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"logManagementOrganizationId"
|
"vectorAgentOrganizationId"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -9114,18 +9120,6 @@
|
|||||||
"gitea"
|
"gitea"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"public.LogProviderType": {
|
|
||||||
"name": "LogProviderType",
|
|
||||||
"schema": "public",
|
|
||||||
"values": [
|
|
||||||
"loki",
|
|
||||||
"datadog",
|
|
||||||
"betterstack",
|
|
||||||
"elasticsearch",
|
|
||||||
"splunk_hec",
|
|
||||||
"aws_cloudwatch"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"public.mountType": {
|
"public.mountType": {
|
||||||
"name": "mountType",
|
"name": "mountType",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
@@ -9277,6 +9271,21 @@
|
|||||||
"tag"
|
"tag"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"public.TelemetryProviderType": {
|
||||||
|
"name": "TelemetryProviderType",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"loki",
|
||||||
|
"datadog",
|
||||||
|
"betterstack",
|
||||||
|
"elasticsearch",
|
||||||
|
"splunk_hec",
|
||||||
|
"aws_cloudwatch",
|
||||||
|
"prometheus_remote_write",
|
||||||
|
"new_relic",
|
||||||
|
"influxdb"
|
||||||
|
]
|
||||||
|
},
|
||||||
"public.VaultProviderType": {
|
"public.VaultProviderType": {
|
||||||
"name": "VaultProviderType",
|
"name": "VaultProviderType",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
|
|||||||
@@ -1384,8 +1384,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 197,
|
"idx": 197,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1791377838843,
|
"when": 1791456930312,
|
||||||
"tag": "0197_smiling_madrox",
|
"tag": "0197_logs_and_metrics",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+15
-17
@@ -48,6 +48,7 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@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-cloudwatch-logs": "^3.1108.0",
|
||||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
"@codemirror/view": "^6.39.15",
|
"@codemirror/view": "^6.39.15",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@dokploy/trpc-openapi": "0.0.18",
|
"@dokploy/trpc-openapi": "0.0.18",
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^10.5.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
"@octokit/webhooks": "^13.9.0",
|
"@octokit/webhooks": "^13.9.0",
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"@xterm/addon-attach": "0.10.0",
|
"@xterm/addon-attach": "0.10.0",
|
||||||
"@xterm/addon-clipboard": "0.1.0",
|
"@xterm/addon-clipboard": "0.1.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.6.1",
|
||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
@@ -99,24 +100,24 @@
|
|||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.4.13",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.7",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.18.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.18",
|
||||||
"next": "16.3.4",
|
"next": "16.3.8",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.1.0",
|
"node-pty": "1.1.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "10.0.15",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
@@ -134,7 +135,7 @@
|
|||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"shadcn": "^4.11.0",
|
"shadcn": "^4.11.0",
|
||||||
"shell-quote": "^1.8.4",
|
"shell-quote": "^1.11.0",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "~1.16.0",
|
"ssh2": "~1.16.0",
|
||||||
@@ -143,19 +144,18 @@
|
|||||||
"swagger-ui-react": "^5.32.6",
|
"swagger-ui-react": "^5.32.6",
|
||||||
"tailwind-merge": "^2.6.1",
|
"tailwind-merge": "^2.6.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"toml": "3.0.0",
|
"toml": "4.2.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"undici": "^6.21.3",
|
"undici": "^6.28.0",
|
||||||
"use-resize-observer": "9.1.0",
|
"use-resize-observer": "9.1.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.21.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.3",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zod-form-data": "^3.0.1"
|
"zod-form-data": "^3.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.3.1",
|
"@tailwindcss/postcss": "^4.3.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
@@ -163,17 +163,15 @@
|
|||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "^24.4.0",
|
"@types/node": "^24.4.0",
|
||||||
"@types/node-schedule": "2.1.6",
|
"@types/node-schedule": "2.1.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"@types/ssh2": "1.15.1",
|
"@types/ssh2": "1.15.1",
|
||||||
"@types/swagger-ui-react": "^4.19.0",
|
"@types/swagger-ui-react": "^4.19.0",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.25.0",
|
||||||
"lint-staged": "^15.5.2",
|
"lint-staged": "^15.5.2",
|
||||||
"memfs": "^4.17.2",
|
"memfs": "^4.17.2",
|
||||||
"simple-icons": "^16.14.0",
|
"simple-icons": "^16.14.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
|
|||||||
appDescription: string | null;
|
appDescription: string | null;
|
||||||
ogImageUrl: string | null;
|
ogImageUrl: string | null;
|
||||||
faviconHref: string | null;
|
faviconHref: string | null;
|
||||||
|
faviconLightHref: string | null;
|
||||||
customCss: string | null;
|
customCss: string | null;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,7 @@ export default function Document({
|
|||||||
appDescription,
|
appDescription,
|
||||||
ogImageUrl,
|
ogImageUrl,
|
||||||
faviconHref,
|
faviconHref,
|
||||||
|
faviconLightHref,
|
||||||
customCss,
|
customCss,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
}: WhitelabelingDocumentProps) {
|
}: WhitelabelingDocumentProps) {
|
||||||
@@ -44,7 +46,22 @@ export default function Document({
|
|||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:image" content={ogImage} />
|
<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 && (
|
{customCss && (
|
||||||
<style
|
<style
|
||||||
id="whitelabeling-styles"
|
id="whitelabeling-styles"
|
||||||
@@ -69,6 +86,7 @@ declare global {
|
|||||||
appDescription: string | null;
|
appDescription: string | null;
|
||||||
ogImageUrl: string | null;
|
ogImageUrl: string | null;
|
||||||
faviconHref: string | null;
|
faviconHref: string | null;
|
||||||
|
faviconLightHref: string | null;
|
||||||
customCss: string | null;
|
customCss: string | null;
|
||||||
};
|
};
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
|
|||||||
let appDescription: string | null = null;
|
let appDescription: string | null = null;
|
||||||
let ogImageUrl: string | null = null;
|
let ogImageUrl: string | null = null;
|
||||||
let faviconHref: string | null = null;
|
let faviconHref: string | null = null;
|
||||||
|
let faviconLightHref: string | null = null;
|
||||||
let customCss: string | null = null;
|
let customCss: string | null = null;
|
||||||
|
|
||||||
const host = ctx.req?.headers?.host || "localhost:3000";
|
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||||
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
|
|||||||
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
||||||
: null;
|
: null;
|
||||||
faviconHref = config.faviconUrl || null;
|
faviconHref = config.faviconUrl || null;
|
||||||
|
faviconLightHref = config.faviconLightUrl || null;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
||||||
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
|
|||||||
appDescription,
|
appDescription,
|
||||||
ogImageUrl,
|
ogImageUrl,
|
||||||
faviconHref,
|
faviconHref,
|
||||||
|
faviconLightHref,
|
||||||
customCss,
|
customCss,
|
||||||
},
|
},
|
||||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||||
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
|
|||||||
appDescription,
|
appDescription,
|
||||||
ogImageUrl,
|
ogImageUrl,
|
||||||
faviconHref,
|
faviconHref,
|
||||||
|
faviconLightHref,
|
||||||
customCss,
|
customCss,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
|
|||||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const appName = whitelabeling?.appName || "Dokploy";
|
const appName = whitelabeling?.appName || "Dokploy";
|
||||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||||
|
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
|
||||||
const errorTitle = whitelabeling?.errorPageTitle;
|
const errorTitle = whitelabeling?.errorPageTitle;
|
||||||
const errorDescription = whitelabeling?.errorPageDescription;
|
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">
|
<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">
|
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||||
<Link href="/" className="flex flex-row items-center gap-2">
|
<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>
|
<span className="font-medium text-sm">{appName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1663,19 +1663,19 @@ const EnvironmentPage = (
|
|||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
<div className="flex flex-row items-center gap-2 justify-between w-full min-w-0">
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap break-words">
|
||||||
{service.name}
|
{service.name}
|
||||||
</span>
|
</span>
|
||||||
{service.description && (
|
{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}
|
{service.description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" && (
|
{service.type === "postgres" && (
|
||||||
<PostgresqlIcon className="h-7 w-7" />
|
<PostgresqlIcon className="h-7 w-7" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+13
-9
@@ -123,9 +123,9 @@ const Service = (
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||||
<div className="relative flex flex-row gap-4 items-center">
|
<div className="relative flex flex-row gap-4 items-center">
|
||||||
<ShowIconSettings
|
<ShowIconSettings
|
||||||
serviceId={applicationId}
|
serviceId={applicationId}
|
||||||
@@ -136,20 +136,22 @@ const Service = (
|
|||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -165,7 +167,9 @@ const Service = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -114,9 +114,9 @@ const Service = (
|
|||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2 items-center">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2 items-center">
|
||||||
<div className="relative flex flex-row gap-4 items-center">
|
<div className="relative flex flex-row gap-4 items-center">
|
||||||
<ShowIconSettings
|
<ShowIconSettings
|
||||||
serviceId={composeId}
|
serviceId={composeId}
|
||||||
@@ -127,20 +127,22 @@ const Service = (
|
|||||||
<StatusTooltip status={data?.composeStatus} />
|
<StatusTooltip status={data?.composeStatus} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -156,7 +158,9 @@ const Service = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
+13
-9
@@ -80,9 +80,9 @@ const Libsql = (
|
|||||||
</Head>
|
</Head>
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -90,20 +90,22 @@ const Libsql = (
|
|||||||
|
|
||||||
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
|
<LibsqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -119,7 +121,9 @@ const Libsql = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -92,9 +92,9 @@ const Mariadb = (
|
|||||||
</Head>
|
</Head>
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -102,20 +102,22 @@ const Mariadb = (
|
|||||||
|
|
||||||
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
|
<MariadbIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -131,7 +133,9 @@ const Mariadb = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -91,9 +91,9 @@ const Mongo = (
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -101,20 +101,22 @@ const Mongo = (
|
|||||||
|
|
||||||
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
|
<MongodbIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -130,7 +132,9 @@ const Mongo = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -91,9 +91,9 @@ const MySql = (
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -101,20 +101,22 @@ const MySql = (
|
|||||||
|
|
||||||
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
|
<MysqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -130,7 +132,9 @@ const MySql = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -90,9 +90,9 @@ const Postgresql = (
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -100,20 +100,22 @@ const Postgresql = (
|
|||||||
|
|
||||||
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
|
<PostgresqlIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -129,7 +131,9 @@ const Postgresql = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+13
-9
@@ -90,9 +90,9 @@ const Redis = (
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="flex flex-row justify-between items-center">
|
<CardHeader className="flex w-full min-w-0 flex-row justify-between items-center">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex min-w-0 flex-row gap-2">
|
||||||
<div className="relative flex flex-row gap-4">
|
<div className="relative flex flex-row gap-4">
|
||||||
<div className="absolute -right-1 -top-2">
|
<div className="absolute -right-1 -top-2">
|
||||||
<StatusTooltip status={data?.applicationStatus} />
|
<StatusTooltip status={data?.applicationStatus} />
|
||||||
@@ -100,20 +100,22 @@ const Redis = (
|
|||||||
|
|
||||||
<RedisIcon className="h-6 w-6 text-muted-foreground" />
|
<RedisIcon className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{data?.name}
|
<span className="min-w-0 truncate" title={data?.name}>
|
||||||
|
{data?.name}
|
||||||
|
</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{data?.description && (
|
{data?.description && (
|
||||||
<CardDescription>{data?.description}</CardDescription>
|
<CardDescription>{data?.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground min-w-0 truncate">
|
||||||
{data?.appName}
|
{data?.appName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col 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 flex-row h-fit w-fit gap-2">
|
<div className="flex max-w-full min-w-0 flex-row h-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ip = data?.server?.ipAddress || serverIp;
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
if (ip) {
|
if (ip) {
|
||||||
@@ -129,7 +131,9 @@ const Redis = (
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{data?.server?.name || "Dokploy Server"}
|
<span className="truncate">
|
||||||
|
{data?.server?.name || "Dokploy Server"}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{data?.server?.serverStatus === "inactive" && (
|
{data?.server?.serverStatus === "inactive" && (
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
|
|||||||
+5
-5
@@ -3,16 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
|
|||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
|
import { ShowServers } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
|
||||||
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
|
import { ShowTelemetryProviders } from "@/components/dashboard/settings/logs-and-metrics/show-telemetry-providers";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<ShowLogProviders />
|
<ShowTelemetryProviders />
|
||||||
<ShowLogManagementServers />
|
<ShowServers />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ const Page = () => {
|
|||||||
export default Page;
|
export default Page;
|
||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
|
return <DashboardLayout metaName="Logs & Metrics">{page}</DashboardLayout>;
|
||||||
};
|
};
|
||||||
export async function getServerSideProps(
|
export async function getServerSideProps(
|
||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
|
||||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
import { Fingerprint } from "lucide-react";
|
import { Fingerprint } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const LoginSchema = z.object({
|
const LoginSchema = z.object({
|
||||||
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
|||||||
whitelabeling?.logoUrl ||
|
whitelabeling?.logoUrl ||
|
||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
|
logoLightUrl={
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
Sign in
|
Sign in
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
@@ -164,6 +164,11 @@ const Invitation = ({
|
|||||||
whitelabeling?.logoUrl ||
|
whitelabeling?.logoUrl ||
|
||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
|
logoLightUrl={
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
Invitation
|
Invitation
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { pushToDataLayer } from "@/lib/analytics";
|
import { pushToDataLayer } from "@/lib/analytics";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
whitelabeling?.logoUrl ||
|
whitelabeling?.logoUrl ||
|
||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
|
logoLightUrl={
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
{isCloud ? "Sign Up" : "Setup the server"}
|
{isCloud ? "Sign Up" : "Setup the server"}
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
|
|||||||
whitelabeling?.logoUrl ||
|
whitelabeling?.logoUrl ||
|
||||||
undefined
|
undefined
|
||||||
}
|
}
|
||||||
|
logoLightUrl={
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
Reset Password
|
Reset Password
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IS_CLOUD } from "@dokploy/server";
|
import { IS_CLOUD } from "@dokploy/server";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -92,6 +92,11 @@ export default function Home() {
|
|||||||
logoUrl={
|
logoUrl={
|
||||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||||
}
|
}
|
||||||
|
logoLightUrl={
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-sm">
|
<span className="font-medium text-sm">
|
||||||
{whitelabeling?.appName || "Dokploy"}
|
{whitelabeling?.appName || "Dokploy"}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { giteaRouter } from "./routers/gitea";
|
|||||||
import { githubRouter } from "./routers/github";
|
import { githubRouter } from "./routers/github";
|
||||||
import { gitlabRouter } from "./routers/gitlab";
|
import { gitlabRouter } from "./routers/gitlab";
|
||||||
import { libsqlRouter } from "./routers/libsql";
|
import { libsqlRouter } from "./routers/libsql";
|
||||||
import { logProviderRouter } from "./routers/log-provider";
|
|
||||||
import { mariadbRouter } from "./routers/mariadb";
|
import { mariadbRouter } from "./routers/mariadb";
|
||||||
import { mongoRouter } from "./routers/mongo";
|
import { mongoRouter } from "./routers/mongo";
|
||||||
import { mountRouter } from "./routers/mount";
|
import { mountRouter } from "./routers/mount";
|
||||||
@@ -54,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key";
|
|||||||
import { stripeRouter } from "./routers/stripe";
|
import { stripeRouter } from "./routers/stripe";
|
||||||
import { swarmRouter } from "./routers/swarm";
|
import { swarmRouter } from "./routers/swarm";
|
||||||
import { tagRouter } from "./routers/tag";
|
import { tagRouter } from "./routers/tag";
|
||||||
|
import { telemetryProviderRouter } from "./routers/telemetry-provider";
|
||||||
import { transferRouter } from "./routers/transfer";
|
import { transferRouter } from "./routers/transfer";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { vaultProviderRouter } from "./routers/vault-provider";
|
import { vaultProviderRouter } from "./routers/vault-provider";
|
||||||
@@ -86,7 +86,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
github: githubRouter,
|
github: githubRouter,
|
||||||
gitlab: gitlabRouter,
|
gitlab: gitlabRouter,
|
||||||
libsql: libsqlRouter,
|
libsql: libsqlRouter,
|
||||||
logProvider: logProviderRouter,
|
telemetryProvider: telemetryProviderRouter,
|
||||||
mariadb: mariadbRouter,
|
mariadb: mariadbRouter,
|
||||||
mongo: mongoRouter,
|
mongo: mongoRouter,
|
||||||
mounts: mountRouter,
|
mounts: mountRouter,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
getAiSettingById,
|
getAiSettingById,
|
||||||
getAiSettingsByOrganizationId,
|
getAiSettingsByOrganizationId,
|
||||||
getCustomAiProviders,
|
getCustomAiProviders,
|
||||||
|
maskAiApiKey,
|
||||||
|
mergeAiApiKey,
|
||||||
|
resolveAiApiKey,
|
||||||
saveAiSettings,
|
saveAiSettings,
|
||||||
saveCustomAiProviders,
|
saveCustomAiProviders,
|
||||||
suggestVariants,
|
suggestVariants,
|
||||||
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
|||||||
import {
|
import {
|
||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
|
hasPermission,
|
||||||
} from "@dokploy/server/services/permission";
|
} from "@dokploy/server/services/permission";
|
||||||
import { findProjectById } from "@dokploy/server/services/project";
|
import { findProjectById } from "@dokploy/server/services/project";
|
||||||
import {
|
import {
|
||||||
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
|
|||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
withAnyPermission,
|
||||||
|
withPermission,
|
||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
|
import { audit } from "@/server/api/utils/audit";
|
||||||
import { generatePassword } from "@/templates/utils";
|
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({
|
export const aiRouter = createTRPCRouter({
|
||||||
one: adminProcedure
|
one: withAnyPermission("ai", ["read", "update"])
|
||||||
.input(z.object({ aiId: z.string() }))
|
.input(z.object({ aiId: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return await getAiSettingById(input.aiId);
|
const setting = await getAiSettingById(
|
||||||
|
input.aiId,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
return maskAiApiKey(setting);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getModels: protectedProcedure
|
getModels: withAnyPermission("ai", ["read", "create", "update"])
|
||||||
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
|
.input(
|
||||||
.query(async ({ input }) => {
|
z.object({
|
||||||
|
apiUrl: z.string().min(1),
|
||||||
|
apiKey: z.string(),
|
||||||
|
aiId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
|
const apiKey = await resolveInputApiKey(input, ctx);
|
||||||
const providerName = getProviderName(input.apiUrl);
|
const providerName = getProviderName(input.apiUrl);
|
||||||
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
|
const headers = getProviderHeaders(input.apiUrl, apiKey);
|
||||||
let response = null;
|
let response = null;
|
||||||
switch (providerName) {
|
switch (providerName) {
|
||||||
case "ollama":
|
case "ollama":
|
||||||
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
|
|||||||
break;
|
break;
|
||||||
case "gemini":
|
case "gemini":
|
||||||
response = await fetch(
|
response = await fetch(
|
||||||
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
|
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
|
||||||
{ headers: {} },
|
{ headers: {} },
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
] as Model[];
|
] as Model[];
|
||||||
default:
|
default:
|
||||||
if (!input.apiKey)
|
if (!apiKey)
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "API key must contain at least 1 character(s)",
|
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 }) => {
|
create: withPermission("ai", "create")
|
||||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
.input(apiCreateAi)
|
||||||
}),
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const result = await saveAiSettings(
|
||||||
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
|
ctx.session.activeOrganizationId,
|
||||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
input,
|
||||||
}),
|
);
|
||||||
|
await audit(ctx, {
|
||||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
action: "create",
|
||||||
return await getAiSettingsByOrganizationId(
|
resourceType: "ai",
|
||||||
ctx.session.activeOrganizationId,
|
resourceName: input.name,
|
||||||
);
|
});
|
||||||
}),
|
return result;
|
||||||
|
|
||||||
get: adminProcedure
|
|
||||||
.input(z.object({ aiId: z.string() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await getAiSettingById(input.aiId);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: adminProcedure
|
update: withPermission("ai", "update")
|
||||||
.input(z.object({ aiId: z.string() }))
|
.input(apiUpdateAi)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return await deleteAiSettings(input.aiId);
|
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);
|
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(
|
const settings = await getAiSettingsByOrganizationId(
|
||||||
ctx.session.activeOrganizationId,
|
ctx.session.activeOrganizationId,
|
||||||
);
|
);
|
||||||
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
|
|||||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
analyzeLogs: protectedProcedure
|
analyzeLogs: withPermission("ai", "read")
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
aiId: z.string().min(1),
|
aiId: z.string().min(1),
|
||||||
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const aiSettings = await getAiSettingById(input.aiId);
|
const aiSettings = await getAiSettingById(
|
||||||
|
input.aiId,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
if (!aiSettings?.isEnabled) {
|
if (!aiSettings?.isEnabled) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
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 provider = selectAIProvider(aiSettings);
|
||||||
const model = provider(aiSettings.model);
|
const model = provider(aiSettings.model);
|
||||||
|
|
||||||
@@ -283,19 +351,21 @@ ${input.logs}`,
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
testConnection: protectedProcedure
|
testConnection: withAnyPermission("ai", ["create", "update"])
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
apiUrl: z.string().min(1),
|
apiUrl: z.string().min(1),
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
|
aiId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
|
const apiKey = await resolveInputApiKey(input, ctx);
|
||||||
const provider = selectAIProvider({
|
const provider = selectAIProvider({
|
||||||
apiUrl: input.apiUrl,
|
apiUrl: input.apiUrl,
|
||||||
apiKey: input.apiKey,
|
apiKey,
|
||||||
});
|
});
|
||||||
const model = provider(input.model);
|
const model = provider(input.model);
|
||||||
const result = await generateText({
|
const result = await generateText({
|
||||||
@@ -317,7 +387,7 @@ ${input.logs}`,
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
suggest: protectedProcedure
|
suggest: withPermission("ai", "read")
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
aiId: z.string(),
|
aiId: z.string(),
|
||||||
@@ -338,7 +408,7 @@ ${input.logs}`,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
deploy: protectedProcedure
|
deploy: withPermission("ai", "read")
|
||||||
.input(deploySuggestionSchema)
|
.input(deploySuggestionSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const environment = await findEnvironmentById(input.environmentId);
|
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,
|
errorPageDescription: null,
|
||||||
ogImageUrl: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
|
logoLightUrl: null,
|
||||||
|
loginLogoLightUrl: null,
|
||||||
|
faviconLightUrl: null,
|
||||||
|
dualModeBranding: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
redactServerSshKey,
|
redactServerSshKey,
|
||||||
removeDeploymentsByServerId,
|
removeDeploymentsByServerId,
|
||||||
removeServerLogManagement,
|
removeServerVectorAgent,
|
||||||
serverAudit,
|
serverAudit,
|
||||||
serverSetup,
|
serverSetup,
|
||||||
serverValidate,
|
serverValidate,
|
||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
updateServerById,
|
updateServerById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
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 { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
@@ -52,6 +55,22 @@ import {
|
|||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
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({
|
export const serverRouter = createTRPCRouter({
|
||||||
create: withPermission("server", "create")
|
create: withPermission("server", "create")
|
||||||
.input(apiCreateServer)
|
.input(apiCreateServer)
|
||||||
@@ -217,7 +236,7 @@ export const serverRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return servers.length ?? 0;
|
return servers.length ?? 0;
|
||||||
}),
|
}),
|
||||||
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
|
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
|
||||||
const result = await db.query.server.findMany({
|
const result = await db.query.server.findMany({
|
||||||
@@ -235,9 +254,12 @@ export const serverRouter = createTRPCRouter({
|
|||||||
eq(server.serverType, "deploy"),
|
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 accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
|
||||||
const result = await db.query.server.findMany({
|
const result = await db.query.server.findMany({
|
||||||
@@ -255,7 +277,10 @@ export const serverRouter = createTRPCRouter({
|
|||||||
eq(server.serverType, "build"),
|
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")
|
setup: withPermission("server", "create")
|
||||||
.input(apiFindOneServer)
|
.input(apiFindOneServer)
|
||||||
@@ -475,7 +500,7 @@ export const serverRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const vectorAgentRemovalWarning =
|
const vectorAgentRemovalWarning =
|
||||||
await removeServerLogManagement(currentServer);
|
await removeServerVectorAgent(currentServer);
|
||||||
await audit(ctx, {
|
await audit(ctx, {
|
||||||
action: "delete",
|
action: "delete",
|
||||||
resourceType: "server",
|
resourceType: "server",
|
||||||
@@ -520,7 +545,6 @@ export const serverRouter = createTRPCRouter({
|
|||||||
message: "Server is inactive",
|
message: "Server is inactive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentServer = await updateServerById(input.serverId, {
|
const currentServer = await updateServerById(input.serverId, {
|
||||||
...input,
|
...input,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
CLEANUP_CRON_JOB,
|
CLEANUP_CRON_JOB,
|
||||||
checkGPUStatus,
|
checkGPUStatus,
|
||||||
@@ -81,6 +82,32 @@ import {
|
|||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} 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({
|
export const settingsRouter = createTRPCRouter({
|
||||||
getWebServerSettings: protectedProcedure.query(async () => {
|
getWebServerSettings: protectedProcedure.query(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
try {
|
try {
|
||||||
await checkPermission(ctx, { traefikFiles: ["read"] });
|
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 { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
|
||||||
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
|
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
|
||||||
return result || [];
|
return result || [];
|
||||||
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.input(apiModifyTraefikConfig)
|
.input(apiModifyTraefikConfig)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
await checkPermission(ctx, { traefikFiles: ["write"] });
|
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(
|
await writeTraefikConfigInPath(
|
||||||
input.path,
|
safePath,
|
||||||
input.traefikConfig,
|
input.traefikConfig,
|
||||||
input?.serverId,
|
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 () => {
|
getIp: protectedProcedure.query(async () => {
|
||||||
if (IS_CLOUD) {
|
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 { hasValidLicense } from "@dokploy/server/index";
|
||||||
import type { statements } from "@dokploy/server/lib/access-control";
|
import type { statements } from "@dokploy/server/lib/access-control";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
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 type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||||
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
|||||||
return createInnerTRPCContext({
|
return createInnerTRPCContext({
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
session: session
|
session: session
|
||||||
? {
|
? {
|
||||||
...session,
|
...session,
|
||||||
activeOrganizationId: session.activeOrganizationId || "",
|
activeOrganizationId: session.activeOrganizationId || "",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
user: user
|
user: user
|
||||||
? {
|
? {
|
||||||
...user,
|
...user,
|
||||||
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
|
|||||||
await checkPermission(ctx, { [resource]: [action] } as any);
|
await checkPermission(ctx, { [resource]: [action] } as any);
|
||||||
return next();
|
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
|
go 1.20
|
||||||
|
|
||||||
require (
|
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/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
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/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 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
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.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
|
||||||
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
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.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.19.15",
|
||||||
"@hono/zod-validator": "0.7.6",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"bullmq": "5.67.3",
|
"bullmq": "5.67.3",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.2",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.13.5",
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
|
|||||||
+23
-3
@@ -26,7 +26,7 @@
|
|||||||
"@biomejs/biome": "2.5.7",
|
"@biomejs/biome": "2.5.7",
|
||||||
"@types/node": "^24.4.0",
|
"@types/node": "^24.4.0",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.25.0",
|
||||||
"lint-staged": "^15.5.2",
|
"lint-staged": "^15.5.2",
|
||||||
"tsx": "4.22.4"
|
"tsx": "4.22.4"
|
||||||
},
|
},
|
||||||
@@ -46,11 +46,31 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.25.0",
|
||||||
"better-call": "1.3.7",
|
"better-call": "1.3.7",
|
||||||
"@better-fetch/fetch": "1.3.1",
|
"@better-fetch/fetch": "1.3.1",
|
||||||
"tar": ">=7.5.19 <8.0.0",
|
"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": {
|
"peerDependencyRules": {
|
||||||
"ignoreMissing": [
|
"ignoreMissing": [
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@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-cloudwatch-logs": "^3.1108.0",
|
||||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||||
@@ -46,14 +47,14 @@
|
|||||||
"@better-auth/scim": "1.6.23",
|
"@better-auth/scim": "1.6.23",
|
||||||
"@better-auth/sso": "1.6.23",
|
"@better-auth/sso": "1.6.23",
|
||||||
"@better-auth/utils": "0.4.2",
|
"@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/auth-app": "^6.1.3",
|
||||||
"@octokit/rest": "^20.1.2",
|
"@octokit/rest": "^20.1.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@react-email/components": "^1.0.12",
|
"@react-email/components": "^1.0.12",
|
||||||
"@trpc/server": "11.10.0",
|
"@trpc/server": "11.10.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.6.1",
|
||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
@@ -66,13 +67,13 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.18.1",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.18",
|
"nanoid": "3.3.18",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.1.0",
|
"node-pty": "1.1.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "10.0.15",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
@@ -83,34 +84,32 @@
|
|||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"shell-quote": "^1.8.4",
|
"shell-quote": "^1.11.0",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"ssh2": "~1.16.0",
|
"ssh2": "~1.16.0",
|
||||||
"toml": "3.0.0",
|
"toml": "4.2.0",
|
||||||
"ws": "8.16.0",
|
"undici": "^6.21.3",
|
||||||
"yaml": "2.8.1",
|
"ws": "8.21.0",
|
||||||
|
"yaml": "2.8.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
"@types/lodash": "4.17.4",
|
"@types/lodash": "4.17.4",
|
||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "^24.4.0",
|
"@types/node": "^24.4.0",
|
||||||
"@types/node-schedule": "2.1.6",
|
"@types/node-schedule": "2.1.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/shell-quote": "^1.7.5",
|
|
||||||
"@types/ssh2": "1.15.1",
|
"@types/ssh2": "1.15.1",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.25.0",
|
||||||
"esbuild-plugin-alias": "0.2.1",
|
"esbuild-plugin-alias": "0.2.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.23",
|
||||||
"rimraf": "6.1.3",
|
"rimraf": "6.1.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tsc-alias": "1.8.10",
|
"tsc-alias": "1.8.10",
|
||||||
|
|||||||
@@ -95,4 +95,5 @@ export type AuditResourceType =
|
|||||||
| "network"
|
| "network"
|
||||||
| "vaultProvider"
|
| "vaultProvider"
|
||||||
| "dnsProvider"
|
| "dnsProvider"
|
||||||
| "logProvider";
|
| "telemetryProvider"
|
||||||
|
| "ai";
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
|
|||||||
applicationKey: z.string().trim().min(1),
|
applicationKey: z.string().trim().min(1),
|
||||||
applicationSecret: z.string().trim().min(1),
|
applicationSecret: z.string().trim().min(1),
|
||||||
consumerKey: z.string().trim().min(1),
|
consumerKey: z.string().trim().min(1),
|
||||||
|
zone: z.string().trim().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export * from "./gitea";
|
|||||||
export * from "./github";
|
export * from "./github";
|
||||||
export * from "./gitlab";
|
export * from "./gitlab";
|
||||||
export * from "./libsql";
|
export * from "./libsql";
|
||||||
export * from "./log-provider";
|
|
||||||
export * from "./mariadb";
|
export * from "./mariadb";
|
||||||
export * from "./mongo";
|
export * from "./mongo";
|
||||||
export * from "./mount";
|
export * from "./mount";
|
||||||
@@ -42,6 +41,7 @@ export * from "./shared";
|
|||||||
export * from "./ssh-key";
|
export * from "./ssh-key";
|
||||||
export * from "./sso";
|
export * from "./sso";
|
||||||
export * from "./tag";
|
export * from "./tag";
|
||||||
|
export * from "./telemetry-provider";
|
||||||
export * from "./transfer";
|
export * from "./transfer";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const server = pgTable("server", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => generateAppName("server")),
|
.$defaultFn(() => generateAppName("server")),
|
||||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||||
logProviderIds: text("logProviderIds")
|
telemetryProviderIds: text("telemetryProviderIds")
|
||||||
.array()
|
.array()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`ARRAY[]::text[]`),
|
.default(sql`ARRAY[]::text[]`),
|
||||||
|
|||||||
+27
-24
@@ -5,22 +5,26 @@ import { z } from "zod";
|
|||||||
import { organization } from "./account";
|
import { organization } from "./account";
|
||||||
import { encryptedText } from "./utils";
|
import { encryptedText } from "./utils";
|
||||||
|
|
||||||
export const logProviderType = pgEnum("LogProviderType", [
|
export const telemetryProviderType = pgEnum("TelemetryProviderType", [
|
||||||
"loki",
|
"loki",
|
||||||
"datadog",
|
"datadog",
|
||||||
"betterstack",
|
"betterstack",
|
||||||
"elasticsearch",
|
"elasticsearch",
|
||||||
"splunk_hec",
|
"splunk_hec",
|
||||||
"aws_cloudwatch",
|
"aws_cloudwatch",
|
||||||
|
"prometheus_remote_write",
|
||||||
|
"new_relic",
|
||||||
|
"influxdb",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const logProvider = pgTable("logProvider", {
|
export const telemetryProvider = pgTable("telemetryProvider", {
|
||||||
logProviderId: text("logProviderId")
|
telemetryProviderId: text("telemetryProviderId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
providerType: logProviderType("providerType").notNull(),
|
providerType: telemetryProviderType("providerType").notNull(),
|
||||||
|
signals: text("signals").array().notNull(),
|
||||||
endpoint: encryptedText("endpoint"),
|
endpoint: encryptedText("endpoint"),
|
||||||
apiKey: encryptedText("apiKey"),
|
apiKey: encryptedText("apiKey"),
|
||||||
apiSecret: encryptedText("apiSecret"),
|
apiSecret: encryptedText("apiSecret"),
|
||||||
@@ -34,17 +38,11 @@ export const logProvider = pgTable("logProvider", {
|
|||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createSchema = createInsertSchema(logProvider, {
|
const createSchema = createInsertSchema(telemetryProvider, {
|
||||||
logProviderId: z.string().min(1),
|
telemetryProviderId: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
providerType: z.enum([
|
providerType: z.enum(telemetryProviderType.enumValues),
|
||||||
"loki",
|
signals: z.array(z.enum(["logs", "metrics"])).min(1),
|
||||||
"datadog",
|
|
||||||
"betterstack",
|
|
||||||
"elasticsearch",
|
|
||||||
"splunk_hec",
|
|
||||||
"aws_cloudwatch",
|
|
||||||
]),
|
|
||||||
endpoint: z
|
endpoint: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -60,28 +58,33 @@ const createSchema = createInsertSchema(logProvider, {
|
|||||||
organizationId: z.string().min(1),
|
organizationId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateLogProvider = createSchema
|
export const apiCreateTelemetryProvider = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
name: true,
|
name: true,
|
||||||
providerType: true,
|
providerType: true,
|
||||||
|
signals: true,
|
||||||
endpoint: true,
|
endpoint: true,
|
||||||
apiKey: true,
|
apiKey: true,
|
||||||
apiSecret: true,
|
apiSecret: true,
|
||||||
extraConfig: true,
|
extraConfig: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
.required({ name: true, providerType: true });
|
.required({ name: true, providerType: true, signals: true });
|
||||||
|
|
||||||
export const apiUpdateLogProvider = apiCreateLogProvider.partial().extend({
|
export const apiUpdateTelemetryProvider = apiCreateTelemetryProvider
|
||||||
logProviderId: z.string().min(1),
|
.partial()
|
||||||
|
.extend({
|
||||||
|
telemetryProviderId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiRemoveTelemetryProvider = z.object({
|
||||||
|
telemetryProviderId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiRemoveLogProvider = z.object({
|
export const apiFindOneTelemetryProvider = z.object({
|
||||||
logProviderId: z.string().min(1),
|
telemetryProviderId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindOneLogProvider = z.object({
|
export const apiTestTelemetryProvider = apiCreateTelemetryProvider.partial({
|
||||||
logProviderId: z.string().min(1),
|
name: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiTestLogProvider = apiCreateLogProvider.partial({ name: true });
|
|
||||||
@@ -27,11 +27,11 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
sshPrivateKey: text("sshPrivateKey"),
|
sshPrivateKey: text("sshPrivateKey"),
|
||||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||||
logProviderIds: text("logProviderIds")
|
telemetryProviderIds: text("telemetryProviderIds")
|
||||||
.array()
|
.array()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`ARRAY[]::text[]`),
|
.default(sql`ARRAY[]::text[]`),
|
||||||
logManagementOrganizationId: text("logManagementOrganizationId").references(
|
vectorAgentOrganizationId: text("vectorAgentOrganizationId").references(
|
||||||
() => organization.id,
|
() => organization.id,
|
||||||
{ onDelete: "set null" },
|
{ onDelete: "set null" },
|
||||||
),
|
),
|
||||||
@@ -97,6 +97,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
errorPageDescription: string | null;
|
errorPageDescription: string | null;
|
||||||
footerText: string | null;
|
footerText: string | null;
|
||||||
ogImageUrl: string | null;
|
ogImageUrl: string | null;
|
||||||
|
logoLightUrl: string | null;
|
||||||
|
loginLogoLightUrl: string | null;
|
||||||
|
faviconLightUrl: string | null;
|
||||||
|
dualModeBranding: boolean;
|
||||||
}>()
|
}>()
|
||||||
.default({
|
.default({
|
||||||
appName: null,
|
appName: null,
|
||||||
@@ -111,6 +115,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
ogImageUrl: null,
|
ogImageUrl: null,
|
||||||
|
logoLightUrl: null,
|
||||||
|
loginLogoLightUrl: null,
|
||||||
|
faviconLightUrl: null,
|
||||||
|
dualModeBranding: false,
|
||||||
}),
|
}),
|
||||||
// Deployment Configuration (self-hosted only)
|
// Deployment Configuration (self-hosted only)
|
||||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||||
@@ -141,46 +149,52 @@ const createSchema = createInsertSchema(webServerSettings, {
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
export const apiUpdateWebServerSettings = createSchema
|
||||||
serverIp: z.string().optional(),
|
.partial()
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
.extend({
|
||||||
https: z.boolean().optional(),
|
serverIp: z.string().optional(),
|
||||||
host: z.string().optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
letsEncryptEmail: z.string().email().optional().nullable(),
|
https: z.boolean().optional(),
|
||||||
sshPrivateKey: z.string().optional(),
|
host: z.string().optional(),
|
||||||
enableDockerCleanup: z.boolean().optional(),
|
letsEncryptEmail: z.string().email().optional().nullable(),
|
||||||
logCleanupCron: z.string().optional().nullable(),
|
sshPrivateKey: z.string().optional(),
|
||||||
metricsConfig: z
|
enableDockerCleanup: z.boolean().optional(),
|
||||||
.object({
|
logCleanupCron: z.string().optional().nullable(),
|
||||||
server: z.object({
|
metricsConfig: z
|
||||||
type: z.enum(["Dokploy", "Remote"]),
|
.object({
|
||||||
refreshRate: z.number(),
|
server: z.object({
|
||||||
port: z.number(),
|
type: z.enum(["Dokploy", "Remote"]),
|
||||||
token: z.string(),
|
refreshRate: z.number(),
|
||||||
urlCallback: z.string(),
|
port: z.number(),
|
||||||
retentionDays: z.number(),
|
token: z.string(),
|
||||||
cronJob: z.string(),
|
urlCallback: z.string(),
|
||||||
thresholds: z.object({
|
retentionDays: z.number(),
|
||||||
cpu: z.number(),
|
cronJob: z.string(),
|
||||||
memory: z.number(),
|
thresholds: z.object({
|
||||||
|
cpu: z.number(),
|
||||||
|
memory: z.number(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
containers: z.object({
|
||||||
containers: z.object({
|
refreshRate: z.number(),
|
||||||
refreshRate: z.number(),
|
services: z.object({
|
||||||
services: z.object({
|
include: z.array(z.string()),
|
||||||
include: z.array(z.string()),
|
exclude: z.array(z.string()),
|
||||||
exclude: z.array(z.string()),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
})
|
||||||
})
|
.optional(),
|
||||||
.optional(),
|
cleanupCacheApplications: z.boolean().optional(),
|
||||||
cleanupCacheApplications: z.boolean().optional(),
|
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
cleanupCacheOnCompose: z.boolean().optional(),
|
||||||
cleanupCacheOnCompose: z.boolean().optional(),
|
remoteServersOnly: z.boolean().optional(),
|
||||||
remoteServersOnly: z.boolean().optional(),
|
enforceSSO: z.boolean().optional(),
|
||||||
enforceSSO: z.boolean().optional(),
|
buildsConcurrency: z.number().int().min(1).max(100).optional(),
|
||||||
buildsConcurrency: z.number().int().min(1).max(100).optional(),
|
})
|
||||||
});
|
.omit({
|
||||||
|
telemetryProviderIds: true,
|
||||||
|
vectorAgentOrganizationId: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const apiUpdateWebServerBuildsConcurrency = z.object({
|
export const apiUpdateWebServerBuildsConcurrency = z.object({
|
||||||
buildsConcurrency: z.number().int().min(1).max(100),
|
buildsConcurrency: z.number().int().min(1).max(100),
|
||||||
@@ -234,6 +248,10 @@ export const whitelabelingConfigSchema = z.object({
|
|||||||
errorPageDescription: z.string().nullable(),
|
errorPageDescription: z.string().nullable(),
|
||||||
footerText: z.string().nullable(),
|
footerText: z.string().nullable(),
|
||||||
ogImageUrl: safeUrl,
|
ogImageUrl: safeUrl,
|
||||||
|
logoLightUrl: safeUrl,
|
||||||
|
loginLogoLightUrl: safeUrl,
|
||||||
|
faviconLightUrl: safeUrl,
|
||||||
|
dualModeBranding: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUpdateWhitelabeling = z.object({
|
export const apiUpdateWhitelabeling = z.object({
|
||||||
|
|||||||
+8
-8
@@ -476,8 +476,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
engines: {node: ^4.5.0 || >= 5.9}
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
@@ -773,8 +773,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||||
engines: {node: '>=10.2.0'}
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.2:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
strip-bom@3.0.0:
|
strip-bom@3.0.0:
|
||||||
@@ -1166,7 +1166,7 @@ snapshots:
|
|||||||
|
|
||||||
base64id@2.0.0: {}
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@@ -1200,7 +1200,7 @@ snapshots:
|
|||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.27.1
|
mdn-data: 2.27.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.2
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
@@ -1361,7 +1361,7 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.6:
|
minimatch@10.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.9
|
brace-expansion: 5.0.12
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
@@ -1490,7 +1490,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.2: {}
|
||||||
|
|
||||||
strip-bom@3.0.0: {}
|
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