Merge pull request #5249 from Dokploy/canary
Some checks are pending
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Docker Build / sync-version (push) Blocked by required conditions
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Waiting to run

🚀 Release v0.30.4
This commit is contained in:
Mauricio Siu 2026-09-01 12:46:23 -06:00 committed by GitHub
commit 27dab3f4bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 22398 additions and 803 deletions

View File

@ -55,4 +55,69 @@ describe("compose createCommand --project-directory", () => {
);
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");
});
});

View File

@ -87,6 +87,224 @@ 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

View File

@ -0,0 +1,211 @@
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.",
);
});
});

View File

@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[0]?.[0] as HasInput;
const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
@ -172,6 +174,82 @@ 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", () => {
@ -222,6 +300,25 @@ 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: [] })

View File

@ -20,6 +20,7 @@ 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();
@ -616,3 +617,129 @@ 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");
});
});

View File

@ -64,6 +64,21 @@ 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
@ -193,6 +208,7 @@ 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);

View File

@ -6,6 +6,7 @@ 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) => {
@ -16,6 +17,7 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
writeFileRemote: mocks.writeFileRemote,
};
});
@ -91,7 +93,7 @@ describe("writeAppTraefikConfig", () => {
});
it("writes the remote file when routers/services are present", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
mocks.writeFileRemote.mockResolvedValue(undefined);
await writeAppTraefikConfig(
{
@ -109,8 +111,11 @@ describe("writeAppTraefikConfig", () => {
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^echo /);
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");
});
});

View File

@ -342,7 +342,7 @@ export const ShowDeployments = ({
)}
{/* Hash (from description) - shown in compact form */}
{deployment.description?.trim() && (
<span className="text-xs text-muted-foreground font-mono">
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
{deployment.description}
</span>
)}

View File

@ -6,6 +6,7 @@ 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,
@ -197,6 +198,18 @@ 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={

View File

@ -151,6 +151,8 @@ 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

View File

@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row justify-between sm: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">

View File

@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
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"
className="h-9 w-full sm:w-auto"
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 sm:w-auto w-full"
className="h-9 w-full sm:w-auto"
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-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[50vh] sm: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) => (

View File

@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
<div
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
/>
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@ -104,7 +106,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-full sm:w-40 shrink-0">
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}

View File

@ -7,6 +7,7 @@ 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,
@ -156,7 +157,17 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<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>
<FormControl>
<CodeEditor
completionSource={completionSource}

View File

@ -7,6 +7,7 @@ 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,
@ -152,7 +153,16 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
name="env"
render={({ field }) => (
<FormItem>
<FormLabel>Environment variables</FormLabel>
<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>
<FormControl>
<CodeEditor
completionSource={completionSource}

View File

@ -17,11 +17,13 @@ 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 {
@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
pageSize: 10,
});
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
const {
data: statsLogs,
isLoading,
isError,
error,
} = api.settings.readStatsLogs.useQuery(
{
sort: sorting[0],
page: pagination,
@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
colSpan={columns.length}
className="h-24 text-center"
>
{statsLogs?.data.length === 0 && (
{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>
) : (
<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.

View File

@ -0,0 +1,421 @@
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>
);
};

View File

@ -0,0 +1,33 @@
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>
);

View File

@ -33,11 +33,17 @@ 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;
@ -49,10 +55,12 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53"]),
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@ -63,6 +71,8 @@ const defaultValues: DnsProviderForm = {
apiToken: "",
accessKeyId: "",
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@ -78,6 +88,12 @@ const buildConfig = (data: DnsProviderForm) => {
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
};
case "porkbun":
return {
providerType: "porkbun" as const,
apiKey: data.apiKey,
secretApiKey: data.secretApiKey,
};
}
};
@ -135,6 +151,10 @@ 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);
@ -180,22 +200,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<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" />
{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" />
Add Provider
</Button>
)}
</DialogTrigger>
</DialogTrigger>
)}
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
@ -319,6 +347,42 @@ 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"

View File

@ -1,271 +0,0 @@
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>
);
};

View File

@ -1,218 +0,0 @@
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>
);
};

View File

@ -1,8 +1,9 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "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,
@ -11,9 +12,13 @@ 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",
@ -21,121 +26,142 @@ const providerLabels: Record<string, string> = {
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const [removingId, setRemovingId] = useState<string | null>(null);
const { mutateAsync } = api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<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">
<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">
<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">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<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-[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>
) : (
<>
{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>
<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 />
<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>
)}
</>
<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
</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete provider</TooltipContent>
</Tooltip>
)}
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</div>

View File

@ -0,0 +1,589 @@
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>
);
};

View File

@ -0,0 +1,132 @@
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>
);
};

View File

@ -446,6 +446,7 @@ 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({
@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: data.name,
dockerCleanup: dockerCleanup,
decoration: data.decoration,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});

View File

@ -0,0 +1,208 @@
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>
);
};

View File

@ -12,9 +12,6 @@ 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 {
@ -33,6 +30,7 @@ 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";
@ -43,7 +41,6 @@ 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 } =
@ -111,7 +108,6 @@ 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 (
@ -350,64 +346,22 @@ export const ShowServers = () => {
<Tooltip>
<TooltipTrigger asChild>
<div>
<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,
);
});
}}
<DeleteServerModal
serverId={server.serverId}
serverName={server.name}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</DeleteServerModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
<p>Delete Server</p>
</TooltipContent>
</Tooltip>
)}

View File

@ -43,6 +43,7 @@ const providerLabels = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
} as const;
type ProviderType = keyof typeof providerLabels;
@ -63,6 +64,7 @@ const VaultProviderSchema = z
"doppler",
"azure",
"scaleway",
"phase",
]),
url: z.string(),
token: z.string(),
@ -89,6 +91,11 @@ 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(),
@ -161,6 +168,17 @@ 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][]>
@ -194,6 +212,11 @@ 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] ?? []) {
@ -254,6 +277,11 @@ const defaultValues: VaultProviderForm = {
scalewayProjectId: "",
scalewaySecretKey: "",
scalewayApiUrl: "https://api.scaleway.com",
phaseToken: "",
phaseAppId: "",
phaseEnv: "",
phasePath: "/",
phaseApiUrl: "https://api.phase.dev",
assignments: [],
};
@ -308,6 +336,15 @@ 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",
};
}
};
@ -429,6 +466,13 @@ 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);
@ -996,8 +1040,118 @@ 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">
<FormLabel>Access</FormLabel>
<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>
<FormDescription>
This provider can only be referenced from the selected projects.
Pick environments to narrow it further none selected means all

View File

@ -21,6 +21,7 @@ const providerLabels: Record<string, string> = {
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
phase: "Phase",
};
export const ShowVaultProviders = () => {

View File

@ -34,7 +34,65 @@ 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;

View File

@ -582,6 +582,28 @@ 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,
@ -589,4 +611,5 @@ export const vaultProviderIcons = {
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
phase: PhaseIcon,
} as const;

View File

@ -1,4 +1,6 @@
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";
@ -8,9 +10,11 @@ interface Props {
metaName?: string;
}
export const DashboardLayout = ({ children }: Props) => {
export const DashboardLayout = ({ children, metaName }: 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,
@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => {
return (
<>
{metaName && (
<Head>
<title>
{metaName} | {appName}
</title>
</Head>
)}
<Page>{children}</Page>
{isChatEnabled && (
<>

View File

@ -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) and where their secrets can be referenced",
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
},
dnsProvider: {
label: "DNS Providers",

View File

@ -0,0 +1,307 @@
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>
);
};

View File

@ -3,6 +3,7 @@ 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,
@ -23,6 +24,8 @@ interface Props {
description: ReactNode;
placeholder: string;
completionSource?: CompletionSource;
projectId?: string;
environmentId?: string;
}
export const Secrets = (props: Props) => {
@ -37,17 +40,27 @@ export const Secrets = (props: Props) => {
<CardDescription>{props.description}</CardDescription>
</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>
<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>
</CardHeader>
<CardContent className="w-full space-y-4 p-0">
<FormField

View File

@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';

View File

@ -0,0 +1 @@
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

View File

@ -1317,6 +1317,20 @@
"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
}
]
}

View File

@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.3",
"version": "v0.30.4",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@ -8,11 +8,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowDnsProviders />
</div>
);
return <ShowDnsProviders />;
};
export default Page;

View File

@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { 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,
},
};
}

View File

@ -0,0 +1,64 @@
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,
},
};
}

View File

@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createBitbucket,
findBitbucketById,
getAccessibleGitProviderIds,
@ -55,6 +56,17 @@ 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 }) => {

View File

@ -551,7 +551,10 @@ export const composeRouter = createTRPCRouter({
const compose = await findComposeById(input.composeId);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const command = createCommand(
compose,
compose.mounts.length > 0 ? projectPath : undefined,
);
return `docker ${command}`;
}),
refreshToken: protectedProcedure

View File

@ -162,6 +162,7 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
});
await audit(ctx, {
action: "create",
@ -188,6 +189,7 @@ export const dnsProviderRouter = createTRPCRouter({
name: input.name,
content: input.content,
ttl: input.ttl,
proxied: input.proxied,
},
);
await audit(ctx, {

View File

@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitea,
findGiteaById,
getAccessibleGitProviderIds,
@ -59,6 +60,16 @@ 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;
}),

View File

@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
@ -28,6 +29,16 @@ 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

View File

@ -1,5 +1,6 @@
import {
assertGitProviderAccess,
canViewGitProviderSecrets,
createGitlab,
findGitlabById,
getAccessibleGitProviderIds,
@ -57,6 +58,16 @@ 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 }) => {

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 } from "@/server/utils/backup";
import { removeJob, schedule, updateJob } 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) {
schedule({
await updateJob({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
@ -141,6 +141,7 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: updatedSchedule.cronExpression,
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
timezone: updatedSchedule.timezone,
});
}
} else {
@ -185,6 +186,7 @@ export const scheduleRouter = createTRPCRouter({
cronSchedule: scheduleItem.cronExpression,
scheduleId: scheduleItem.scheduleId,
type: "schedule",
timezone: scheduleItem.timezone,
});
} else {
removeScheduleJob(scheduleItem.scheduleId);

View File

@ -7,6 +7,7 @@ import {
findUserById,
getAccessibleServerIds,
getPublicIpWithFallback,
getServicesByServerId,
haveActiveServices,
IS_CLOUD,
redactServerSshKey,
@ -18,6 +19,7 @@ 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";
@ -115,6 +117,41 @@ 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);

View File

@ -80,33 +80,42 @@ export const stripeRouter = createTRPCRouter({
let currentPlan: CurrentPlan = "legacy";
let isAnnualCurrent = false;
let currentPriceAmount: number | null = null;
const activeSub = subscriptions.data[0];
if (activeSub) {
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
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,
),
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_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) {
currentPlan = "startup";
} else if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
} else if (hobbySub) {
currentPlan = "hobby";
} else if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
} else if (legacySub) {
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;

View File

@ -27,11 +27,10 @@ export const getCurrentPlanForUser = async (
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
if (subscriptions.data.length === 0) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
const priceIds = subscriptions.data.flatMap((sub) =>
sub.items.data.map((item) => (item.price as Stripe.Price).id),
);
if (

View File

@ -531,3 +531,98 @@ 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;
}
}
}

View File

@ -60,6 +60,7 @@ 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({

View File

@ -66,9 +66,10 @@ export const removeJob = async (data: QueueJob) => {
return result;
}
if (data.type === "schedule") {
const { scheduleId, cronSchedule } = data;
const { scheduleId, cronSchedule, timezone } = data;
const result = await jobQueue.removeRepeatable(scheduleId, {
pattern: cronSchedule,
tz: timezone || "UTC",
});
return result;
}

View File

@ -7,6 +7,7 @@ import { organization } from "./account";
export const dnsProviderType = pgEnum("DnsProviderType", [
"cloudflare",
"route53",
"porkbun",
]);
export const cloudflareDnsConfigSchema = z.object({
@ -20,9 +21,16 @@ 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>;
@ -96,11 +104,28 @@ 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(["A", "CNAME"]),
type: z.enum(dnsRecordTypes),
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({

View File

@ -433,6 +433,7 @@ export const apiCreateGotify = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),
@ -468,6 +469,7 @@ export const apiCreateNtfy = notificationsSchema
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
serverUrl: z.string().min(1),

View File

@ -11,6 +11,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"doppler",
"azure",
"scaleway",
"phase",
]);
export const hashicorpVaultConfigSchema = z.object({
@ -62,6 +63,15 @@ 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,
@ -69,6 +79,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
phaseVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;

View File

@ -125,10 +125,24 @@ export const findComposeById = async (composeId: string) => {
deployments: true,
mounts: true,
domains: true,
github: true,
gitlab: true,
bitbucket: true,
gitea: 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,
},
},
server: true,
backups: {
with: {

View File

@ -17,6 +17,7 @@ export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
};
export const maskDnsProviderConfig = (

View File

@ -124,8 +124,12 @@ 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.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*
* 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.
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
@ -146,3 +150,24 @@ 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";
};

View File

@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} 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";
@ -149,6 +152,7 @@ export const deployLibsql = async (
}
await buildLibsql(libsql);
await waitForSwarmServiceConvergence(libsql.appName, libsql.serverId);
await updateLibsqlById(libsqlId, {
applicationStatus: "done",
});

View File

@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} 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";
@ -154,6 +157,7 @@ export const deployMariadb = async (
}
await buildMariadb(mariadb);
await waitForSwarmServiceConvergence(mariadb.appName, mariadb.serverId);
await updateMariadbById(mariadbId, {
applicationStatus: "done",
});

View File

@ -8,7 +8,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} 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";
@ -169,6 +172,7 @@ export const deployMongo = async (
}
await buildMongo(mongo);
await waitForSwarmServiceConvergence(mongo.appName, mongo.serverId);
await updateMongoById(mongoId, {
applicationStatus: "done",
});

View File

@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMysql } from "@dokploy/server/utils/databases/mysql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} 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,6 +155,7 @@ export const deployMySql = async (
}
await buildMysql(mysql);
await waitForSwarmServiceConvergence(mysql.appName, mysql.serverId);
await updateMySqlById(mysqlId, {
applicationStatus: "done",
});

View File

@ -561,6 +561,7 @@ export const createGotifyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
notificationType: "gotify",
organizationId: organizationId,
})
@ -593,6 +594,7 @@ export const updateGotifyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))
@ -655,6 +657,7 @@ export const createNtfyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
notificationType: "ntfy",
organizationId: organizationId,
})
@ -687,6 +690,7 @@ export const updateNtfyNotification = async (
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
serverThreshold: input.serverThreshold,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))

View File

@ -7,7 +7,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} 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";
@ -165,6 +168,8 @@ export const deployPostgres = async (
await buildPostgres(postgres);
await waitForSwarmServiceConvergence(postgres.appName, postgres.serverId);
await updatePostgresById(postgresId, {
applicationStatus: "done",
});

View File

@ -6,7 +6,10 @@ import {
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildRedis } from "@dokploy/server/utils/databases/redis";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import {
pullImage,
waitForSwarmServiceConvergence,
} from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
@ -119,6 +122,7 @@ export const deployRedis = async (
}
await buildRedis(redis);
await waitForSwarmServiceConvergence(redis.appName, redis.serverId);
await updateRedisById(redisId, {
applicationStatus: "done",
});

View File

@ -134,6 +134,97 @@ export const haveActiveServices = async (serverId: string) => {
return true;
};
export const SERVICE_TYPES_BY_SERVER = [
{ type: "application", relation: "applications", idColumn: "applicationId" },
{ type: "compose", relation: "compose", idColumn: "composeId" },
{ type: "postgres", relation: "postgres", idColumn: "postgresId" },
{ type: "mysql", relation: "mysql", idColumn: "mysqlId" },
{ type: "mariadb", relation: "mariadb", idColumn: "mariadbId" },
{ type: "mongo", relation: "mongo", idColumn: "mongoId" },
{ type: "redis", relation: "redis", idColumn: "redisId" },
{ type: "libsql", relation: "libsql", idColumn: "libsqlId" },
] as const;
export interface ServerService {
id: string;
type: (typeof SERVICE_TYPES_BY_SERVER)[number]["type"];
name: string;
projectId: string;
environmentId: string;
url: string;
}
export const getServicesByServerId = async (
serverId: string,
): Promise<ServerService[]> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { serverId: true },
with: {
applications: {
columns: { applicationId: true, name: true },
with: { environment: { with: { project: true } } },
},
compose: {
columns: { composeId: true, name: true },
with: { environment: { with: { project: true } } },
},
postgres: {
columns: { postgresId: true, name: true },
with: { environment: { with: { project: true } } },
},
mysql: {
columns: { mysqlId: true, name: true },
with: { environment: { with: { project: true } } },
},
mariadb: {
columns: { mariadbId: true, name: true },
with: { environment: { with: { project: true } } },
},
mongo: {
columns: { mongoId: true, name: true },
with: { environment: { with: { project: true } } },
},
redis: {
columns: { redisId: true, name: true },
with: { environment: { with: { project: true } } },
},
libsql: {
columns: { libsqlId: true, name: true },
with: { environment: { with: { project: true } } },
},
},
});
if (!currentServer) {
return [];
}
const services: ServerService[] = [];
for (const { type, relation, idColumn } of SERVICE_TYPES_BY_SERVER) {
const rows = currentServer[relation as keyof typeof currentServer] as Array<
Record<string, any>
>;
for (const row of rows) {
const projectId = row.environment.project.projectId as string;
const environmentId = row.environment.environmentId as string;
services.push({
id: row[idColumn],
type,
name: row.name,
projectId,
environmentId,
url: `/dashboard/project/${projectId}/environment/${environmentId}/services/${type}/${row[idColumn]}`,
});
}
}
return services;
};
export const updateServerById = async (
serverId: string,
serverData: Partial<Server>,

View File

@ -23,6 +23,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
doppler: ["serviceToken"],
azure: ["clientSecret"],
scaleway: ["secretKey"],
phase: ["token"],
};
export const maskVaultProviderConfig = (

View File

@ -22,7 +22,10 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const command = createCommand(
compose,
mounts.length > 0 ? projectPath : undefined,
);
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
: "";
@ -133,7 +136,10 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const projectDirectoryFlag = projectPath
? `--project-directory ${quote([projectPath])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}-f ${quote([path])} up -d --build --remove-orphans`;
const envFileFlag = compose.createEnvFile
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}

View File

@ -1,4 +1,7 @@
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
import {
type cloudflareDnsConfigSchema,
proxiableDnsRecordTypes,
} from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
@ -13,6 +16,68 @@ type CloudflareResponse<T> = {
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
const inlinePriority = (record: {
type: string;
content: string;
priority?: number;
}) =>
record.type === "MX" && typeof record.priority === "number"
? `${record.priority} ${record.content}`
: record.content;
const proxySettings = (record: { type: string; proxied?: boolean }) =>
record.proxied !== undefined &&
(proxiableDnsRecordTypes as readonly string[]).includes(record.type)
? { proxied: record.proxied }
: {};
const buildValue = (record: { type: string; content: string }) => {
const value = record.content.trim();
if (record.type === "MX") {
const match = /^(\d+)\s+(\S.*)$/.exec(value);
return match
? { content: match[2] as string, priority: Number(match[1]) }
: { content: value, priority: 10 };
}
if (record.type === "SRV") {
const parts = value.split(/\s+/);
const [priority, weight, port, target] = parts;
if (parts.length !== 4 || !target) {
throw new Error(
`Cloudflare: an SRV value must be "priority weight port target", got "${value}"`,
);
}
return {
data: {
priority: Number(priority),
weight: Number(weight),
port: Number(port),
target,
},
};
}
if (record.type === "CAA") {
const match = /^(\d+)\s+(\S+)\s+"?([^"]+)"?$/.exec(value);
if (!match) {
throw new Error(
`Cloudflare: a CAA value must be \`flags tag "value"\`, got "${value}"`,
);
}
return {
data: {
flags: Number(match[1]),
tag: match[2] as string,
value: match[3] as string,
},
};
}
return { content: value };
};
const cfFetch = async <T>(
config: CloudflareConfig,
path: string,
@ -72,9 +137,20 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
name: string;
content: string;
ttl: number;
priority?: number;
proxied?: boolean;
}[]
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
records.push(...result);
records.push(
...result.map((record) => ({
id: record.id,
type: record.type,
name: record.name,
content: inlinePriority(record),
ttl: record.ttl,
proxied: record.proxied,
})),
);
if (result.length < 50) {
break;
}
@ -84,18 +160,19 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
},
async upsertRecord(config, record) {
const payload = {
type: record.type,
name: record.name,
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
};
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
@ -123,7 +200,8 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
...buildValue(record),
...proxySettings(record),
ttl: record.ttl ?? 1,
}),
},

View File

@ -1,11 +1,13 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { porkbunClient } from "./porkbun";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
porkbun: porkbunClient as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>

View File

@ -0,0 +1,134 @@
import type { porkbunDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type PorkbunConfig = z.infer<typeof porkbunDnsConfigSchema>;
type PorkbunResponse<T> = T & {
status: "SUCCESS" | "ERROR";
message?: string;
};
const PORKBUN_API = "https://api.porkbun.com/api/json/v3";
const pbFetch = async <T>(
config: PorkbunConfig,
path: string,
body: Record<string, unknown> = {},
): Promise<PorkbunResponse<T>> => {
const response = await dnsFetch(`${PORKBUN_API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apikey: config.apiKey,
secretapikey: config.secretApiKey,
...body,
}),
});
const result = (await response.json()) as PorkbunResponse<T>;
if (!response.ok || result.status !== "SUCCESS") {
throw new Error(
`Porkbun: request to ${path} failed${
result.message ? `: ${result.message}` : ` (status ${response.status})`
}`,
);
}
return result;
};
// Porkbun's "name" only accepts the subdomain portion, without the zone (domain) itself.
const toSubdomain = (name: string, domain: string) => {
if (name === domain) {
return "";
}
const suffix = `.${domain}`;
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
};
interface PorkbunRecord {
id: string;
name: string;
type: string;
content: string;
ttl: string;
prio: string;
notes: string;
}
export const porkbunClient: DnsClient<PorkbunConfig> = {
async listZones(config) {
const result = await pbFetch<{ domains: { domain: string }[] }>(
config,
"/domain/listAll",
);
return result.domains.map((domain) => ({
id: domain.domain,
name: domain.domain,
}));
},
async listRecords(config, zoneId) {
const result = await pbFetch<{ records: PorkbunRecord[] }>(
config,
`/dns/retrieve/${zoneId}`,
);
return result.records.map((record) => ({
id: record.id,
type: record.type,
name: record.name,
content: record.content,
ttl: Number(record.ttl),
}));
},
async upsertRecord(config, record) {
const subdomain = toSubdomain(record.name, record.zoneId);
const existing = await pbFetch<{ records: PorkbunRecord[] }>(
config,
`/dns/retrieveByNameType/${record.zoneId}/${record.type}/${subdomain}`,
);
const payload = {
name: subdomain,
type: record.type,
content: record.content,
ttl: record.ttl ?? 600,
};
const existingRecord = existing.records[0];
if (existingRecord) {
await pbFetch(
config,
`/dns/edit/${record.zoneId}/${existingRecord.id}`,
payload,
);
return { id: existingRecord.id };
}
const created = await pbFetch<{ id: string }>(
config,
`/dns/create/${record.zoneId}`,
payload,
);
return { id: created.id };
},
async updateRecord(config, zoneId, recordId, record) {
await pbFetch(config, `/dns/edit/${zoneId}/${recordId}`, {
name: toSubdomain(record.name, zoneId),
type: record.type,
content: record.content,
ttl: record.ttl ?? 600,
});
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await pbFetch(config, `/dns/delete/${zoneId}/${recordId}`);
},
async testConnection(config) {
await pbFetch(config, "/ping");
},
};

View File

@ -5,7 +5,10 @@ import {
type ResourceRecordSet,
Route53Client,
} from "@aws-sdk/client-route-53";
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
import type {
DnsRecordType,
route53DnsConfigSchema,
} from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { DnsClient } from "./types";
@ -65,16 +68,23 @@ const findExactRecordSet = async (
return undefined;
};
const formatValue = (type: DnsRecordType, value: string) =>
type === "TXT" && !value.startsWith('"') ? JSON.stringify(value) : value;
const buildRecordSet = (record: {
type: "A" | "CNAME";
type: DnsRecordType;
name: string;
content: string;
ttl?: number;
}): ResourceRecordSet => ({
Name: ensureTrailingDot(record.name),
Type: record.type,
Type: record.type as ResourceRecordSet["Type"],
TTL: record.ttl ?? 300,
ResourceRecords: [{ Value: record.content }],
ResourceRecords: record.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean)
.map((value) => ({ Value: formatValue(record.type, value) })),
});
export const route53Client: DnsClient<Route53Config> = {
@ -126,7 +136,7 @@ export const route53Client: DnsClient<Route53Config> = {
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
content: set.ResourceRecords.map((r) => r.Value).join(", "),
content: set.ResourceRecords.map((r) => r.Value).join("\n"),
ttl: set.TTL ?? 300,
});
}
@ -138,13 +148,25 @@ export const route53Client: DnsClient<Route53Config> = {
async upsertRecord(config, record) {
const client = createClient(config);
const existing = await findExactRecordSet(
config,
record.zoneId,
record.type,
record.name,
);
const recordSet = buildRecordSet(record);
if (existing?.ResourceRecords?.length) {
const values = new Set([
...existing.ResourceRecords.map((r) => r.Value),
...(recordSet.ResourceRecords ?? []).map((r) => r.Value),
]);
recordSet.ResourceRecords = [...values].map((Value) => ({ Value }));
}
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
Changes: [
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
],
Changes: [{ Action: "UPSERT", ResourceRecordSet: recordSet }],
},
}),
);

View File

@ -1,4 +1,7 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import type {
DnsProviderConfig,
DnsRecordType,
} from "@dokploy/server/db/schema";
export interface DnsZone {
id: string;
@ -7,10 +10,11 @@ export interface DnsZone {
export interface DnsRecordInput {
zoneId: string;
type: "A" | "CNAME";
type: DnsRecordType;
name: string;
content: string;
ttl?: number;
proxied?: boolean;
}
export interface DnsRecord {
@ -19,6 +23,7 @@ export interface DnsRecord {
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {

View File

@ -934,6 +934,57 @@ const getSwarmServiceContainerId = async (
}
};
export class ServiceConvergenceError extends Error {}
export const waitForSwarmServiceConvergence = async (
appName: string,
serverId?: string | null,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> => {
const timeoutMs = options?.timeoutMs ?? 45_000;
const intervalMs = options?.intervalMs ?? 2_000;
const remoteDocker = await getRemoteDocker(serverId);
const service = remoteDocker.getService(appName);
const deadline = Date.now() + timeoutMs;
let lastState = "unknown";
while (true) {
const info = await service.inspect();
const desiredTasksCount = info.Spec?.Mode?.Replicated?.Replicas ?? 1;
const tasks = await remoteDocker.listTasks({
filters: JSON.stringify({ service: [appName] }),
});
const currentTasks = tasks.filter(
(task) => task.DesiredState === "running",
);
const runningTasksCount = currentTasks.filter(
(task) => task.Status?.State === "running",
).length;
if (runningTasksCount >= desiredTasksCount) {
return;
}
const failedTask = currentTasks.find((task) =>
["failed", "rejected"].includes(task.Status?.State ?? ""),
);
lastState =
failedTask?.Status?.Err ??
failedTask?.Status?.State ??
currentTasks[0]?.Status?.State ??
lastState;
if (Date.now() >= deadline) {
throw new ServiceConvergenceError(
`Service ${appName} did not converge within ${timeoutMs}ms: ${runningTasksCount}/${desiredTasksCount} tasks running (last state: ${lastState})`,
);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
};
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
if (serviceCheck.status === "unhealthy") {

View File

@ -369,22 +369,28 @@ export const sendServerThresholdNotifications = async (
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{
name: "Current Value",
value: `${payload.Value.toFixed(2)}%`,
},
{
name: "Threshold",
value: `${payload.Threshold.toFixed(2)}%`,
},
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
} catch (error) {
console.log(error);
}
if (teams) {
await sendTeamsNotification(teams, {
title: `⚠️ Server ${payload.Type} Alert`,
facts: [
{ name: "Server Name", value: payload.ServerName },
{ name: "Type", value: payload.Type },
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
{ name: "Time", value: date.toLocaleString() },
{ name: "Message", value: payload.Message },
],
});
}
}
};

View File

@ -4,6 +4,20 @@ import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2";
import { ExecError } from "./ExecError";
export class WriteFileRemoteError extends Error {
constructor(
message: string,
public readonly context: {
remotePath: string;
serverId: string;
originalError: Error;
},
) {
super(message);
this.name = "WriteFileRemoteError";
}
}
// Re-export ExecError for easier imports
export { ExecError } from "./ExecError";
@ -21,11 +35,11 @@ export const execAsync = async (
};
} catch (error) {
if (error instanceof Error) {
// @ts-ignore - exec error has these properties
// @ts-expect-error - exec error has these properties
const exitCode = error.code;
// @ts-ignore
// @ts-expect-error
const stdout = error.stdout?.toString() || "";
// @ts-ignore
// @ts-expect-error
const stderr = error.stderr?.toString() || "";
throw new ExecError(`Command execution failed: ${error.message}`, {
@ -61,7 +75,6 @@ export const execAsyncStream = (
command,
stdout: stdoutComplete,
stderr: stderrComplete,
// @ts-ignore
exitCode: error.code,
originalError: error,
}),
@ -249,6 +262,65 @@ export const execAsyncRemote = async (
});
};
export const writeFileRemote = async (
serverId: string,
remotePath: string,
content: string,
): Promise<void> => {
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.sftp((err, sftp) => {
if (err) {
conn.end();
reject(
new WriteFileRemoteError(`SFTP session failed: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
return;
}
sftp.writeFile(remotePath, content, (writeErr) => {
conn.end();
if (writeErr) {
reject(
new WriteFileRemoteError(
`Failed to write remote file ${remotePath}: ${writeErr.message}`,
{ remotePath, serverId, originalError: writeErr },
),
);
return;
}
resolve();
});
});
})
.on("error", (err) => {
conn.end();
reject(
new WriteFileRemoteError(`SSH connection error: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};

View File

@ -5,8 +5,11 @@ import { paths } from "@dokploy/server/constants";
import type { Domain } from "@dokploy/server/services/domain";
import { quote } from "shell-quote";
import { parse, stringify } from "yaml";
import { encodeBase64 } from "../docker/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
execAsync,
execAsyncRemote,
writeFileRemote,
} from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
export const createTraefikConfig = (appName: string) => {
@ -228,11 +231,7 @@ export const writeConfigRemote = async (
try {
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, traefikConfig);
} catch (e) {
console.error("Error saving the YAML config file:", e);
}
@ -246,11 +245,7 @@ export const writeTraefikConfigInPath = async (
try {
const configPath = path.join(pathFile);
if (serverId) {
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, traefikConfig);
} else {
fs.writeFileSync(configPath, traefikConfig, "utf8");
}
@ -282,11 +277,7 @@ export const writeTraefikConfigRemote = async (
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
const yamlStr = stringify(traefikConfig);
const encoded = encodeBase64(yamlStr);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
);
await writeFileRemote(serverId, configPath, yamlStr);
} catch (e) {
console.error("Error saving the YAML config file:", e);
}

View File

@ -0,0 +1,106 @@
import type { phaseVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type PhaseConfig = z.infer<typeof phaseVaultConfigSchema>;
type PhaseSecret = {
key: string;
value: string;
path?: string;
};
const baseUrl = (config: PhaseConfig) => config.apiUrl.replace(/\/+$/, "");
const authHeaders = (config: PhaseConfig) => ({
Authorization: `Bearer ServiceAccount ${config.token}`,
Accept: "application/json",
});
const parseErrorDetail = async (response: Response) => {
try {
const body = (await response.json()) as {
error?: string;
message?: string;
};
return body.error ?? body.message ?? "";
} catch {
return "";
}
};
const request = async (
config: PhaseConfig,
path: string,
params?: Record<string, string>,
) => {
const url = new URL(`${baseUrl(config)}${path}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
}
const response = await vaultFetch(url.toString(), {
headers: authHeaders(config),
});
if (response.ok) {
return response;
}
const detail = await parseErrorDetail(response);
const reason =
response.status === 401 || response.status === 403
? "authentication failed"
: "request failed";
throw new Error(
`Phase: ${reason} (status ${response.status}${detail ? `: ${detail}` : ""})`,
);
};
const fetchSecrets = async (config: PhaseConfig) => {
const response = await request(config, "/v1/secrets/", {
app_id: config.appId,
env: config.env,
path: config.path || "/",
});
const body = (await response.json()) as PhaseSecret[];
const secrets: Record<string, string> = {};
for (const secret of body ?? []) {
secrets[secret.key] = secret.value;
}
return secrets;
};
export const phaseClient: VaultClient<PhaseConfig> = {
async getSecrets(config, refs) {
const secrets = await fetchSecrets(config);
const result: Record<string, string> = {};
for (const ref of refs) {
if (secrets[ref] === undefined) {
throw new Error(
`Phase: secret "${ref}" not found in environment "${config.env}"`,
);
}
result[ref] = secrets[ref];
}
return result;
},
async testConnection(config) {
const appResponse = await request(config, `/v1/apps/${config.appId}/`);
const app = (await appResponse.json()) as { sseEnabled?: boolean };
if (app.sseEnabled === false) {
throw new Error(
"Phase: enable Server-side Encryption (SSE) on this Phase App to use the REST secrets API",
);
}
await fetchSecrets(config);
},
async listSecretNames(config) {
const secrets = await fetchSecrets(config);
return Object.keys(secrets);
},
};