mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
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
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:
commit
27dab3f4bf
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
211
apps/dokploy/__test__/dns/porkbun.test.ts
Normal file
211
apps/dokploy/__test__/dns/porkbun.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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: [] })
|
||||
|
||||
127
apps/dokploy/__test__/env/vault.test.ts
vendored
127
apps/dokploy/__test__/env/vault.test.ts
vendored
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 || "",
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -21,6 +21,7 @@ const providerLabels: Record<string, string> = {
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
@ -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",
|
||||
|
||||
307
apps/dokploy/components/shared/vault-import-dialog.tsx
Normal file
307
apps/dokploy/components/shared/vault-import-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
|
||||
1
apps/dokploy/drizzle/0188_volatile_piledriver.sql
Normal file
1
apps/dokploy/drizzle/0188_volatile_piledriver.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
|
||||
1
apps/dokploy/drizzle/0189_wooden_nextwave.sql
Normal file
1
apps/dokploy/drizzle/0189_wooden_nextwave.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
|
||||
9146
apps/dokploy/drizzle/meta/0188_snapshot.json
Normal file
9146
apps/dokploy/drizzle/meta/0188_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
9147
apps/dokploy/drizzle/meta/0189_snapshot.json
Normal file
9147
apps/dokploy/drizzle/meta/0189_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.3",
|
||||
"version": "v0.30.4",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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";
|
||||
};
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -23,6 +23,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
scaleway: ["secretKey"],
|
||||
phase: ["token"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@ -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"]) =>
|
||||
|
||||
134
packages/server/src/utils/dns/porkbun.ts
Normal file
134
packages/server/src/utils/dns/porkbun.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
@ -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 }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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));
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
106
packages/server/src/utils/vault/phase.ts
Normal file
106
packages/server/src/utils/vault/phase.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user