From 9c4ad14c7084005186169e4c396df54841a60882 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 10:24:52 +0200 Subject: [PATCH 1/5] feat(dns): rework provider management and support all record types Providers, domains and records now each have their own page instead of a stack of modals. A provider opens its domains as cards, with the record count loading separately so the domains appear right away. A domain opens its records as a table with search, type filter and pagination. Creating or editing a record happens in a panel that slides in next to the table. The type list was capped at A and CNAME in the zod schema, so widening the dropdown alone would not have worked. AAAA, MX, TXT, NS, SRV, CAA and PTR work now. MX carries a priority that Cloudflare takes as a separate field and Route53 takes inline in the value, so the Cloudflare client splits it out on write and puts it back on read. The form keeps one value field for both providers. Cloudflare proxy status is now editable and visible. A, AAAA and CNAME records get a Proxied / DNS only toggle in the form and a cloud icon in the table. The proxy field only goes to the API when the caller sets it, so an update from another path cannot silently disable the proxy. Tests cover the MX priority round trip and the proxy rules in the Cloudflare client. --- apps/dokploy/__test__/dns/cloudflare.test.ts | 148 +++++ .../settings/dns/dns-page-transition.tsx | 32 + .../settings/dns/dns-record-panel.tsx | 375 ++++++++++++ .../settings/dns/dns-record-type-badge.tsx | 33 + .../settings/dns/handle-dns-provider.tsx | 41 +- .../settings/dns/handle-dns-record.tsx | 271 --------- .../settings/dns/show-dns-provider-zones.tsx | 218 ------- .../settings/dns/show-dns-providers.tsx | 244 ++++---- .../settings/dns/show-dns-records.tsx | 566 ++++++++++++++++++ .../dashboard/settings/dns/show-dns-zones.tsx | 134 +++++ apps/dokploy/pages/dashboard/settings/dns.tsx | 5 +- .../settings/dns/[dnsProviderId].tsx | 62 ++ .../settings/dns/[dnsProviderId]/[zoneId].tsx | 69 +++ .../server/api/routers/dns-provider.ts | 2 + apps/dokploy/styles/globals.css | 95 +++ packages/server/src/db/schema/dns-provider.ts | 19 +- packages/server/src/utils/dns/cloudflare.ts | 49 +- packages/server/src/utils/dns/route53.ts | 9 +- packages/server/src/utils/dns/types.ts | 9 +- 19 files changed, 1758 insertions(+), 623 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx create mode 100644 apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx create mode 100644 apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx delete mode 100644 apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx create mode 100644 apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx create mode 100644 apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx create mode 100644 apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx create mode 100644 apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index 356d429a4..f2abbc4bb 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -87,6 +87,154 @@ 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 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 diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx new file mode 100644 index 000000000..20accf405 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-page-transition.tsx @@ -0,0 +1,32 @@ +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +let lastDepth: number | null = null; + +const depthOf = (path: string) => + path.split("?")[0]!.split("/").filter(Boolean).length; + +export const DnsPageTransition = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { asPath } = useRouter(); + const depth = depthOf(asPath); + const [direction] = useState(() => + lastDepth !== null && depth < lastDepth ? "back" : "forward", + ); + + useEffect(() => { + lastDepth = depth; + }, [depth]); + + return ( +
+ {children} +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx new file mode 100644 index 000000000..3f06581a5 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -0,0 +1,375 @@ +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 { 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 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(), +}); + +type DnsRecordForm = z.infer; + +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({ + 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 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 ( +
+
+
+ + {record ? "Edit record" : "New record"} + + {zoneName} +
+ +
+ + {isError && {error?.message}} + +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + {valueFields[type].label} + + + + {valueFields[type].hint && ( + {valueFields[type].hint} + )} + + + )} + /> + {canProxy && ( + ( + + Proxy status +
+ Proxy status +
+ + {field.value + ? "Traffic runs through Cloudflare and the origin IP stays hidden." + : "Cloudflare only answers the DNS query; traffic reaches the origin directly."} + +
+ )} + /> + )} + ( + + TTL (optional) + + + + {usesAutomaticTtl && ( + + Proxied records always use automatic TTL. + + )} + + + )} + /> +
+ + +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx new file mode 100644 index 000000000..2673bd0c7 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-type-badge.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; + +const recordTypeStyles: Record = { + 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) => ( + + {type.toUpperCase()} + +); diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index 67d6a7547..166b00c3e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -33,6 +33,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { api } from "@/utils/api"; const providerLabels = { @@ -180,22 +185,30 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { return ( - - {dnsProviderId ? ( - - ) : ( - + + + Edit provider + + ) : ( + + - )} - + + )} diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx deleted file mode 100644 index 00ab06074..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-record.tsx +++ /dev/null @@ -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; - -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({ - 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 ( - - - {record ? ( - - ) : ( - - )} - - - - {record ? "Edit Record" : "Add Record"} - - {record - ? "Update this DNS record." - : "Create a new A or CNAME record in this zone."} - - - {isError && {error?.message}} -
- - ( - - Type - - - - )} - /> - ( - - Name - - - - - Use @ for the root domain. - - - - )} - /> - {type === "A" && ipSuggestions.length > 0 && ( - - Fill from server (optional) - - - )} - ( - - - {type === "A" ? "IPv4 Address" : "Target"} - - - - - - - )} - /> - ( - - TTL (optional) - - - - - - )} - /> - - - - - -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx deleted file mode 100644 index 2ca61abfa..000000000 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-provider-zones.tsx +++ /dev/null @@ -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 ( -
- {isLoading && ( -
- Loading records... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No records found in this zone. -

- )} - {data?.map((record) => { - const isEditable = record.type === "A" || record.type === "CNAME"; - return ( -
- - {record.type} - - {record.name} - - → {record.content} - - {canWrite && isEditable && ( - - )} - {canDelete && ( - { - 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"); - }); - }} - > - - - )} -
- ); - })} - {canWrite && ( -
- -
- )} -
- ); -}; - -interface Props { - dnsProviderId: string; - providerName: string; -} - -export const ShowDnsProviderZones = ({ - dnsProviderId, - providerName, -}: Props) => { - const [isOpen, setIsOpen] = useState(false); - const [expandedZoneId, setExpandedZoneId] = useState(null); - - const { data, isLoading, isError, error } = - api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen }); - - return ( - { - setIsOpen(open); - if (!open) { - setExpandedZoneId(null); - } - }} - > - - - - - - Domains for {providerName} - - Zones this provider's API token can manage. Click a zone to see and - manage its records. - - - {isLoading && ( -
- Loading... - -
- )} - {isError && ( -

{error?.message}

- )} - {data && data.length === 0 && ( -

- No zones found for this token. Make sure it has access to at least - one zone. -

- )} - {data && data.length > 0 && ( -
- {data.map((zone) => { - const isExpanded = expandedZoneId === zone.id; - return ( -
- - {isExpanded && ( - - )} -
- ); - })} -
- )} -
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx index 9797ab93d..4d511ff35 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-providers.tsx @@ -1,8 +1,9 @@ -import { Globe, Loader2, Trash2 } from "lucide-react"; +import { EyeIcon, Globe, 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,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +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 = { cloudflare: "Cloudflare", @@ -21,121 +27,143 @@ const providerLabels: Record = { }; export const ShowDnsProviders = () => { - const { mutateAsync, isPending: isRemoving } = - api.dnsProvider.remove.useMutation(); + const [removingId, setRemovingId] = useState(null); + const { mutateAsync } = api.dnsProvider.remove.useMutation(); const { data, isPending, refetch } = api.dnsProvider.all.useQuery(); const { data: permissions } = api.user.getPermissions.useQuery(); return (
- +
- - - - DNS Providers - - - Connect a DNS provider so Dokploy can create the A/CNAME record - for a domain instead of you setting it up by hand. - - - +
+ + + + DNS Providers + + + Connect a DNS provider so Dokploy can create the A/CNAME record + for a domain instead of you setting it up by hand. + + + {permissions?.dnsProvider.create && } +
+ + {isPending ? ( -
- Loading... - +
+ {[0, 1, 2].map((row) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+ + + No DNS providers connected + + + Add Cloudflare or Route53 credentials to manage domain records + without leaving Dokploy. +
) : ( - <> - {data?.length === 0 ? ( -
- - - You don't have any DNS providers configured - - {permissions?.dnsProvider.create && } -
- ) : ( -
-
- {data?.map((provider) => { - const ProviderIcon = - dnsProviderIcons[provider.providerType]; - return ( -
-
-
- -
- - {provider.name} - - - {providerLabels[provider.providerType] ?? - provider.providerType} - -
-
- -
- - {permissions?.dnsProvider.update && ( - - )} - {permissions?.dnsProvider.delete && ( - { - await mutateAsync({ - dnsProviderId: provider.dnsProviderId, - }) - .then(() => { - toast.success("DNS provider deleted"); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting the DNS provider", - ); - }); - }} - > - - - )} -
-
-
- ); - })} -
- - {permissions?.dnsProvider.create && ( -
- +
    + {data?.map((provider) => { + const ProviderIcon = dnsProviderIcons[provider.providerType]; + const href = `/dashboard/settings/dns/${provider.dnsProviderId}`; + return ( +
  • + + +
    + + {provider.name} + + + {providerLabels[provider.providerType] ?? + provider.providerType} +
    - )} -
- )} - + +
+ + + + + View domains + + + {permissions?.dnsProvider.update && ( + + )} + {permissions?.dnsProvider.delete && ( + + { + 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)); + }} + > + + + + + Delete provider + + )} +
+ + ); + })} + )}
diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx new file mode 100644 index 000000000..9431545dc --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx @@ -0,0 +1,566 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { + ArrowLeft, + ArrowUpDown, + Cloud, + CloudOff, + ListTree, + 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 { Skeleton } from "@/components/ui/skeleton"; +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; +}) => ( + +); + +export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { + const utils = api.useUtils(); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [sorting, setSorting] = useState([ + { id: "name", desc: false }, + ]); + const [pagination, setPagination] = useState({ + 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[]>( + () => [ + { + accessorKey: "type", + header: ({ column }) => , + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: "content", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.content} + + ), + }, + { + accessorKey: "ttl", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.ttl === 1 ? "Auto" : row.original.ttl} + + ), + }, + ...(isCloudflare + ? [ + { + accessorKey: "proxied", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + if (!PROXIABLE_TYPES.includes(row.original.type)) { + return null; + } + const proxied = !!row.original.proxied; + return ( + + + + {proxied ? ( + + ) : ( + + )} + + {proxied ? "Proxied" : "DNS only"} + + + + + {proxied ? "Proxied" : "DNS only"} + + + ); + }, + } satisfies ColumnDef, + ] + : []), + { + id: "actions", + enableSorting: false, + header: () => Actions, + cell: ({ row }) => { + const record = row.original; + const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes( + record.type, + ); + return ( +
+ {canWrite && isEditable && ( + + + + + Edit record + + )} + {canDelete && ( + + handleDelete(record)} + > + + + + + Delete record + + )} +
+ ); + }, + }, + ], + [canWrite, canDelete, deletingId, editing?.id, isCloudflare], + ); + + const table = useReactTable({ + data: filteredRecords, + columns, + getRowId: (row) => row.id, + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+ +
+
+
+ + + + {zoneName || "DNS records"} + + + Records managed through {provider?.name ?? "this provider"}. + Changes are written straight to the provider. + + +
+ {canWrite && ( + + )} +
+ + + {isError && {error?.message}} +
+
+ {isPending ? ( + + ) : data?.length === 0 ? ( +
+
+ +
+
+

+ No records in this zone +

+

+ Add an A or CNAME record to point this domain at one of + your servers. +

+
+
+ ) : ( + <> +
+
+ setSearch(e.target.value)} + className="pr-10" + /> + +
+ + + {filteredRecords.length} of {data?.length ?? 0} + +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {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 ( + openEdit(row.original) + : undefined + } + className={cn( + "duration-150 ease-out", + canWrite && isEditable && "cursor-pointer", + )} + > + {row.getVisibleCells().map((cell) => ( + event.stopPropagation() + : undefined + } + className="px-4 py-2" + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + }) + ) : ( + + + No records match your filters. + + + )} + +
+
+ +
+
+ + Rows per page + + +
+
+ + Page {table.getState().pagination.pageIndex + 1} of{" "} + {Math.max(table.getPageCount(), 1)} + +
+ + +
+
+
+ + )} +
+ +
+
+
+ {canWrite && ( + setIsPanelOpen(false)} + /> + )} +
+
+
+
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx new file mode 100644 index 000000000..839c2083b --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-zones.tsx @@ -0,0 +1,134 @@ +import { ArrowLeft, Globe } 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 { Skeleton } from "@/components/ui/skeleton"; +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 ; + } + + if (isError) { + return ( + Records unavailable + ); + } + + return ( + + {data.length === 0 + ? "No records" + : `${data.length} record${data.length === 1 ? "" : "s"}`} + + ); +}; + +export const ShowDnsZones = ({ dnsProviderId }: Props) => { + const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId }); + const { data, isPending, isError, error } = + api.dnsProvider.listZones.useQuery({ dnsProviderId }); + + return ( +
+ +
+
+
+ + + + {provider?.name ?? "Domains"} + + + Domains this provider's credentials can manage. Open one to + see and edit its DNS records. + + +
+
+ + + {isError && {error?.message}} + {isPending ? ( +
+ {[0, 1, 2, 3, 4, 5].map((card) => ( + + ))} +
+ ) : data?.length === 0 ? ( +
+
+ +
+
+

No domains found

+

+ These credentials can't reach any zone. Check that the token + has access to at least one domain. +

+
+
+ ) : ( +
+ {data?.map((zone) => ( + +
+ + + + + {zone.name} + +
+
+ +
+ + ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/dashboard/settings/dns.tsx b/apps/dokploy/pages/dashboard/settings/dns.tsx index 60aaae76e..0600117b1 100644 --- a/apps/dokploy/pages/dashboard/settings/dns.tsx +++ b/apps/dokploy/pages/dashboard/settings/dns.tsx @@ -3,15 +3,16 @@ import { createServerSideHelpers } from "@trpc/react-query/server"; import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import superjson from "superjson"; +import { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { appRouter } from "@/server/api/root"; const Page = () => { return ( -
+ -
+ ); }; diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx new file mode 100644 index 000000000..105b6d378 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId].tsx @@ -0,0 +1,62 @@ +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 { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +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 ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +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, + }, + }; +} diff --git a/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx new file mode 100644 index 000000000..c307738f1 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/dns/[dnsProviderId]/[zoneId].tsx @@ -0,0 +1,69 @@ +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 { DnsPageTransition } from "@/components/dashboard/settings/dns/dns-page-transition"; +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 ( + + + + ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +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, + }, + }; +} diff --git a/apps/dokploy/server/api/routers/dns-provider.ts b/apps/dokploy/server/api/routers/dns-provider.ts index 673c91a21..88796101d 100644 --- a/apps/dokploy/server/api/routers/dns-provider.ts +++ b/apps/dokploy/server/api/routers/dns-provider.ts @@ -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, { diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index 016513952..4dcc3d697 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -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; + } + } +} diff --git a/packages/server/src/db/schema/dns-provider.ts b/packages/server/src/db/schema/dns-provider.ts index 50677e646..fa2135173 100644 --- a/packages/server/src/db/schema/dns-provider.ts +++ b/packages/server/src/db/schema/dns-provider.ts @@ -96,11 +96,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({ diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 9fedd00e5..51eeaee31 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -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,31 @@ type CloudflareResponse = { 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 splitPriority = (record: { type: string; content: string }) => { + if (record.type !== "MX") { + return { content: record.content }; + } + const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); + return match + ? { content: match[2] as string, priority: Number(match[1]) } + : { content: record.content.trim(), priority: 10 }; +}; + const cfFetch = async ( config: CloudflareConfig, path: string, @@ -72,9 +100,20 @@ export const cloudflareClient: DnsClient = { 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; } @@ -92,7 +131,8 @@ export const cloudflareClient: DnsClient = { const payload = { type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }; @@ -123,7 +163,8 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - content: record.content, + ...splitPriority(record), + ...proxySettings(record), ttl: record.ttl ?? 1, }), }, diff --git a/packages/server/src/utils/dns/route53.ts b/packages/server/src/utils/dns/route53.ts index 112f56689..c00bfbd68 100644 --- a/packages/server/src/utils/dns/route53.ts +++ b/packages/server/src/utils/dns/route53.ts @@ -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"; @@ -66,13 +69,13 @@ const findExactRecordSet = async ( }; 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 }], }); diff --git a/packages/server/src/utils/dns/types.ts b/packages/server/src/utils/dns/types.ts index c939c2230..e7e800a2a 100644 --- a/packages/server/src/utils/dns/types.ts +++ b/packages/server/src/utils/dns/types.ts @@ -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 { From db2eb4f60a5ce2b2a114b281b6742a1322d5c517 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 11:47:29 +0200 Subject: [PATCH 2/5] fix(dns): send SRV and CAA values to Cloudflare as structured data Cloudflare treats content as read-only for SRV and CAA and expects a data object instead, so both types were rejected on write. The client now parses the inline value into the fields Cloudflare wants, and builds the payload before the lookup request so a malformed value fails without spending an API call. The form rejects a malformed SRV or CAA value up front and shows the expected shape, so the error lands on the field instead of coming back from the provider. --- apps/dokploy/__test__/dns/cloudflare.test.ts | 70 +++++++++++++++++++ .../settings/dns/dns-record-panel.tsx | 30 ++++++-- packages/server/src/utils/dns/cloudflare.ts | 69 +++++++++++++----- 3 files changed, 146 insertions(+), 23 deletions(-) diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index f2abbc4bb..f27e7bc60 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -164,6 +164,76 @@ describe("cloudflareClient MX priority", () => { }); }); +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 diff --git a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx index 3f06581a5..237c2f32c 100644 --- a/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/dns-record-panel.tsx @@ -68,13 +68,29 @@ const valueFields: Record< PTR: { label: "Target", placeholder: "host.example.com" }, }; -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(), -}); +const structuredValuePatterns: Partial> = { + 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 pattern = structuredValuePatterns[data.type]; + if (pattern && !pattern.test(data.content.trim())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["content"], + message: `Expected ${valueFields[data.type].placeholder}`, + }); + } + }); type DnsRecordForm = z.infer; diff --git a/packages/server/src/utils/dns/cloudflare.ts b/packages/server/src/utils/dns/cloudflare.ts index 51eeaee31..24cb9c196 100644 --- a/packages/server/src/utils/dns/cloudflare.ts +++ b/packages/server/src/utils/dns/cloudflare.ts @@ -31,14 +31,51 @@ const proxySettings = (record: { type: string; proxied?: boolean }) => ? { proxied: record.proxied } : {}; -const splitPriority = (record: { type: string; content: string }) => { - if (record.type !== "MX") { - return { content: record.content }; +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 }; } - const match = /^\s*(\d+)\s+(\S.*)$/.exec(record.content); - return match - ? { content: match[2] as string, priority: Number(match[1]) } - : { content: record.content.trim(), 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 ( @@ -123,19 +160,19 @@ export const cloudflareClient: DnsClient = { }, 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, - ...splitPriority(record), - ...proxySettings(record), - ttl: record.ttl ?? 1, - }; - const existingRecord = existing[0]; if (existingRecord) { const updated = await cfFetch<{ id: string }>( @@ -163,7 +200,7 @@ export const cloudflareClient: DnsClient = { body: JSON.stringify({ type: record.type, name: record.name, - ...splitPriority(record), + ...buildValue(record), ...proxySettings(record), ttl: record.ttl ?? 1, }), From 130be40b49c0bfb0944a2631fc43e3d105bf32a6 Mon Sep 17 00:00:00 2001 From: logical-tech Date: Fri, 21 Aug 2026 11:47:29 +0200 Subject: [PATCH 3/5] fix(dns): keep the record table on a valid page while filtering Filtering from a later page left pageIndex past the end of the filtered set, so the table said no records matched while the counter above it reported matches. The page index is clamped to the available page count, and changing the search or the type filter goes back to the first page. --- .../settings/dns/show-dns-records.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx index 9431545dc..57129452e 100644 --- a/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/show-dns-records.tsx @@ -294,11 +294,23 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { [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 }, + state: { + sorting, + pagination: { pageIndex, pageSize: pagination.pageSize }, + }, onSortingChange: setSorting, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), @@ -369,12 +381,21 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => { setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + resetToFirstPage(); + }} className="pr-10" />
- { + setTypeFilter(value); + resetToFirstPage(); + }} + > @@ -504,8 +525,7 @@ export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
- Page {table.getState().pagination.pageIndex + 1} of{" "} - {Math.max(table.getPageCount(), 1)} + Page {pageIndex + 1} of {pageCount}