Compare commits

..

2 Commits

Author SHA1 Message Date
autofix-ci[bot] ebeb95cefd [autofix.ci] apply automated fixes 2026-08-27 06:42:22 +00:00
Mauricio Siu 2d8dfee9f3 fix(server): keep ${VAR} self-references interpolatable in .env files
Only escape $ signs that are not part of a well-formed ${VAR} reference
when writing .env files, so Docker Compose's own variable interpolation
still works for legitimate ASSET_URL=${APP_URL}-style references, while
literal dollar signs (e.g. in passwords) stay escaped.

Fixes #5151
2026-08-27 00:41:17 -06:00
127 changed files with 1000 additions and 41685 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"mcpServers": {
"dokploy": {
"type": "http",
"url": "${DOKPLOY_BASE_URL}/api/mcp",
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
},
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
}
}
}
-6
View File
@@ -1,6 +0,0 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.
+1 -9
View File
@@ -120,20 +120,12 @@ pnpm run docker:push
## Password Reset
In the case you lost your password, you can reset the owner's password using the following command
In the case you lost your password, you can reset it using the following command
```bash
pnpm run reset-password
```
To reset the password of a specific user instead, pass their email as an argument
```bash
pnpm run reset-password -- user@example.com
```
Both commands print the new randomly generated password to the console.
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
```bash
@@ -1,123 +0,0 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
const cmd = createCommand({
...base,
composePath: "./backend/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
expect(cmd).toContain("-f ./backend/docker-compose.yml");
});
});
describe("compose createCommand --env-file", () => {
it("points --env-file at the generated .env next to a nested compose file", () => {
const cmd = createCommand(
{
...base,
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file deploy/.env");
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
});
it("omits --env-file when createEnvFile is disabled", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
it("uses the code-root .env for raw sourceType", () => {
const cmd = createCommand(
{
...base,
sourceType: "raw",
composePath: "docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("--env-file .env");
});
it("does not add --env-file to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
createEnvFile: true,
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--env-file");
});
});
@@ -32,9 +32,6 @@ const cases: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
@@ -49,9 +46,6 @@ const inputEncoding: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {
@@ -99,4 +93,46 @@ describe("getCreateEnvFileCommand", () => {
expect(actual[key], key).toBe(value);
}
}, 60000);
it("still interpolates a ${VAR} self-reference in the .env file", () => {
mkdirSync(codePath, { recursive: true });
const serviceEnv = [
"APP_URL=https://example.com",
'ASSET_URL="${APP_URL}"',
].join("\n");
const command = getCreateEnvFileCommand({
appName,
composePath: "docker-compose.yml",
env: serviceEnv,
randomize: false,
suffix: "",
serverId: null,
environment: { project: { env: "" }, env: "" },
} as Parameters<typeof getCreateEnvFileCommand>[0]);
execFileSync("bash", ["-c", command]);
const composeFile =
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
const out = execFileSync(
"docker",
[
"compose",
"run",
"--rm",
"-T",
"test",
"sh",
"-c",
"printf '%s' \"$ASSET_URL\"",
],
{ cwd: codePath, encoding: "utf8" },
);
expect(out).toBe("https://example.com");
}, 60000);
});
@@ -50,15 +50,6 @@ describe("getRailpackCommand", () => {
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({
@@ -16,30 +16,6 @@ describe("shouldDeploy", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should apply multiple negated watch paths as one pattern set", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
false,
);
});
it("should deploy when a changed file remains after exclusions", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
});
it("should combine positive and negated watch paths", () => {
const watchPaths = ["src/**", "!src/**/*.test.ts"];
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
@@ -87,224 +87,6 @@ describe("cloudflareClient.listRecords", () => {
});
});
describe("cloudflareClient MX priority", () => {
it("inlines the priority into the content when listing", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "mx-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
ttl: 300,
priority: 20,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.content).toBe("20 mail.example.com");
});
it("splits the priority back out when writing", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "20 mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 20,
});
});
it("falls back to priority 10 when the content has no leading number", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 10,
});
});
it("leaves non-MX content untouched", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "10 not-a-priority",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.content).toBe("10 not-a-priority");
expect(body.priority).toBeUndefined();
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBe(true);
});
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "hello",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
});
it("leaves the proxy status untouched when the caller does not set it", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
});
it("returns the proxy status when listing records", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "a-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
proxied: true,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.proxied).toBe(true);
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
-211
View File
@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
const jsonResponse = (body: unknown, ok = true, status = 200) =>
({
ok,
status,
json: async () => body,
}) as Response;
const pbSuccess = (result: Record<string, unknown> = {}) =>
jsonResponse({ status: "SUCCESS", ...result });
const pbError = (message: string, status = 400) =>
jsonResponse({ status: "ERROR", message }, false, status);
const config = {
providerType: "porkbun" as const,
apiKey: "pk1_test",
secretApiKey: "sk1_test",
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("porkbunClient.listZones", () => {
it("lists all domains as zones", async () => {
mockFetch.mockResolvedValue(
pbSuccess({ domains: [{ domain: "example.com" }] }),
);
const zones = await porkbunClient.listZones(config);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/domain/listAll");
const body = JSON.parse(init.body as string);
expect(body).toMatchObject({
apikey: "pk1_test",
secretapikey: "sk1_test",
});
});
});
describe("porkbunClient.listRecords", () => {
it("lists records for a domain", async () => {
mockFetch.mockResolvedValue(
pbSuccess({
records: [
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: "600",
prio: "0",
notes: "",
},
],
}),
);
const records = await porkbunClient.listRecords(config, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
]);
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
});
});
describe("porkbunClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "new-1" });
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
string,
RequestInit,
];
expect(createUrl).toContain("/dns/create/example.com");
const body = JSON.parse(createInit.body as string);
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
});
it("resolves the apex domain to an empty subdomain", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "example.com",
content: "1.2.3.4",
});
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
});
it("edits the existing record instead of creating a duplicate", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
.mockResolvedValueOnce(pbSuccess({}));
const result = await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "5.6.7.8",
});
expect(result).toEqual({ id: "existing-1" });
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
});
it("defaults ttl to 600 when not provided", async () => {
mockFetch
.mockResolvedValueOnce(pbSuccess({ records: [] }))
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
await porkbunClient.upsertRecord(config, {
zoneId: "example.com",
type: "CNAME",
name: "www.example.com",
content: "example.com",
});
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(createInit.body as string);
expect(body.ttl).toBe(600);
});
});
describe("porkbunClient.updateRecord", () => {
it("edits the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
const result = await porkbunClient.updateRecord(
config,
"example.com",
"1",
{
type: "A",
name: "app.example.com",
content: "9.9.9.9",
ttl: 300,
},
);
expect(result).toEqual({ id: "1" });
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toContain("/dns/edit/example.com/1");
expect(JSON.parse(init.body as string)).toMatchObject({
name: "app",
type: "A",
content: "9.9.9.9",
ttl: 300,
});
});
});
describe("porkbunClient.deleteRecord", () => {
it("posts to the delete endpoint for the given record id", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await porkbunClient.deleteRecord(config, "example.com", "1");
const [url] = mockFetch.mock.calls[0] as [string];
expect(url).toContain("/dns/delete/example.com/1");
});
});
describe("porkbunClient.testConnection", () => {
it("succeeds when the credentials can ping the API", async () => {
mockFetch.mockResolvedValue(pbSuccess({}));
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
});
it("surfaces Porkbun's error message on invalid credentials", async () => {
mockFetch.mockResolvedValue(pbError("Invalid API key."));
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
"Invalid API key.",
);
});
});
+4 -101
View File
@@ -142,15 +142,13 @@ describe("route53Client.listRecords", () => {
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
@@ -160,7 +158,7 @@ describe("route53Client.upsertRecord", () => {
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[1]?.[0] as HasInput;
const command = send.mock.calls[0]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
@@ -174,82 +172,6 @@ describe("route53Client.upsertRecord", () => {
},
]);
});
it("keeps the values already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "3.3.3.3",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "1.1.1.1" },
{ Value: "2.2.2.2" },
{ Value: "3.3.3.3" },
]);
});
it("does not duplicate a value that is already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.1.1.1",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: "1.1.1.1" }]);
});
it("wraps unquoted TXT values in double quotes", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "TXT",
name: "example.com",
content: 'v=spf1 ~all\n"already quoted"',
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
});
});
describe("route53Client.updateRecord", () => {
@@ -300,25 +222,6 @@ describe("route53Client.updateRecord", () => {
]);
});
it("keeps every line of a multi-value record set", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
type: "NS",
name: "example.com",
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
});
const command = send.mock.calls[0]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
{ Value: "ns3.example.com" },
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
+51
View File
@@ -1,5 +1,6 @@
import {
prepareEnvironmentVariables,
prepareEnvironmentVariablesForFile,
prepareEnvironmentVariablesForShell,
} from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
@@ -642,3 +643,53 @@ SPECIAL=café résumé naïve
expect(resolved[2]).toContain("café");
});
});
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
const serviceEnv = `
APP_URL=https://example.com
ASSET_URL=\${APP_URL}
`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([
`APP_URL="https://example.com"`,
`ASSET_URL="\${APP_URL}"`,
]);
});
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
});
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
const serviceEnv = "PASSWORD=pa$word";
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
});
it("escapes a trailing dollar sign", () => {
const serviceEnv = "PRICE=100$";
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([`PRICE="100\\$"`]);
});
it("still escapes double quotes and backslashes", () => {
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
expect(resolved).toEqual([
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
]);
});
});
-127
View File
@@ -20,7 +20,6 @@ import {
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
import { phaseClient } from "@dokploy/server/utils/vault/phase";
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
const mockFetch = vi.fn();
@@ -617,129 +616,3 @@ describe("scaleway client", () => {
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
describe("phase client", () => {
const config = {
providerType: "phase" as const,
token: "phase-rest-token",
appId: "app-123",
env: "Production",
path: "/",
apiUrl: "https://api.phase.dev",
};
it("fetches secrets by key and sends ServiceAccount auth", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "postgres://real", path: "/" },
{ key: "API_KEY", value: "key-123", path: "/" },
]),
);
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer ServiceAccount phase-rest-token",
);
});
it("throws when a requested secret is missing", async () => {
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
);
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
'secret "MISSING" not found in environment "Production"',
);
});
it("reports authentication failures with the status code", async () => {
mockFetch.mockResolvedValue(
jsonResponse({ error: "unauthorized" }, false, 401),
);
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
"authentication failed (status 401: unauthorized)",
);
});
it("rejects apps without SSE enabled during testConnection", async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: false,
}),
);
await expect(phaseClient.testConnection(config)).rejects.toThrow(
"enable Server-side Encryption (SSE)",
);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
});
it("tests connection against the app then secrets listing", async () => {
mockFetch
.mockResolvedValueOnce(
jsonResponse({
id: "app-123",
name: "My App",
sseEnabled: true,
}),
)
.mockResolvedValueOnce(jsonResponse([]));
await phaseClient.testConnection(config);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe(
"https://api.phase.dev/v1/apps/app-123/",
);
expect(mockFetch.mock.calls[1]?.[0]).toBe(
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
);
});
it("lists secret names from the configured path", async () => {
mockFetch.mockResolvedValue(
jsonResponse([
{ key: "DB_URL", value: "x", path: "/" },
{ key: "API_KEY", value: "y", path: "/" },
]),
);
const names = await phaseClient.listSecretNames?.(config);
expect(names).toEqual(["DB_URL", "API_KEY"]);
});
it("resolves env refs end to end through a phase provider", async () => {
findMany.mockResolvedValue([
{
name: "phase-prod",
providerType: "phase",
config,
assignments: assignedEverywhere,
},
]);
mockFetch.mockResolvedValue(
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
);
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
@@ -126,13 +126,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied server.terminal", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
@@ -105,7 +105,6 @@ describe("enterprise resources for static roles", () => {
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.server.terminal).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
@@ -1,105 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (role: string) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
accessedServers: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn = mockMemberData("deployer");
let rolesToReturn: { permission: string }[] = [];
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(true)),
}));
const { checkPermission, resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const withPermissions = (permissions: Record<string, string[]>) => {
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
};
beforeEach(() => {
vi.clearAllMocks();
memberToReturn = mockMemberData("deployer");
rolesToReturn = [];
});
describe("server.terminal on custom roles", () => {
it("a role with server.read alone cannot open a terminal", async () => {
withPermissions({ server: ["read"] });
await expect(
checkPermission(ctx, { server: ["read"] }),
).resolves.toBeUndefined();
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(true);
expect(perms.server.terminal).toBe(false);
});
it("a role with server.terminal can open a terminal", async () => {
withPermissions({ server: ["read", "terminal"] });
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
});
it("owner and admin keep terminal access", async () => {
for (const role of ["owner", "admin"]) {
memberToReturn = mockMemberData(role);
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
}
});
});
@@ -64,21 +64,6 @@ const serviceExists = async (name: string) => {
}
};
// Swarm keeps converging a service for a bit after it's created (scheduling
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
// setupMonitoring again before that settles races that internal bump, so wait
// for two consecutive reads to agree before treating the service as stable.
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
const deadline = Date.now() + timeoutMs;
let lastIndex: string | null = null;
while (Date.now() < deadline) {
const inspect = await docker.getService(name).inspect();
if (inspect.Version.Index === lastIndex) return;
lastIndex = inspect.Version.Index;
await new Promise((resolve) => setTimeout(resolve, 50));
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
@@ -208,7 +193,6 @@ describe.skipIf(hasRealMonitoring())(
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await waitForServiceConvergence(SERVICE_NAME);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
@@ -1,121 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
writeFileRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
writeFileRemote: mocks.writeFileRemote,
};
});
describe("writeAppTraefikConfig", () => {
let cwd: string;
let dynamicPath: string;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
// Regression test for #5189: Traefik's file provider rejects a standalone
// `routers: {}` / `services: {}` map and aborts its watcher for every
// dynamic config once it hits one, so an app with no domains must never
// get an on-disk config file at all.
it("removes the file instead of writing empty routers/services", async () => {
const appName = "no-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
fs.writeFileSync(configPath, "stale content", "utf8");
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
appName,
);
expect(fs.existsSync(configPath)).toBe(false);
});
it("writes the file when routers/services are present", async () => {
const appName = "with-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
await writeAppTraefikConfig(
{
http: {
routers: {
[`${appName}-router-1`]: {
rule: "Host(`x`)",
service: `${appName}-service`,
},
},
services: {},
},
},
appName,
);
expect(fs.existsSync(configPath)).toBe(true);
});
it("removes the remote file instead of writing empty routers/services", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
"no-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^rm -f /);
expect(command).toContain("no-domain-app.yml");
});
it("writes the remote file when routers/services are present", async () => {
mocks.writeFileRemote.mockResolvedValue(undefined);
await writeAppTraefikConfig(
{
http: {
routers: {
"with-domain-app-router-1": {
rule: "Host(`x`)",
service: "with-domain-app-service",
},
},
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
const [serverId, remotePath, content] =
mocks.writeFileRemote.mock.calls[0] ?? [];
expect(serverId).toBe("server-id");
expect(remotePath).toContain("with-domain-app.yml");
expect(content).toContain("with-domain-app-router-1");
});
});
+1 -26
View File
@@ -95,35 +95,10 @@ describe("canAccessTerminalOverWss", () => {
});
it("gates a remote server terminal on server access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// the remote path must never fall through to the owner/admin local branch
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
it("denies a remote server terminal without the server.terminal permission", async () => {
// Reaching a server (to deploy on it) must not imply a root shell on it.
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(false);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
expect(mockHasPermission).toHaveBeenCalledWith(
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
{ server: ["terminal"] },
);
});
it("allows a remote server terminal with the server.terminal permission", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("does not check permissions for a server the caller cannot access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
expect(mockHasPermission).not.toHaveBeenCalled();
});
});
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
<span className="text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}
@@ -191,7 +191,8 @@ export const ShowDomains = ({ id, type }: Props) => {
try {
const result = await validateDomain({
domain: host,
serverId: application?.serverId ?? undefined,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
});
setValidationStates((prev) => ({
@@ -6,7 +6,6 @@ import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -198,18 +197,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
name="environment"
render={({ field }) => (
<FormItem>
<div className="flex justify-end">
<VaultImportDialog
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("environment", next, {
shouldDirty: true,
})
}
/>
</div>
<FormControl className="">
<CodeEditor
style={
@@ -151,8 +151,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
completionSource={completionSource}
projectId={data?.environment?.projectId}
environmentId={data?.environment?.environmentId}
/>
{data?.buildType === "dockerfile" && (
<Secrets
@@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
@@ -55,7 +55,6 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "swarm",
refetchInterval: 5000,
},
);
@@ -68,7 +67,6 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "native",
refetchInterval: 5000,
},
);
@@ -2,7 +2,6 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -53,15 +52,14 @@ export const ShowDockerLogsCompose = ({
},
{
enabled: !!appName,
refetchInterval: 5000,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, data),
);
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
}, [data]);
return (
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="h-9 w-full sm:w-auto"
className="h-9"
onClick={handlePauseResume}
title={isPaused ? "Resume logs" : "Pause logs"}
>
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 w-full sm:w-auto"
className="h-9"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 w-full sm:w-auto"
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
@@ -64,9 +64,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
/>
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -90,7 +88,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@@ -106,7 +104,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
{formattedTime}
</span>
)}
@@ -1,6 +1,5 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
@@ -168,8 +167,6 @@ export const ContainerFreeMonitoring = ({
}, [data]);
useEffect(() => {
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
@@ -199,9 +196,7 @@ export const ContainerFreeMonitoring = ({
};
ws.onclose = (e) => {
if (e.reason) {
toast.error(e.reason);
}
console.log(e.reason);
};
return () => ws.close();
@@ -19,11 +19,6 @@ interface NetworkChartProps {
data: any[];
}
function formatNetworkGB(valueInMB: number) {
if (Number.isNaN(valueInMB)) return "0";
return (valueInMB / 1024).toFixed(1);
}
const chartConfig = {
networkIn: {
label: "Network In",
@@ -43,8 +38,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
{latestData.networkIn} KB/s
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
@@ -88,7 +83,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
@@ -110,8 +105,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
Network
</span>
<span className="font-bold">
↑ {formatNetworkGB(data.networkOut)} GB
<br />↓ {formatNetworkGB(data.networkIn)} GB
↑ {data.networkOut} KB/s
<br />↓ {data.networkIn} KB/s
</span>
</div>
</div>
@@ -34,7 +34,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const resyncMutation = api.network.resync.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
@@ -103,20 +102,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
}
};
const onResync = async (networkId: string, name: string) => {
try {
await resyncMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" updated from Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error updating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
@@ -192,45 +177,6 @@ export const SyncNetworks = ({ serverId }: Props) => {
)}
</div>
{!!data?.changed.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Changed ({data.changed.length})
</span>
<span className="text-xs text-muted-foreground">
These networks were deleted and recreated in Docker under
the same name — attributes in Dokploy are outdated.
</span>
{data.changed.map((changed) => (
<div
key={changed.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm">{changed.name}</span>
{changed.driver && (
<Badge variant="outline">{changed.driver}</Badge>
)}
</div>
<Button
variant="outline"
size="xs"
isLoading={resyncMutation.isPending}
onClick={() =>
onResync(changed.networkId, changed.name)
}
>
<RotateCcw className="size-3.5" />
Update
</Button>
</div>
))}
</div>
</>
)}
{!!data?.missing.length && (
<>
<Separator />
@@ -48,7 +48,6 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@@ -7,7 +7,6 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -157,17 +156,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={data?.projectId}
environmentId={environmentId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -7,7 +7,6 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -153,16 +152,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Environment variables</FormLabel>
<VaultImportDialog
projectId={projectId}
currentEnv={field.value ?? ""}
onImport={(next) =>
form.setValue("env", next, { shouldDirty: true })
}
/>
</div>
<FormLabel>Environment variables</FormLabel>
<FormControl>
<CodeEditor
completionSource={completionSource}
@@ -17,13 +17,11 @@ import {
Download,
Globe,
InfoIcon,
Loader2,
Server,
TrendingUpIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -102,12 +100,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
pageSize: 10,
});
const {
data: statsLogs,
isLoading,
isError,
error,
} = api.settings.readStatsLogs.useQuery(
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
{
sort: sorting[0],
page: pagination,
@@ -280,18 +273,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
colSpan={columns.length}
className="h-24 text-center"
>
{isLoading ? (
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading requests...</span>
</div>
) : isError ? (
<div className="w-full flex items-center justify-center h-[55vh]">
<AlertBlock type="error" className="w-full">
{error?.message}
</AlertBlock>
</div>
) : (
{statsLogs?.data.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No results.
@@ -1,421 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cloud, CloudOff, XIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
export const DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
type RecordType = (typeof DNS_RECORD_TYPES)[number];
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
const valueFields: Record<
RecordType,
{ label: string; placeholder: string; hint?: string }
> = {
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
CNAME: { label: "Target", placeholder: "app.example.com" },
MX: {
label: "Mail server",
placeholder: "10 mail.example.com",
hint: "Start with the priority, then the mail server.",
},
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
SRV: {
label: "Target",
placeholder: "1 10 5269 talk.example.com",
hint: "Priority, weight, port, then target.",
},
CAA: {
label: "Value",
placeholder: '0 issue "letsencrypt.org"',
hint: "Flags, tag, then the quoted value.",
},
PTR: { label: "Target", placeholder: "host.example.com" },
};
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
CAA: /^\d+\s+\S+\s+.+$/,
};
const DnsRecordSchema = z
.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
const values = data.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!values.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: "Content is required",
});
return;
}
const pattern = structuredValuePatterns[data.type];
if (pattern && !values.every((value) => pattern.test(value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: `Expected ${valueFields[data.type].placeholder}`,
});
}
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
export interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record: DnsRecordValue | null;
onClose: () => void;
}
export const DnsRecordPanel = ({
dnsProviderId,
zoneId,
zoneName,
record,
onClose,
}: Props) => {
const utils = api.useUtils();
const createRecord = api.dnsProvider.createRecord.useMutation();
const updateRecord = api.dnsProvider.updateRecord.useMutation();
const { mutateAsync, isPending, error, isError } = record
? updateRecord
: createRecord;
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: servers } = api.server.all.useQuery();
const { data: panelPublicIp } = api.server.publicIp.useQuery();
const { data: panelStoredIp } = api.settings.getIp.useQuery();
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
? record.type
: "A") as RecordType,
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
proxied: record.proxied ?? false,
}
: { type: "A", name: "", content: "", ttl: "", proxied: false },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const proxied = form.watch("proxied");
const canProxy =
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
const supportsMultipleValues = provider?.providerType === "route53";
const usesAutomaticTtl = canProxy && proxied;
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const isProxied = canProxy && data.proxied;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
...(canProxy && { proxied: data.proxied }),
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
onClose();
})
.catch(() => {});
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{record ? "Edit record" : "New record"}
</span>
<span className="text-xs text-muted-foreground">{zoneName}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Close panel"
>
<XIcon className="size-4" />
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{DNS_RECORD_TYPES.map((recordType) => (
<SelectItem key={recordType} value={recordType}>
{recordType}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{valueFields[type].label}</FormLabel>
<FormControl>
{supportsMultipleValues ? (
<Textarea
className="min-h-[60px] font-mono text-xs"
placeholder={valueFields[type].placeholder}
{...field}
/>
) : (
<Input
placeholder={valueFields[type].placeholder}
{...field}
/>
)}
</FormControl>
{(supportsMultipleValues || valueFields[type].hint) && (
<FormDescription>
{[
valueFields[type].hint,
supportsMultipleValues &&
"One value per line: every line belongs to the same record set.",
]
.filter(Boolean)
.join(" ")}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{canProxy && (
<FormField
control={form.control}
name="proxied"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy status</FormLabel>
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
<legend className="sr-only">Proxy status</legend>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
field.value && "translate-x-full",
)}
/>
<button
type="button"
aria-pressed={!field.value}
onClick={() => field.onChange(false)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
<CloudOff className="size-3.5" />
DNS only
</button>
<button
type="button"
aria-pressed={field.value}
onClick={() => field.onChange(true)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-[#f6821f]"
: "text-muted-foreground hover:text-foreground",
)}
>
<Cloud className="size-3.5" />
Proxied
</button>
</fieldset>
<FormDescription>
{field.value
? "Traffic runs through Cloudflare and the origin IP stays hidden."
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
className="tabular-nums"
disabled={usesAutomaticTtl}
{...field}
/>
</FormControl>
{usesAutomaticTtl && (
<FormDescription>
Proxied records always use automatic TTL.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
</div>
);
};
@@ -1,33 +0,0 @@
import { cn } from "@/lib/utils";
const recordTypeStyles: Record<string, string> = {
A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
CNAME:
"bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
};
interface Props {
type: string;
className?: string;
}
export const DnsRecordTypeBadge = ({ type, className }: Props) => (
<span
className={cn(
"inline-flex h-6 min-w-16 shrink-0 items-center justify-center rounded-md px-2 font-mono text-[11px] font-semibold tracking-wide ring-1 ring-inset",
recordTypeStyles[type.toUpperCase()] ??
"bg-foreground/5 text-muted-foreground ring-foreground/15",
className,
)}
>
{type.toUpperCase()}
</span>
);
@@ -33,17 +33,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const providerLabels = {
cloudflare: "Cloudflare",
route53: "AWS Route53",
porkbun: "Porkbun",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -55,12 +49,10 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
providerType: z.enum(["cloudflare", "route53"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -71,8 +63,6 @@ const defaultValues: DnsProviderForm = {
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -88,12 +78,6 @@ const buildConfig = (data: DnsProviderForm) => {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
};
case "porkbun":
return {
providerType: "porkbun" as const,
apiKey: data.apiKey,
secretApiKey: data.secretApiKey,
};
}
};
@@ -151,10 +135,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
}),
...(provider.config.providerType === "porkbun" && {
apiKey: provider.config.apiKey,
secretApiKey: provider.config.secretApiKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@@ -200,30 +180,22 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{dnsProviderId ? (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
>
<PenBoxIcon className="size-4" />
<span className="sr-only">Edit provider</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>Edit provider</TooltipContent>
</Tooltip>
) : (
<DialogTrigger asChild>
<Button>
<PlusIcon className="size-4" />
<DialogTrigger asChild>
{dnsProviderId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button className="cursor-pointer space-x-3">
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
</DialogTrigger>
)}
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
@@ -347,42 +319,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</>
)}
{providerType === "porkbun" && (
<>
<FormField
control={form.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretApiKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret API Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create API keys at porkbun.com/account/api and make sure
API access is enabled for the domains you want Dokploy
to manage.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
@@ -0,0 +1,271 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({
dnsProviderId,
zoneId,
zoneName,
record,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,218 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
zoneName: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
→ {record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
/>
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
zoneName={zone.name}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,9 +1,8 @@
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Globe, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -12,13 +11,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
@@ -26,142 +21,121 @@ const providerLabels: Record<string, string> = {
};
export const ShowDnsProviders = () => {
const [removingId, setRemovingId] = useState<string | null>(null);
const { mutateAsync } = api.dnsProvider.remove.useMutation();
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<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">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<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 min-h-[50vh] flex-col items-center justify-center gap-3">
<Globe className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No DNS providers connected
</span>
<span className="max-w-sm text-center text-sm text-muted-foreground">
Add Cloudflare or Route53 credentials to manage domain records
without leaving Dokploy.
</span>
</div>
) : (
<ul className="flex flex-col gap-2">
{data?.map((provider) => {
const ProviderIcon = dnsProviderIcons[provider.providerType];
const href = `/dashboard/settings/dns/${provider.dnsProviderId}`;
return (
<li
key={provider.dnsProviderId}
className="group relative flex items-center gap-3 rounded-lg border bg-background px-4 py-3 transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/50 focus-within:border-ring"
>
<Link
href={href}
aria-label={`View domains for ${provider.name}`}
className="absolute inset-0 rounded-lg outline-none"
/>
<ProviderIcon className="size-7 shrink-0" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{provider.name}
</span>
<span className="text-xs text-muted-foreground">
{providerLabels[provider.providerType] ??
provider.providerType}
</span>
</div>
<div className="relative z-10 ml-auto flex flex-row items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
asChild
>
<Link href={href}>
<EyeIcon className="size-4" />
<span className="sr-only">View domains</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View domains</TooltipContent>
</Tooltip>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<Tooltip>
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
setRemovingId(provider.dnsProviderId);
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
})
.finally(() => setRemovingId(null));
}}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={
removingId === provider.dnsProviderId
}
>
<Trash2 className="size-4" />
<span className="sr-only">
Delete provider
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
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 className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete provider</TooltipContent>
</Tooltip>
)}
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS 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>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
</div>
</li>
);
})}
</ul>
)}
</div>
)}
</>
)}
</CardContent>
</div>
@@ -1,589 +0,0 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowLeft,
ArrowUpDown,
Cloud,
CloudOff,
ListTree,
Loader2,
PenBoxIcon,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import {
DNS_RECORD_TYPES,
DnsRecordPanel,
type DnsRecordValue,
PROXIABLE_TYPES,
} from "./dns-record-panel";
import { DnsRecordTypeBadge } from "./dns-record-type-badge";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const PAGE_SIZES = [10, 20, 50, 100];
const SortableHeader = ({
column,
title,
className,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
className?: string;
}) => (
<Button
variant="ghost"
size="xs"
className={cn("-ml-2.5 text-muted-foreground", className)}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="size-3" />
</Button>
);
export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
const utils = api.useUtils();
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editing, setEditing] = useState<DnsRecordValue | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord } =
api.dnsProvider.deleteRecord.useMutation();
const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? "";
const isCloudflare = provider?.providerType === "cloudflare";
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
const openEdit = (record: DnsRecordValue) => {
setEditing(record);
setIsPanelOpen(true);
};
const availableTypes = useMemo(
() => [...new Set((data ?? []).map((record) => record.type))].sort(),
[data],
);
const filteredRecords = useMemo(() => {
const query = search.trim().toLowerCase();
return (data ?? []).filter((record) => {
if (typeFilter !== "all" && record.type !== typeFilter) return false;
if (!query) return true;
return (
record.name.toLowerCase().includes(query) ||
record.content.toLowerCase().includes(query) ||
record.type.toLowerCase().includes(query)
);
});
}, [data, search, typeFilter]);
const handleDelete = async (record: DnsRecordValue) => {
setDeletingId(record.id);
await deleteRecord({ dnsProviderId, zoneId, recordId: record.id })
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
if (editing?.id === record.id) setIsPanelOpen(false);
})
.catch(() => {
toast.error("Error deleting the record");
})
.finally(() => setDeletingId(null));
};
const columns = useMemo<ColumnDef<DnsRecordValue>[]>(
() => [
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <DnsRecordTypeBadge type={row.original.type} />,
},
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<span
className="block max-w-[22ch] truncate font-medium"
title={row.original.name}
>
{row.original.name}
</span>
),
},
{
accessorKey: "content",
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
cell: ({ row }) => (
<span
className="block max-w-[32ch] truncate font-mono text-xs text-muted-foreground"
title={row.original.content}
>
{row.original.content}
</span>
),
},
{
accessorKey: "ttl",
header: ({ column }) => <SortableHeader column={column} title="TTL" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground tabular-nums">
{row.original.ttl === 1 ? "Auto" : row.original.ttl}
</span>
),
},
...(isCloudflare
? [
{
accessorKey: "proxied",
header: ({ column }) => (
<SortableHeader column={column} title="Proxy" />
),
cell: ({ row }) => {
if (!PROXIABLE_TYPES.includes(row.original.type)) {
return null;
}
const proxied = !!row.original.proxied;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
{proxied ? (
<Cloud className="size-4 text-[#f6821f]" />
) : (
<CloudOff className="size-4 text-muted-foreground" />
)}
<span className="sr-only">
{proxied ? "Proxied" : "DNS only"}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{proxied ? "Proxied" : "DNS only"}
</TooltipContent>
</Tooltip>
);
},
} satisfies ColumnDef<DnsRecordValue>,
]
: []),
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const record = row.original;
const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes(
record.type,
);
return (
<div className="flex items-center justify-end gap-1">
{canWrite && isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => openEdit(record)}
>
<PenBoxIcon className="size-4" />
<span className="sr-only">Edit record</span>
</Button>
</TooltipTrigger>
<TooltipContent>Edit record</TooltipContent>
</Tooltip>
)}
{canDelete && (
<Tooltip>
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={() => handleDelete(record)}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={deletingId === record.id}
>
<Trash2 className="size-4" />
<span className="sr-only">Delete record</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete record</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
],
[canWrite, canDelete, deletingId, editing?.id, isCloudflare],
);
const pageCount = Math.max(
1,
Math.ceil(filteredRecords.length / pagination.pageSize),
);
const pageIndex = Math.min(pagination.pageIndex, pageCount - 1);
const resetToFirstPage = () =>
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
const table = useReactTable({
data: filteredRecords,
columns,
getRowId: (row) => row.id,
state: {
sorting,
pagination: { pageIndex, pageSize: pagination.pageSize },
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<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">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href={`/dashboard/settings/dns/${dnsProviderId}`}>
<ArrowLeft className="size-4" />
<span className="sr-only">Back to domains</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{zoneName || "DNS records"}
</CardTitle>
<CardDescription>
Records managed through {provider?.name ?? "this provider"}.
Changes are written straight to the provider.
</CardDescription>
</CardHeader>
</div>
{canWrite && (
<Button
onClick={() => {
setEditing(null);
setIsPanelOpen(true);
}}
>
<PlusIcon className="size-4" />
Add Record
</Button>
)}
</div>
<CardContent className="min-h-[60vh] border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col-reverse gap-4 lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-4">
{isPending ? (
<div className="flex min-h-[45vh] flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<ListTree className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">
No records in this zone
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add an A or CNAME record to point this domain at one of
your servers.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-52 flex-1">
<FocusShortcutInput
placeholder="Filter records..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
resetToFirstPage();
}}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
resetToFirstPage();
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{availableTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRecords.length} of {data?.length ?? 0}
</span>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="[&_tr]:border-b">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-muted/40 hover:bg-muted/40"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn(
"h-9 px-4 text-xs",
header.id === "actions" && "text-right",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => {
const isEditable = (
DNS_RECORD_TYPES as readonly string[]
).includes(row.original.type);
const isSelected =
isPanelOpen && editing?.id === row.original.id;
return (
<TableRow
key={row.id}
data-state={
isSelected ? "selected" : undefined
}
onClick={
canWrite && isEditable
? () => openEdit(row.original)
: undefined
}
className={cn(
"duration-150 ease-out",
canWrite && isEditable && "cursor-pointer",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
onClick={
cell.column.id === "actions"
? (event) => event.stopPropagation()
: undefined
}
className="px-4 py-2"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No records match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) =>
setPagination({
pageIndex: 0,
pageSize: Number(value),
})
}
>
<SelectTrigger size="sm" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground tabular-nums">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
<div
className={cn(
"t-panel-track grid lg:shrink-0 lg:grid-rows-[1fr]",
isPanelOpen
? "grid-rows-[1fr] lg:grid-cols-[1fr]"
: "grid-rows-[0fr] lg:grid-cols-[0fr]",
)}
>
<div className="overflow-hidden">
<div
data-open={isPanelOpen}
className="t-panel-slide-x w-full lg:w-[380px]"
>
{canWrite && (
<DnsRecordPanel
key={editing?.id ?? "new"}
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={editing}
onClose={() => setIsPanelOpen(false)}
/>
)}
</div>
</div>
</div>
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,132 +0,0 @@
import { ArrowLeft, Globe, Loader2 } from "lucide-react";
import Link from "next/link";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
interface Props {
dnsProviderId: string;
}
const RecordCount = ({
dnsProviderId,
zoneId,
}: {
dnsProviderId: string;
zoneId: string;
}) => {
const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({
dnsProviderId,
zoneId,
});
if (isPending) {
return <Loader2 className="animate-spin size-4 text-muted-foreground" />;
}
if (isError) {
return (
<span className="text-xs text-muted-foreground">Records unavailable</span>
);
}
return (
<span className="text-xs text-muted-foreground tabular-nums">
{data.length === 0
? "No records"
: `${data.length} record${data.length === 1 ? "" : "s"}`}
</span>
);
};
export const ShowDnsZones = ({ dnsProviderId }: Props) => {
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard/settings/dns">
<ArrowLeft className="size-4" />
<span className="sr-only">Back to DNS providers</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{provider?.name ?? "Domains"}
</CardTitle>
<CardDescription>
Domains this provider's credentials can manage. Open one to
see and edit its DNS records.
</CardDescription>
</CardHeader>
</div>
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isPending ? (
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Globe className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No domains found</p>
<p className="max-w-sm text-sm text-muted-foreground">
These credentials can't reach any zone. Check that the token
has access to at least one domain.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{data?.map((zone) => (
<Link
key={zone.id}
href={`/dashboard/settings/dns/${dnsProviderId}/${zone.id}`}
className="group flex flex-col justify-between gap-6 rounded-xl border bg-background p-4 outline-none transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/40 focus-visible:border-ring"
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors duration-150 group-hover:text-foreground">
<Globe className="size-4" />
</span>
<span
className="min-w-0 flex-1 truncate pt-2 text-sm font-medium"
title={zone.name}
>
{zone.name}
</span>
</div>
<div className="flex min-h-4 items-center justify-end">
<RecordCount
dnsProviderId={dnsProviderId}
zoneId={zone.id}
/>
</div>
</Link>
))}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -446,7 +446,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverUrl: notification.gotify?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "ntfy") {
form.reset({
@@ -682,7 +681,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: data.name,
dockerCleanup: dockerCleanup,
decoration: data.decoration,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
@@ -700,7 +698,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
@@ -1,208 +0,0 @@
import { ExternalLink, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
serverId: string;
serverName: string;
children: React.ReactNode;
}
const serviceTypeLabel: Record<string, string> = {
application: "Application",
compose: "Compose",
postgres: "Postgres",
mysql: "MySQL",
mariadb: "MariaDB",
mongo: "MongoDB",
redis: "Redis",
libsql: "LibSQL",
};
export const DeleteServerModal = ({
serverId,
serverName,
children,
}: Props) => {
const [open, setOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const utils = api.useUtils();
const {
data: services,
isLoading,
refetch,
} = api.server.getServices.useQuery({ serverId }, { enabled: open });
const { mutateAsync: deleteApplication } =
api.application.delete.useMutation();
const { mutateAsync: deleteCompose } = api.compose.delete.useMutation();
const { mutateAsync: deletePostgres } = api.postgres.remove.useMutation();
const { mutateAsync: deleteMysql } = api.mysql.remove.useMutation();
const { mutateAsync: deleteMariadb } = api.mariadb.remove.useMutation();
const { mutateAsync: deleteMongo } = api.mongo.remove.useMutation();
const { mutateAsync: deleteRedis } = api.redis.remove.useMutation();
const { mutateAsync: deleteLibsql } = api.libsql.remove.useMutation();
const { mutateAsync: deleteServer, isPending: isDeletingServer } =
api.server.remove.useMutation();
const canDelete = (services?.length ?? 0) === 0;
const handleDeleteService = async (
service: NonNullable<typeof services>[number],
) => {
setDeletingId(service.id);
try {
switch (service.type) {
case "application":
await deleteApplication({ applicationId: service.id });
break;
case "compose":
await deleteCompose({
composeId: service.id,
deleteVolumes: false,
});
break;
case "postgres":
await deletePostgres({ postgresId: service.id });
break;
case "mysql":
await deleteMysql({ mysqlId: service.id });
break;
case "mariadb":
await deleteMariadb({ mariadbId: service.id });
break;
case "mongo":
await deleteMongo({ mongoId: service.id });
break;
case "redis":
await deleteRedis({ redisId: service.id });
break;
case "libsql":
await deleteLibsql({ libsqlId: service.id });
break;
}
toast.success(`${service.name} deleted successfully`);
await refetch();
utils.server.all.invalidate();
} catch (error: any) {
toast.error(error.message);
} finally {
setDeletingId(null);
}
};
const handleDeleteServer = async () => {
try {
await deleteServer({ serverId });
toast.success(`Server ${serverName} deleted successfully`);
setOpen(false);
utils.server.all.invalidate();
} catch (error: any) {
toast.error(error.message);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Delete Server</DialogTitle>
<DialogDescription>
This will permanently delete "{serverName}" and all associated data.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : canDelete ? (
<AlertBlock type="info">
No services are associated with this server. You can delete it
safely.
</AlertBlock>
) : (
<div className="flex flex-col gap-3">
<AlertBlock type="warning">
This server has {services?.length} service
{services?.length === 1 ? "" : "s"} associated. Delete them before
removing the server.
</AlertBlock>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto pr-1">
{services?.map((service) => (
<div
key={`${service.type}-${service.id}`}
className="flex items-center justify-between gap-2 rounded-lg border p-2"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-sm font-medium">
{service.name}
</span>
<Badge variant="outline" className="w-fit text-xs">
{serviceTypeLabel[service.type]}
</Badge>
</div>
<div className="flex shrink-0 items-center gap-1">
<Link href={service.url} target="_blank">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<DialogAction
title="Delete Service"
description={`This will permanently delete "${service.name}" and all its associated data.`}
onClick={() => handleDeleteService(service)}
>
<Button
variant="ghost"
size="icon"
disabled={deletingId === service.id}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{deletingId === service.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</DialogAction>
</div>
</div>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!canDelete || isDeletingServer}
onClick={handleDeleteServer}
>
Delete Server
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -12,6 +12,9 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner";
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 {
@@ -30,7 +33,6 @@ import {
import { api } from "@/utils/api";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { DeleteServerModal } from "./delete-server-modal";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowHealthModal } from "./show-health-modal";
@@ -41,6 +43,7 @@ export const ShowServers = () => {
const router = useRouter();
const query = router.query;
const { data, refetch, isPending } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: canCreateMoreServers } =
@@ -108,6 +111,7 @@ export const ShowServers = () => {
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer = server.serverType === "build";
return (
@@ -246,30 +250,29 @@ export const ShowServers = () => {
</div>
<TooltipProvider>
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
{server.sshKeyId && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
@@ -346,22 +349,64 @@ export const ShowServers = () => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active
services associated
with this server,
please delete them
first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(
err.message,
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DeleteServerModal>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Delete Server</p>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
)}
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
const { mutateAsync } = api.user.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: hasValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
@@ -237,7 +237,6 @@ export const ShowUsers = () => {
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
disabled={isRemoving}
onClick={async () => {
await mutateAsync({
userId: member.user.id,
@@ -270,7 +269,6 @@ export const ShowUsers = () => {
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
disabled={isRemoving}
onClick={async () => {
if (!isCloud) {
const orgCount =
@@ -43,7 +43,6 @@ const providerLabels = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -64,7 +63,6 @@ const VaultProviderSchema = z
"doppler",
"azure",
"scaleway",
"phase",
]),
url: z.string(),
token: z.string(),
@@ -91,11 +89,6 @@ const VaultProviderSchema = z
scalewayProjectId: z.string(),
scalewaySecretKey: z.string(),
scalewayApiUrl: z.string(),
phaseToken: z.string(),
phaseAppId: z.string(),
phaseEnv: z.string(),
phasePath: z.string(),
phaseApiUrl: z.string(),
assignments: z.array(
z.object({
projectId: z.string(),
@@ -168,17 +161,6 @@ const VaultProviderSchema = z
path: ["siteUrl"],
});
}
if (
data.providerType === "phase" &&
data.phaseApiUrl &&
!isValidUrl(data.phaseApiUrl)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter a valid URL (e.g. https://api.phase.dev)",
path: ["phaseApiUrl"],
});
}
const required: Partial<
Record<ProviderType, [keyof typeof data, string][]>
@@ -212,11 +194,6 @@ const VaultProviderSchema = z
["scalewayProjectId", "Project ID is required"],
["scalewaySecretKey", "Secret Key is required"],
],
phase: [
["phaseToken", "Service Account REST API token is required"],
["phaseAppId", "App ID is required"],
["phaseEnv", "Environment is required"],
],
};
for (const [field, message] of required[data.providerType] ?? []) {
@@ -277,11 +254,6 @@ const defaultValues: VaultProviderForm = {
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
phaseToken: "",
phaseAppId: "",
phaseEnv: "",
phasePath: "/",
phaseApiUrl: "https://api.phase.dev",
assignments: [],
};
@@ -336,15 +308,6 @@ const buildConfig = (data: VaultProviderForm) => {
secretKey: data.scalewaySecretKey,
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
};
case "phase":
return {
providerType: "phase" as const,
token: data.phaseToken,
appId: data.phaseAppId,
env: data.phaseEnv,
path: data.phasePath || "/",
apiUrl: data.phaseApiUrl || "https://api.phase.dev",
};
}
};
@@ -466,13 +429,6 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
scalewaySecretKey: provider.config.secretKey,
scalewayApiUrl: provider.config.apiUrl,
}),
...(provider.config.providerType === "phase" && {
phaseToken: provider.config.token,
phaseAppId: provider.config.appId,
phaseEnv: provider.config.env,
phasePath: provider.config.path,
phaseApiUrl: provider.config.apiUrl,
}),
});
} else if (!vaultProviderId) {
form.reset(defaultValues);
@@ -1040,118 +996,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "phase" && (
<>
<FormField
control={form.control}
name="phaseToken"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account REST API Token</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Use the REST API token from a Service Account — not the
CLI/SDK <code>pss_*</code> token. The Phase App must
have Server-side Encryption (SSE) enabled.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="phaseAppId"
render={({ field }) => (
<FormItem>
<FormLabel>App ID</FormLabel>
<FormControl>
<Input
placeholder="00000000-0000-0000-0000-000000000000"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phaseEnv"
render={({ field }) => (
<FormItem>
<FormLabel>Environment</FormLabel>
<FormControl>
<Input placeholder="Production" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="phasePath"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phaseApiUrl"
render={({ field }) => (
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.phase.dev" {...field} />
</FormControl>
<FormDescription>
Self-hosted Phase defaults to{" "}
<code>{"${HTTP_PROTOCOL}${HOST}/service/public"}</code>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.SECRET_KEY}}"}</code>
</FormDescription>
</>
)}
<div className="flex flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-row items-center justify-between">
<FormLabel>Access</FormLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
setAssignments(
assignments.length === orgProjects?.length
? []
: (orgProjects ?? []).map((project) => ({
projectId: project.projectId,
environmentIds: [],
})),
)
}
>
{assignments.length === orgProjects?.length
? "Clear all"
: "Access all"}
</Button>
</div>
<FormLabel>Access</FormLabel>
<FormDescription>
This provider can only be referenced from the selected projects.
Pick environments to narrow it further — none selected means all
@@ -21,7 +21,6 @@ const providerLabels: Record<string, string> = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
};
export const ShowVaultProviders = () => {
@@ -34,65 +34,7 @@ export const Route53Icon = ({ className }: Props) => (
</svg>
);
export const PorkbunIcon = ({ className }: Props) => (
<svg
viewBox="0 0 129.3 114.3"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill="#ED7778"
d="M76,23.6c-18.7,0-33.8,15.1-33.8,33.8S57.3,91.3,76,91.3s33.8-15.1,33.8-33.8S94.7,23.6,76,23.6z"
/>
<path
fill="#FFFFFF"
d="M67.1,43.4c-2.6-1.4-5.5-2.5-8.5-3.2c-0.6,1.3-0.9,2.6-0.9,4.1c0,2.2,0.7,4.2,1.9,5.8 C61.5,47.3,64,44.9,67.1,43.4z"
/>
<path
fill="#FFFFFF"
d="M92.4,50.1c1.2-1.6,1.9-3.6,1.9-5.8c0-1.5-0.3-2.9-0.9-4.1c-3,0.6-5.9,1.7-8.5,3.2 C87.9,44.9,90.5,47.3,92.4,50.1z"
/>
<path
fill="#FFFFFF"
d="M80.5,54.7c-0.6,0-1.1,0.5-1.1,1.1c0,0.2,0.1,0.4,0.2,0.6l0,0c0.4,0.6,1,1,1.7,1.2 c0.2-0.4,0.3-0.9,0.3-1.4c0-0.2,0-0.3,0-0.5C81.5,55.1,81.1,54.7,80.5,54.7z"
/>
<path
fill="#FFFFFF"
d="M75.3,42.5c-9.9,0.4-17.6,8.8-17.6,18.7v10.3c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3v-2.7h23.2v2.7 c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3V60.7C94.2,50.4,85.7,42.1,75.3,42.5z M85.7,56.9c-0.6,1-1.5,1.7-2.6,2.1 c-0.7,1.4-2.2,2.4-3.9,2.4c-0.2,0-0.4,0-0.5,0c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1v0c0.5,0,1-0.2,1.4-0.4 c-0.6-0.3-1.2-0.7-1.6-1.3c-0.4-0.5-0.6-1-0.6-1.7c0-1.4,1.2-2.6,2.6-2.6c0.9,0,1.6,0.4,2.1,1.1c0.6,0.8,1,1.7,1,2.8 c0,0.1,0,0.2,0,0.4c0.5-0.2,0.9-0.6,1.2-1c0.2-0.3,0.5-0.3,0.8-0.2C85.8,56.2,85.9,56.6,85.7,56.9z"
/>
<path
fill="#212222"
d="M128,44.6h4.5v2.2c0,1-0.1,1.9-0.1,1.9h0.1c0,0,2.2-4.6,8.5-4.6c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.6,0-7.8-4.2-7.8-4.2h-0.1c0,0,0.1,0.9,0.1,2.2v11.4H128V44.6z M139.9,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C132.7,61.9,135.2,66.4,139.9,66.4z"
/>
<path
fill="#212222"
d="M170.9,44c7.6,0,13.7,5.6,13.7,13.2c0,7.7-6.1,13.3-13.7,13.3s-13.7-5.6-13.7-13.3 C157.2,49.6,163.3,44,170.9,44z M170.9,66.3c4.8,0,8.7-3.8,8.7-9.1c0-5.3-3.9-9-8.7-9c-4.8,0-8.7,3.8-8.7,9 C162.2,62.5,166.1,66.3,170.9,66.3z"
/>
<path
fill="#212222"
d="M191.3,44.6h4.7V49c0,1.1-0.1,1.9-0.1,1.9h0.1c1.2-3.7,4.1-6.6,8-6.6c0.7,0,1.3,0.1,1.3,0.1v4.8 c0,0-0.7-0.1-1.4-0.1c-3.1,0-6,2.2-7.1,6c-0.5,1.5-0.6,3-0.6,4.6v10.4h-4.9V44.6z"
/>
<path
fill="#212222"
d="M211,34.6h4.9v19.3h3.6l6.9-9.3h5.5l-8.4,11.2v0.1l9.4,14.1h-5.7L219.5,58h-3.7v11.9H211V34.6z"
/>
<path
fill="#212222"
d="M238.3,34.6h4.9v11.6c0,1.3-0.1,2.2-0.1,2.2h0.1c0,0,2.2-4.3,8.1-4.3c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.7,0-8-4.4-8-4.4h-0.1c0,0,0.1,0.8,0.1,1.9v1.9h-4.6V34.6z M250.2,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C243,61.9,245.4,66.4,250.2,66.4z"
/>
<path
fill="#212222"
d="M269,44.6h4.9v15.1c0,3.5,0.7,6.3,4.8,6.3c5.2,0,8.2-4.6,8.2-9.6V44.6h4.9v25.3H287v-3.4 c0-1.1,0.1-1.9,0.1-1.9H287c-1.1,2.5-4.4,5.8-9.3,5.8c-5.7,0-8.7-3-8.7-9.7V44.6z"
/>
<path
fill="#212222"
d="M300.3,44.6h4.7V48c0,1-0.1,1.9-0.1,1.9h0.1c1-2.2,4-5.8,9.5-5.8c6,0,8.7,3.3,8.7,9.7v16.2h-4.9V54.8 c0-3.6-0.8-6.4-4.8-6.4c-3.9,0-7,2.6-8,6.2c-0.3,1-0.4,2.2-0.4,3.4v11.9h-4.9V44.6z"
/>
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
} as const;
@@ -582,28 +582,6 @@ export const ScalewayIcon = ({ className }: Props) => (
</svg>
);
export const PhaseIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M12 2L3.5 6.5v11L12 22l8.5-4.5v-11L12 2z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M12 2v20M3.5 6.5L12 11l8.5-4.5M3.5 17.5L12 13l8.5 4.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
</svg>
);
export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
@@ -611,5 +589,4 @@ export const vaultProviderIcons = {
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
phase: PhaseIcon,
} as const;
@@ -1,6 +1,4 @@
import Head from "next/head";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
@@ -10,11 +8,9 @@ interface Props {
metaName?: string;
}
export const DashboardLayout = ({ children, metaName }: Props) => {
export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
enabled: isCloud === true,
refetchOnWindowFocus: false,
@@ -26,13 +22,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
return (
<>
{metaName && (
<Head>
<title>
{metaName} | {appName}
</title>
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>
@@ -173,7 +173,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
vaultProvider: {
label: "Secrets Providers",
description:
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",
@@ -328,10 +328,6 @@ const ACTION_META: Record<
label: "Delete",
description: "Remove servers from the organization",
},
terminal: {
label: "Terminal",
description: "Open an SSH root shell on remote servers",
},
},
registry: {
read: { label: "Read", description: "View configured Docker registries" },
@@ -573,7 +569,7 @@ const ROLE_PRESETS: {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete", "terminal"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "delete", "restore"],
+3 -13
View File
@@ -1,4 +1,3 @@
import { OpenPanelComponent } from "@openpanel/nextjs";
import { useRouter } from "next/router";
import Script from "next/script";
import { useEffect } from "react";
@@ -6,10 +5,9 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
import { api } from "@/utils/api";
/**
* Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel
* product analytics on the cloud version only, and translates tracking
* query params appended by the auth/billing flows (?signup=...,
* ?subscription=new) into dataLayer events.
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
* version only, and translates tracking query params appended by the
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
*/
export const Analytics = () => {
const router = useRouter();
@@ -42,14 +40,6 @@ export const Analytics = () => {
return (
<>
<OpenPanelComponent
apiUrl="https://openpanel.dokploy.com/api"
clientId="bf5a178b-7f28-4461-bf47-d63feff15922"
trackScreenViews={true}
trackOutgoingLinks={true}
trackAttributes={true}
globalProperties={{ site: "app" }}
/>
<Script id="analytics-init" strategy="afterInteractive">
{`
window.hsConversationsSettings = { loadImmediately: false };
@@ -1,307 +0,0 @@
import { DownloadIcon, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
projectId?: string;
environmentId?: string;
currentEnv: string;
onImport: (nextEnv: string) => void;
}
const normalizeSecretName = (secretName: string) => {
const normalized = secretName
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
return /^[0-9]/.test(normalized) ? `_${normalized}` : normalized || "SECRET";
};
const parseEnvKeys = (env: string) => {
const keys = new Set<string>();
for (const line of env.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
keys.add(trimmed.slice(0, trimmed.indexOf("=")).trim());
}
return keys;
};
const setEnvValue = (env: string, key: string, ref: string) => {
const lines = env.split("\n");
const line = `${key}=${ref}`;
const index = lines.findIndex((l) => {
const trimmed = l.trim();
return (
trimmed &&
!trimmed.startsWith("#") &&
trimmed.includes("=") &&
trimmed.slice(0, trimmed.indexOf("=")).trim() === key
);
});
if (index >= 0) {
lines[index] = line;
return lines.join("\n");
}
const withTrailingNewline = env.length > 0 && !env.endsWith("\n") ? "\n" : "";
return `${env}${withTrailingNewline}${line}\n`;
};
export const VaultImportDialog = ({
projectId,
environmentId,
currentEnv,
onImport,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [vaultProviderId, setVaultProviderId] = useState<string | undefined>();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [keyOverrides, setKeyOverrides] = useState<Record<string, string>>({});
const { data: allProviders } = api.vaultProvider.all.useQuery();
const providers = useMemo(
() =>
(allProviders ?? []).filter((provider) =>
provider.assignments?.some(
(assignment) =>
assignment.projectId === projectId &&
(assignment.environmentIds.length === 0 ||
!environmentId ||
assignment.environmentIds.includes(environmentId)),
),
),
[allProviders, projectId, environmentId],
);
const activeProviderId = vaultProviderId ?? providers[0]?.vaultProviderId;
const activeProvider = providers.find(
(p) => p.vaultProviderId === activeProviderId,
);
const { data: secretNames, isLoading } =
api.vaultProvider.listSecretNames.useQuery(
{
vaultProviderId: activeProviderId!,
projectId: projectId!,
environmentId,
},
{ enabled: isOpen && !!activeProviderId && !!projectId },
);
const existingKeys = useMemo(() => parseEnvKeys(currentEnv), [currentEnv]);
const rows = useMemo(
() =>
(secretNames ?? []).map((secretName) => {
const defaultKey = normalizeSecretName(secretName);
const key = keyOverrides[secretName] ?? defaultKey;
return {
secretName,
key,
conflict: existingKeys.has(key),
};
}),
[secretNames, keyOverrides, existingKeys],
);
const resetState = () => {
setSelected(new Set());
setKeyOverrides({});
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open) {
setVaultProviderId(undefined);
resetState();
}
};
const toggleRow = (secretName: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(secretName);
else next.delete(secretName);
return next;
});
};
const toggleAll = () => {
const selectableNonConflicting = rows.filter((r) => !r.conflict);
const allSelected =
selectableNonConflicting.length > 0 &&
selectableNonConflicting.every((r) => selected.has(r.secretName));
setSelected(
allSelected
? new Set()
: new Set(selectableNonConflicting.map((r) => r.secretName)),
);
};
const handleImport = () => {
if (!activeProvider) return;
let next = currentEnv;
for (const row of rows) {
if (!selected.has(row.secretName)) continue;
const ref = `\${{vault.${activeProvider.name}.${row.secretName}}}`;
next = setEnvValue(next, row.key, ref);
}
onImport(next);
setIsOpen(false);
setVaultProviderId(undefined);
resetState();
};
if (!projectId || providers.length === 0) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<DownloadIcon className="mr-2 size-4" />
Import from Vault
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Import secrets from vault</DialogTitle>
<DialogDescription>
Select the secrets to import as{" "}
<code>{"${{vault.<provider>.<secret>}}"}</code> references. Secrets
already defined below are skipped unless you check them explicitly.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Vault provider</Label>
<Select
value={activeProviderId}
onValueChange={(value) => {
setVaultProviderId(value);
resetState();
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((provider) => (
<SelectItem
key={provider.vaultProviderId}
value={provider.vaultProviderId}
>
{provider.name} ({provider.providerType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin" />
Loading secrets...
</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-muted-foreground text-sm">
No secrets found for this provider.
</div>
) : (
<>
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
<Checkbox
checked={
rows.some((r) => !r.conflict) &&
rows
.filter((r) => !r.conflict)
.every((r) => selected.has(r.secretName))
}
onCheckedChange={toggleAll}
/>
Select all
</Label>
<span className="text-muted-foreground text-sm">
{selected.size} selected
</span>
</div>
<ScrollArea className="h-80 rounded-md border">
<div className="flex flex-col divide-y">
{rows.map((row) => (
<div
key={row.secretName}
className="flex items-center gap-3 p-3"
>
<Checkbox
checked={selected.has(row.secretName)}
onCheckedChange={(checked) =>
toggleRow(row.secretName, checked === true)
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-mono text-muted-foreground text-xs">
{row.secretName}
</span>
<Input
value={row.key}
onChange={(e) =>
setKeyOverrides((prev) => ({
...prev,
[row.secretName]: e.target.value
.toUpperCase()
.replace(/[^A-Z0-9_]/g, "_"),
}))
}
className="h-8 font-mono text-sm"
/>
</div>
{row.conflict && (
<Badge variant="secondary">already exists</Badge>
)}
</div>
))}
</div>
</ScrollArea>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
onClick={handleImport}
disabled={selected.size === 0}
>
Import {selected.size > 0 ? selected.size : ""} secret
{selected.size === 1 ? "" : "s"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+11 -24
View File
@@ -3,7 +3,6 @@ import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, type ReactNode, useState } from "react";
import { useFormContext } from "react-hook-form";
import { CodeEditor } from "@/components/shared/code-editor";
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
import {
CardContent,
CardDescription,
@@ -24,8 +23,6 @@ interface Props {
description: ReactNode;
placeholder: string;
completionSource?: CompletionSource;
projectId?: string;
environmentId?: string;
}
export const Secrets = (props: Props) => {
@@ -40,27 +37,17 @@ export const Secrets = (props: Props) => {
<CardDescription>{props.description}</CardDescription>
</div>
<div className="flex items-center gap-2">
<VaultImportDialog
projectId={props.projectId}
environmentId={props.environmentId}
currentEnv={form.watch(props.name) ?? ""}
onImport={(next) =>
form.setValue(props.name, next, { shouldDirty: true })
}
/>
<Toggle
aria-label="Toggle bold"
pressed={isVisible}
onPressedChange={setIsVisible}
>
{isVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isVisible}
onPressedChange={setIsVisible}
>
{isVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent className="w-full space-y-4 p-0">
<FormField
@@ -1 +0,0 @@
ALTER TABLE "network" ADD COLUMN "dockerId" text;
@@ -1,13 +0,0 @@
-- Grant the new "server.terminal" permission to existing custom roles that already have
-- "server.read", which is the permission that surfaces the terminal in the UI today.
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
-- UI, so from now on they are denied at the websocket too.
UPDATE "organization_role" AS r
SET "permission" = jsonb_set(
r."permission"::jsonb,
'{server}',
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
)::text
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
@@ -1 +0,0 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
@@ -1 +0,0 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-28
View File
@@ -1303,34 +1303,6 @@
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1787813846067,
"tag": "0186_tearful_dragon_man",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1787937580323,
"tag": "0187_grant_terminal_permission_to_read_roles",
"breakpoints": true
},
{
"idx": 188,
"version": "7",
"when": 1788249519837,
"tag": "0188_volatile_piledriver",
"breakpoints": true
},
{
"idx": 189,
"version": "7",
"when": 1788251861403,
"tag": "0189_wooden_nextwave",
"breakpoints": true
}
]
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.4",
"version": "v0.30.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -67,7 +67,6 @@
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
"@openpanel/nextjs": "^1.5.1",
"@react-email/components": "^1.0.12",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0",
+1 -32
View File
@@ -1,8 +1,4 @@
import {
OPENAPI_MAX_JSON_BODY_SIZE,
OPENAPI_MAX_UPLOAD_SIZE,
validateRequest,
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "@/server/api/root";
@@ -16,31 +12,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return;
}
// getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
const contentLength = Number(req.headers["content-length"]);
const isMultipart = req.headers["content-type"]?.startsWith(
"multipart/form-data",
);
if (isMultipart && !Number.isFinite(contentLength)) {
res.status(411).json({ message: "Content-Length required" });
return;
}
const limit = isMultipart
? OPENAPI_MAX_UPLOAD_SIZE
: OPENAPI_MAX_JSON_BODY_SIZE;
if (Number.isFinite(contentLength) && contentLength > limit) {
res.status(413).json({ message: "Payload too large" });
return;
}
// @ts-ignore
return createOpenApiNextHandler({
router: appRouter,
createContext: createTRPCContext,
maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }: { path: string | undefined; error: Error }) => {
@@ -53,9 +28,3 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
};
export default handler;
export const config = {
api: {
bodyParser: false,
},
};
@@ -8,7 +8,11 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return <ShowDnsProviders />;
return (
<div className="flex flex-col gap-4 w-full">
<ShowDnsProviders />
</div>
);
};
export default Page;
@@ -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 { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
}
const Page = ({ dnsProviderId }: Props) => {
return <ShowDnsZones dnsProviderId={dnsProviderId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member" || !params?.dnsProviderId) {
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(),
dnsProviderId: params.dnsProviderId,
},
};
}
@@ -1,64 +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 { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const Page = ({ dnsProviderId, zoneId }: Props) => {
return <ShowDnsRecords dnsProviderId={dnsProviderId} zoneId={zoneId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (
!user ||
user.role === "member" ||
!params?.dnsProviderId ||
!params?.zoneId
) {
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(),
dnsProviderId: params.dnsProviderId,
zoneId: params.zoneId,
},
};
}
+6 -30
View File
@@ -1,54 +1,30 @@
import { findOwner, generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { account, user } from "@dokploy/server/db/schema";
import { and, eq } from "drizzle-orm";
import { account } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
(async () => {
try {
const email = process.argv[2]?.trim().toLowerCase();
const randomPassword = await generateRandomPassword();
let userId: string;
if (email) {
const foundUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (!foundUser) {
console.log(`User not found for email: ${email}`);
process.exit(1);
}
userId = foundUser.id;
} else {
const owner = await findOwner();
userId = owner.userId;
}
const result = await findOwner();
const update = await db
.update(account)
.set({
password: randomPassword.hashedPassword,
})
.where(
and(eq(account.userId, userId), eq(account.providerId, "credential")),
);
.where(eq(account.userId, result.userId));
if (update.count > 0) {
if (update) {
console.log("Password reset successful");
console.log("New password: ", randomPassword.randomPassword);
} else {
console.log(
"Password reset failed: no credential account found for this user",
);
process.exit(1);
console.log("Password reset failed");
}
process.exit(0);
} catch (error) {
console.log("Error resetting password", error);
process.exit(1);
}
})();
@@ -1,6 +1,5 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createBitbucket,
findBitbucketById,
getAccessibleGitProviderIds,
@@ -56,17 +55,6 @@ export const bitbucketRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const bitbucket = await findBitbucketById(input.bitbucketId);
await assertGitProviderAccess(ctx.session, bitbucket.gitProvider);
if (
!(await canViewGitProviderSecrets(ctx.session, bitbucket.gitProvider))
) {
return {
...bitbucket,
appPassword: null,
apiToken: null,
};
}
return bitbucket;
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
+1 -8
View File
@@ -1,4 +1,3 @@
import { join } from "node:path";
import {
addDomainToCompose,
clearOldDeployments,
@@ -33,7 +32,6 @@ import {
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
@@ -549,12 +547,7 @@ export const composeRouter = createTRPCRouter({
service: ["create"],
});
const compose = await findComposeById(input.composeId);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(
compose,
compose.mounts.length > 0 ? projectPath : undefined,
);
const command = createCommand(compose);
return `docker ${command}`;
}),
refreshToken: protectedProcedure
@@ -162,7 +162,6 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
});
await audit(ctx, {
action: "create",
@@ -189,7 +188,6 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
},
);
await audit(ctx, {
+3 -15
View File
@@ -7,7 +7,6 @@ import {
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getServerIpCandidates,
getWebServerSettings,
manageDomain,
removeDomain,
@@ -249,21 +248,10 @@ export const domainRouter = createTRPCRouter({
.input(
z.object({
domain: z.string(),
serverId: z.string().optional(),
serverIp: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const expectedIps = await getServerIpCandidates(input.serverId);
return validateDomain(input.domain, expectedIps);
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
}),
});
-11
View File
@@ -1,6 +1,5 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitea,
findGiteaById,
getAccessibleGitProviderIds,
@@ -60,16 +59,6 @@ export const giteaRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const gitea = await findGiteaById(input.giteaId);
await assertGitProviderAccess(ctx.session, gitea.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, gitea.gitProvider))) {
return {
...gitea,
clientSecret: null,
accessToken: null,
refreshToken: null,
};
}
return gitea;
}),
-11
View File
@@ -1,6 +1,5 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
@@ -29,16 +28,6 @@ export const githubRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, github.gitProvider))) {
return {
...github,
githubClientSecret: null,
githubPrivateKey: null,
githubWebhookSecret: null,
};
}
return github;
}),
getGithubRepositories: protectedProcedure
-11
View File
@@ -1,6 +1,5 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitlab,
findGitlabById,
getAccessibleGitProviderIds,
@@ -58,16 +57,6 @@ export const gitlabRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const gitlab = await findGitlabById(input.gitlabId);
await assertGitProviderAccess(ctx.session, gitlab.gitProvider);
if (!(await canViewGitProviderSecrets(ctx.session, gitlab.gitProvider))) {
return {
...gitlab,
secret: null,
accessToken: null,
refreshToken: null,
};
}
return gitlab;
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
@@ -6,7 +6,6 @@ import {
inspectNetwork,
recreateNetwork,
removeNetwork,
resyncNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNull } from "drizzle-orm";
@@ -130,29 +129,6 @@ export const networkRouter = createTRPCRouter({
return recreated;
}),
resync: protectedProcedure
.input(apiFindOneNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const resynced = await resyncNetwork(
input.networkId,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "update",
resourceType: "network",
resourceId: resynced.networkId,
resourceName: resynced.name,
});
return resynced;
}),
remove: protectedProcedure
.input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => {
+2 -4
View File
@@ -25,7 +25,7 @@ import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const scheduleRouter = createTRPCRouter({
@@ -130,7 +130,7 @@ export const scheduleRouter = createTRPCRouter({
if (IS_CLOUD) {
if (updatedSchedule?.enabled) {
await updateJob({
schedule({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
@@ -141,7 +141,6 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: updatedSchedule.cronExpression,
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
timezone: updatedSchedule.timezone,
});
}
} else {
@@ -186,7 +185,6 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: scheduleItem.cronExpression,
scheduleId: scheduleItem.scheduleId,
type: "schedule",
timezone: scheduleItem.timezone,
});
} else {
removeScheduleJob(scheduleItem.scheduleId);
-37
View File
@@ -7,7 +7,6 @@ import {
findUserById,
getAccessibleServerIds,
getPublicIpWithFallback,
getServicesByServerId,
haveActiveServices,
IS_CLOUD,
redactServerSshKey,
@@ -19,7 +18,6 @@ import {
updateServerById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { findMemberByUserId } from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
@@ -117,41 +115,6 @@ export const serverRouter = createTRPCRouter({
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
getServices: withPermission("server", "read")
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
const services = await getServicesByServerId(input.serverId);
const isPrivileged =
ctx.user.role === "owner" || ctx.user.role === "admin";
if (isPrivileged) {
return services;
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
return services.filter((service) =>
accessedServices.includes(service.id),
);
}),
all: withPermission("server", "read").query(async ({ ctx }) => {
const accessibleIds = await getAccessibleServerIds(ctx.session);
+19 -28
View File
@@ -80,42 +80,33 @@ export const stripeRouter = createTRPCRouter({
let currentPlan: CurrentPlan = "legacy";
let isAnnualCurrent = false;
let currentPriceAmount: number | null = null;
if (subscriptions.data.length > 0) {
const matchedSub = subscriptions.data.find((sub) =>
sub.items.data.some(
(item) =>
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_MONTHLY_ID ||
(item.price as Stripe.Price).id === STARTUP_BASE_PRICE_ANNUAL_ID,
),
const activeSub = subscriptions.data[0];
if (activeSub) {
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
const hobbySub = subscriptions.data.find((sub) =>
sub.items.data.some(
(item) =>
(item.price as Stripe.Price).id === HOBBY_PRICE_MONTHLY_ID ||
(item.price as Stripe.Price).id === HOBBY_PRICE_ANNUAL_ID,
),
);
const legacySub = subscriptions.data.find((sub) =>
sub.items.data.some((item) =>
LEGACY_PRICE_IDS.includes((item.price as Stripe.Price).id),
),
);
const activeSub = matchedSub ?? hobbySub ?? legacySub;
if (matchedSub) {
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
currentPlan = "startup";
} else if (hobbySub) {
} else if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
currentPlan = "hobby";
} else if (legacySub) {
} else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
currentPlan = "legacy";
}
const firstPrice = activeSub?.items.data[0]?.price as
const firstPrice = activeSub.items.data[0]?.price as
| Stripe.Price
| undefined;
isAnnualCurrent = firstPrice?.recurring?.interval === "year";
const totalCents = (activeSub?.items.data ?? []).reduce((sum, item) => {
const totalCents = activeSub.items.data.reduce((sum, item) => {
const price = item.price as Stripe.Price;
const amount = price.unit_amount ?? 0;
const qty = item.quantity ?? 1;
+4 -3
View File
@@ -27,10 +27,11 @@ export const getCurrentPlanForUser = async (
status: "active",
expand: ["data.items.data.price"],
});
if (subscriptions.data.length === 0) return null;
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = subscriptions.data.flatMap((sub) =>
sub.items.data.map((item) => (item.price as Stripe.Price).id),
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
+2 -7
View File
@@ -62,8 +62,7 @@ export const canAccessDockerOverWss = async (
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal needs server access plus
// server.terminal.
// to owner/admin. A remote server terminal is gated on server access.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
@@ -76,11 +75,7 @@ export const canAccessTerminalOverWss = async (
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
if (!accessible.has(serverId)) return false;
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
server: ["terminal"],
});
return accessible.has(serverId);
}
try {
@@ -182,10 +182,6 @@ export const setupDockerContainerTerminalWebSocketServer = (
ptyProcess.onData((data) => {
ws.send(data);
});
ptyProcess.onExit(({ exitCode }) => {
ws.send(`\nContainer closed with code: ${exitCode}\n`);
ws.close();
});
ws.on("close", () => {
ptyProcess.kill();
});
+1 -55
View File
@@ -8,57 +8,9 @@ import {
recordAdvancedStats,
validateRequest,
} from "@dokploy/server";
import { quote } from "shell-quote";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
type AppType = "application" | "stack" | "docker-compose";
// Swarm task names are "<service>.<slot>.<taskId>"; the manager's local
// `docker ps` only sees containers scheduled on this host, so a task running
// on another node looks identical to a stopped one. `docker service ps` is
// swarm-aggregated and answers from the manager regardless of which node the
// task landed on, so we use it here to tell the two cases apart.
const findRemoteSwarmNode = async (
appName: string,
appType: AppType,
): Promise<string | null> => {
if (appType === "docker-compose") {
return null;
}
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
// never the taskId, so we match on the task ID instead — the last segment
// of the stack task name — rather than trying to reconstruct the full name.
const [serviceName, taskId] =
appType === "stack"
? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()]
: [appName, null];
if (!serviceName) {
return null;
}
try {
const { stdout } = await execAsync(
`docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`,
);
for (const line of stdout.trim().split("\n")) {
if (!line) continue;
const task = JSON.parse(line);
const isMatch = taskId ? task.ID === taskId : true;
if (isMatch && task.CurrentState?.startsWith("Running")) {
return task.Node;
}
}
} catch {
// Not a swarm service (or the swarm CLI isn't available) — fall back to "not running".
}
return null;
};
export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
@@ -146,13 +98,7 @@ export const setupDockerStatsMonitoringSocketServer = (
const container = containers[0];
if (!container || container?.State !== "running") {
const remoteNode = await findRemoteSwarmNode(appName, appType);
ws.close(
4000,
remoteNode
? `Container running on remote node "${remoteNode}"`.slice(0, 123)
: "Container not running",
);
ws.close(4000, "Container not running");
return;
}
const { stdout, stderr } = await execAsync(
-95
View File
@@ -531,98 +531,3 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] {
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--duration-quick: 150ms;
--duration-fast: 250ms;
--duration-medium: 350ms;
--duration-slow: 400ms;
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
--distance-base: 8px;
--blur-medium: 3px;
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-x: 32px;
--panel-blur: 2px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
@layer components {
.t-panel-slide-x {
transform: translateX(var(--panel-translate-x));
opacity: 0;
filter: blur(var(--panel-blur));
pointer-events: none;
transition:
transform var(--panel-close-dur) var(--panel-ease),
opacity var(--panel-close-dur) var(--panel-ease),
filter var(--panel-close-dur) var(--panel-ease);
will-change: transform, opacity, filter;
}
.t-panel-slide-x[data-open="true"] {
transform: translateX(0);
opacity: 1;
filter: blur(0);
pointer-events: auto;
transition:
transform var(--panel-open-dur) var(--panel-ease),
opacity var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel-track {
transition:
grid-template-columns var(--panel-open-dur) var(--panel-ease),
grid-template-rows var(--panel-open-dur) var(--panel-ease);
}
@media (prefers-reduced-motion: reduce) {
.t-panel-slide-x,
.t-panel-track {
transition: none !important;
}
}
}
@layer components {
.t-page-enter {
animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both;
}
.t-page-enter[data-direction="back"] {
animation-name: t-page-enter-back;
}
@keyframes t-page-enter {
from {
opacity: 0;
transform: translateX(var(--distance-base));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@keyframes t-page-enter-back {
from {
opacity: 0;
transform: translateX(calc(var(--distance-base) * -1));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.t-page-enter {
animation: none;
}
}
}
-1
View File
@@ -60,7 +60,6 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
scheduleId: data.scheduleId,
type: "schedule",
cronSchedule: job.pattern || "",
timezone: job.tz || data.timezone,
});
} else if (data.type === "volume-backup") {
result = await removeJob({
+1 -2
View File
@@ -66,10 +66,9 @@ export const removeJob = async (data: QueueJob) => {
return result;
}
if (data.type === "schedule") {
const { scheduleId, cronSchedule, timezone } = data;
const { scheduleId, cronSchedule } = data;
const result = await jobQueue.removeRepeatable(scheduleId, {
pattern: cronSchedule,
tz: timezone || "UTC",
});
return result;
}
-22
View File
@@ -13,28 +13,6 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
export const CLEANUP_CRON_JOB = "50 23 * * *";
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
const parseByteSize = (envVar: string, fallback: number): number => {
const raw = process.env[envVar];
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
console.warn(`Invalid ${envVar}="${raw}", using default ${fallback}`);
return fallback;
}
return parsed;
};
export const OPENAPI_MAX_JSON_BODY_SIZE = parseByteSize(
"OPENAPI_MAX_JSON_BODY_SIZE",
10 * 1024 * 1024, // 10mb
);
export const OPENAPI_MAX_UPLOAD_SIZE = parseByteSize(
"OPENAPI_MAX_UPLOAD_SIZE",
1024 * 1024 * 1024, // 1gb
);
type DockerSocketCandidate = {
label: string;
path: string;
+1 -26
View File
@@ -7,7 +7,6 @@ import { organization } from "./account";
export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
]);
export const cloudflareDnsConfigSchema = z.object({
@@ -21,16 +20,9 @@ export const route53DnsConfigSchema = z.object({
secretAccessKey: z.string().trim().min(1),
});
export const porkbunDnsConfigSchema = z.object({
providerType: z.literal("porkbun"),
apiKey: z.string().trim().min(1),
secretApiKey: z.string().trim().min(1),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
@@ -104,28 +96,11 @@ export const apiListDnsRecords = z.object({
zoneId: z.string().min(1),
});
export const dnsRecordTypes = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
export type DnsRecordType = (typeof dnsRecordTypes)[number];
export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const;
const dnsRecordFieldsSchema = z.object({
type: z.enum(dnsRecordTypes),
type: z.enum(["A", "CNAME"]),
name: z.string().min(1),
content: z.string().min(1),
ttl: z.number().int().positive().optional(),
proxied: z.boolean().optional(),
});
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
-1
View File
@@ -21,7 +21,6 @@ export const network = pgTable("network", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
dockerId: text("dockerId"),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),
@@ -433,7 +433,6 @@ export const apiCreateGotify = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),
@@ -469,7 +468,6 @@ export const apiCreateNtfy = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),
@@ -11,7 +11,6 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"doppler",
"azure",
"scaleway",
"phase",
]);
export const hashicorpVaultConfigSchema = z.object({
@@ -63,15 +62,6 @@ export const scalewayVaultConfigSchema = z.object({
apiUrl: z.string().url().default("https://api.scaleway.com"),
});
export const phaseVaultConfigSchema = z.object({
providerType: z.literal("phase"),
token: z.string().min(1),
appId: z.string().min(1),
env: z.string().min(1),
path: z.string().default("/"),
apiUrl: z.string().url().default("https://api.phase.dev"),
});
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
@@ -79,7 +69,6 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
phaseVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
+3 -3
View File
@@ -35,7 +35,7 @@ export const statements = {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete", "terminal"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete", "terminal"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete", "terminal"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
+4 -18
View File
@@ -125,24 +125,10 @@ export const findComposeById = async (composeId: string) => {
deployments: true,
mounts: true,
domains: true,
github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
gitlab: {
columns: { secret: false, accessToken: false, refreshToken: false },
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
github: true,
gitlab: true,
bitbucket: true,
gitea: true,
server: true,
backups: {
with: {
@@ -17,7 +17,6 @@ export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
};
export const maskDnsProviderConfig = (
+6 -52
View File
@@ -3,9 +3,7 @@ import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { generateRandomDomain } from "@dokploy/server/templates";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
@@ -156,7 +154,7 @@ const resolveDns = promisify(dns.resolve4);
export const validateDomain = async (
domain: string,
expectedIps?: string[],
expectedIp?: string,
): Promise<{
isValid: boolean;
resolvedIp?: string;
@@ -188,13 +186,13 @@ export const validateDomain = async (
};
}
if (expectedIps && expectedIps.length > 0) {
const isValid = resolvedIps.some((ip) => expectedIps.includes(ip));
// If we have an expected IP, validate against it
if (expectedIp) {
return {
isValid,
isValid: resolvedIps.includes(expectedIp),
resolvedIp: resolvedIps.join(", "),
error: !isValid
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIps.join(" or ")}`
error: !resolvedIps.includes(expectedIp)
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
: undefined,
};
}
@@ -212,47 +210,3 @@ export const validateDomain = async (
};
}
};
export const getServerIpCandidates = async (
serverId?: string | null,
): Promise<string[]> => {
const candidates = new Set<string>();
if (serverId) {
const server = await findServerById(serverId);
if (server.ipAddress) {
candidates.add(server.ipAddress);
}
const publicIp = await withTimeout(
execAsyncRemote(
serverId,
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
),
7000,
);
const detectedIp = publicIp?.stdout?.trim();
if (detectedIp) {
candidates.add(detectedIp);
}
} else {
const settings = await getWebServerSettings();
if (settings?.serverIp) {
candidates.add(settings.serverIp);
}
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
if (publicIp) {
candidates.add(publicIp);
}
}
return Array.from(candidates);
};
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]).catch(() => null);
};
+2 -27
View File
@@ -124,12 +124,8 @@ export const getAccessibleGitProviderIds = async (session: {
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
*
* This only proves the caller may *use* the provider (e.g. pick it as a repo
* source when creating a deploy) - it does NOT mean they may see its raw
* credentials. Being able to use a shared provider and being able to read its
* OAuth tokens / client secrets / private keys are different privileges; gate
* the latter with canViewGitProviderSecrets before returning secret fields.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
@@ -150,24 +146,3 @@ export const assertGitProviderAccess = async (
});
}
};
// Being allowed to use a shared provider (assertGitProviderAccess) must not
// imply being allowed to read its raw OAuth tokens / client secrets / private
// keys. Only the provider's owner or an org owner/admin gets those back.
export const canViewGitProviderSecrets = async (
session: { userId: string; activeOrganizationId: string },
provider: { userId: string; organizationId: string },
): Promise<boolean> => {
if (provider.organizationId !== session.activeOrganizationId) return false;
if (provider.userId === session.userId) return true;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, session.userId),
eq(member.organizationId, session.activeOrganizationId),
),
columns: { role: true },
});
return memberRecord?.role === "owner" || memberRecord?.role === "admin";
};
+1 -5
View File
@@ -7,10 +7,7 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -152,7 +149,6 @@ export const deployLibsql = async (
}
await buildLibsql(libsql);
await waitForSwarmServiceConvergence(libsql.appName, libsql.serverId);
await updateLibsqlById(libsqlId, {
applicationStatus: "done",
});
+1 -5
View File
@@ -7,10 +7,7 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
@@ -157,7 +154,6 @@ export const deployMariadb = async (
}
await buildMariadb(mariadb);
await waitForSwarmServiceConvergence(mariadb.appName, mariadb.serverId);
await updateMariadbById(mariadbId, {
applicationStatus: "done",
});

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