Merge pull request #5150 from logical-tech/feat/dns-records-management

feat(dns): rework provider management and support all record types
This commit is contained in:
Mauricio Siu 2026-09-01 02:42:42 -06:00 committed by GitHub
commit 78fe5e3a33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2016 additions and 641 deletions

View File

@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => {
});
});
describe("cloudflareClient MX priority", () => {
it("inlines the priority into the content when listing", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "mx-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
ttl: 300,
priority: 20,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.content).toBe("20 mail.example.com");
});
it("splits the priority back out when writing", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "20 mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 20,
});
});
it("falls back to priority 10 when the content has no leading number", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "MX",
name: "example.com",
content: "mail.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string)).toMatchObject({
content: "mail.example.com",
priority: 10,
});
});
it("leaves non-MX content untouched", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "10 not-a-priority",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.content).toBe("10 not-a-priority");
expect(body.priority).toBeUndefined();
});
});
describe("cloudflareClient structured records", () => {
const stubCreate = () =>
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
it("sends SRV values as structured data", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269 talk.example.com",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
priority: 1,
weight: 10,
port: 5269,
target: "talk.example.com",
});
expect(body.content).toBeUndefined();
});
it("sends CAA values as structured data and drops the quotes", async () => {
stubCreate();
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: '0 issue "letsencrypt.org"',
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.data).toEqual({
flags: 0,
tag: "issue",
value: "letsencrypt.org",
});
expect(body.content).toBeUndefined();
});
it("rejects an SRV value that is missing a part", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "SRV",
name: "_sip._tcp.example.com",
content: "1 10 5269",
}),
).rejects.toThrow(/SRV value/);
});
it("rejects a CAA value that has no tag", async () => {
await expect(
cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "CAA",
name: "example.com",
content: "0",
}),
).rejects.toThrow(/CAA value/);
});
});
describe("cloudflareClient proxy status", () => {
it("sends the proxy status for proxiable types", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBe(true);
});
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "TXT",
name: "example.com",
content: "hello",
proxied: true,
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
});
it("leaves the proxy status untouched when the caller does not set it", async () => {
mockFetch
.mockResolvedValueOnce(cfSuccess([]))
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
await cloudflareClient.upsertRecord(config, {
zoneId: "zone-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
});
it("returns the proxy status when listing records", async () => {
mockFetch.mockResolvedValue(
cfSuccess([
{
id: "a-1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 1,
proxied: true,
},
]),
);
const records = await cloudflareClient.listRecords(config, "zone-1");
expect(records[0]?.proxied).toBe(true);
});
});
describe("cloudflareClient.upsertRecord", () => {
it("creates a record when none exists for the name/type", async () => {
mockFetch

View File

@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => {
const records = await route53Client.listRecords(config, "Z123");
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
});
});
describe("route53Client.upsertRecord", () => {
it("sends a single UPSERT change", async () => {
send.mockResolvedValueOnce({});
it("sends a single UPSERT change when nothing exists yet", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
const result = await route53Client.upsertRecord(config, {
zoneId: "Z123",
@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => {
});
expect(result).toEqual({ id: "A:app.example.com" });
const command = send.mock.calls[0]?.[0] as HasInput;
const command = send.mock.calls[1]?.[0] as HasInput;
expect(command.input.HostedZoneId).toBe("Z123");
expect(command.input.ChangeBatch.Changes).toEqual([
{
@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => {
},
]);
});
it("keeps the values already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "3.3.3.3",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "1.1.1.1" },
{ Value: "2.2.2.2" },
{ Value: "3.3.3.3" },
]);
});
it("does not duplicate a value that is already in the record set", async () => {
send
.mockResolvedValueOnce({
ResourceRecordSets: [
{
Name: "app.example.com.",
Type: "A",
TTL: 300,
ResourceRecords: [{ Value: "1.1.1.1" }],
},
],
})
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "A",
name: "app.example.com",
content: "1.1.1.1",
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: "1.1.1.1" }]);
});
it("wraps unquoted TXT values in double quotes", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })
.mockResolvedValueOnce({});
await route53Client.upsertRecord(config, {
zoneId: "Z123",
type: "TXT",
name: "example.com",
content: 'v=spf1 ~all\n"already quoted"',
});
const command = send.mock.calls[1]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
});
});
describe("route53Client.updateRecord", () => {
@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => {
]);
});
it("keeps every line of a multi-value record set", async () => {
send.mockResolvedValueOnce({});
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
type: "NS",
name: "example.com",
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
});
const command = send.mock.calls[0]?.[0] as HasInput;
expect(
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
).toEqual([
{ Value: "ns1.example.com" },
{ Value: "ns2.example.com" },
{ Value: "ns3.example.com" },
]);
});
it("skips the DELETE when the old record no longer exists", async () => {
send
.mockResolvedValueOnce({ ResourceRecordSets: [] })

View File

@ -0,0 +1,421 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cloud, CloudOff, XIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
export const DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
type RecordType = (typeof DNS_RECORD_TYPES)[number];
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
const valueFields: Record<
RecordType,
{ label: string; placeholder: string; hint?: string }
> = {
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
CNAME: { label: "Target", placeholder: "app.example.com" },
MX: {
label: "Mail server",
placeholder: "10 mail.example.com",
hint: "Start with the priority, then the mail server.",
},
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
SRV: {
label: "Target",
placeholder: "1 10 5269 talk.example.com",
hint: "Priority, weight, port, then target.",
},
CAA: {
label: "Value",
placeholder: '0 issue "letsencrypt.org"',
hint: "Flags, tag, then the quoted value.",
},
PTR: { label: "Target", placeholder: "host.example.com" },
};
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
CAA: /^\d+\s+\S+\s+.+$/,
};
const DnsRecordSchema = z
.object({
type: z.enum(DNS_RECORD_TYPES),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
proxied: z.boolean(),
})
.superRefine((data, ctx) => {
const values = data.content
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!values.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: "Content is required",
});
return;
}
const pattern = structuredValuePatterns[data.type];
if (pattern && !values.every((value) => pattern.test(value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["content"],
message: `Expected ${valueFields[data.type].placeholder}`,
});
}
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
export interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied?: boolean;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record: DnsRecordValue | null;
onClose: () => void;
}
export const DnsRecordPanel = ({
dnsProviderId,
zoneId,
zoneName,
record,
onClose,
}: Props) => {
const utils = api.useUtils();
const createRecord = api.dnsProvider.createRecord.useMutation();
const updateRecord = api.dnsProvider.updateRecord.useMutation();
const { mutateAsync, isPending, error, isError } = record
? updateRecord
: createRecord;
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: servers } = api.server.all.useQuery();
const { data: panelPublicIp } = api.server.publicIp.useQuery();
const { data: panelStoredIp } = api.settings.getIp.useQuery();
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
? record.type
: "A") as RecordType,
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
proxied: record.proxied ?? false,
}
: { type: "A", name: "", content: "", ttl: "", proxied: false },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const proxied = form.watch("proxied");
const canProxy =
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
const supportsMultipleValues = provider?.providerType === "route53";
const usesAutomaticTtl = canProxy && proxied;
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const isProxied = canProxy && data.proxied;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
...(canProxy && { proxied: data.proxied }),
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
onClose();
})
.catch(() => {});
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{record ? "Edit record" : "New record"}
</span>
<span className="text-xs text-muted-foreground">{zoneName}</span>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
aria-label="Close panel"
>
<XIcon className="size-4" />
</Button>
</div>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{DNS_RECORD_TYPES.map((recordType) => (
<SelectItem key={recordType} value={recordType}>
{recordType}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>{valueFields[type].label}</FormLabel>
<FormControl>
{supportsMultipleValues ? (
<Textarea
className="min-h-[60px] font-mono text-xs"
placeholder={valueFields[type].placeholder}
{...field}
/>
) : (
<Input
placeholder={valueFields[type].placeholder}
{...field}
/>
)}
</FormControl>
{(supportsMultipleValues || valueFields[type].hint) && (
<FormDescription>
{[
valueFields[type].hint,
supportsMultipleValues &&
"One value per line: every line belongs to the same record set.",
]
.filter(Boolean)
.join(" ")}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
{canProxy && (
<FormField
control={form.control}
name="proxied"
render={({ field }) => (
<FormItem>
<FormLabel>Proxy status</FormLabel>
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
<legend className="sr-only">Proxy status</legend>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
field.value && "translate-x-full",
)}
/>
<button
type="button"
aria-pressed={!field.value}
onClick={() => field.onChange(false)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
<CloudOff className="size-3.5" />
DNS only
</button>
<button
type="button"
aria-pressed={field.value}
onClick={() => field.onChange(true)}
className={cn(
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
field.value
? "text-[#f6821f]"
: "text-muted-foreground hover:text-foreground",
)}
>
<Cloud className="size-3.5" />
Proxied
</button>
</fieldset>
<FormDescription>
{field.value
? "Traffic runs through Cloudflare and the origin IP stays hidden."
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Auto"
className="tabular-nums"
disabled={usesAutomaticTtl}
{...field}
/>
</FormControl>
{usesAutomaticTtl && (
<FormDescription>
Proxied records always use automatic TTL.
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@ -0,0 +1,33 @@
import { cn } from "@/lib/utils";
const recordTypeStyles: Record<string, string> = {
A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
CNAME:
"bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
};
interface Props {
type: string;
className?: string;
}
export const DnsRecordTypeBadge = ({ type, className }: Props) => (
<span
className={cn(
"inline-flex h-6 min-w-16 shrink-0 items-center justify-center rounded-md px-2 font-mono text-[11px] font-semibold tracking-wide ring-1 ring-inset",
recordTypeStyles[type.toUpperCase()] ??
"bg-foreground/5 text-muted-foreground ring-foreground/15",
className,
)}
>
{type.toUpperCase()}
</span>
);

View File

@ -33,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 = {
@ -195,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>

View File

@ -1,271 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DnsRecordSchema = z.object({
type: z.enum(["A", "CNAME"]),
name: z.string().min(1, { message: "Name is required" }),
content: z.string().min(1, { message: "Content is required" }),
ttl: z.string(),
});
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
interface DnsRecordValue {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
interface Props {
dnsProviderId: string;
zoneId: string;
zoneName: string;
record?: DnsRecordValue;
}
export const HandleDnsRecord = ({
dnsProviderId,
zoneId,
zoneName,
record,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isPending, error, isError } = record
? api.dnsProvider.updateRecord.useMutation()
: api.dnsProvider.createRecord.useMutation();
const { data: servers } = api.server.all.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
enabled: isOpen,
});
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
enabled: isOpen,
});
const panelIp = panelPublicIp || panelStoredIp;
const ipSuggestions = [
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
...(servers ?? []).map((server) => ({
ip: server.ipAddress,
label: server.name,
})),
].filter(
(suggestion, index, all) =>
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
);
const form = useForm<DnsRecordForm>({
defaultValues: record
? {
type: record.type === "CNAME" ? "CNAME" : "A",
name: record.name,
content: record.content,
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
}
: { type: "A", name: "", content: "", ttl: "" },
resolver: zodResolver(DnsRecordSchema),
});
const type = form.watch("type");
const onSubmit = async (data: DnsRecordForm) => {
const name = data.name.trim() === "@" ? zoneName : data.name;
const payload = {
dnsProviderId,
zoneId,
type: data.type,
name,
content: data.content,
ttl: data.ttl ? Number(data.ttl) : undefined,
...(record && { recordId: record.id }),
};
await mutateAsync(payload as any)
.then(() => {
toast.success(record ? "Record updated" : "Record created");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
setIsOpen(false);
})
.catch(() => {});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{record ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-6"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button variant="outline" size="sm">
<PlusIcon className="size-3.5 mr-1.5" />
Add Record
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
<DialogDescription>
{record
? "Update this DNS record."
: "Create a new A or CNAME record in this zone."}
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="app.example.com" {...field} />
</FormControl>
<FormDescription>
Use <code>@</code> for the root domain.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{type === "A" && ipSuggestions.length > 0 && (
<FormItem>
<FormLabel>Fill from server (optional)</FormLabel>
<Select
onValueChange={(ip) =>
form.setValue("content", ip, { shouldValidate: true })
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ipSuggestions.map((suggestion) => (
<SelectItem key={suggestion.ip} value={suggestion.ip}>
<div className="flex flex-row items-center gap-2">
<span>{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.ip}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{type === "A" ? "IPv4 Address" : "Target"}
</FormLabel>
<FormControl>
<Input
placeholder={
type === "A" ? "203.0.113.10" : "app.example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (optional)</FormLabel>
<FormControl>
<Input type="number" placeholder="Auto" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" isLoading={isPending}>
{record ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,218 +0,0 @@
import {
ChevronDown,
ChevronRight,
Globe,
Loader2,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { HandleDnsRecord } from "./handle-dns-record";
interface ZoneRecordsProps {
dnsProviderId: string;
zoneId: string;
zoneName: string;
}
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
const utils = api.useUtils();
const { data, isLoading, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord, isPending: isDeleting } =
api.dnsProvider.deleteRecord.useMutation();
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
return (
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
{isLoading && (
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
<span>Loading records...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No records found in this zone.
</p>
)}
{data?.map((record) => {
const isEditable = record.type === "A" || record.type === "CNAME";
return (
<div
key={record.id}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
>
<Badge variant="outline" className="shrink-0">
{record.type}
</Badge>
<span className="font-medium truncate">{record.name}</span>
<span className="text-muted-foreground truncate flex-1">
{record.content}
</span>
{canWrite && isEditable && (
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={record}
/>
)}
{canDelete && (
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={async () => {
await deleteRecord({
dnsProviderId,
zoneId,
recordId: record.id,
})
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({
dnsProviderId,
zoneId,
});
})
.catch(() => {
toast.error("Error deleting the record");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-6"
isLoading={isDeleting}
>
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
})}
{canWrite && (
<div className="pt-1">
<HandleDnsRecord
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
/>
</div>
)}
</div>
);
};
interface Props {
dnsProviderId: string;
providerName: string;
}
export const ShowDnsProviderZones = ({
dnsProviderId,
providerName,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
const { data, isLoading, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
setExpandedZoneId(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Globe className="size-3.5 mr-1.5" />
View Domains
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Domains for {providerName}</DialogTitle>
<DialogDescription>
Zones this provider's API token can manage. Click a zone to see and
manage its records.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
)}
{isError && (
<p className="text-sm text-destructive py-2">{error?.message}</p>
)}
{data && data.length === 0 && (
<p className="text-sm text-muted-foreground py-2">
No zones found for this token. Make sure it has access to at least
one zone.
</p>
)}
{data && data.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
{data.map((zone) => {
const isExpanded = expandedZoneId === zone.id;
return (
<div key={zone.id} className="rounded-md border">
<button
type="button"
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
onClick={() =>
setExpandedZoneId(isExpanded ? null : zone.id)
}
>
{isExpanded ? (
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
)}
<Globe className="size-3.5 text-muted-foreground shrink-0" />
{zone.name}
</button>
{isExpanded && (
<ZoneRecords
dnsProviderId={dnsProviderId}
zoneId={zone.id}
zoneName={zone.name}
/>
)}
</div>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
};

View File

@ -1,8 +1,9 @@
import { Globe, Loader2, Trash2 } from "lucide-react";
import { EyeIcon, Globe, Loader2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@ -11,9 +12,13 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HandleDnsProvider } from "./handle-dns-provider";
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
const providerLabels: Record<string, string> = {
cloudflare: "Cloudflare",
@ -21,121 +26,142 @@ const providerLabels: Record<string, string> = {
};
export const ShowDnsProviders = () => {
const { mutateAsync, isPending: isRemoving } =
api.dnsProvider.remove.useMutation();
const [removingId, setRemovingId] = useState<string | null>(null);
const { mutateAsync } = api.dnsProvider.remove.useMutation();
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl flex flex-row gap-2">
<Globe className="size-6 text-muted-foreground self-center" />
DNS Providers
</CardTitle>
<CardDescription>
Connect a DNS provider so Dokploy can create the A/CNAME record
for a domain instead of you setting it up by hand.
</CardDescription>
</CardHeader>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
{isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
<Globe className="size-8 text-muted-foreground" />
<span className="font-medium text-muted-foreground">
No DNS providers connected
</span>
<span className="max-w-sm text-center text-sm text-muted-foreground">
Add Cloudflare or Route53 credentials to manage domain records
without leaving Dokploy.
</span>
</div>
) : (
<>
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<Globe className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
You don't have any DNS providers configured
</span>
{permissions?.dnsProvider.create && <HandleDnsProvider />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
<div className="flex flex-col gap-4 rounded-lg">
{data?.map((provider) => {
const ProviderIcon =
dnsProviderIcons[provider.providerType];
return (
<div
key={provider.dnsProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-row items-center gap-3">
<ProviderIcon className="size-7 shrink-0" />
<div className="flex gap-2 flex-col">
<span className="text-sm font-medium">
{provider.name}
</span>
<Badge variant="outline">
{providerLabels[provider.providerType] ??
provider.providerType}
</Badge>
</div>
</div>
<div className="flex flex-row gap-1">
<ShowDnsProviderZones
dnsProviderId={provider.dnsProviderId}
providerName={provider.name}
/>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
);
})}
</div>
{permissions?.dnsProvider.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDnsProvider />
<ul className="flex flex-col gap-2">
{data?.map((provider) => {
const ProviderIcon = dnsProviderIcons[provider.providerType];
const href = `/dashboard/settings/dns/${provider.dnsProviderId}`;
return (
<li
key={provider.dnsProviderId}
className="group relative flex items-center gap-3 rounded-lg border bg-background px-4 py-3 transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/50 focus-within:border-ring"
>
<Link
href={href}
aria-label={`View domains for ${provider.name}`}
className="absolute inset-0 rounded-lg outline-none"
/>
<ProviderIcon className="size-7 shrink-0" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{provider.name}
</span>
<span className="text-xs text-muted-foreground">
{providerLabels[provider.providerType] ??
provider.providerType}
</span>
</div>
)}
</div>
)}
</>
<div className="relative z-10 ml-auto flex flex-row items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground"
asChild
>
<Link href={href}>
<EyeIcon className="size-4" />
<span className="sr-only">View domains</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>View domains</TooltipContent>
</Tooltip>
{permissions?.dnsProvider.update && (
<HandleDnsProvider
dnsProviderId={provider.dnsProviderId}
/>
)}
{permissions?.dnsProvider.delete && (
<Tooltip>
<DialogAction
title="Delete DNS Provider"
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
type="destructive"
onClick={async () => {
setRemovingId(provider.dnsProviderId);
await mutateAsync({
dnsProviderId: provider.dnsProviderId,
})
.then(() => {
toast.success("DNS provider deleted");
refetch();
})
.catch(() => {
toast.error(
"Error deleting the DNS provider",
);
})
.finally(() => setRemovingId(null));
}}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={
removingId === provider.dnsProviderId
}
>
<Trash2 className="size-4" />
<span className="sr-only">
Delete provider
</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete provider</TooltipContent>
</Tooltip>
)}
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</div>

View File

@ -0,0 +1,589 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import {
ArrowLeft,
ArrowUpDown,
Cloud,
CloudOff,
ListTree,
Loader2,
PenBoxIcon,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import {
DNS_RECORD_TYPES,
DnsRecordPanel,
type DnsRecordValue,
PROXIABLE_TYPES,
} from "./dns-record-panel";
import { DnsRecordTypeBadge } from "./dns-record-type-badge";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const PAGE_SIZES = [10, 20, 50, 100];
const SortableHeader = ({
column,
title,
className,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
className?: string;
}) => (
<Button
variant="ghost"
size="xs"
className={cn("-ml-2.5 text-muted-foreground", className)}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="size-3" />
</Button>
);
export const ShowDnsRecords = ({ dnsProviderId, zoneId }: Props) => {
const utils = api.useUtils();
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [editing, setEditing] = useState<DnsRecordValue | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data: zones } = api.dnsProvider.listZones.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync: deleteRecord } =
api.dnsProvider.deleteRecord.useMutation();
const zoneName = zones?.find((zone) => zone.id === zoneId)?.name ?? "";
const isCloudflare = provider?.providerType === "cloudflare";
const canWrite = !!permissions?.dnsProvider.update;
const canDelete = !!permissions?.dnsProvider.delete;
const openEdit = (record: DnsRecordValue) => {
setEditing(record);
setIsPanelOpen(true);
};
const availableTypes = useMemo(
() => [...new Set((data ?? []).map((record) => record.type))].sort(),
[data],
);
const filteredRecords = useMemo(() => {
const query = search.trim().toLowerCase();
return (data ?? []).filter((record) => {
if (typeFilter !== "all" && record.type !== typeFilter) return false;
if (!query) return true;
return (
record.name.toLowerCase().includes(query) ||
record.content.toLowerCase().includes(query) ||
record.type.toLowerCase().includes(query)
);
});
}, [data, search, typeFilter]);
const handleDelete = async (record: DnsRecordValue) => {
setDeletingId(record.id);
await deleteRecord({ dnsProviderId, zoneId, recordId: record.id })
.then(() => {
toast.success("Record deleted");
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
if (editing?.id === record.id) setIsPanelOpen(false);
})
.catch(() => {
toast.error("Error deleting the record");
})
.finally(() => setDeletingId(null));
};
const columns = useMemo<ColumnDef<DnsRecordValue>[]>(
() => [
{
accessorKey: "type",
header: ({ column }) => <SortableHeader column={column} title="Type" />,
cell: ({ row }) => <DnsRecordTypeBadge type={row.original.type} />,
},
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<span
className="block max-w-[22ch] truncate font-medium"
title={row.original.name}
>
{row.original.name}
</span>
),
},
{
accessorKey: "content",
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
cell: ({ row }) => (
<span
className="block max-w-[32ch] truncate font-mono text-xs text-muted-foreground"
title={row.original.content}
>
{row.original.content}
</span>
),
},
{
accessorKey: "ttl",
header: ({ column }) => <SortableHeader column={column} title="TTL" />,
cell: ({ row }) => (
<span className="text-xs text-muted-foreground tabular-nums">
{row.original.ttl === 1 ? "Auto" : row.original.ttl}
</span>
),
},
...(isCloudflare
? [
{
accessorKey: "proxied",
header: ({ column }) => (
<SortableHeader column={column} title="Proxy" />
),
cell: ({ row }) => {
if (!PROXIABLE_TYPES.includes(row.original.type)) {
return null;
}
const proxied = !!row.original.proxied;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
{proxied ? (
<Cloud className="size-4 text-[#f6821f]" />
) : (
<CloudOff className="size-4 text-muted-foreground" />
)}
<span className="sr-only">
{proxied ? "Proxied" : "DNS only"}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{proxied ? "Proxied" : "DNS only"}
</TooltipContent>
</Tooltip>
);
},
} satisfies ColumnDef<DnsRecordValue>,
]
: []),
{
id: "actions",
enableSorting: false,
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => {
const record = row.original;
const isEditable = (DNS_RECORD_TYPES as readonly string[]).includes(
record.type,
);
return (
<div className="flex items-center justify-end gap-1">
{canWrite && isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={() => openEdit(record)}
>
<PenBoxIcon className="size-4" />
<span className="sr-only">Edit record</span>
</Button>
</TooltipTrigger>
<TooltipContent>Edit record</TooltipContent>
</Tooltip>
)}
{canDelete && (
<Tooltip>
<DialogAction
title="Delete Record"
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
type="destructive"
onClick={() => handleDelete(record)}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
isLoading={deletingId === record.id}
>
<Trash2 className="size-4" />
<span className="sr-only">Delete record</span>
</Button>
</TooltipTrigger>
</DialogAction>
<TooltipContent>Delete record</TooltipContent>
</Tooltip>
)}
</div>
);
},
},
],
[canWrite, canDelete, deletingId, editing?.id, isCloudflare],
);
const pageCount = Math.max(
1,
Math.ceil(filteredRecords.length / pagination.pageSize),
);
const pageIndex = Math.min(pagination.pageIndex, pageCount - 1);
const resetToFirstPage = () =>
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
const table = useReactTable({
data: filteredRecords,
columns,
getRowId: (row) => row.id,
state: {
sorting,
pagination: { pageIndex, pageSize: pagination.pageSize },
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full ">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href={`/dashboard/settings/dns/${dnsProviderId}`}>
<ArrowLeft className="size-4" />
<span className="sr-only">Back to domains</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{zoneName || "DNS records"}
</CardTitle>
<CardDescription>
Records managed through {provider?.name ?? "this provider"}.
Changes are written straight to the provider.
</CardDescription>
</CardHeader>
</div>
{canWrite && (
<Button
onClick={() => {
setEditing(null);
setIsPanelOpen(true);
}}
>
<PlusIcon className="size-4" />
Add Record
</Button>
)}
</div>
<CardContent className="min-h-[60vh] border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col-reverse gap-4 lg:flex-row lg:items-start">
<div className="flex min-w-0 flex-1 flex-col gap-4">
{isPending ? (
<div className="flex min-h-[45vh] flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<ListTree className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">
No records in this zone
</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add an A or CNAME record to point this domain at one of
your servers.
</p>
</div>
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-52 flex-1">
<FocusShortcutInput
placeholder="Filter records..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
resetToFirstPage();
}}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
resetToFirstPage();
}}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{availableTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRecords.length} of {data?.length ?? 0}
</span>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader className="[&_tr]:border-b">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className="bg-muted/40 hover:bg-muted/40"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn(
"h-9 px-4 text-xs",
header.id === "actions" && "text-right",
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => {
const isEditable = (
DNS_RECORD_TYPES as readonly string[]
).includes(row.original.type);
const isSelected =
isPanelOpen && editing?.id === row.original.id;
return (
<TableRow
key={row.id}
data-state={
isSelected ? "selected" : undefined
}
onClick={
canWrite && isEditable
? () => openEdit(row.original)
: undefined
}
className={cn(
"duration-150 ease-out",
canWrite && isEditable && "cursor-pointer",
)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
onClick={
cell.column.id === "actions"
? (event) => event.stopPropagation()
: undefined
}
className="px-4 py-2"
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No records match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) =>
setPagination({
pageIndex: 0,
pageSize: Number(value),
})
}
>
<SelectTrigger size="sm" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground tabular-nums">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</>
)}
</div>
<div
className={cn(
"t-panel-track grid lg:shrink-0 lg:grid-rows-[1fr]",
isPanelOpen
? "grid-rows-[1fr] lg:grid-cols-[1fr]"
: "grid-rows-[0fr] lg:grid-cols-[0fr]",
)}
>
<div className="overflow-hidden">
<div
data-open={isPanelOpen}
className="t-panel-slide-x w-full lg:w-[380px]"
>
{canWrite && (
<DnsRecordPanel
key={editing?.id ?? "new"}
dnsProviderId={dnsProviderId}
zoneId={zoneId}
zoneName={zoneName}
record={editing}
onClose={() => setIsPanelOpen(false)}
/>
)}
</div>
</div>
</div>
</div>
</CardContent>
</div>
</Card>
</div>
);
};

View File

@ -0,0 +1,132 @@
import { ArrowLeft, Globe, Loader2 } from "lucide-react";
import Link from "next/link";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
interface Props {
dnsProviderId: string;
}
const RecordCount = ({
dnsProviderId,
zoneId,
}: {
dnsProviderId: string;
zoneId: string;
}) => {
const { data, isPending, isError } = api.dnsProvider.listRecords.useQuery({
dnsProviderId,
zoneId,
});
if (isPending) {
return <Loader2 className="animate-spin size-4 text-muted-foreground" />;
}
if (isError) {
return (
<span className="text-xs text-muted-foreground">Records unavailable</span>
);
}
return (
<span className="text-xs text-muted-foreground tabular-nums">
{data.length === 0
? "No records"
: `${data.length} record${data.length === 1 ? "" : "s"}`}
</span>
);
};
export const ShowDnsZones = ({ dnsProviderId }: Props) => {
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
const { data, isPending, isError, error } =
api.dnsProvider.listZones.useQuery({ dnsProviderId });
return (
<div className="w-full max-w-5xl mx-auto">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
<div className="flex flex-1 flex-row items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/dashboard/settings/dns">
<ArrowLeft className="size-4" />
<span className="sr-only">Back to DNS providers</span>
</Link>
</Button>
<CardHeader className="flex-1 p-0">
<CardTitle className="text-xl">
{provider?.name ?? "Domains"}
</CardTitle>
<CardDescription>
Domains this provider's credentials can manage. Open one to
see and edit its DNS records.
</CardDescription>
</CardHeader>
</div>
</div>
<CardContent className="flex min-h-[60vh] flex-col gap-4 border-t py-8">
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isPending ? (
<div className="flex flex-1 flex-row items-center justify-center gap-2 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : data?.length === 0 ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Globe className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No domains found</p>
<p className="max-w-sm text-sm text-muted-foreground">
These credentials can't reach any zone. Check that the token
has access to at least one domain.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{data?.map((zone) => (
<Link
key={zone.id}
href={`/dashboard/settings/dns/${dnsProviderId}/${zone.id}`}
className="group flex flex-col justify-between gap-6 rounded-xl border bg-background p-4 outline-none transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/40 focus-visible:border-ring"
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground transition-colors duration-150 group-hover:text-foreground">
<Globe className="size-4" />
</span>
<span
className="min-w-0 flex-1 truncate pt-2 text-sm font-medium"
title={zone.name}
>
{zone.name}
</span>
</div>
<div className="flex min-h-4 items-center justify-end">
<RecordCount
dnsProviderId={dnsProviderId}
zoneId={zone.id}
/>
</div>
</Link>
))}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};

View File

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

View File

@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsZones } from "@/components/dashboard/settings/dns/show-dns-zones";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
}
const Page = ({ dnsProviderId }: Props) => {
return <ShowDnsZones dnsProviderId={dnsProviderId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member" || !params?.dnsProviderId) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
},
};
}

View File

@ -0,0 +1,64 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDnsRecords } from "@/components/dashboard/settings/dns/show-dns-records";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
interface Props {
dnsProviderId: string;
zoneId: string;
}
const Page = ({ dnsProviderId, zoneId }: Props) => {
return <ShowDnsRecords dnsProviderId={dnsProviderId} zoneId={zoneId} />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ dnsProviderId: string; zoneId: string }>,
) {
const { req, res, params } = ctx;
const { user, session } = await validateRequest(req);
if (
!user ||
user.role === "member" ||
!params?.dnsProviderId ||
!params?.zoneId
) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
dnsProviderId: params.dnsProviderId,
zoneId: params.zoneId,
},
};
}

View File

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

View File

@ -531,3 +531,98 @@ body[data-scroll-locked] [data-slot="sidebar-inset"] {
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--duration-quick: 150ms;
--duration-fast: 250ms;
--duration-medium: 350ms;
--duration-slow: 400ms;
--ease-smooth-out: cubic-bezier(0.22, 1, 0.36, 1);
--distance-base: 8px;
--blur-medium: 3px;
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-x: 32px;
--panel-blur: 2px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
@layer components {
.t-panel-slide-x {
transform: translateX(var(--panel-translate-x));
opacity: 0;
filter: blur(var(--panel-blur));
pointer-events: none;
transition:
transform var(--panel-close-dur) var(--panel-ease),
opacity var(--panel-close-dur) var(--panel-ease),
filter var(--panel-close-dur) var(--panel-ease);
will-change: transform, opacity, filter;
}
.t-panel-slide-x[data-open="true"] {
transform: translateX(0);
opacity: 1;
filter: blur(0);
pointer-events: auto;
transition:
transform var(--panel-open-dur) var(--panel-ease),
opacity var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel-track {
transition:
grid-template-columns var(--panel-open-dur) var(--panel-ease),
grid-template-rows var(--panel-open-dur) var(--panel-ease);
}
@media (prefers-reduced-motion: reduce) {
.t-panel-slide-x,
.t-panel-track {
transition: none !important;
}
}
}
@layer components {
.t-page-enter {
animation: t-page-enter var(--duration-fast) var(--ease-smooth-out) both;
}
.t-page-enter[data-direction="back"] {
animation-name: t-page-enter-back;
}
@keyframes t-page-enter {
from {
opacity: 0;
transform: translateX(var(--distance-base));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@keyframes t-page-enter-back {
from {
opacity: 0;
transform: translateX(calc(var(--distance-base) * -1));
filter: blur(var(--blur-medium));
}
to {
opacity: 1;
transform: translateX(0);
filter: blur(0);
}
}
@media (prefers-reduced-motion: reduce) {
.t-page-enter {
animation: none;
}
}
}

View File

@ -104,11 +104,28 @@ export const apiListDnsRecords = z.object({
zoneId: z.string().min(1),
});
export const dnsRecordTypes = [
"A",
"AAAA",
"CNAME",
"MX",
"TXT",
"NS",
"SRV",
"CAA",
"PTR",
] as const;
export type DnsRecordType = (typeof dnsRecordTypes)[number];
export const proxiableDnsRecordTypes = ["A", "AAAA", "CNAME"] as const;
const dnsRecordFieldsSchema = z.object({
type: z.enum(["A", "CNAME"]),
type: z.enum(dnsRecordTypes),
name: z.string().min(1),
content: z.string().min(1),
ttl: z.number().int().positive().optional(),
proxied: z.boolean().optional(),
});
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({

View File

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

View File

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

View File

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

View File

@ -75,7 +75,6 @@ export const execAsyncStream = (
command,
stdout: stdoutComplete,
stderr: stderrComplete,
// @ts-expect-error
exitCode: error.code,
originalError: error,
}),