Compare commits

..

8 Commits

Author SHA1 Message Date
Mauricio Siu 12784ec0ff Merge remote-tracking branch 'origin/canary' into feature/managed-servers
# Conflicts:
#	apps/dokploy/drizzle/meta/0167_snapshot.json
#	apps/dokploy/drizzle/meta/0168_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
#	apps/dokploy/server/api/root.ts
#	pnpm-lock.yaml
2026-09-28 09:36:15 -06:00
Mauricio Siu 5bc53bfbff feat(managed-server): update managed server functionality and database schema
- Refactored the managed server creation process to utilize UpCloud services, including the introduction of new utility functions for server management.
- Updated the managed server schema to include new fields such as `providerVmId`, `zone`, and `sshKeyId`, enhancing server management capabilities.
- Implemented validation to prevent deletion of SSH keys that are in use by managed servers.
- Introduced a new SQL migration to add the `sshKeyId` column and associated foreign key constraints to the `managed_server` table.
- Enhanced the user experience by grouping available zones by continent in the UI.
- Updated pricing display logic to reflect new pricing structure and improved formatting.
2026-05-22 16:19:18 -06:00
Mauricio Siu 549b124fcd feat(database): add managed_server table and associated constraints
- Introduced a new SQL migration file "0167_dizzy_solo.sql" to create the "managed_server" table with relevant columns and a custom ENUM type for server status.
- Added foreign key constraints linking "organizationId" to the "organization" table and "serverId" to the "server" table, enhancing data integrity.
- Updated journal and snapshot metadata to reflect the new migration.
2026-05-13 01:25:40 -06:00
Mauricio Siu 0c5da0b36f Merge branch 'canary' into feature/managed-servers 2026-05-13 01:25:23 -06:00
Mauricio Siu ee18724dd7 chore(database): remove obsolete migration files for managed servers
- Deleted the SQL migration file and associated journal entry for the "0166_lame_meltdown" migration, as it is no longer required.
- This cleanup contributes to a more organized migration history and minimizes potential confusion in future database management.
2026-05-13 01:25:05 -06:00
Mauricio Siu 1ae9b4025c chore(database): remove deprecated cultured captain cross migration files
- Deleted the SQL migration file and associated journal entry for the "0167_cultured_captain_cross" migration, as it is no longer needed.
- This cleanup helps maintain a tidy migration history and reduces potential confusion in future database management.
2026-05-13 01:24:24 -06:00
Mauricio Siu 5bc870dc2d fix(hostinger): add error handling to getHostingerDataCenters function
- Wrapped the API call in a try/catch block to log errors when fetching data centers.
- Reformatted the OFFERED_PLAN_IDS array for improved readability.

This update enhances the robustness of the Hostinger data center retrieval process.
2026-05-07 13:36:30 -06:00
Mauricio Siu 299950a323 feat(managed-servers): add managed servers functionality and API integration
- Introduced a new API for managing servers, including endpoints for listing, purchasing, and retrieving server plans.
- Added a new page and components for displaying managed servers in the dashboard.
- Updated the sidebar to include navigation for managed servers.
- Created database migrations for managed server types and status.
- Enhanced environment configuration with a new API key for Hostinger services.

This update enables users to manage their servers directly from the Dokploy dashboard, improving the overall user experience and functionality.
2026-05-05 08:10:30 -06:00
163 changed files with 10532 additions and 7815 deletions
-1
View File
@@ -26,7 +26,6 @@ Dokploy includes multiple features to make your life easier.
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, tagged by project/environment/application.
- **Docker Management**: Easily deploy and manage Docker containers.
- **CLI/API**: Manage your applications and databases using the command line or through the API.
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
+8403
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,3 +1,6 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=
@@ -1,156 +0,0 @@
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,6 +1,4 @@
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)", () => {
@@ -50,48 +48,3 @@ 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]");
});
});
@@ -1,14 +0,0 @@
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,16 +33,8 @@ services:
volumes:
test-data:
`,
composeId: "compose-1",
name: "raw-stack",
composePath: "./docker-compose.yml",
composeType: "stack",
environment: {
environmentId: "env-1",
name: "production",
projectId: "project-1",
project: { name: "shop", organizationId: "org-1" },
},
isolatedDeployment: false,
isolatedDeploymentsVolume: false,
randomize: false,
@@ -62,16 +54,6 @@ volumes:
},
]);
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
expect(converted?.services?.test?.labels).toEqual(
expect.arrayContaining([
"dokploy.organization.id=org-1",
"dokploy.project=shop",
"dokploy.service=test",
]),
);
expect(converted?.services?.test?.deploy?.labels).toEqual(
expect.arrayContaining(["dokploy.organization.id=org-1"]),
);
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,66 +0,0 @@
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" }),
);
},
);
});
@@ -1,46 +0,0 @@
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);
});
});
@@ -1,66 +0,0 @@
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);
});
});
@@ -1,356 +0,0 @@
import {
getLogProviderAdapter,
logProviderAdapters,
} from "@dokploy/server/services/log-management/providers/registry";
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
import { describe, expect, it } from "vitest";
const baseConfig: LogProviderRuntimeConfig = {
logProviderId: "log-provider-1",
name: "test",
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: null,
};
describe("getLogProviderAdapter", () => {
it("returns the registered adapter for each known type", () => {
expect(getLogProviderAdapter("loki").type).toBe("loki");
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
});
it("throws an explicit error for an unregistered type", () => {
// @ts-expect-error: intentionally an unregistered type
expect(() => getLogProviderAdapter("unknown")).toThrow(
/No LogProviderAdapter registered/,
);
});
});
describe("lokiAdapter.toVectorSink", () => {
it("maps endpoint + labels, without tenant_id if not set", () => {
const adapter = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://loki.example.com" },
"sink_1",
"dokploy_scope",
);
expect(sink.type).toBe("loki");
expect(sink.inputs).toEqual(["dokploy_scope"]);
expect(sink.endpoint).toBe("https://loki.example.com");
expect(sink.labels).toMatchObject({
dokploy_project: "{{ dokploy_project }}",
dokploy_application: "{{ dokploy_application }}",
dokploy_organization: "{{ dokploy_organization }}",
});
expect(sink.tenant_id).toBeUndefined();
expect(sink.buffer).toEqual({
type: "disk",
max_size: 268_435_488,
when_full: "block",
});
});
it("includes tenant_id when present in extraConfig", () => {
const adapter = logProviderAdapters.loki;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://loki.example.com",
extraConfig: { tenantId: "tenant-a" },
},
"sink_1",
"dokploy_scope",
);
expect(sink.tenant_id).toBe("tenant-a");
});
it("has no toVectorTransform — goes directly scope -> sink", () => {
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
});
});
describe("datadogAdapter.toVectorSink", () => {
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{ ...baseConfig, apiKey: "dd-key" },
"sink_2",
"transform_2",
);
expect(sink.type).toBe("datadog_logs");
expect(sink.default_api_key).toBe("dd-key");
expect(sink.site).toBe("datadoghq.com");
expect(sink.tags).toBeUndefined();
expect(sink.inputs).toEqual(["transform_2"]);
});
it("uses extraConfig.site when present", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "datadoghq.eu" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
const adapter = logProviderAdapters.datadog;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "dd-key",
extraConfig: { site: "https://datadoghq.eu/" },
},
"sink_2",
"transform_2",
);
expect(sink.site).toBe("datadoghq.eu");
});
it("has toVectorTransform to build .ddtags from the scoping fields — confirmado que datadog_logs no tiene un campo `tags` de config (vector generate-schema)", () => {
const adapter = logProviderAdapters.datadog;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain(".ddtags");
});
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
const adapter = logProviderAdapters.datadog;
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_2",
"dokploy_scope",
);
expect(transform?.source).toContain(
'replace(to_string!(.dokploy_project), ",", "_")',
);
});
});
describe("betterStackAdapter", () => {
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
const adapter = logProviderAdapters.betterstack;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_1",
"dokploy_scope",
);
expect(transform).toEqual({
type: "remap",
inputs: ["dokploy_scope"],
source: ".dt = del(.timestamp)",
});
});
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
const adapter = logProviderAdapters.betterstack;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://in.logs.betterstack.com",
apiKey: "source-token",
},
"sink_3",
"transform_1",
);
expect(sink.type).toBe("http");
expect(sink.inputs).toEqual(["transform_1"]);
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
expect(sink.compression).toBe("gzip");
});
});
describe("elasticsearchAdapter", () => {
it("has a toVectorTransform that flattens .label to a JSON string", () => {
const adapter = logProviderAdapters.elasticsearch;
expect(adapter.toVectorTransform).toBeDefined();
const transform = adapter.toVectorTransform?.(
baseConfig,
"transform_4",
"dokploy_scope",
);
expect(transform?.type).toBe("remap");
expect(transform?.inputs).toEqual(["dokploy_scope"]);
expect(transform?.source).toContain("encode_json(.label)");
});
it("uses basic auth when a username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "secret",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
);
expect(sink.type).toBe("elasticsearch");
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
expect(sink.auth).toEqual({
strategy: "basic",
user: "elastic",
password: "secret",
});
expect(sink.request).toBeUndefined();
});
it("falls back to an ApiKey header when no username is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
apiKey: "key-123",
},
"sink_4",
"dokploy_scope",
);
expect(sink.auth).toBeUndefined();
expect(sink.request).toEqual({
headers: { Authorization: "ApiKey key-123" },
});
});
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
const adapter = logProviderAdapters.elasticsearch;
expect(() =>
adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
},
"sink_4",
"dokploy_scope",
),
).toThrow(/Password.*required/i);
});
it("includes bulk.index only when extraConfig.index is set", () => {
const adapter = logProviderAdapters.elasticsearch;
const withoutIndex = adapter.toVectorSink(
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
"sink_4",
"dokploy_scope",
);
expect(withoutIndex.bulk).toBeUndefined();
const withIndex = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://es.example.com:9200",
extraConfig: { index: "dokploy-%Y.%m.%d" },
},
"sink_4",
"dokploy_scope",
);
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
});
});
describe("splunkAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
});
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
const adapter = logProviderAdapters.splunk_hec;
const sink = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
},
"sink_5",
"dokploy_scope",
);
expect(sink.type).toBe("splunk_hec_logs");
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
expect(sink.default_token).toBe("hec-token");
expect(sink.encoding).toEqual({ codec: "json" });
expect(sink.index).toBeUndefined();
expect(sink.sourcetype).toBeUndefined();
const withExtras = adapter.toVectorSink(
{
...baseConfig,
endpoint: "https://splunk.example.com:8088",
apiKey: "hec-token",
extraConfig: { index: "dokploy", sourcetype: "docker" },
},
"sink_5",
"dokploy_scope",
);
expect(withExtras.index).toBe("dokploy");
expect(withExtras.sourcetype).toBe("docker");
});
});
describe("awsCloudwatchAdapter", () => {
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
expect(
logProviderAdapters.aws_cloudwatch.toVectorTransform,
).toBeUndefined();
});
it("toVectorSink maps access key/secret, region and log group/stream", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
},
"sink_6",
"dokploy_scope",
);
expect(sink.type).toBe("aws_cloudwatch_logs");
expect(sink.group_name).toBe("/dokploy/logs");
expect(sink.stream_name).toBe("{{ container_name }}");
expect(sink.region).toBe("us-east-1");
expect(sink.auth).toEqual({
access_key_id: "AKIA...",
secret_access_key: "shh",
});
});
it("uses extraConfig.logStream as the stream template when set", () => {
const adapter = logProviderAdapters.aws_cloudwatch;
const sink = adapter.toVectorSink(
{
...baseConfig,
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: {
region: "us-east-1",
logGroup: "/dokploy/logs",
logStream: "{{ dokploy_application }}",
},
},
"sink_6",
"dokploy_scope",
);
expect(sink.stream_name).toBe("{{ dokploy_application }}");
});
});
@@ -1,162 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
lookup: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
lookup: mocks.lookup,
}));
const loadFetch = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
const { logProviderFetch } = await import(
"@dokploy/server/services/log-management/types"
);
return logProviderFetch;
};
describe("logProviderFetch — blocked endpoint guard", () => {
const originalFetch = global.fetch;
beforeEach(() => {
mocks.lookup.mockReset();
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://169.254.169.254/latest/meta-data/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
const logProviderFetch = await loadFetch(false);
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
await expect(
logProviderFetch(`http://${address}/latest/meta-data/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects the AWS ECS metadata IPv6 address", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://[fd00:ec2::254]/v2/credentials"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
const logProviderFetch = await loadFetch(false);
for (const address of [
"[fe80::1]",
"[fe90::1]",
"[fea0::1]",
"[febf::1]",
]) {
await expect(
logProviderFetch(`http://${address}/`),
`expected ${address} to be rejected`,
).rejects.toThrow(/metadata/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not block a hostname just because it starts like a link-local prefix", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://feature-logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
const logProviderFetch = await loadFetch(false);
await expect(
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
});
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
await expect(
logProviderFetch("http://attacker-controlled.example.com/"),
).rejects.toThrow(/metadata/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a private-network endpoint when self-hosted (Loki behind a VPN, say)", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
await logProviderFetch("http://loki.internal.example.com:3100/ready");
await logProviderFetch("http://10.0.0.5:3100/ready");
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("rejects loopback and private-network endpoints in cloud, literal or resolved", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
for (const url of [
"http://10.0.0.5:3100/ready",
"http://127.0.0.1:3100/ready",
"http://localhost:3100/ready",
"http://[::1]:3100/ready",
"http://0.0.0.0:3100/ready",
"http://100.100.100.200/latest/meta-data/",
"http://[::]:3100/ready",
"http://loki.internal.example.com:3100/ready",
]) {
await expect(
logProviderFetch(url),
`expected ${url} to be rejected`,
).rejects.toThrow(/blocked/i);
}
expect(global.fetch).not.toHaveBeenCalled();
});
it("allows a normal public endpoint", async () => {
const logProviderFetch = await loadFetch(true);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch(
"https://api.us-east-1.datadoghq.com/api/v1/validate",
);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("does not follow redirects", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
await logProviderFetch("https://logs.example.com/ready");
expect(global.fetch).toHaveBeenCalledWith(
"https://logs.example.com/ready",
expect.objectContaining({ redirect: "error" }),
);
});
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
const logProviderFetch = await loadFetch(false);
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
await logProviderFetch("http://does-not-exist.invalid/");
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
@@ -1,315 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
insertValues: vi.fn(),
updateSet: vi.fn(),
findFirst: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
insert: () => ({
values: (v: any) => ({
returning: () => Promise.resolve(mocks.insertValues(v)),
}),
}),
update: () => ({
set: (v: any) => ({
where: () => ({
returning: () => Promise.resolve(mocks.updateSet(v)),
}),
}),
}),
query: {
logProvider: {
findFirst: mocks.findFirst,
},
},
},
}));
const { createLogProvider, updateLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
describe("createLogProvider — required credential fields per adapter", () => {
it("rejects a loki provider without endpoint", async () => {
await expect(
createLogProvider(
{ name: "loki-prod", providerType: "loki" } as any,
"org-1",
),
).rejects.toThrow(/endpoint/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider without apiKey", async () => {
await expect(
createLogProvider(
{ name: "dd", providerType: "datadog" } as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a betterstack provider missing either field", async () => {
await expect(
createLogProvider(
{
name: "bs",
providerType: "betterstack",
apiKey: "token-only",
} as any,
"org-1",
),
).rejects.toThrow();
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a splunk_hec provider without a HEC token", async () => {
await expect(
createLogProvider(
{
name: "splunk",
providerType: "splunk_hec",
endpoint: "https://splunk.example.com:8088",
} as any,
"org-1",
),
).rejects.toThrow(/token/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
await expect(
createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
} as any,
"org-1",
),
).rejects.toThrow(/region|log group/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
await expect(
createLogProvider(
{
name: "dd",
providerType: "datadog",
extraConfig: { apiKey: "sneaky-value" },
} as any,
"org-1",
),
).rejects.toThrow(/api key/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
await expect(
createLogProvider(
{
name: "es",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
} as any,
"org-1",
),
).rejects.toThrow(/password.*required/i);
expect(mocks.insertValues).not.toHaveBeenCalled();
});
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
]);
const created = await createLogProvider(
{
name: "cw",
providerType: "aws_cloudwatch",
apiKey: "AKIA...",
apiSecret: "shh",
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-2");
});
it("accepts a loki provider with endpoint", async () => {
mocks.insertValues.mockReturnValue([
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
]);
const created = await createLogProvider(
{
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
} as any,
"org-1",
);
expect(created.logProviderId).toBe("lp-1");
expect(mocks.insertValues).toHaveBeenCalled();
});
});
describe("updateLogProvider — merges partial update with existing row before validating", () => {
it("allows updating just the name without re-sending existing credentials", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "old-name",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
mocks.updateSet.mockReturnValue([
{ logProviderId: "lp-1", name: "new-name" },
]);
const updated = await updateLogProvider("lp-1", { name: "new-name" });
expect(updated.name).toBe("new-name");
});
it("rejects clearing the only required field via update", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
});
await expect(updateLogProvider("lp-1", { endpoint: null })).rejects.toThrow(
/endpoint/i,
);
});
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: { site: "datadoghq.com" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", {
providerType: "loki",
endpoint: "https://loki.example.com",
});
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: null,
apiSecret: null,
extraConfig: null,
}),
);
});
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "secret",
apiSecret: null,
extraConfig: { username: "elastic", index: "custom-index" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: { username: "new-user" } });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({
extraConfig: { username: "new-user", index: "custom-index" },
}),
);
});
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: null,
apiSecret: null,
extraConfig: { username: "elastic" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { extraConfig: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ extraConfig: null }),
);
});
it("clears an optional credential when it is explicitly set to null", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
apiKey: "old-token",
apiSecret: null,
extraConfig: { username: "user" },
});
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
await updateLogProvider("lp-1", { apiKey: null });
expect(mocks.updateSet).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: null }),
);
});
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "es-prod",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
apiKey: "old-password",
apiSecret: null,
extraConfig: { username: "elastic" },
});
await expect(updateLogProvider("lp-1", { apiKey: null })).rejects.toThrow(
/Password/,
);
});
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
mocks.findFirst.mockResolvedValue({
logProviderId: "lp-1",
name: "dd-prod",
providerType: "datadog",
endpoint: null,
apiKey: "old-datadog-key",
apiSecret: null,
extraConfig: null,
});
await expect(
updateLogProvider("lp-1", { providerType: "loki" }),
).rejects.toThrow(/endpoint/i);
});
});
@@ -1,71 +0,0 @@
import type { SQL } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
deleted: [] as unknown[],
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
}));
vi.mock("@dokploy/server/db", () => {
const tx = {
delete: () => ({
where: () => ({
returning: () => Promise.resolve(mocks.deleted),
}),
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
mocks.updates.push({ table, values });
return Promise.resolve();
},
}),
}),
};
return {
db: { transaction: (fn: (tx: unknown) => unknown) => fn(tx) },
};
});
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
const { removeLogProvider } = await import(
"@dokploy/server/services/log-management/service"
);
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
describe("removeLogProvider", () => {
beforeEach(() => {
mocks.updates = [];
});
it("drops the provider id from every server and from the local host", async () => {
mocks.deleted = [{ logProviderId: "lp-1" }];
await removeLogProvider("lp-1");
expect(mocks.updates.map((u) => u.table)).toEqual([
server,
webServerSettings,
]);
expect(render(mocks.updates[0]?.values.logProviderIds)).toBe(
'array_remove("server"."logProviderIds", $1)',
);
expect(render(mocks.updates[1]?.values.logProviderIds)).toBe(
'array_remove("webServerSettings"."logProviderIds", $1)',
);
expect(mocks.updates[1]?.values).not.toHaveProperty(
"logManagementOrganizationId",
);
});
it("does not touch assignments when the provider does not exist", async () => {
mocks.deleted = [];
await expect(removeLogProvider("missing")).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.updates).toEqual([]);
});
});
@@ -1,306 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
claimWebServerLogManagement: vi.fn(),
releaseWebServerLogManagement: vi.fn(),
getWebServerSettings: vi.fn(),
updateServerLogProviders: vi.fn(),
getAccessibleServerIds: vi.fn(),
removeVectorAgent: vi.fn(),
setupVectorAgent: vi.fn(),
assertServerBelongsToOrg: vi.fn(),
assertLogProvidersBelongToOrg: vi.fn(),
filterExistingLogProviderIds: vi.fn(),
}));
const loadAgent = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
vi.doMock("@dokploy/server/services/server", () => ({
updateServerLogProviders: mocks.updateServerLogProviders,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
claimWebServerLogManagement: mocks.claimWebServerLogManagement,
releaseWebServerLogManagement: mocks.releaseWebServerLogManagement,
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
removeVectorAgent: mocks.removeVectorAgent,
setupVectorAgent: mocks.setupVectorAgent,
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
fn(),
}));
vi.doMock("@dokploy/server/services/log-management/service", () => ({
assertServerBelongsToOrg: mocks.assertServerBelongsToOrg,
assertLogProvidersBelongToOrg: mocks.assertLogProvidersBelongToOrg,
filterExistingLogProviderIds: mocks.filterExistingLogProviderIds,
}));
return await import("@dokploy/server/services/log-management/vector-agent");
};
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
describe("local Vector agent guards", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("does not remove the local agent when another organization owns it", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionB, undefined),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes the local agent and only then releases the claim", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
expect(mocks.removeVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.releaseWebServerLogManagement.mock.invocationCallOrder[0] ?? 0,
);
});
it("keeps the claim when removing the local agent fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
});
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
const { removeLogManagement } = await loadAgent(false);
await expect(removeLogManagement(sessionA, undefined)).rejects.toThrow(
"docker down",
);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("removes an unclaimed local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
});
const { removeLogManagement } = await loadAgent(false);
await removeLogManagement(sessionA, undefined);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
});
it("rejects deploying the local agent in cloud", async () => {
const { deployLogManagement } = await loadAgent(true);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.claimWebServerLogManagement).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing the local agent in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await expect(
removeLogManagement(sessionA, undefined),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.getWebServerSettings).not.toHaveBeenCalled();
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("still manages remote servers in cloud", async () => {
const { removeLogManagement } = await loadAgent(true);
await removeLogManagement(sessionA, "server-1");
expect(mocks.assertServerBelongsToOrg).toHaveBeenCalledWith(
"server-1",
"org-a",
);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", []);
});
});
describe("remote server access", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
});
it("rejects deploying on a server the member cannot access", async () => {
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-2", ["lp-1"]),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
it("rejects removing from a server the member cannot access", async () => {
const { removeLogManagement } = await loadAgent(false);
await expect(
removeLogManagement(sessionA, "server-2"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
});
it("deploys on an accessible server", async () => {
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1"]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
expect(mocks.setupVectorAgent).toHaveBeenCalledWith("org-a", "server-1", [
"lp-1",
]);
});
it("does not save a provider deleted while the server deploy ran", async () => {
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, "server-1", ["lp-1", "lp-2"]);
expect(mocks.filterExistingLogProviderIds).toHaveBeenCalledWith([
"lp-1",
"lp-2",
]);
expect(mocks.updateServerLogProviders).toHaveBeenCalledWith("server-1", [
"lp-1",
]);
});
it("keeps the saved providers when the server deploy fails", async () => {
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, "server-1", ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.updateServerLogProviders).not.toHaveBeenCalled();
});
it("removes the agent on server deletion even with no providers left", async () => {
const { removeServerLogManagement } = await loadAgent(false);
await removeServerLogManagement({
serverId: "server-1",
sshKeyId: "key-1",
logProviderIds: [],
} as never);
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
});
});
describe("local agent deploy", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.filterExistingLogProviderIds.mockImplementation(
async (ids: string[]) => ids,
);
mocks.claimWebServerLogManagement.mockResolvedValue({});
});
it("saves the new providers only after the deploy succeeds", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
1,
"org-a",
["lp-1"],
);
expect(mocks.claimWebServerLogManagement).toHaveBeenNthCalledWith(
2,
"org-a",
["lp-2"],
);
expect(mocks.setupVectorAgent.mock.invocationCallOrder[0]).toBeLessThan(
mocks.claimWebServerLogManagement.mock.invocationCallOrder[1] ?? 0,
);
});
it("does not save a provider deleted while the local deploy ran", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.filterExistingLogProviderIds.mockResolvedValue(["lp-1"]);
const { deployLogManagement } = await loadAgent(false);
await deployLogManagement(sessionA, undefined, ["lp-1", "lp-2"]);
expect(mocks.claimWebServerLogManagement).toHaveBeenLastCalledWith(
"org-a",
["lp-1"],
);
});
it("keeps the previous providers when a redeploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-2"]),
).rejects.toThrow("validation failed");
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledTimes(1);
expect(mocks.claimWebServerLogManagement).toHaveBeenCalledWith("org-a", [
"lp-1",
]);
expect(mocks.releaseWebServerLogManagement).not.toHaveBeenCalled();
});
it("releases the claim when the first local deploy fails", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: null,
logProviderIds: [],
});
mocks.setupVectorAgent.mockRejectedValue(new Error("validation failed"));
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionA, undefined, ["lp-1"]),
).rejects.toThrow("validation failed");
expect(mocks.releaseWebServerLogManagement).toHaveBeenCalledWith("org-a");
});
it("rejects deploying when another organization owns the local agent", async () => {
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-a",
logProviderIds: ["lp-1"],
});
mocks.claimWebServerLogManagement.mockResolvedValue(null);
const { deployLogManagement } = await loadAgent(false);
await expect(
deployLogManagement(sessionB, undefined, ["lp-9"]),
).rejects.toMatchObject({ code: "CONFLICT" });
expect(mocks.setupVectorAgent).not.toHaveBeenCalled();
});
});
@@ -1,145 +0,0 @@
import { buildVectorConfigYaml } from "@dokploy/server/setup/vector-setup";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const provider = (overrides: Record<string, unknown>) =>
({
apiKey: null,
apiSecret: null,
endpoint: null,
extraConfig: null,
...overrides,
}) as any;
describe("buildVectorConfigYaml", () => {
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "log-provider-1",
name: "loki-prod",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
expect(config.data_dir).toBe("/var/lib/vector");
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
expect(config.transforms.dokploy_scope.type).toBe("remap");
expect(config.transforms.dokploy_scope.inputs).toEqual([
"docker_logs_source",
]);
const sinkIds = Object.keys(config.sinks);
expect(sinkIds).toEqual(["sink_log-provider-1"]);
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
"dokploy_scope",
]);
});
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
provider({
logProviderId: "bs-1",
name: "betterstack",
providerType: "betterstack",
endpoint: "https://in.logs.betterstack.com",
apiKey: "token",
}),
provider({
logProviderId: "dd-1",
name: "datadog",
providerType: "datadog",
apiKey: "dd-key",
}),
]);
const config = parse(yamlStr) as any;
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
expect(config.transforms["transform_bs-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
expect(config.transforms["transform_dd-1"].inputs).toEqual([
"dokploy_scope",
]);
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
});
it("reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope.source).toBe(
[
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n"),
);
});
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", async () => {
const yamlStr = await buildVectorConfigYaml([
provider({
logProviderId: "broken-es",
name: "es-broken",
providerType: "elasticsearch",
endpoint: "https://es.example.com:9200",
extraConfig: { username: "elastic" },
}),
provider({
logProviderId: "good-loki",
name: "loki-ok",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
]);
const config = parse(yamlStr) as any;
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
});
it("only keeps containers of the owning organization on the local host, which is shared by every organization", async () => {
const yamlStr = await buildVectorConfigYaml(
[
provider({
logProviderId: "loki-1",
name: "loki",
providerType: "loki",
endpoint: "https://loki.example.com",
}),
],
{ organizationId: "org-a" },
);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toEqual({
type: "filter",
inputs: ["dokploy_scope"],
condition: '.dokploy_organization == "org-a"',
});
expect(config.sinks["sink_loki-1"].inputs).toEqual([
"dokploy_scope_local_only",
]);
});
it("does not add the organization filter for the per-server (non-local) path", async () => {
const yamlStr = await buildVectorConfigYaml([]);
const config = parse(yamlStr) as any;
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
});
});
@@ -1,37 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
getRemoteDocker: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
findServersByOrganizationForLogManagement: vi.fn(),
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: mocks.getRemoteDocker,
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: mocks.execAsyncRemote,
}));
const { removeVectorAgent } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("removeVectorAgent on a server without SSH key", () => {
it("refuses instead of falling back to the local docker socket", async () => {
mocks.findServerById.mockResolvedValue({
serverId: "server-1",
sshKeyId: null,
});
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
expect(mocks.getRemoteDocker).not.toHaveBeenCalled();
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
});
@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => ({
db: {
query: { projects: { findMany: vi.fn() } },
},
}));
const { collectSecretValues, redactSecrets } = await import(
"@dokploy/server/setup/vector-setup"
);
describe("collectSecretValues / redactSecrets", () => {
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
const providers = [
{
endpoint: "https://loki.example.com",
apiKey: "loki-key",
apiSecret: null,
extraConfig: { tenantId: "tenant-a" },
},
{
endpoint: null,
apiKey: "dd-key",
apiSecret: null,
extraConfig: { site: "datadoghq.eu" },
},
] as any;
expect(collectSecretValues(providers)).toEqual(
expect.arrayContaining([
"https://loki.example.com",
"loki-key",
"tenant-a",
"dd-key",
"datadoghq.eu",
]),
);
});
it("ignores non-string extraConfig values and null/empty credentials", () => {
const providers = [
{
endpoint: null,
apiKey: null,
apiSecret: null,
extraConfig: { limit: 10, enabled: true, empty: "" },
},
] as any;
expect(collectSecretValues(providers)).toEqual([]);
});
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
const providers = [
{
endpoint: null,
apiKey: "hunter2-long-suffix",
apiSecret: null,
extraConfig: { other: "hunter2" },
},
] as any;
const text =
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
const redacted = redactSecrets(text, collectSecretValues(providers));
expect(redacted).not.toContain("hunter2");
expect(redacted).toBe(
"sink validation error near apiKey=[redacted] and [redacted]",
);
});
});
@@ -1,127 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listTasks: vi.fn(),
inspectService: vi.fn(),
findServersByOrganizationForLogManagement: vi.fn(),
getAccessibleServerIds: vi.fn(),
getWebServerSettings: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
vi.mock("@dokploy/server/constants", () => ({
IS_CLOUD: false,
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
findServersByOrganizationForLogManagement:
mocks.findServersByOrganizationForLogManagement,
getAccessibleServerIds: mocks.getAccessibleServerIds,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
getRemoteDocker: async () => ({
listTasks: mocks.listTasks,
getService: () => ({ inspect: mocks.inspectService }),
}),
}));
const { getLogManagementServerStatus, withVectorTargetLock } = await import(
"@dokploy/server/setup/vector-setup"
);
const session = { userId: "user-1", activeOrganizationId: "org-1" };
describe("getLogManagementServerStatus", () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset();
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([]);
mocks.inspectService.mockResolvedValue({});
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
mocks.getWebServerSettings.mockResolvedValue({
logManagementOrganizationId: "org-1",
logProviderIds: ["lp-1"],
});
});
it("reports running only when a task of the service is running", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("running");
expect(mocks.listTasks).toHaveBeenCalledWith({
filters: JSON.stringify({
service: ["dokploy-vector"],
"desired-state": ["running"],
}),
});
});
it("reports not-running when the service has no running task", async () => {
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("not-running");
});
it("reports stopped when the service does not exist", async () => {
mocks.inspectService.mockRejectedValue(
Object.assign(new Error("not found"), { statusCode: 404 }),
);
mocks.listTasks.mockResolvedValue([]);
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("stopped");
expect(mocks.listTasks).not.toHaveBeenCalled();
});
it("reports unknown on any other docker error", async () => {
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
const [local] = await getLogManagementServerStatus(session);
expect(local?.status).toBe("unknown");
});
it("hides servers the member cannot access", async () => {
mocks.listTasks.mockResolvedValue([]);
mocks.findServersByOrganizationForLogManagement.mockResolvedValue([
{ serverId: "server-1", name: "one", ipAddress: "1.1.1.1" },
{ serverId: "server-2", name: "two", ipAddress: "2.2.2.2" },
]);
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
const targets = await getLogManagementServerStatus(session);
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
});
});
describe("withVectorTargetLock", () => {
it("runs operations on the same target one after another", async () => {
const events: string[] = [];
let releaseFirst = () => {};
const first = withVectorTargetLock("server-1", async () => {
events.push("first:start");
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
events.push("first:end");
throw new Error("first failed");
});
const second = withVectorTargetLock("server-1", async () => {
events.push("second");
});
const other = withVectorTargetLock("server-2", async () => {
events.push("other");
});
await other;
expect(events).toEqual(["first:start", "other"]);
releaseFirst();
await expect(first).rejects.toThrow("first failed");
await second;
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
});
});
@@ -1,104 +0,0 @@
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,20 +73,6 @@ describe("owner and admin bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("owner bypasses logProvider.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses logProvider.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).resolves.toBeUndefined();
});
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -153,20 +139,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
it("member is denied logProvider.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["read"] }),
).rejects.toThrow();
});
it("member is denied logProvider.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { logProvider: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
@@ -18,7 +18,6 @@ const FREE_TIER_RESOURCES = [
"gitProviders",
"traefikFiles",
"api",
"logProvider",
];
const ENTERPRISE_RESOURCES = [
@@ -111,7 +111,6 @@ describe("enterprise resources for static roles", () => {
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
expect(perms.logProvider.read).toBe(false);
});
});
@@ -25,8 +25,6 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logProviderIds: [],
logManagementOrganizationId: null,
buildsConcurrency: 1,
logCleanupCron: null,
metricsConfig: {
@@ -1,182 +0,0 @@
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);
});
});
@@ -1,169 +0,0 @@
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 flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Patches</CardTitle>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
};
return (
<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>
<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} />
)}
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</CardHeader>
<CardContent>
<CardContent className="px-0">
{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,26 +77,35 @@ export const ShowVolumeBackups = ({
};
return (
<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>
</>
)}
<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>
</div>
</CardHeader>
<CardContent>
<CardContent className="px-0">
{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,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
})
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any)
@@ -155,6 +155,7 @@ 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,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
})
@@ -1,4 +1,10 @@
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -95,9 +101,12 @@ export const ShowBackups = ({
return (
<Card className="bg-background">
<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>
<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>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -1,8 +1,10 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
@@ -25,6 +27,7 @@ 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,
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
return null;
}
interface ShowDeploymentsTableProps {
globalFilter: string;
statusFilter: string;
typeFilter: string;
}
export function ShowDeploymentsTable({
globalFilter,
statusFilter,
typeFilter,
}: ShowDeploymentsTableProps) {
export function ShowDeploymentsTable() {
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,
@@ -450,140 +446,177 @@ 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="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>
<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>
))}
</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>
))}
</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>
</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 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 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>
</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..."
@@ -1,143 +0,0 @@
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,7 +46,6 @@ 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;
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
});
const filteredUsers = response.data?.users.filter(
// @ts-expect-error
// @ts-ignore
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-expect-error
// @ts-ignore
setUsers(filteredUsers || []);
}
} catch (error) {
@@ -283,7 +282,6 @@ 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,6 +1,5 @@
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 {
@@ -9,14 +8,6 @@ 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;
@@ -35,10 +26,6 @@ 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;
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
onValueChange={setSubtab}
className="w-full min-w-0"
>
<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>
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
<ShowDeploymentsTable
globalFilter={globalFilter}
statusFilter={statusFilter}
typeFilter={typeFilter}
/>
<ShowDeploymentsTable />
</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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row 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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,4 +1,4 @@
import { CreditCard, FileText } from "lucide-react";
import { CreditCard, FileText, Server } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
@@ -17,6 +17,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -29,7 +34,7 @@ export const ShowBillingInvoices = () => {
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -10,6 +10,7 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -83,6 +84,11 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -0,0 +1,502 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
const STATUS_MAP: Record<
string,
{
label: string;
icon: React.ReactNode;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
pending: {
label: "Pending",
icon: <Clock className="size-3" />,
variant: "secondary",
},
provisioning: {
label: "Provisioning",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
configuring: {
label: "Installing Dokploy",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
ready: {
label: "Ready",
icon: <CheckCircle2 className="size-3" />,
variant: "default",
},
error: {
label: "Error",
icon: <XCircle className="size-3" />,
variant: "destructive",
},
terminating: {
label: "Terminating",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
terminated: {
label: "Terminated",
icon: <AlertCircle className="size-3" />,
variant: "outline",
},
};
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
}
function formatPrice(priceCents: number) {
return `$${(priceCents / 100).toFixed(2)}/mo`;
}
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedZone, setSelectedZone] = useState<string>("");
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: zones, isLoading: loadingZones } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingZones;
const purchase = api.managedServer.purchase.useMutation({
onSuccess: () => {
toast.success("Server order placed! Provisioning will take ~5 minutes.");
setOpen(false);
onSuccess();
},
onError: (err) => {
toast.error(err.message);
},
});
const plan = plans?.find((p) => p.id === selectedPlan);
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
if (!acc[z.continent]) acc[z.continent] = [];
acc[z.continent]!.push(z);
return acc;
}, {});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-2" />
Order Server
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order a Managed Server</DialogTitle>
<DialogDescription>
We'll provision and configure a server for you automatically. Ready
in ~5 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingOptions ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Loading available plans...</p>
</div>
) : (
<div className="space-y-5">
{/* Plan cards */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid grid-cols-2 gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">{p.name}</p>
<p className="text-sm font-medium text-primary">
{formatPrice(p.priceCents)}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
</p>
</button>
))}
</div>
</div>
{/* Zone selector grouped by continent */}
<div className="space-y-2">
<Label>Location</Label>
<Select value={selectedZone} onValueChange={setSelectedZone}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
sideOffset={4}
className="max-h-64 overflow-y-auto"
>
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
<div key={continent}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{continent}
</div>
{groupedZones?.[continent]?.map((z) => (
<SelectItem key={z.id} value={z.id}>
{z.description}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{plan && selectedZone && (
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{plan.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Specs</span>
<span className="font-medium">
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Price</span>
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zone</span>
<span className="font-medium">
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedZone || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedZone) return;
purchase.mutate({
plan: selectedPlan,
zone: selectedZone,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
starter: "Starter",
pro: "Pro",
business: "Business",
};
export const ShowManagedServers = () => {
const router = useRouter();
const utils = api.useUtils();
const { data: servers, isLoading } = api.managedServer.list.useQuery();
const syncStatus = api.managedServer.syncStatus.useMutation({
onSuccess: () => utils.managedServer.list.invalidate(),
});
const reconnect = api.managedServer.reconnect.useMutation({
onSuccess: () => {
toast.success("Server reconnected successfully.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
const deleteServer = api.managedServer.delete.useMutation({
onSuccess: () => {
toast.success("Server terminated.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
return (
<div className="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">
<Server className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and servers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-base">Managed Servers</h3>
<p className="text-sm text-muted-foreground">
Servers provisioned and managed by Dokploy Cloud
</p>
</div>
<OrderServerDialog
onSuccess={() => utils.managedServer.list.invalidate()}
/>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : servers?.length === 0 ? (
<div className="text-center py-12 border rounded-lg border-dashed">
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">No managed servers yet</p>
<p className="text-xs text-muted-foreground mt-1">
Order a server and we'll provision and configure it for you
automatically.
</p>
</div>
) : (
<div className="space-y-3">
{servers?.map((s) => {
const status =
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
const isProvisioning = [
"pending",
"provisioning",
"configuring",
].includes(s.status);
return (
<div
key={s.managedServerId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Server className="size-5 text-muted-foreground shrink-0" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PLAN_LABELS[s.plan] ?? s.plan}
</span>
<Badge
variant={status?.variant}
className="flex items-center gap-1 text-xs h-5"
>
{status?.icon}
{status?.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{s.hostname ?? ""}
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
{s.zone ? ` · ${s.zone}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isProvisioning && (
<Button
variant="ghost"
size="sm"
onClick={() =>
syncStatus.mutate({
managedServerId: s.managedServerId,
})
}
disabled={syncStatus.isPending}
>
<Loader2
className={cn(
"size-4",
syncStatus.isPending && "animate-spin",
)}
/>
</Button>
)}
{s.status === "ready" && !s.serverId && (
<Button
variant="outline"
size="sm"
onClick={() =>
reconnect.mutate({
managedServerId: s.managedServerId,
})
}
disabled={reconnect.isPending}
>
{reconnect.isPending ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
Reconnect
</Button>
)}
{s.status === "ready" && s.server && (
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/settings/server?serverId=${s.serverId}`}
>
<ExternalLink className="size-3.5 mr-1.5" />
Open
</Link>
</Button>
)}
<DialogAction
title="Terminate Server"
description="This will permanently destroy the server and all data on it. This action cannot be undone."
type="destructive"
onClick={() =>
deleteServer.mutate({
managedServerId: s.managedServerId,
})
}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<div className="w-full">
<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 max-w-5xl mx-auto">
<div className="w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -1,402 +0,0 @@
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
interface Props {
logProviderId?: string;
}
export const agentsUsingProvider = (
targets: Array<{ name: string; logProviderIds: string[] }> | undefined,
logProviderId: string,
) =>
(targets ?? [])
.filter((target) => target.logProviderIds.includes(logProviderId))
.map((target) => target.name)
.join(", ");
export const HandleLogProvider = ({ logProviderId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
const [providerType, setProviderType] = useState("");
const [enabled, setEnabled] = useState(true);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [initialFieldValues, setInitialFieldValues] = useState<
Record<string, string>
>({});
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: provider } = api.logProvider.one.useQuery(
{ logProviderId: logProviderId || "" },
{ enabled: !!logProviderId },
);
const {
mutateAsync,
isPending: isSaving,
error,
isError,
} = logProviderId
? api.logProvider.update.useMutation()
: api.logProvider.create.useMutation();
const {
mutateAsync: testConnection,
isPending: isTestingRaw,
error: testError,
isError: testIsError,
} = api.logProvider.testConnection.useMutation();
const {
mutateAsync: testConnectionById,
isPending: isTestingById,
error: testByIdError,
isError: testByIdIsError,
} = api.logProvider.testConnectionById.useMutation();
const selectedType = availableTypes?.find((t) => t.type === providerType);
const handleProviderTypeChange = (value: string) => {
setProviderType(value);
setFieldValues({});
};
useEffect(() => {
if (provider) {
setName(provider.name);
setProviderType(provider.providerType);
setEnabled(provider.enabled);
const extraConfig = (provider.extraConfig ?? {}) as Record<
string,
string
>;
setFieldValues(extraConfig);
setInitialFieldValues(extraConfig);
setClearedKeys({});
} else if (isOpen) {
setName("");
setProviderType("");
setEnabled(true);
setFieldValues({});
setInitialFieldValues({});
setClearedKeys({});
}
}, [provider, isOpen]);
const buildPayload = () => {
const payload: Record<string, unknown> = { name, providerType, enabled };
const extraConfig: Record<string, string> = {};
let hasExtraConfigFields = false;
for (const field of selectedType?.credentialFields ?? []) {
const value = fieldValues[field.key];
if (TOP_LEVEL_KEYS.has(field.key)) {
if (value) {
payload[field.key] = value;
} else if (clearedKeys[field.key]) {
payload[field.key] = null;
}
} else {
hasExtraConfigFields = true;
extraConfig[field.key] = value ?? "";
}
}
if (hasExtraConfigFields) {
payload.extraConfig = extraConfig;
}
return payload;
};
const topLevelKeys = (selectedType?.credentialFields ?? [])
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedTopLevelKeys = topLevelKeys.filter(
(key) => !!fieldValues[key] || clearedKeys[key],
);
const extraConfigKeys = (selectedType?.credentialFields ?? [])
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
.map((field) => field.key);
const touchedExtraConfigKeys = extraConfigKeys.filter(
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
);
const touchedAnyCredentialField =
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
const isPartiallyTouched =
!!logProviderId &&
touchedAnyCredentialField &&
touchedTopLevelKeys.length < topLevelKeys.length;
const onTest = async () => {
if (logProviderId && !touchedAnyCredentialField) {
await testConnectionById({ logProviderId })
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(
e instanceof Error ? e.message : "Connection test failed",
);
});
return;
}
const payload = buildPayload();
await testConnection({
name: payload.name as string,
providerType: payload.providerType as LogProviderType,
enabled: payload.enabled as boolean,
endpoint: (payload.endpoint as string) ?? null,
apiKey: (payload.apiKey as string) ?? null,
apiSecret: (payload.apiSecret as string) ?? null,
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
})
.then((result) => {
if (result.warning) {
toast.message(result.warning);
} else {
toast.success("Connection tested successfully");
}
})
.catch((e) => {
toast.error(e instanceof Error ? e.message : "Connection test failed");
});
};
const onSubmit = async () => {
const payload = buildPayload();
await mutateAsync({
...(logProviderId ? { logProviderId } : {}),
...payload,
} as any)
.then(() => {
utils.logProvider.all.invalidate();
if (logProviderId) {
utils.logProvider.one.invalidate({ logProviderId });
}
const agents =
logProviderId &&
agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
logProviderId,
);
toast.success(
logProviderId ? "Log provider updated" : "Log provider added",
agents
? {
description: `Redeploy the Vector agent on ${agents} to apply the changes.`,
}
: undefined,
);
setIsOpen(false);
})
.catch(() => {
toast.error(
logProviderId
? "Error updating log provider"
: "Error adding log provider",
);
});
};
const isTesting = isTestingRaw || isTestingById;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{logProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Log Provider
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
</DialogTitle>
<DialogDescription>
Vector will ship container logs from every server with Log
Management enabled to this provider.
</DialogDescription>
</DialogHeader>
{(isError || testIsError || testByIdIsError) && (
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<AlertTriangle className="text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{testError?.message ||
testByIdError?.message ||
error?.message ||
""}
</span>
</div>
)}
<div className="flex flex-col w-full gap-4">
<div className="flex flex-col gap-2">
<Label>Name</Label>
<Input
placeholder="e.g. Production Loki"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Provider</Label>
<Select
value={providerType}
onValueChange={handleProviderTypeChange}
disabled={!!logProviderId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{availableTypes?.map((type) => {
const ProviderIcon =
logProviderIcons[
type.type as keyof typeof logProviderIcons
];
return (
<SelectItem key={type.type} value={type.type}>
<div className="flex flex-row items-center gap-2">
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
{type.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{selectedType?.credentialFields.map((field) => (
<div key={field.key} className="flex flex-col gap-2">
<Label>
{field.label}
{!field.required && " (Optional)"}
</Label>
{field.helpText && (
<span className="text-xs text-muted-foreground">
{field.helpText}
</span>
)}
<div className="flex flex-row gap-2">
<Input
type={field.type === "password" ? "password" : "text"}
disabled={!!clearedKeys[field.key]}
placeholder={
clearedKeys[field.key]
? "Will be cleared on update"
: logProviderId && TOP_LEVEL_KEYS.has(field.key)
? "Leave blank to keep existing"
: field.placeholder
}
autoComplete={
field.type === "password" ? "one-time-code" : "off"
}
value={fieldValues[field.key] ?? ""}
onChange={(e) =>
setFieldValues((prev) => ({
...prev,
[field.key]: e.target.value,
}))
}
/>
{logProviderId &&
TOP_LEVEL_KEYS.has(field.key) &&
!field.required && (
<Button
type="button"
variant="outline"
onClick={() => {
setClearedKeys((prev) => ({
...prev,
[field.key]: !prev[field.key],
}));
setFieldValues((prev) => ({
...prev,
[field.key]: "",
}));
}}
>
{clearedKeys[field.key] ? "Keep" : "Clear"}
</Button>
)}
</div>
</div>
))}
<div className="flex flex-row items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} />
<Label>Enabled</Label>
</div>
</div>
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
{isPartiallyTouched && (
<span className="text-xs text-muted-foreground">
You changed something that needs a fresh credential to test — fill
in every credential field to test with the new values, or revert
it to test with what's already saved.
</span>
)}
<div className="flex flex-row gap-2 justify-between">
<Button
type="button"
variant="secondary"
isLoading={isTesting}
disabled={!providerType || isPartiallyTouched}
onClick={onTest}
>
Test connection
</Button>
<Button
onClick={onSubmit}
isLoading={isSaving}
disabled={!providerType || !name || isSaving}
>
{logProviderId ? "Update" : "Create"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,324 +0,0 @@
import { Copy, Loader2, ScrollText, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
type Status = "running" | "not-running" | "stopped" | "unknown";
const statusBadge = (status: Status) => {
if (status === "running") {
return <Badge variant="green">Running</Badge>;
}
if (status === "not-running") {
return <Badge variant="yellow">Not running</Badge>;
}
if (status === "stopped") {
return <Badge variant="secondary">Not deployed</Badge>;
}
return <Badge variant="destructive">Unreachable</Badge>;
};
export const ShowLogManagementServers = () => {
const utils = api.useUtils();
const [selection, setSelection] = useState<Record<string, string[]>>({});
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: providers } = api.logProvider.all.useQuery();
const { data: targets, isPending } = api.logProvider.serverStatus.useQuery(
undefined,
{
refetchOnWindowFocus: false,
staleTime: 30_000,
trpc: { context: { skipBatch: true } },
},
);
const { mutateAsync: deployOnServer } =
api.logProvider.deployOnServer.useMutation();
const { mutateAsync: removeOnServer } =
api.logProvider.removeOnServer.useMutation();
const keyOf = (serverId: string | null) => serverId ?? "local";
const idsFor = (serverId: string | null, saved: string[]) =>
selection[keyOf(serverId)] ?? saved;
const toggleProvider = (
serverId: string | null,
saved: string[],
logProviderId: string,
) => {
const key = keyOf(serverId);
const current = selection[key] ?? saved;
setSelection({
...selection,
[key]: current.includes(logProviderId)
? current.filter((id) => id !== logProviderId)
: [...current, logProviderId],
});
};
const setError = (serverId: string | null, message?: string) =>
setErrors((prev) => {
const next = { ...prev };
if (message) {
next[keyOf(serverId)] = message;
} else {
delete next[keyOf(serverId)];
}
return next;
});
const handleDeploy = async (serverId: string | null, ids: string[]) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await deployOnServer({ serverId, logProviderIds: ids })
.then(() => {
toast.success("Vector agent deployed");
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to deploy the agent",
);
})
.finally(() => setPendingTarget(null));
};
const handleRemove = async (serverId: string | null) => {
setPendingTarget(keyOf(serverId));
setError(serverId);
await removeOnServer({ serverId })
.then(() => {
toast.success("Vector agent removed");
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
utils.logProvider.serverStatus.invalidate();
})
.catch((error) => {
setError(
serverId,
error instanceof Error ? error.message : "Failed to remove the agent",
);
})
.finally(() => setPendingTarget(null));
};
const hasProviders = (providers?.length ?? 0) > 0;
const visibleTargets = hasProviders
? targets
: targets?.filter((target) => target.status !== "stopped");
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Servers
</CardTitle>
<CardDescription>
Pick which providers each server ships to, then deploy its Vector
agent. Changing a provider only takes effect on a server after you
redeploy it. Services deployed before Log Management need a
redeploy to get the labels it uses; until then their logs are not
shipped from the Dokploy server.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Checking servers...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !hasProviders && !visibleTargets?.length ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Add a log provider first — there's nowhere to ship logs to yet
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{visibleTargets?.map((target) => {
const key = keyOf(target.serverId);
const ids = idsFor(target.serverId, target.logProviderIds);
const isBusy = pendingTarget === key;
const isDeployed = target.status !== "stopped";
const hasEnabledSelected = ids.some(
(id) =>
providers?.find((p) => p.logProviderId === id)?.enabled,
);
return (
<div
key={key}
className="flex flex-col gap-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium">
{target.name}
</span>
{target.ipAddress && (
<span className="text-xs text-muted-foreground">
{target.ipAddress}
</span>
)}
</div>
<div className="flex items-center gap-2">
{statusBadge(target.status as Status)}
{isDeployed && (
<DialogAction
title="Remove the Vector agent"
description="This server will stop shipping container logs until you deploy it again. Continue?"
type="destructive"
onClick={() => handleRemove(target.serverId)}
>
<Button
variant="ghost"
size="sm"
isLoading={isBusy}
>
Remove
</Button>
</DialogAction>
)}
</div>
</div>
<div className="flex flex-col gap-2">
{hasProviders && (
<span className="text-xs font-medium">
Log providers
</span>
)}
<div className="flex flex-col gap-2">
{providers?.map((provider) => {
const ProviderIcon =
logProviderIcons[
provider.providerType as keyof typeof logProviderIcons
];
return (
<div
key={provider.logProviderId}
className={`flex flex-row items-center gap-2 text-sm ${
provider.enabled ? "" : "opacity-60"
}`}
>
<Checkbox
id={`${key}-${provider.logProviderId}`}
disabled={!provider.enabled}
checked={ids.includes(provider.logProviderId)}
onCheckedChange={() =>
toggleProvider(
target.serverId,
target.logProviderIds,
provider.logProviderId,
)
}
/>
{ProviderIcon && (
<ProviderIcon className="size-4 shrink-0" />
)}
<Label
htmlFor={`${key}-${provider.logProviderId}`}
className={`font-normal ${
provider.enabled
? "cursor-pointer"
: "cursor-not-allowed"
}`}
>
{provider.name}
</Label>
{!provider.enabled && (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
);
})}
</div>
</div>
{errors[key] && (
<AlertBlock type="error" className="items-start">
<div className="flex w-full flex-col gap-2">
<span className="font-medium">
Deploy failed — the config was not applied, this
server keeps shipping with its previous one.
</span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
{errors[key]}
</pre>
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
navigator.clipboard.writeText(
errors[key] ?? "",
);
toast.success("Error copied");
}}
>
<Copy className="size-3.5" />
Copy
</Button>
</div>
</AlertBlock>
)}
{hasProviders && (
<div className="flex items-center justify-end gap-3">
{ids.length > 0 && !hasEnabledSelected && (
<span className="text-xs text-muted-foreground">
Every selected provider is disabled — nothing
would ship.
</span>
)}
<Button
size="sm"
isLoading={isBusy}
disabled={!hasEnabledSelected}
onClick={() => handleDeploy(target.serverId, ids)}
>
{isDeployed ? "Redeploy" : "Deploy"}
</Button>
</div>
)}
</div>
);
})}
{visibleTargets?.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<Server className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any server yet
</span>
</div>
)}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,186 +0,0 @@
import { Loader2, ScrollText, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { logProviderIcons } from "@/components/icons/log-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { agentsUsingProvider, HandleLogProvider } from "./handle-log-provider";
interface LogProviderRowProps {
provider: {
logProviderId: string;
name: string;
enabled: boolean;
providerType: string;
};
index: number;
typeLabel: string;
canEdit: boolean;
canDelete: boolean;
onDeleted: () => void;
}
const LogProviderRow = ({
provider,
index,
typeLabel,
canEdit,
canDelete,
onDeleted,
}: LogProviderRowProps) => {
const utils = api.useUtils();
const { mutateAsync, isPending: isRemoving } =
api.logProvider.remove.useMutation();
const ProviderIcon =
logProviderIcons[provider.providerType as keyof typeof logProviderIcons];
return (
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium flex items-center gap-2">
{index + 1}. {provider.name}
<Badge variant={provider.enabled ? "green" : "secondary"}>
{provider.enabled ? "Enabled" : "Disabled"}
</Badge>
</span>
<div className="text-xs text-muted-foreground">{typeLabel}</div>
</div>
</div>
<div className="flex flex-row gap-1">
{canEdit && (
<HandleLogProvider logProviderId={provider.logProviderId} />
)}
{canDelete && (
<DialogAction
title="Delete Log Provider"
description="Are you sure you want to delete this log provider? Agents already deployed keep shipping logs to it until you redeploy or remove them."
type="destructive"
onClick={async () => {
const agents = agentsUsingProvider(
utils.logProvider.serverStatus.getData(),
provider.logProviderId,
);
await mutateAsync({
logProviderId: provider.logProviderId,
})
.then(() => {
toast.success(
"Log provider deleted successfully",
agents
? {
description: `Redeploy or remove the Vector agent on ${agents} to stop shipping logs to it.`,
}
: undefined,
);
onDeleted();
})
.catch(() => {
toast.error("Error deleting log provider");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
};
export const ShowLogProviders = () => {
const utils = api.useUtils();
const { data, isPending, refetch } = api.logProvider.all.useQuery();
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const providerListLabel = availableTypes?.length
? ` (${availableTypes.map((t) => t.label).join(", ")})`
: "";
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<ScrollText className="size-6 text-muted-foreground self-center" />
Log Management
</CardTitle>
<CardDescription>
{`Ship container logs to an external provider${providerListLabel}. Add one here, then deploy the Vector agent on the servers below.`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ScrollText className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any log provider configured
</span>
{permissions?.logProvider.create && <HandleLogProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg ">
{data?.map((provider, index) => (
<LogProviderRow
key={provider.logProviderId}
provider={provider}
index={index}
typeLabel={
availableTypes?.find(
(t) => t.type === provider.providerType,
)?.label ?? provider.providerType
}
canEdit={!!permissions?.logProvider.create}
canDelete={!!permissions?.logProvider.delete}
onDeleted={() => {
refetch();
utils.logProvider.serverStatus.invalidate();
}}
/>
))}
</div>
{permissions?.logProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleLogProvider />
</div>
)}
</div>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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">
@@ -1,214 +0,0 @@
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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
<div>
@@ -1,4 +1,6 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -13,31 +15,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
interface Props {
serverId: string;
children?: React.ReactNode;
asButton?: boolean;
}
export const ShowServerActions = ({ serverId, children }: Props) => {
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
{asButton ? (
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
}}
>
View Actions
</DropdownMenuItem>
<Button variant="outline" size="icon" className="h-9 w-9">
<Activity className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setIsOpen(true);
}}
>
View Actions
</DropdownMenuItem>
)}
<DialogContent
className="sm:max-w-xl"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-xl">
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl">Web server settings</DialogTitle>
<DialogDescription>Reload or clean the web server.</DialogDescription>
@@ -109,13 +109,8 @@ export const DeleteServerModal = ({
const handleDeleteServer = async () => {
try {
const result = await deleteServer({ serverId });
await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
if (result?.vectorAgentRemovalWarning) {
toast.error(
`Couldn't remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
);
}
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
@@ -126,10 +121,7 @@ export const DeleteServerModal = ({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-lg"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react";
import { Pencil, 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;
children?: React.ReactNode;
asButton?: boolean;
}
export const HandleServers = ({ serverId, children }: Props) => {
export const HandleServers = ({ serverId, asButton = false }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: canCreateMoreServers, refetch } =
@@ -126,7 +126,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async () => {
.then(async (_data) => {
await utils.server.all.invalidate();
refetchServer();
toast.success(serverId ? "Server Updated" : "Server Created");
@@ -141,19 +141,24 @@ export const HandleServers = ({ serverId, children }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : serverId ? (
<DialogTrigger asChild>
{serverId ? (
asButton ? (
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
) : (
<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">
@@ -162,10 +167,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
</Button>
</DialogTrigger>
)}
<DialogContent
className="sm:max-w-3xl "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
<DialogDescription>
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
interface Props {
serverId: string;
children?: React.ReactNode;
asButton?: boolean;
}
export const SetupServer = ({ serverId, children }: Props) => {
export const SetupServer = ({ serverId, asButton = false }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: server } = api.server.one.useQuery(
{
@@ -81,19 +81,24 @@ export const SetupServer = ({ serverId, children }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{children ? (
<DialogTrigger asChild>{children}</DialogTrigger>
) : (
{asButton ? (
<DialogTrigger asChild>
<Button className="w-full cursor-pointer " size="sm">
Setup Server <Settings className="size-4" />
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings className="h-4 w-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 "
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
@@ -7,27 +7,19 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
interface Props {
url: string;
token: string;
children?: React.ReactNode;
}
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
export const ShowMonitoringModal = ({ url, token }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{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()}
>
<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 ">
<div className="flex gap-4 py-4 w-full">
<ShowPaidMonitoring BASE_URL={url} token={token} />
</div>
@@ -1,15 +1,11 @@
import { format } from "date-fns";
import {
Activity,
BarChartHorizontalBigIcon,
Clock,
Key,
KeyIcon,
Loader2,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -53,7 +49,7 @@ export const ShowServers = () => {
return (
<div className="w-full">
{query?.success && isCloud && <WelcomeSubscription />}
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
<Card className="h-full p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -228,19 +224,11 @@ 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>
<SetupServer
serverId={server.serverId}
>
<TooltipTrigger asChild>
<Button
className="w-full cursor-pointer "
size="sm"
>
Setup Server{" "}
<Settings className="size-4" />
</Button>
</TooltipTrigger>
</SetupServer>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
@@ -263,20 +251,22 @@ export const ShowServers = () => {
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Terminal className="h-4 w-4" />
</Button>
</TooltipTrigger>
</TerminalModal>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
@@ -284,19 +274,14 @@ export const ShowServers = () => {
)}
<Tooltip>
<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>
<TooltipTrigger asChild>
<div>
<HandleServers
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Edit Server</p>
</TooltipContent>
@@ -304,19 +289,14 @@ export const ShowServers = () => {
{server.sshKeyId && !isBuildServer && (
<Tooltip>
<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>
<TooltipTrigger asChild>
<div>
<ShowServerActions
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Web Server Actions</p>
</TooltipContent>
@@ -327,23 +307,17 @@ export const ShowServers = () => {
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<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>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
@@ -354,20 +328,22 @@ export const ShowServers = () => {
{permissions?.server.delete && (
<Tooltip>
<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"
<TooltipTrigger asChild>
<div>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
</DeleteServerModal>
<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>
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -124,11 +124,8 @@ export const AddInvitation = () => {
const mode = form.watch("mode");
useEffect(() => {
if (!open) {
form.reset();
setError(null);
}
}, [open, form, form.reset]);
form.reset();
}, [form, form.formState.isSubmitSuccessful, form.reset]);
useEffect(() => {
if (isCloud && form.getValues("mode") === "credentials") {
@@ -228,7 +225,7 @@ export const AddInvitation = () => {
<FormLabel>Invite Method</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
@@ -316,7 +313,7 @@ export const AddInvitation = () => {
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
defaultValue={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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="flex flex-row 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 max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
@@ -45,8 +45,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
term.loadAddon(clipboardAddon);
fixMacOsAltKeys(term);
// @ts-expect-error
// @ts-ignore
term.open(termRef.current);
// @ts-ignore
term.loadAddon(addonFit);
addonFit.fit();
@@ -88,9 +89,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}, [id, serverId]);
return (
<div className="flex flex-col gap-4 h-full w-full">
<div className="flex flex-col gap-4">
<div className="w-full h-full bg-transparent border rounded-lg p-2">
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
<div id={id} ref={termRef} className="rounded-xl" />
</div>
</div>
);
@@ -1,69 +0,0 @@
import { AwsIcon } from "@/components/icons/vault-provider-icons";
interface Props {
className?: string;
}
export const GrafanaLokiIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#F46800"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
</svg>
);
export const DatadogIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#632CA6"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
</svg>
);
export const BetterStackIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
</svg>
);
export const ElasticsearchIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="#00BFB3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
</svg>
);
export const SplunkIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
</svg>
);
export const logProviderIcons = {
loki: GrafanaLokiIcon,
datadog: DatadogIcon,
betterstack: BetterStackIcon,
elasticsearch: ElasticsearchIcon,
splunk_hec: SplunkIcon,
aws_cloudwatch: AwsIcon,
} as const;
@@ -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 { useHubSpotChat } from "../shared/analytics";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
interface Props {
@@ -26,7 +26,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
useHubSpotChat(isChatEnabled);
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
const shouldRedirectToOnboarding =
@@ -53,6 +52,12 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
</>
);
-8
View File
@@ -29,7 +29,6 @@ import {
type LucideIcon,
Package,
Palette,
ScrollText,
Server,
ShieldCheck,
Smartphone,
@@ -375,13 +374,6 @@ const MENU: Menu = {
icon: Package,
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "Log Management",
url: "/dashboard/settings/log-providers",
icon: ScrollText,
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
},
{
isSingle: true,
title: "Secrets",
@@ -96,7 +96,6 @@ const RESOURCE_LABELS: Record<string, string> = {
settings: "Settings",
session: "Session",
network: "Network",
logProvider: "Log Provider",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -180,11 +180,6 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
},
logProvider: {
label: "Log Providers",
description:
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
},
};
/** Descriptions for each action within a resource */
@@ -480,14 +475,6 @@ const ACTION_META: Record<
description: "Remove DNS providers and delete their records",
},
},
logProvider: {
read: { label: "Read", description: "View configured log providers" },
create: {
label: "Create",
description: "Add new log providers and test their connection",
},
delete: { label: "Delete", description: "Remove log providers" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -618,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
@@ -0,0 +1,14 @@
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,11 +92,5 @@ export const useHubSpotChat = (enabled: boolean) => {
loadWidget,
];
}
return () => {
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
(callback) => callback !== loadWidget,
);
window.HubSpotConversations?.widget.remove();
};
}, [enabled]);
};
@@ -0,0 +1,22 @@
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
CREATE TABLE "managed_server" (
"managedServerId" text PRIMARY KEY NOT NULL,
"organizationId" text NOT NULL,
"serverId" text,
"plan" text NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"providerVmId" text,
"zone" text NOT NULL,
"sshKeyId" text,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" text,
"errorMessage" text,
"createdAt" text NOT NULL,
"updatedAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
@@ -1,19 +0,0 @@
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
CREATE TABLE "logProvider" (
"logProviderId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"providerType" "LogProviderType" NOT NULL,
"endpoint" text,
"apiKey" text,
"apiSecret" text,
"extraConfig" jsonb,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
+124 -101
View File
@@ -1,5 +1,5 @@
{
"id": "bcb4d155-4bad-4373-bbfa-73705d3a9714",
"id": "2e24da90-0d77-4655-a4a3-fb4c0e22f364",
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
"version": "7",
"dialect": "postgresql",
@@ -4310,78 +4310,108 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.logProvider": {
"name": "logProvider",
"public.managed_server": {
"name": "managed_server",
"schema": "",
"columns": {
"logProviderId": {
"name": "logProviderId",
"managedServerId": {
"name": "managedServerId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerType": {
"name": "providerType",
"type": "LogProviderType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiKey": {
"name": "apiKey",
"type": "text",
"primaryKey": false,
"notNull": false
},
"apiSecret": {
"name": "apiSecret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"extraConfig": {
"name": "extraConfig",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organizationId": {
"name": "organizationId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serverId": {
"name": "serverId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "managedServerStatus",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"providerVmId": {
"name": "providerVmId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"zone": {
"name": "zone",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sshKeyId": {
"name": "sshKeyId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ipAddress": {
"name": "ipAddress",
"type": "text",
"primaryKey": false,
"notNull": false
},
"hostname": {
"name": "hostname",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripeSubscriptionId": {
"name": "stripeSubscriptionId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"stripePriceId": {
"name": "stripePriceId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updatedAt": {
"name": "updatedAt",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"logProvider_organizationId_organization_id_fk": {
"name": "logProvider_organizationId_organization_id_fk",
"tableFrom": "logProvider",
"managed_server_organizationId_organization_id_fk": {
"name": "managed_server_organizationId_organization_id_fk",
"tableFrom": "managed_server",
"tableTo": "organization",
"columnsFrom": [
"organizationId"
@@ -4391,6 +4421,32 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"managed_server_serverId_server_serverId_fk": {
"name": "managed_server_serverId_server_serverId_fk",
"tableFrom": "managed_server",
"tableTo": "server",
"columnsFrom": [
"serverId"
],
"columnsTo": [
"serverId"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"managed_server_sshKeyId_ssh-key_sshKeyId_fk": {
"name": "managed_server_sshKeyId_ssh-key_sshKeyId_fk",
"tableFrom": "managed_server",
"tableTo": "ssh-key",
"columnsFrom": [
"sshKeyId"
],
"columnsTo": [
"sshKeyId"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -7765,13 +7821,6 @@
"notNull": true,
"default": false
},
"logProviderIds": {
"name": "logProviderIds",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "ARRAY[]::text[]"
},
"buildsConcurrency": {
"name": "buildsConcurrency",
"type": "integer",
@@ -8901,19 +8950,6 @@
"notNull": false,
"default": "'0 0 * * *'"
},
"logProviderIds": {
"name": "logProviderIds",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "ARRAY[]::text[]"
},
"logManagementOrganizationId": {
"name": "logManagementOrganizationId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metricsConfig": {
"name": "metricsConfig",
"type": "jsonb",
@@ -8986,21 +9022,7 @@
}
},
"indexes": {},
"foreignKeys": {
"webServerSettings_logManagementOrganizationId_organization_id_fk": {
"name": "webServerSettings_logManagementOrganizationId_organization_id_fk",
"tableFrom": "webServerSettings",
"tableTo": "organization",
"columnsFrom": [
"logManagementOrganizationId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
@@ -9114,16 +9136,17 @@
"gitea"
]
},
"public.LogProviderType": {
"name": "LogProviderType",
"public.managedServerStatus": {
"name": "managedServerStatus",
"schema": "public",
"values": [
"loki",
"datadog",
"betterstack",
"elasticsearch",
"splunk_hec",
"aws_cloudwatch"
"pending",
"provisioning",
"configuring",
"ready",
"error",
"terminating",
"terminated"
]
},
"public.mountType": {
+2 -2
View File
@@ -1384,8 +1384,8 @@
{
"idx": 197,
"version": "7",
"when": 1791377838843,
"tag": "0197_smiling_madrox",
"when": 1790609715152,
"tag": "0197_aberrant_psynapse",
"breakpoints": true
}
]
-25
View File
@@ -1,25 +0,0 @@
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,7 +48,6 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
+1 -24
View File
@@ -1,30 +1,7 @@
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 } };
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);
}
export default toNodeHandler(auth.handler);
@@ -32,14 +32,6 @@ 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,14 +15,6 @@ import {
logWebhookError,
} from "../[refreshToken]";
export const config = {
api: {
bodyParser: {
sizeLimit: "25mb",
},
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
-8
View File
@@ -26,14 +26,6 @@ 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,
+10 -4
View File
@@ -158,9 +158,11 @@ export default async function handler(
newSubscription.customer as string,
);
// The account may already be gone when the deletion flow cancelled
// the subscription; nothing left to disable.
if (!admin || admin.isEnterpriseCloud) {
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
break;
}
@@ -312,7 +314,11 @@ export default async function handler(
const customer = event.data.object as Stripe.Customer;
const admin = await findUserByStripeCustomerId(customer.id);
if (!admin || admin.isEnterpriseCloud) {
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
break;
}
+6 -4
View File
@@ -12,10 +12,12 @@ function SchedulesPage() {
{(serverId) => (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
</Card>
</div>
)}
@@ -21,7 +21,7 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full 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">
@@ -1,57 +0,0 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowLogManagementServers } from "@/components/dashboard/settings/log-management/show-log-management-servers";
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowLogProviders />
<ShowLogManagementServers />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
@@ -0,0 +1,39 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
const Page = () => {
return <ShowManagedServers />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
}
@@ -5,7 +5,6 @@ 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";
@@ -17,11 +16,10 @@ const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full 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 max-w-5xl mx-auto flex flex-col gap-4">
<div className="h-full rounded-xl w-full flex flex-col gap-4">
<WebDomain />
<WebServer />
<div className="w-full flex flex-col gap-4">

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