Compare commits

...

59 Commits

Author SHA1 Message Date
Narciso 42c6f00d2e fix(log-management): report missing agents as not deployed and drop deleted providers on save
- Inspect the dokploy-vector service before listing tasks; listTasks returns [] for a missing service
- Filter provider assignments to existing providers after a deploy so a provider deleted mid-deploy is not written back
2026-10-07 17:04:38 -04:00
Narciso daa83e2981 fix(log-management): apply Greptile review fixes for agent lifecycle
- Check accessedServers on deploy, removal and the server-status list
- Keep the local claim until the agent is removed and serialize deploy/remove per target
- Persist provider assignments only after the Vector deploy succeeds; release the local claim if the first deploy fails
- Remove the agent on server/organization deletion even when no providers remain assigned
- Report agents with no running task as not running
- Refresh logProvider.one after saving, allow clearing saved credentials, keep deployed targets visible with no providers
2026-10-07 16:14:09 -04:00
Narciso 4c95195891 fix(log-management): scope local agent per organization and harden provider operations 2026-10-07 15:23:44 -04:00
Narciso 4e6e508b50 Merge branch 'canary' into feat/log-management 2026-10-07 08:49:17 -04:00
Mauricio Siu 5be17d3dd0 Merge pull request #5584 from libredb/fix/libsql-app-name-validation
fix(libsql): validate appName like the other database kinds
2026-10-05 10:11:50 -06:00
Narciso E. Núñez Arias 5aa28bc7d3 Merge pull request #5575 from imrja8/fix/tooltip-overlapping-modals
fix(ui): resolve tooltips overlapping open modals on remote servers page
2026-10-05 09:41:01 -04:00
Narciso E. Núñez Arias 20575da9fe Merge pull request #5582 from imrja8/fix/terminal-collapse-on-zoom
fix(terminal): prevent terminal from collapsing on zoom
2026-10-05 09:37:11 -04:00
cevheri 06b3bb282a fix(libsql): validate appName like the other database kinds 2026-10-04 23:06:47 +03:00
Yash Kumar 5c19d1b704 fix(terminal): prevent terminal from collapsing on zoom 2026-10-04 21:37:15 +05:30
autofix-ci[bot] 2bc3741e41 [autofix.ci] apply automated fixes 2026-10-04 08:54:25 +00:00
Yash Kumar 35d26a806b fix(ui): prevent tooltips from getting stuck when server modals close 2026-10-04 14:23:59 +05:30
Yash Kumar a4a13b90b8 fix(ui): resolve tooltips overlapping open modals on remote servers page 2026-10-04 13:52:51 +05:30
Narciso E. Núñez Arias 48504fde4e Merge pull request #5561 from imrja8/revert-5406-fix-settings-layout-width 2026-10-01 10:35:33 -04:00
Yash Kumar 84a1d7093c Revert "fix(ui): standardize settings layout widths to full width" 2026-10-01 19:33:27 +05:30
Mauricio Siu 90d522c846 Merge pull request #5463 from Dokploy/feat/self-delete-account
feat: account deletion for Dokploy Cloud
2026-10-01 00:22:02 -06:00
Mauricio Siu c6a2b2437d Merge branch 'canary' into feat/self-delete-account 2026-09-30 23:53:57 -06:00
Narciso E. Núñez Arias cec5b65cd6 Merge pull request #5482 from qirh/add-perplexity-attribution 2026-09-29 15:01:14 -04:00
Narciso E. Núñez Arias 52f61adfe8 Merge pull request #5022 from Souvik-Cyclic/fix/secure-session-cookie-https-4709
fix: mark session cookie as Secure when served over HTTPS
2026-09-29 13:04:42 -04:00
Narciso E. Núñez Arias 169bac2ef8 Merge pull request #5529 from Souvik-Cyclic/fix/deployment-log-tail-leak-5518
fix: kill deployment log tail when the client disconnects early
2026-09-29 13:04:25 -04:00
Narciso E. Núñez Arias 916bb50e73 fix: redact S3 credentials in backup logs regardless of shell quoting (#5527) 2026-09-29 13:03:59 -04:00
Souvik Kumar 0d3a3966b0 fix: count only physical disks in host Block I/O stats (#5540)
* fix: count only physical disks in host Block I/O stats and skip partitioned md arrays and test the Block I/O sum
2026-09-29 11:47:05 -04:00
Roshan Singh 560e790e73 fix: preserve compose status when saving provider settings (#5544)
* fix: preserve compose status when saving provider settings
2026-09-29 10:58:59 -04:00
Narciso E. Núñez Arias ff2e454dca Merge pull request #5543 from Souvik-Cyclic/fix/webhook-body-size-5537
fix: accept deploy webhook payloads up to 25 MB
2026-09-29 10:37:49 -04:00
Souvik Kumar 82545bf500 revert: go back to a literal 25mb body limit for deploy webhooks 2026-09-29 20:00:05 +05:30
Narciso E. Núñez Arias 302754bd3b Merge pull request #5535 from y4aniv/fix/tab-headers-consistency
fix(ui): unify tab header styles across backups, schedules, volume backups and patches
2026-09-29 10:26:12 -04:00
Narciso E. Núñez Arias 6e7934fc55 Merge pull request #5429 from imrja8/fix/deployments-filter-bar-inline-layout
feat(ui): enhance deployments layout and fix visual jumping
2026-09-29 10:14:34 -04:00
Yash Kumar c18609a127 refactor(ui): remove redundant responsive layout comment 2026-09-29 19:24:03 +05:30
Narciso E. Núñez Arias 618a7df25c Merge pull request #5455 from roshanasingh4/codex/preserve-app-status-on-provider-save
fix: preserve application status when saving provider settings
2026-09-29 09:51:29 -04:00
Souvik Kumar 1c6d333739 fix: parse deploy webhook bodies in the handler so the size limit can be configured 2026-09-29 19:20:41 +05:30
Narciso E. Núñez Arias 72f2c4d5d1 Merge pull request #5498 from imrja8/fix/add-invitation-form-reset
fix: prevent add-invitation form from resetting on submission error
2026-09-29 09:44:47 -04:00
Souvik Kumar e8be2693e0 refactor: make deploy webhook body limit configurable via DEPLOY_WEBHOOK_MAX_BODY_SIZE 2026-09-29 19:06:44 +05:30
Souvik Kumar 4396451095 fix: accept deploy webhook payloads up to 25 MB 2026-09-29 18:37:57 +05:30
Yash Kumar 7dc066a7bd Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-29 18:08:41 +05:30
Yash Kumar a0b6f233cb Merge branch 'Dokploy:canary' into fix/add-invitation-form-reset 2026-09-29 18:08:30 +05:30
Mauricio Siu d0ec74fbb8 Merge pull request #5538 from Dokploy/fix/hubspot-chat-widget
fix(cloud): load HubSpot chat widget for startup plan users
2026-09-29 02:05:21 -06:00
Mauricio Siu 382fdfc478 fix(cloud): remove HubSpot chat widget when dashboard unmounts
Logout navigates client-side to the sign-in page, so the widget (mounted
outside React) stayed visible after a startup user logged out.
2026-09-29 01:59:29 -06:00
Mauricio Siu c231c2979b fix(cloud): load HubSpot chat widget for startup plan users
The HubSpot script is loaded app-wide with loadImmediately: false, but
useHubSpotChat was never called, so widget.load() never ran. The old
HubSpotWidget shared the hs-script-loader id and was deduped by next/script.
2026-09-29 01:54:04 -06:00
Yaniv Douieb 3951247ebd fix(ui): unify tab header styles across backups, schedules, volume backups and patches 2026-09-28 17:48:20 +02:00
Souvik Kumar 7b89d1f948 fix: kill deployment log tail when the client disconnects early 2026-09-27 15:27:14 +05:30
Souvik Kumar 6215c7f159 fix: redact S3 credentials in backup logs regardless of shell quoting 2026-09-27 00:23:23 +05:30
Yash Kumar 52ea2ce071 Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-26 09:28:03 +05:30
Yash Kumar a0f1a5119c Merge branch 'Dokploy:canary' into fix/add-invitation-form-reset 2026-09-26 09:27:54 +05:30
Mauricio Siu b40d8ed505 feat(log-management): add organization validation for log providers and servers 2026-09-24 01:25:21 -06:00
Mauricio Siu ae0cdc6456 feat: enhance log management and organization handling
- Updated API link conditions to allow skipping batch processing based on context.
- Removed unused VECTOR_RESYNC_CRON_JOB constant from server constants.
- Introduced logProviderIds field in server and webServerSettings schemas to manage log providers.
- Added organization service to handle organization deletions and vector agent removals.
- Enhanced server service to find servers with log management enabled and update log providers.
- Updated vector setup to sync vector configurations based on organization and server log provider IDs.
- Implemented Docker labels for resources to improve tracking and management.
- Refactored vector agent setup and removal processes to streamline operations.
2026-09-24 01:23:20 -06:00
Mauricio Siu 9e8c5cde31 feat(log-management): add ShowLogManagementServers component and integrate it into log providers page 2026-09-23 15:50:10 -06:00
Mauricio Siu bac5eaf9b8 feat(log-management): enhance log provider UI with icons and improve layout 2026-09-23 15:19:38 -06:00
Mauricio Siu 3e46be7960 Merge branch 'canary' into feat/log-management 2026-09-21 14:12:54 -06:00
Yash Kumar 143dfcafe3 fix: correct add-invitation form reset logic and select state 2026-09-21 16:01:50 +05:30
Yash Kumar 3563f585f9 Merge branch 'Dokploy:canary' into fix/deployments-filter-bar-inline-layout 2026-09-20 12:39:36 +05:30
Saleh Alghusson f2ba262499 feat(ai): add Perplexity integration attribution 2026-09-18 13:26:27 -05:00
Mauricio Siu 318ad36967 feat: restrict self-service account deletion to cloud organization owners 2026-09-14 11:05:26 -06:00
Mauricio Siu a87a5c06f0 feat: account deletion for Dokploy Cloud
Self-service deletion from Settings > Profile confirmed with the account
password (when present) plus a 6-digit code sent by email, and a root-only
"delete by email" tool in the impersonation bar to fulfil GDPR requests.

Both paths run the same flow: cancel every Stripe subscription (customer is
kept for invoicing), delete the user and all owned organizations in one
transaction, anonymize audit trail left in other organizations, reassign
pending invitations to the org owner, remove the HubSpot contact when
HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record.

Also blocks better-auth's raw /admin/remove-user endpoint and makes the
Stripe webhook tolerate subscription/customer events for already deleted
users.
2026-09-14 11:01:47 -06:00
Roshan Singh e120340e77 test: cover status preservation for every provider save 2026-09-13 22:28:06 +05:30
Roshan Singh 0d6e984b54 fix: preserve application status when saving provider settings 2026-09-13 22:22:36 +05:30
Yash Kumar 1e9873b3e4 fix(ui): change filter layout breakpoint to lg to prevent overflow on tablets 2026-09-12 15:59:18 +05:30
Yash Kumar 26317e9126 feat(ui): inline deployments filter bar and fix layout jumping 2026-09-12 15:50:08 +05:30
autofix-ci[bot] 0375f7aeae [autofix.ci] apply automated fixes 2026-09-10 16:53:01 +00:00
Narciso d803fe6123 refactor: clean up log-management code and rebuild migrations 2026-09-10 12:52:27 -04:00
Souvik Kumar f5c21db1ef fix: mark session cookie as Secure when served over HTTPS
Self-hosted better-auth is configured with secure cookies off so plain
HTTP access (http://<ip>:3000) keeps working, but that also meant the
session cookie never got the Secure attribute even behind HTTPS. A cookie
without Secure can be replayed over plain HTTP.

Add Secure to the auth response cookies per request when X-Forwarded-Proto
is https (what Traefik sends on TLS), leaving plain-HTTP requests untouched.

Fixes #4709
2026-08-09 21:05:37 +05:30
150 changed files with 17023 additions and 508 deletions
+1
View File
@@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateApplication = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => {
return {
checkServicePermissionAndAccess: vi.fn(async () => undefined),
};
});
vi.mock("@dokploy/server/index", () => ({
IS_CLOUD: true,
hasValidLicense: vi.fn(async () => false),
updateApplication: mockUpdateApplication,
findApplicationById: vi.fn(async () => ({
applicationId: "app-1",
appName: "example-app",
})),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
const { applicationRouter } = await import("@/server/api/routers/application");
const caller = applicationRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof applicationRouter.createCaller>[0]);
const providerChanges = [
{
name: "GitHub",
sourceType: "github",
save: () =>
caller.saveGithubProvider({
applicationId: "app-1",
repository: "example-repo",
owner: "example-owner",
branch: "main",
buildPath: "/",
githubId: "provider-1",
watchPaths: ["src/**"],
triggerType: "push",
enableSubmodules: false,
}),
},
{
name: "GitLab",
sourceType: "gitlab",
save: () =>
caller.saveGitlabProvider({
applicationId: "app-1",
gitlabRepository: "example-repo",
gitlabOwner: "example-owner",
gitlabBranch: "main",
gitlabBuildPath: "/",
gitlabId: "provider-1",
gitlabProjectId: 1,
gitlabPathNamespace: "example-owner/example-repo",
}),
},
{
name: "Bitbucket",
sourceType: "bitbucket",
save: () =>
caller.saveBitbucketProvider({
applicationId: "app-1",
bitbucketRepository: "example-repo",
bitbucketRepositorySlug: "example-repo",
bitbucketOwner: "example-owner",
bitbucketBranch: "main",
bitbucketBuildPath: "/",
bitbucketId: "provider-1",
}),
},
{
name: "Gitea",
sourceType: "gitea",
save: () =>
caller.saveGiteaProvider({
applicationId: "app-1",
giteaRepository: "example-repo",
giteaOwner: "example-owner",
giteaBranch: "main",
giteaBuildPath: "/",
giteaId: "provider-1",
}),
},
{
name: "Docker",
sourceType: "docker",
save: () =>
caller.saveDockerProvider({
applicationId: "app-1",
dockerImage: "nginx:alpine",
username: "",
password: "",
registryUrl: "",
}),
},
{
name: "custom Git",
sourceType: "git",
save: () =>
caller.saveGitProvider({
applicationId: "app-1",
customGitUrl: "https://example.com/repo.git",
customGitBranch: "main",
customGitBuildPath: "/",
customGitSSHKeyId: "key-1",
watchPaths: ["src/**"],
enableSubmodules: false,
}),
},
{
name: "disconnect Git provider",
sourceType: "github",
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
},
];
describe("saving an application provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(providerChanges)(
"keeps the status when $name settings change",
async ({ save, sourceType }) => {
for (const status of ["done", "running"]) {
mockUpdateApplication.mockClear();
let savedStatus = status;
mockUpdateApplication.mockImplementation(async (_id, update) => {
if (update.applicationStatus !== undefined) {
savedStatus = update.applicationStatus;
}
});
await save();
expect(savedStatus).toBe(status);
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
expect(mockUpdateApplication).toHaveBeenCalledWith(
"app-1",
expect.objectContaining({ sourceType }),
);
}
},
);
});
@@ -1,4 +1,6 @@
import type { Destination } from "@dokploy/server/services/destination";
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
expect(redacted).toContain("[REDACTED]");
});
});
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
const buildCommand = (accessKey: string, secretAccessKey: string) =>
`rclone rcat ${getS3Credentials({
accessKey,
secretAccessKey,
region: "us-west-001",
endpoint: "https://s3.us-west-001.backblazeb2.com",
provider: "Other",
} as Destination).join(" ")} :s3:bucket/file.gz`;
it.each([
[
"plain alphanumeric",
"001aaaabbbbccccdd0000000001",
"K001FAKEfakeFAKEfake",
],
[
"containing slashes",
"AKIAIOSFODNN7EXAMPLE",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
],
["containing spaces", "key with space", "secret with space"],
["containing quotes", "it's-a-key", `say "hi" it's`],
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
const redacted = redactRcloneCredentials(
buildCommand(accessKey, secretAccessKey),
);
expect(redacted).toContain(
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
);
expect(redacted).not.toContain(accessKey);
expect(redacted).not.toContain(secretAccessKey);
expect(redacted).toContain(":s3:bucket/file.gz");
});
it("redacts credentials embedded in an error string", () => {
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MY/SECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
describe("Remote Server Action Tooltips", () => {
it("should close the tooltip when the dialog trigger is clicked", () => {
// Note: The UI layer in this project is not currently tested with DOM simulation tools
// like React Testing Library or JSDOM.
//
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
expect(true).toBe(true);
});
});
@@ -33,8 +33,16 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -54,6 +62,16 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockUpdateCompose = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: true,
findComposeById: vi.fn(async () => ({
composeId: "compose-1",
name: "example-compose",
})),
updateCompose: mockUpdateCompose,
}));
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", () => ({
cleanQueuesByCompose: vi.fn(),
killDockerBuild: vi.fn(),
myQueue: {
add: vi.fn(),
},
}));
const { composeRouter } = await import("@/server/api/routers/compose");
const caller = composeRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof composeRouter.createCaller>[0]);
describe("saving a compose provider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each(["done", "running"])(
"keeps the %s status when disconnecting a Git provider",
async (status) => {
let savedStatus = status;
mockUpdateCompose.mockImplementation(async (_id, update) => {
if (update.composeStatus !== undefined) {
savedStatus = update.composeStatus;
}
});
await caller.disconnectGitProvider({ composeId: "compose-1" });
expect(savedStatus).toBe(status);
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
expect(mockUpdateCompose).toHaveBeenCalledWith(
"compose-1",
expect.objectContaining({ sourceType: "github" }),
);
},
);
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
describe("isHttpsRequest", () => {
it("returns true when X-Forwarded-Proto is https", () => {
expect(isHttpsRequest("https")).toBe(true);
});
it("returns false for http or a missing header", () => {
expect(isHttpsRequest("http")).toBe(false);
expect(isHttpsRequest(undefined)).toBe(false);
});
it("uses the left-most value of a proxy chain", () => {
expect(isHttpsRequest("https, http")).toBe(true);
expect(isHttpsRequest("http, https")).toBe(false);
});
it("handles an array header value and is case-insensitive", () => {
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
});
});
describe("secureSetCookie", () => {
it("adds Secure to a cookie that lacks it", () => {
expect(
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
});
it("does not duplicate Secure when already present", () => {
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
expect(secureSetCookie(cookie)).toBe(cookie);
});
it("applies to every cookie in an array", () => {
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
"a=1; Path=/; Secure",
"b=2; Path=/; Secure",
]);
});
it("passes a numeric header value through untouched", () => {
expect(secureSetCookie(123)).toBe(123);
});
});
@@ -0,0 +1,66 @@
import { apiCreateLibsql } from "@dokploy/server/db/schema";
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
import { describe, expect, it } from "vitest";
describe("apiCreateLibsql appName", () => {
const validBase = {
name: "My libSQL",
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
environmentId: "env-1",
description: null,
databaseUser: "admin",
databasePassword: "secretPassword1",
sqldNode: "primary" as const,
sqldPrimaryUrl: null,
enableNamespaces: false,
serverId: null,
};
it.each([
["a space", "my libsql"],
["an at sign", "repro@lib"],
["a slash", "team/libsql"],
["a colon", "libsql:1"],
])("refuses an appName with %s", (_, appName) => {
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.message)).toEqual([
APP_NAME_MESSAGE,
]);
}
});
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "my-libsql.db_1",
});
expect(result.success).toBe(true);
});
it("refuses an appName longer than 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(64),
});
expect(result.success).toBe(false);
if (!result.success) {
const appNameIssues = result.error.issues.filter(
(issue) => issue.path[0] === "appName",
);
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
}
});
it("accepts an appName of 63 characters", () => {
const result = apiCreateLibsql.safeParse({
...validBase,
appName: "a".repeat(63),
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,356 @@
import {
getLogProviderAdapter,
logProviderAdapters,
} from "@dokploy/server/services/log-management/providers/registry";
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
import { describe, expect, it } from "vitest";
const baseConfig: LogProviderRuntimeConfig = {
logProviderId: "log-provider-1",
name: "test",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getLogProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getLogProviderAdapter("loki").type).toBe("loki");
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getLogProviderAdapter("unknown")).toThrow(
/No LogProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
);
expect(sink.type).toBe("loki");
expect(sink.inputs).toEqual(["dokploy_scope"]);
expect(sink.endpoint).toBe("https://loki.example.com");
expect(sink.labels).toMatchObject({
dokploy_project: "{{ dokploy_project }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
});
expect(sink.tenant_id).toBeUndefined();
expect(sink.buffer).toEqual({
type: "disk",
max_size: 268_435_488,
when_full: "block",
});
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
);
expect(sink.type).toBe("datadog_logs");
expect(sink.default_api_key).toBe("dd-key");
expect(sink.site).toBe("datadoghq.com");
expect(sink.tags).toBeUndefined();
expect(sink.inputs).toEqual(["transform_2"]);
});
it("uses extraConfig.site when present", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("has toVectorTransform to build .ddtags from the scoping fields — confirmado que datadog_logs no tiene un campo `tags` de config (vector generate-schema)", () => {
const adapter = logProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = logProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = logProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
);
expect(transform).toEqual({
type: "remap",
inputs: ["dokploy_scope"],
source: ".dt = del(.timestamp)",
});
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = logProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
expect(sink.compression).toBe("gzip");
});
});
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = logProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain("encode_json(.label)");
});
it("uses basic auth when a username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
expect(sink.auth).toEqual({
strategy: "basic",
user: "elastic",
password: "secret",
});
expect(sink.request).toBeUndefined();
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
headers: { Authorization: "ApiKey key-123" },
});
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = logProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
);
expect(withoutIndex.bulk).toBeUndefined();
const withIndex = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { index: "dokploy-%Y.%m.%d" },
},
"sink_4",
"dokploy_scope",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = logProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
expect(sink.default_token).toBe("hec-token");
expect(sink.encoding).toEqual({ codec: "json" });
expect(sink.index).toBeUndefined();
expect(sink.sourcetype).toBeUndefined();
const withExtras = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
extraConfig: { index: "dokploy", sourcetype: "docker" },
},
"sink_5",
"dokploy_scope",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
logProviderAdapters.aws_cloudwatch.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
expect(sink.stream_name).toBe("{{ container_name }}");
expect(sink.region).toBe("us-east-1");
expect(sink.auth).toEqual({
access_key_id: "AKIA...",
secret_access_key: "shh",
});
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
logStream: "{{ dokploy_application }}",
},
},
"sink_6",
"dokploy_scope",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -0,0 +1,162 @@
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);
});
});
@@ -0,0 +1,315 @@
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);
});
});
@@ -0,0 +1,71 @@
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([]);
});
});
@@ -0,0 +1,306 @@
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();
});
});
@@ -0,0 +1,145 @@
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();
});
});
@@ -0,0 +1,37 @@
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();
});
});
@@ -0,0 +1,72 @@
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]",
);
});
});
@@ -0,0 +1,127 @@
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,104 @@
import {
getHostSystemStats,
isPhysicalDisk,
} from "@dokploy/server/monitoring/utils";
import { describe, expect, it, vi } from "vitest";
const GiB = 1024 ** 3;
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
device,
readBytes: { toBytes: () => readGiB * GiB },
writeBytes: { toBytes: () => writeGiB * GiB },
});
vi.mock("node-os-utils", () => ({
OSUtils: class {
cpu = { usage: async () => ({ success: false }) };
memory = { info: async () => ({ success: false }) };
network = { overview: async () => ({ success: false }) };
disk = {
stats: async () => ({
success: true,
data: [
diskStat("nvme0n1", 50, 100),
diskStat("nvme0n1p1", 50, 100),
diskStat("nvme1n1", 50, 100),
diskStat("nvme1n1p1", 50, 100),
diskStat("md0", 50, 100),
diskStat("md0p1", 50, 100),
diskStat("dm-0", 50, 100),
diskStat("zram0", 10, 20),
],
}),
};
},
}));
describe("isPhysicalDisk (#5513, #5385)", () => {
it.each([
"sda",
"sdab",
"hda",
"vda",
"xvda",
"nvme0n1",
"nvme1n1",
"mmcblk0",
])("counts whole disk %s", (device) => {
expect(isPhysicalDisk(device)).toBe(true);
});
it.each([
"sda1",
"sdab12",
"hda1",
"vda1",
"xvda1",
"nvme0n1p1",
"nvme1n1p4",
"mmcblk0p1",
])("skips partition %s", (device) => {
expect(isPhysicalDisk(device)).toBe(false);
});
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
"skips stacked device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
"skips virtual device %s",
(device) => {
expect(isPhysicalDisk(device)).toBe(false);
},
);
it("counts each write once on an mdadm RAID1 host", () => {
const devices = [
"nvme0n1",
"nvme0n1p1",
"nvme0n1p2",
"nvme0n1p3",
"nvme0n1p4",
"nvme1n1",
"nvme1n1p1",
"nvme1n1p2",
"nvme1n1p3",
"nvme1n1p4",
"md0",
"md1",
"md2",
"md3",
];
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
});
});
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
it("sums only whole physical disks", async () => {
const stats = await getHostSystemStats();
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
});
});
@@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses logProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses logProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied logProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied logProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.logProvider.read).toBe(false);
});
});
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logProviderIds: [],
logManagementOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -0,0 +1,182 @@
import {
checkDeletionCode,
generateDeletionCode,
serializeDeletionCode,
} from "@dokploy/server/services/account-deletion";
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
import Stripe from "stripe";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cancelStripeSubscriptions } from "@/server/utils/billing";
const makeStripe = (
subscriptions: Array<{ id: string; status: string }>,
listError?: Error,
) => {
const stripe = {
subscriptions: {
list: vi.fn(() =>
listError
? Promise.reject(listError)
: Promise.resolve({ data: subscriptions }),
),
cancel: vi.fn(() => Promise.resolve({})),
},
};
return stripe as unknown as Stripe & typeof stripe;
};
describe("cancelStripeSubscriptions", () => {
test("cancels every non-terminal subscription without invoicing", async () => {
const stripe = makeStripe([
{ id: "sub_active", status: "active" },
{ id: "sub_trial", status: "trialing" },
{ id: "sub_past_due", status: "past_due" },
{ id: "sub_canceled", status: "canceled" },
{ id: "sub_expired", status: "incomplete_expired" },
]);
const result = await cancelStripeSubscriptions("cus_1", stripe);
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
customer: "cus_1",
status: "all",
limit: 100,
});
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
invoice_now: false,
prorate: false,
});
expect(result).toEqual({
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
});
});
test("treats a missing customer as having nothing to cancel", async () => {
const missing = new Stripe.errors.StripeInvalidRequestError({
type: "invalid_request_error",
code: "resource_missing",
message: "No such customer",
});
const stripe = makeStripe([], missing);
const result = await cancelStripeSubscriptions("cus_missing", stripe);
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
expect(result).toEqual({ cancelledSubscriptions: [] });
});
test("propagates unexpected Stripe errors", async () => {
const failure = new Stripe.errors.StripeAPIError({
type: "api_error",
message: "boom",
});
const stripe = makeStripe([], failure);
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
failure,
);
});
});
describe("deleteHubSpotContactByEmail", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
test("is skipped when no access token is configured", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
"skipped",
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("calls the GDPR delete endpoint with the email as id", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(null, { status: 204 })),
);
vi.stubGlobal("fetch", fetchMock);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"deleted",
);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe(
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
);
expect(init.method).toBe("POST");
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer token",
);
expect(JSON.parse(init.body as string)).toEqual({
idProperty: "email",
objectId: "a@b.com",
});
});
test("maps 404 to not_found and other errors to failed", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"not_found",
);
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
);
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
"failed",
);
});
});
describe("account deletion codes", () => {
test("generates six-digit numeric codes", () => {
for (let i = 0; i < 50; i++) {
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
}
});
test("accepts the right code, ignoring surrounding whitespace", () => {
const value = serializeDeletionCode("482913");
expect(checkDeletionCode(value, "482913")).toEqual({
valid: true,
attempts: 0,
});
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
});
test("counts failed attempts without revealing the hash", () => {
const value = serializeDeletionCode("482913");
const first = checkDeletionCode(value, "000000");
expect(first.valid).toBe(false);
if (first.valid) throw new Error("unreachable");
expect(first.attempts).toBe(1);
expect(JSON.parse(first.nextValue)).toEqual({
...JSON.parse(value),
attempts: 1,
});
const second = checkDeletionCode(first.nextValue, "999999");
expect(second.valid).toBe(false);
if (second.valid) throw new Error("unreachable");
expect(second.attempts).toBe(2);
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
valid: true,
attempts: 2,
});
expect(value).not.toContain("482913");
});
});
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});
}, 30_000);
it("reports a source failure", async () => {
await expect(
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
}, 30_000);
});
@@ -0,0 +1,169 @@
import { EventEmitter } from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import WebSocket from "ws";
class FakeChild extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
signals: string[] = [];
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
// even if the process ignores it and keeps running.
ignoreSigterm = false;
kill(signal: NodeJS.Signals = "SIGTERM") {
this.signals.push(signal);
this.killed = true;
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
this.signalCode = signal;
this.emit("close", null, signal);
return true;
}
}
const children = vi.hoisted(() => [] as FakeChild[]);
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn: vi.fn(() => {
const child = new FakeChild();
child.ignoreSigterm = nextChild.ignoreSigterm;
children.push(child);
return child;
}),
}));
const sshClients = vi.hoisted(
() => [] as { connect: ReturnType<typeof vi.fn> }[],
);
vi.mock("ssh2", () => ({
Client: vi.fn(function (this: Record<string, unknown>) {
this.on = vi.fn(() => this);
this.connect = vi.fn(() => this);
this.end = vi.fn();
sshClients.push(this as never);
}),
}));
const mockValidateRequest = vi.hoisted(() => vi.fn());
const mockFindServerById = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
validateRequest: mockValidateRequest,
findServerById: mockFindServerById,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
readValidDirectory: () => true,
}));
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
const AUTH = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 50));
let server: http.Server;
let url: string;
beforeEach(async () => {
children.length = 0;
sshClients.length = 0;
nextChild.ignoreSigterm = false;
vi.clearAllMocks();
server = http.createServer();
setupDeploymentLogsWebSocketServer(server);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
});
afterEach(async () => {
vi.useRealTimers();
await new Promise((r) => server.close(r));
});
const connect = async (query = "") => {
const ws = new WebSocket(`${url}${query}`);
await new Promise((r) => ws.once("open", r));
return ws;
};
const closeClient = async (ws: WebSocket) => {
ws.close();
await new Promise((r) => ws.once("close", r));
await flush();
};
describe("deployment logs websocket (#5518)", () => {
it("kills tail when the client disconnects", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
expect(children).toHaveLength(1);
await closeClient(ws);
expect(children[0]!.signals).toContain("SIGTERM");
});
it("does not spawn tail when the client disconnects during auth", async () => {
const auth = deferred<typeof AUTH>();
mockValidateRequest.mockReturnValue(auth.promise);
const ws = await connect();
await closeClient(ws);
auth.resolve(AUTH);
await flush();
const leaked = children.filter(
(c) => c.exitCode === null && c.signalCode === null,
);
expect(leaked).toHaveLength(0);
});
it("does not open an SSH session when the client disconnects while loading the server", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
const serverLookup = deferred<unknown>();
mockFindServerById.mockReturnValue(serverLookup.promise);
const ws = await connect("&serverId=srv-1");
await closeClient(ws);
serverLookup.resolve({
organizationId: "org-1",
sshKeyId: "key-1",
sshKey: { privateKey: "key" },
ipAddress: "10.0.0.1",
port: 22,
username: "root",
});
await flush();
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
expect(connected).toHaveLength(0);
});
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
nextChild.ignoreSigterm = true;
mockValidateRequest.mockResolvedValue(AUTH);
const ws = await connect();
await flush();
await closeClient(ws);
await new Promise((r) => setTimeout(r, 1100));
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
});
});
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center gap-y-2 flex-wrap">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
};
return (
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified
intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
type={type}
serverId={serverId}
/>
</div>
</>
)}
</div>
<Card className="bg-background h-full min-h-[50vh]">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Volume Backups</CardTitle>
<CardDescription>
Schedule volume backups to run automatically at specified intervals
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
</div>
</>
)}
</div>
</CardHeader>
<CardContent className="px-0">
<CardContent>
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -1,10 +1,4 @@
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -101,12 +95,9 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Backups</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,10 +1,8 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -27,7 +25,6 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
export function ShowDeploymentsTable() {
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will appear
here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
@@ -0,0 +1,143 @@
import copy from "copy-to-clipboard";
import { Copy, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
const CONFIRMATION_WORD = "DELETE";
export const DeleteAccountByEmail = () => {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [confirmation, setConfirmation] = useState("");
const [record, setRecord] = useState<string | null>(null);
const { mutateAsync, isPending } =
api.user.deleteAccountByEmail.useMutation();
const canConfirm =
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
const reset = () => {
setEmail("");
setConfirmation("");
setRecord(null);
};
const handleDelete = async () => {
try {
const result = await mutateAsync({ email: email.trim() });
setRecord(JSON.stringify(result, null, 2));
toast.success(`Account ${result.email} deleted`);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error deleting account",
);
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) reset();
}}
>
<DialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<Trash2 className="h-4 w-4" />
Delete account
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Delete an account by email</DialogTitle>
<DialogDescription>
Cancels every Stripe subscription and deletes the user with all the
organizations they own. Invoices stay in Stripe for accounting. Use
it to fulfil data deletion requests.
</DialogDescription>
</DialogHeader>
{record ? (
<div className="flex flex-col gap-3">
<AlertBlock type="success">
Deletion completed. Keep this record as evidence of the request
being fulfilled.
</AlertBlock>
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
{record}
</pre>
<Button
variant="outline"
size="sm"
className="gap-2 self-end"
onClick={() => {
copy(record);
toast.success("Deletion record copied to clipboard");
}}
>
<Copy className="h-4 w-4" />
Copy record
</Button>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-email">Account email</Label>
<Input
id="delete-account-email"
type="email"
autoComplete="off"
placeholder="user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="delete-account-word">
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
confirm
</Label>
<Input
id="delete-account-word"
autoComplete="off"
placeholder={CONFIRMATION_WORD}
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter>
{record ? (
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
) : (
<Button
variant="destructive"
disabled={!canConfirm || isPending}
onClick={handleDelete}
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Delete account
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { DeleteAccountByEmail } from "./delete-account-by-email";
type User = typeof authClient.$Infer.Session.user;
@@ -78,12 +79,12 @@ export const ImpersonationBar = () => {
});
const filteredUsers = response.data?.users.filter(
// @ts-ignore
// @ts-expect-error
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-ignore
// @ts-expect-error
setUsers(filteredUsers || []);
}
} catch (error) {
@@ -282,6 +283,7 @@ export const ImpersonationBar = () => {
<Shield className="h-4 w-4" />
Impersonate
</Button>
<DeleteAccountByEmail />
</div>
) : (
<div className="flex items-center gap-4 w-full flex-wrap">
@@ -1,5 +1,6 @@
import { Rocket } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import {
@@ -8,6 +9,14 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const SUBTAB_VALUES = ["deployments", "queue"] as const;
@@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => {
? router.query.subtab
: DEFAULT_SUBTAB;
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const setSubtab = (value: string) => {
if (!isValidSubtab(value)) return;
const { subtab: _current, ...query } = router.query;
@@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => {
onValueChange={setSubtab}
className="w-full min-w-0"
>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
<TabsList className="self-start shrink-0">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
{subtab === "deployments" && (
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable />
<ShowDeploymentsTable
globalFilter={globalFilter}
statusFilter={statusFilter}
typeFilter={typeFilter}
/>
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
@@ -24,7 +24,7 @@ export const AiForm = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 justify-between">
<div>
@@ -62,7 +62,7 @@ export const AiForm = () => {
key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div>
<span className="text-sm font-medium">
{config.name}
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1">
<span className="text-sm">
{index + 1}. {destination.name}
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -0,0 +1,402 @@
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>
);
};
@@ -0,0 +1,324 @@
import { Copy, Loader2, ScrollText, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
type Status = "running" | "not-running" | "stopped" | "unknown";
const statusBadge = (status: Status) => {
if (status === "running") {
return <Badge variant="green">Running</Badge>;
}
if (status === "not-running") {
return <Badge variant="yellow">Not running</Badge>;
}
if (status === "stopped") {
return <Badge variant="secondary">Not deployed</Badge>;
}
return <Badge variant="destructive">Unreachable</Badge>;
};
export const ShowLogManagementServers = () => {
const utils = api.useUtils();
const [selection, setSelection] = useState<Record<string, string[]>>({});
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: providers } = api.logProvider.all.useQuery();
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 30_000,
trpc: { context: { skipBatch: true } },
},
);
const { mutateAsync: deployOnServer } =
api.logProvider.deployOnServer.useMutation();
const { mutateAsync: removeOnServer } =
api.logProvider.removeOnServer.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
const idsFor = (serverId: string | null, saved: string[]) =>
selection[keyOf(serverId)] ?? saved;
const toggleProvider = (
serverId: string | null,
saved: string[],
logProviderId: string,
) => {
const key = keyOf(serverId);
const current = selection[key] ?? saved;
setSelection({
...selection,
[key]: current.includes(logProviderId)
? current.filter((id) => id !== logProviderId)
: [...current, logProviderId],
});
};
const setError = (serverId: string | null, message?: string) =>
setErrors((prev) => {
const next = { ...prev };
if (message) {
next[keyOf(serverId)] = message;
} else {
delete next[keyOf(serverId)];
}
return next;
});
const handleDeploy = async (serverId: string | null, ids: string[]) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await deployOnServer({ serverId, logProviderIds: ids })
.then(() => {
toast.success("Vector agent deployed");
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to deploy the agent",
);
})
.finally(() => setPendingTarget(null));
};
const handleRemove = async (serverId: string | null) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await removeOnServer({ serverId })
.then(() => {
toast.success("Vector agent removed");
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to remove the agent",
);
})
.finally(() => setPendingTarget(null));
};
const hasProviders = (providers?.length ?? 0) > 0;
const visibleTargets = hasProviders
? targets
: targets?.filter((target) => target.status !== "stopped");
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
Pick which providers each server ships to, then deploy its Vector
agent. Changing a provider only takes effect on a server after you
redeploy it. Services deployed before Log Management need a
redeploy to get the labels it uses; until then their logs are not
shipped from the Dokploy server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Checking servers...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a log provider first — there's nowhere to ship logs to yet
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{visibleTargets?.map((target) => {
const key = keyOf(target.serverId);
const ids = idsFor(target.serverId, target.logProviderIds);
const isBusy = pendingTarget === key;
const isDeployed = target.status !== "stopped";
const hasEnabledSelected = ids.some(
(id) =>
providers?.find((p) => p.logProviderId === id)?.enabled,
);
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{target.name}
</span>
{target.ipAddress && (
<span className="text-xs text-muted-foreground">
{target.ipAddress}
</span>
)}
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{isDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server will stop shipping container logs until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isBusy}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">
Log providers
</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
logProviderIcons[
provider.providerType as keyof typeof logProviderIcons
];
return (
<div
key={provider.logProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.logProviderId}`}
disabled={!provider.enabled}
checked={ids.includes(provider.logProviderId)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
target.logProviderIds,
provider.logProviderId,
)
}
/>
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.logProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{provider.name}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
);
})}
</div>
</div>
{errors[key] && (
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
Deploy failed — the config was not applied, this
server keeps shipping with its previous one.
</span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
{errors[key]}
</pre>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key] ?? "",
);
toast.success("Error copied");
}}
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</AlertBlock>
)}
{hasProviders && (
<div className="flex items-center justify-end gap-3">
{ids.length > 0 && !hasEnabledSelected && (
<span className="text-xs text-muted-foreground">
Every selected provider is disabled — nothing
would ship.
</span>
)}
<Button
size="sm"
isLoading={isBusy}
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{isDeployed ? "Redeploy" : "Deploy"}
</Button>
</div>
)}
</div>
);
})}
{visibleTargets?.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any server yet
</span>
</div>
)}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,186 @@
import { Loader2, ScrollText, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { agentsUsingProvider, HandleLogProvider } from "./handle-log-provider";
interface LogProviderRowProps {
provider: {
logProviderId: string;
name: string;
enabled: boolean;
providerType: string;
};
index: number;
typeLabel: string;
canEdit: boolean;
canDelete: boolean;
onDeleted: () => void;
}
const LogProviderRow = ({
provider,
index,
typeLabel,
canEdit,
canDelete,
onDeleted,
}: LogProviderRowProps) => {
const utils = api.useUtils();
const { mutateAsync, isPending: isRemoving } =
api.logProvider.remove.useMutation();
const ProviderIcon =
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
return (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium flex items-center gap-2">
{index + 1}. {provider.name}
<Badge variant={provider.enabled ? "green" : "secondary"}>
{provider.enabled ? "Enabled" : "Disabled"}
</Badge>
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<HandleLogProvider logProviderId={provider.logProviderId} />
)}
{canDelete && (
<DialogAction
title="Delete Log Provider"
description="Are you sure you want to delete this log provider? Agents already deployed keep shipping logs to it until you redeploy or remove them."
type="destructive"
onClick={async () => {
const agents = agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
provider.logProviderId,
);
await mutateAsync({
logProviderId: provider.logProviderId,
})
.then(() => {
toast.success(
"Log provider deleted successfully",
agents
? {
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
}
: undefined,
);
onDeleted();
})
.catch(() => {
toast.error("Error deleting log provider");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
};
export const ShowLogProviders = () => {
const utils = api.useUtils();
const { data, isPending, refetch } = api.logProvider.all.useQuery();
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const providerListLabel = availableTypes?.length
? ` (${availableTypes.map((t) => t.label).join(", ")})`
: "";
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Log Management
</CardTitle>
<CardDescription>
{`Ship container logs to an external provider${providerListLabel}. Add one here, then deploy the Vector agent on the servers below.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any log provider configured
</span>
{permissions?.logProvider.create && <HandleLogProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((provider, index) => (
<LogProviderRow
key={provider.logProviderId}
provider={provider}
index={index}
typeLabel={
availableTypes?.find(
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.logProvider.create}
canDelete={!!permissions?.logProvider.delete}
onDeleted={() => {
refetch();
utils.logProvider.serverStatus.invalidate();
}}
/>
))}
</div>
{permissions?.logProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleLogProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg">
@@ -0,0 +1,214 @@
import { Loader2, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const errorMessage = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : fallback;
export const DeleteAccount = () => {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"request" | "confirm">("request");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [expiresInMinutes, setExpiresInMinutes] = useState(10);
const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: hasPassword } = api.user.hasPassword.useQuery();
const requestCode = api.user.requestAccountDeletionCode.useMutation();
const deleteAccount = api.user.deleteAccount.useMutation();
const email = data?.user?.email ?? "";
const isBusy = requestCode.isPending || deleteAccount.isPending;
if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
return null;
}
const reset = () => {
setStep("request");
setPassword("");
setCode("");
};
const handleRequestCode = async () => {
try {
const result = await requestCode.mutateAsync(
hasPassword ? { password } : {},
);
setExpiresInMinutes(result.expiresInMinutes);
setCode("");
setStep("confirm");
toast.success(`We sent a confirmation code to ${email}`);
} catch (error) {
toast.error(errorMessage(error, "Error sending the confirmation code"));
}
};
const handleDelete = async () => {
try {
await deleteAccount.mutateAsync({ code });
toast.success("Your account has been deleted");
await authClient.signOut().catch(() => undefined);
router.push("/");
} catch (error) {
toast.error(errorMessage(error, "Error deleting account"));
}
};
return (
<Card className="bg-transparent border-destructive/40">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
<Trash2 className="size-5" />
Delete account
</CardTitle>
<CardDescription>
Permanently remove your account and all the data associated with it.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
<li>
Active subscriptions are cancelled immediately. Invoices already
issued are kept for accounting.
</li>
<li>
Every organization you own is deleted, including its projects,
services, environments, domains and server connections.
</li>
<li>
In organizations you belong to but do not own, only your membership,
API keys and the Git providers you connected are removed.
Applications using those providers keep running but must be
reconnected to redeploy.
</li>
<li>Containers already running on your servers are not touched.</li>
<li>
We will email a confirmation code to{" "}
<span className="font-medium text-foreground">{email}</span> before
anything is deleted.
</li>
<li>This action cannot be undone.</li>
</ul>
<div>
<AlertDialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<AlertDialogTrigger asChild>
<Button variant="destructive">Delete account</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
{step === "request"
? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.`
: `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-2">
{step === "request" && hasPassword && (
<>
<Label htmlFor="delete-account-password">Password</Label>
<Input
id="delete-account-password"
type="password"
autoComplete="current-password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</>
)}
{step === "confirm" && (
<>
<Label htmlFor="delete-account-code">
Confirmation code
</Label>
<Input
id="delete-account-code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, ""))
}
/>
<Button
type="button"
variant="link"
size="sm"
className="self-start px-0"
disabled={isBusy}
onClick={handleRequestCode}
>
Resend code
</Button>
</>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
{step === "request" ? (
<Button
type="button"
variant="destructive"
disabled={isBusy || (hasPassword && password.length === 0)}
onClick={handleRequestCode}
>
{requestCode.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Send confirmation code
</Button>
) : (
<Button
type="button"
variant="destructive"
disabled={isBusy || code.length !== 6}
onClick={handleDelete}
>
{deleteAccount.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
Delete account
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
};
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,6 +1,4 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
export const ShowServerActions = ({ serverId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
}}
>
View Actions
</DropdownMenuItem>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-xl">
<DialogContent
className="sm:max-w-xl"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>
<DialogDescription>Reload or clean the web server.</DialogDescription>
@@ -109,8 +109,13 @@ export const DeleteServerModal = ({
const handleDeleteServer = async () => {
try {
await deleteServer({ serverId });
const result = await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
if (result?.vectorAgentRemovalWarning) {
toast.error(
`Couldn't remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
);
}
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
@@ -121,7 +126,10 @@ export const DeleteServerModal = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogContent
className="max-w-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Pencil, PlusIcon } from "lucide-react";
import { PlusIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
interface Props {
serverId?: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const HandleServers = ({ serverId, asButton = false }: Props) => {
export const HandleServers = ({ serverId, children }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async (_data) => {
.then(async () => {
await utils.server.all.invalidate();
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
@@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : serverId ? (
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
Edit Server
</DropdownMenuItem>
)
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button className="cursor-pointer space-x-3">
@@ -167,7 +162,10 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-3xl ">
<DialogContent
className="sm:max-w-3xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription>
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
asButton?: boolean;
children?: React.ReactNode;
}
export const SetupServer = ({ serverId, asButton = false }: Props) => {
export const SetupServer = ({ serverId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{asButton ? (
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-4" />
<Button className="w-full cursor-pointer " size="sm">
Setup Server <Settings className="size-4" />
</Button>
</DialogTrigger>
) : (
<Button
className="w-full cursor-pointer "
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogContent
className="sm:max-w-4xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
@@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
interface Props {
url: string;
token: string;
children?: React.ReactNode;
}
export const ShowMonitoringModal = ({ url, token }: Props) => {
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-7xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} />
</div>
@@ -1,11 +1,15 @@
import { format } from "date-fns";
import {
Activity,
BarChartHorizontalBigIcon,
Clock,
Key,
KeyIcon,
Loader2,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -49,7 +53,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl w-full">
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -224,11 +228,19 @@ export const ShowServers = () => {
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<SetupServer
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
className="w-full cursor-pointer "
size="sm"
>
Setup Server{" "}
<Settings className="size-4" />
</Button>
</TooltipTrigger>
</SetupServer>
<TooltipContent
className="max-w-xs"
side="bottom"
@@ -251,22 +263,20 @@ export const ShowServers = () => {
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<Terminal className="h-4 w-4" />
</Button>
</TooltipTrigger>
</TerminalModal>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
@@ -274,14 +284,19 @@ export const ShowServers = () => {
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<HandleServers
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
</HandleServers>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
@@ -289,14 +304,19 @@ export const ShowServers = () => {
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<ShowServerActions
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Activity className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowServerActions>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
@@ -307,17 +327,23 @@ export const ShowServers = () => {
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
</ShowMonitoringModal>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
@@ -328,22 +354,20 @@ export const ShowServers = () => {
{permissions?.server.delete && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DeleteServerModal>
</div>
</TooltipTrigger>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
</DeleteServerModal>
<TooltipContent>
<p>Delete Server</p>
</TooltipContent>
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
@@ -22,7 +22,7 @@ export const TagManager = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -124,8 +124,11 @@ export const AddInvitation = () => {
const mode = form.watch("mode");
useEffect(() => {
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
if (!open) {
form.reset();
setError(null);
}
}, [open, form, form.reset]);
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
@@ -225,7 +228,7 @@ export const AddInvitation = () => {
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
@@ -313,7 +316,7 @@ export const AddInvitation = () => {
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -123,7 +123,7 @@ export const WebDomain = () => {
return (
<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 max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div className="flex flex-col gap-1">
@@ -24,7 +24,7 @@ export const WebServer = () => {
return (
<div className="w-full">
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -45,9 +45,8 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-ignore
// @ts-expect-error
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
addonFit.fit();
@@ -89,9 +88,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}, [id, serverId]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 h-full w-full">
<div className="w-full h-full bg-transparent border rounded-lg p-2">
<div id={id} ref={termRef} className="rounded-xl" />
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
</div>
</div>
);
@@ -0,0 +1,69 @@
import { AwsIcon } from "@/components/icons/vault-provider-icons";
interface Props {
className?: string;
}
export const GrafanaLokiIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#F46800"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
</svg>
);
export const DatadogIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#632CA6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
</svg>
);
export const BetterStackIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
</svg>
);
export const ElasticsearchIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#00BFB3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
</svg>
);
export const SplunkIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
</svg>
);
export const logProviderIcons = {
loki: GrafanaLokiIcon,
datadog: DatadogIcon,
betterstack: BetterStackIcon,
elasticsearch: ElasticsearchIcon,
splunk_hec: SplunkIcon,
aws_cloudwatch: AwsIcon,
} as const;
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import { useHubSpotChat } from "../shared/analytics";
import Page from "./side";
interface Props {
@@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
useHubSpotChat(isChatEnabled);
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
const shouldRedirectToOnboarding =
@@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
+8
View File
@@ -29,6 +29,7 @@ import {
type LucideIcon,
Package,
Palette,
ScrollText,
Server,
ShieldCheck,
Smartphone,
@@ -374,6 +375,13 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Log Management",
url: "/dashboard/settings/log-providers",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
},
{
isSingle: true,
title: "Secrets",
@@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
logProvider: "Log Provider",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,6 +180,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
logProvider: {
label: "Log Providers",
description:
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
},
};
/** Descriptions for each action within a resource */
@@ -475,6 +480,14 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
logProvider: {
read: { label: "Read", description: "View configured log providers" },
create: {
label: "Create",
description: "Add new log providers and test their connection",
},
delete: { label: "Delete", description: "Remove log providers" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -605,7 +618,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -1,14 +0,0 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};
@@ -92,5 +92,11 @@ export const useHubSpotChat = (enabled: boolean) => {
loadWidget,
];
}
return () => {
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
(callback) => callback !== loadWidget,
);
window.HubSpotConversations?.widget.remove();
};
}, [enabled]);
};
@@ -0,0 +1,19 @@
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
CREATE TABLE "logProvider" (
"logProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "LogProviderType" NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1380,6 +1380,13 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1791377838843,
"tag": "0197_smiling_madrox",
"breakpoints": true
}
]
}
+25
View File
@@ -0,0 +1,25 @@
export const isHttpsRequest = (
forwardedProto: string | string[] | undefined,
): boolean => {
if (!forwardedProto) return false;
const value = Array.isArray(forwardedProto)
? forwardedProto[0]
: forwardedProto;
// take the first value in case a proxy chain sent a list
return value?.split(",")[0]?.trim().toLowerCase() === "https";
};
const hasSecureAttribute = (cookie: string): boolean =>
/;\s*secure(?:\s*;|\s*$)/i.test(cookie);
const withSecure = (cookie: string): string =>
hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`;
// Add Secure to a Set-Cookie header value (string or string[]).
export const secureSetCookie = (
value: string | number | readonly string[],
): string | number | string[] => {
if (typeof value === "number") return value;
if (Array.isArray(value)) return value.map(withSecure);
return withSecure(value as string);
};
+1
View File
@@ -48,6 +48,7 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
+24 -1
View File
@@ -1,7 +1,30 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { auth } from "@dokploy/server/index";
import { toNodeHandler } from "better-auth/node";
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
// Disallow body parsing, we will parse it manually
export const config = { api: { bodyParser: false } };
export default toNodeHandler(auth.handler);
const handler = toNodeHandler(auth.handler);
// Mark the session cookie as Secure when the request comes over HTTPS
export default function authHandler(req: IncomingMessage, res: ServerResponse) {
if (isHttpsRequest(req.headers["x-forwarded-proto"])) {
const setHeader = res.setHeader.bind(res);
res.setHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? setHeader(name, secureSetCookie(value))
: setHeader(name, value);
const appendHeader = res.appendHeader?.bind(res);
if (appendHeader) {
res.appendHeader = (name, value) =>
String(name).toLowerCase() === "set-cookie"
? appendHeader(name, secureSetCookie(value) as string | string[])
: appendHeader(name, value);
}
}
return handler(req, res);
}
@@ -32,6 +32,14 @@ const getPackageVersion = (headers: any, body: any) => {
return null;
};
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@@ -15,6 +15,14 @@ import {
logWebhookError,
} from "../[refreshToken]";
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
+8
View File
@@ -26,6 +26,14 @@ import {
const getGithubRepositoryOwner = (githubBody: any) =>
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
+4 -10
View File
@@ -158,11 +158,9 @@ export default async function handler(
newSubscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
// The account may already be gone when the deletion flow cancelled
// the subscription; nothing left to disable.
if (!admin || admin.isEnterpriseCloud) {
break;
}
@@ -314,11 +312,7 @@ export default async function handler(
const customer = event.data.object as Stripe.Customer;
const admin = await findUserByStripeCustomerId(customer.id);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
if (!admin || admin.isEnterpriseCloud) {
break;
}
+4 -6
View File
@@ -12,12 +12,10 @@ function SchedulesPage() {
{(serverId) => (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</Card>
</div>
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
@@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowLogProviders />
<ShowLogManagementServers />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
@@ -16,10 +17,11 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{permissions?.api.read && <ShowApiKeys />}
<DeleteAccount />
</div>
</div>
);
@@ -15,7 +15,7 @@ const Page = () => {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">
@@ -25,7 +25,7 @@ interface Props {
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
@@ -12,7 +12,7 @@ import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
+2
View File
@@ -21,6 +21,7 @@ import { giteaRouter } from "./routers/gitea";
import { githubRouter } from "./routers/github";
import { gitlabRouter } from "./routers/gitlab";
import { libsqlRouter } from "./routers/libsql";
import { logProviderRouter } from "./routers/log-provider";
import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount";
@@ -85,6 +86,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter,
gitlab: gitlabRouter,
libsql: libsqlRouter,
logProvider: logProviderRouter,
mariadb: mariadbRouter,
mongo: mongoRouter,
mounts: mountRouter,
@@ -545,7 +545,6 @@ export const applicationRouter = createTRPCRouter({
sourceType: "github",
owner: input.owner,
buildPath: input.buildPath,
applicationStatus: "idle",
githubId: input.githubId,
watchPaths: input.watchPaths,
triggerType: input.triggerType,
@@ -572,7 +571,6 @@ export const applicationRouter = createTRPCRouter({
gitlabBranch: input.gitlabBranch,
gitlabBuildPath: input.gitlabBuildPath,
sourceType: "gitlab",
applicationStatus: "idle",
gitlabId: input.gitlabId,
gitlabProjectId: input.gitlabProjectId,
gitlabPathNamespace: input.gitlabPathNamespace,
@@ -601,7 +599,6 @@ export const applicationRouter = createTRPCRouter({
bitbucketBranch: input.bitbucketBranch,
bitbucketBuildPath: input.bitbucketBuildPath,
sourceType: "bitbucket",
applicationStatus: "idle",
bitbucketId: input.bitbucketId,
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
@@ -627,7 +624,6 @@ export const applicationRouter = createTRPCRouter({
giteaBranch: input.giteaBranch,
giteaBuildPath: input.giteaBuildPath,
sourceType: "gitea",
applicationStatus: "idle",
giteaId: input.giteaId,
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
@@ -652,7 +648,6 @@ export const applicationRouter = createTRPCRouter({
username: input.username,
password: input.password,
sourceType: "docker",
applicationStatus: "idle",
registryUrl: input.registryUrl,
});
const application = await findApplicationById(input.applicationId);
@@ -676,7 +671,6 @@ export const applicationRouter = createTRPCRouter({
customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git",
applicationStatus: "idle",
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
@@ -729,7 +723,6 @@ export const applicationRouter = createTRPCRouter({
customGitSSHKeyId: null,
sourceType: "github", // Reset to default
applicationStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
@@ -759,7 +759,6 @@ export const composeRouter = createTRPCRouter({
customGitSSHKeyId: null,
sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
@@ -0,0 +1,157 @@
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,
}));
}),
});

Some files were not shown because too many files have changed in this diff Show More