mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
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.
This commit is contained in:
parent
5ff0c26954
commit
9c4ad14c70
@ -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
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
data-direction={direction}
|
||||
className="t-page-enter flex w-full flex-col gap-4"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<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 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>
|
||||
<Input
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{valueFields[type].hint && (
|
||||
<FormDescription>{valueFields[type].hint}</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canProxy && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy status</FormLabel>
|
||||
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
|
||||
<legend className="sr-only">Proxy status</legend>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
|
||||
field.value && "translate-x-full",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!field.value}
|
||||
onClick={() => field.onChange(false)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-muted-foreground hover:text-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<CloudOff className="size-3.5" />
|
||||
DNS only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={field.value}
|
||||
onClick={() => field.onChange(true)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-[#f6821f]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Cloud className="size-3.5" />
|
||||
Proxied
|
||||
</button>
|
||||
</fieldset>
|
||||
<FormDescription>
|
||||
{field.value
|
||||
? "Traffic runs through Cloudflare and the origin IP stays hidden."
|
||||
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
className="tabular-nums"
|
||||
disabled={usesAutomaticTtl}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{usesAutomaticTtl && (
|
||||
<FormDescription>
|
||||
Proxied records always use automatic TTL.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const recordTypeStyles: Record<string, string> = {
|
||||
A: "bg-blue-500/10 text-blue-700 ring-blue-500/25 dark:text-blue-300",
|
||||
AAAA: "bg-indigo-500/10 text-indigo-700 ring-indigo-500/25 dark:text-indigo-300",
|
||||
CNAME:
|
||||
"bg-violet-500/10 text-violet-700 ring-violet-500/25 dark:text-violet-300",
|
||||
MX: "bg-amber-500/10 text-amber-700 ring-amber-500/25 dark:text-amber-300",
|
||||
TXT: "bg-emerald-500/10 text-emerald-700 ring-emerald-500/25 dark:text-emerald-300",
|
||||
NS: "bg-cyan-500/10 text-cyan-700 ring-cyan-500/25 dark:text-cyan-300",
|
||||
SRV: "bg-rose-500/10 text-rose-700 ring-rose-500/25 dark:text-rose-300",
|
||||
CAA: "bg-teal-500/10 text-teal-700 ring-teal-500/25 dark:text-teal-300",
|
||||
PTR: "bg-orange-500/10 text-orange-700 ring-orange-500/25 dark:text-orange-300",
|
||||
SOA: "bg-slate-500/10 text-slate-700 ring-slate-500/25 dark:text-slate-300",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DnsRecordTypeBadge = ({ type, className }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 min-w-16 shrink-0 items-center justify-center rounded-md px-2 font-mono text-[11px] font-semibold tracking-wide ring-1 ring-inset",
|
||||
recordTypeStyles[type.toUpperCase()] ??
|
||||
"bg-foreground/5 text-muted-foreground ring-foreground/15",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
@ -33,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 (
|
||||
<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>
|
||||
|
||||
@ -1,271 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record?: DnsRecordValue;
|
||||
}
|
||||
|
||||
export const HandleDnsRecord = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? api.dnsProvider.updateRecord.useMutation()
|
||||
: api.dnsProvider.createRecord.useMutation();
|
||||
|
||||
const { data: servers } = api.server.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: record.type === "CNAME" ? "CNAME" : "A",
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "" },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: data.ttl ? Number(data.ttl) : undefined,
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{record ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-6"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<PlusIcon className="size-3.5 mr-1.5" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{record
|
||||
? "Update this DNS record."
|
||||
: "Create a new A or CNAME record in this zone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{type === "A" ? "IPv4 Address" : "Target"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
type === "A" ? "203.0.113.10" : "app.example.com"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="Auto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,218 +0,0 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsRecord } from "./handle-dns-record";
|
||||
|
||||
interface ZoneRecordsProps {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
}
|
||||
|
||||
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord, isPending: isDeleting } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
|
||||
<span>Loading records...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No records found in this zone.
|
||||
</p>
|
||||
)}
|
||||
{data?.map((record) => {
|
||||
const isEditable = record.type === "A" || record.type === "CNAME";
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{record.type}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{record.name}</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
→ {record.content}
|
||||
</span>
|
||||
{canWrite && isEditable && (
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteRecord({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
recordId: record.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-6"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export const ShowDnsProviderZones = ({
|
||||
dnsProviderId,
|
||||
providerName,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setExpandedZoneId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Globe className="size-3.5 mr-1.5" />
|
||||
View Domains
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains for {providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zones this provider's API token can manage. Click a zone to see and
|
||||
manage its records.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No zones found for this token. Make sure it has access to at least
|
||||
one zone.
|
||||
</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((zone) => {
|
||||
const isExpanded = expandedZoneId === zone.id;
|
||||
return (
|
||||
<div key={zone.id} className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
|
||||
onClick={() =>
|
||||
setExpandedZoneId(isExpanded ? null : zone.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Globe className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{zone.name}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ZoneRecords
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
zoneName={zone.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -1,8 +1,9 @@
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { EyeIcon, Globe, 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<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
@ -21,121 +27,143 @@ 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">
|
||||
<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]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Skeleton key={row} className="h-[68px] w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3">
|
||||
<Globe className="size-8 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground">
|
||||
No DNS providers connected
|
||||
</span>
|
||||
<span className="max-w-sm text-center text-sm text-muted-foreground">
|
||||
Add Cloudflare or Route53 credentials to manage domain records
|
||||
without leaving Dokploy.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Globe className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any DNS providers configured
|
||||
</span>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.dnsProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShowDnsProviderZones
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
providerName={provider.name}
|
||||
/>
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{permissions?.dnsProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDnsProvider />
|
||||
<ul className="flex flex-col gap-2">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon = dnsProviderIcons[provider.providerType];
|
||||
const href = `/dashboard/settings/dns/${provider.dnsProviderId}`;
|
||||
return (
|
||||
<li
|
||||
key={provider.dnsProviderId}
|
||||
className="group relative flex items-center gap-3 rounded-lg border bg-background px-4 py-3 transition-colors duration-150 ease-out hover:border-foreground/20 hover:bg-muted/50 focus-within:border-ring"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`View domains for ${provider.name}`}
|
||||
className="absolute inset-0 rounded-lg outline-none"
|
||||
/>
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<div className="relative z-10 ml-auto flex flex-row items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
asChild
|
||||
>
|
||||
<Link href={href}>
|
||||
<EyeIcon className="size-4" />
|
||||
<span className="sr-only">View domains</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View domains</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<Tooltip>
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
setRemovingId(provider.dnsProviderId);
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
})
|
||||
.finally(() => setRemovingId(null));
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
|
||||
isLoading={
|
||||
removingId === provider.dnsProviderId
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">
|
||||
Delete provider
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DialogAction>
|
||||
<TooltipContent>Delete provider</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,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;
|
||||
}) => (
|
||||
<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 table = useReactTable({
|
||||
data: filteredRecords,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
state: { sorting, pagination },
|
||||
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 ? (
|
||||
<Skeleton className="h-[420px] w-full rounded-lg" />
|
||||
) : 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)}
|
||||
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={setTypeFilter}>
|
||||
<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 {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{Math.max(table.getPageCount(), 1)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"t-panel-track grid lg:shrink-0 lg:grid-rows-[1fr]",
|
||||
isPanelOpen
|
||||
? "grid-rows-[1fr] lg:grid-cols-[1fr]"
|
||||
: "grid-rows-[0fr] lg:grid-cols-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
data-open={isPanelOpen}
|
||||
className="t-panel-slide-x w-full lg:w-[380px]"
|
||||
>
|
||||
{canWrite && (
|
||||
<DnsRecordPanel
|
||||
key={editing?.id ?? "new"}
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={editing}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,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 <Skeleton className="h-4 w-20 rounded-full" />;
|
||||
}
|
||||
|
||||
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">
|
||||
<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="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||
{[0, 1, 2, 3, 4, 5].map((card) => (
|
||||
<Skeleton key={card} className="h-[124px] rounded-xl" />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -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 (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<DnsPageTransition>
|
||||
<ShowDnsProviders />
|
||||
</div>
|
||||
</DnsPageTransition>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 (
|
||||
<DnsPageTransition>
|
||||
<ShowDnsZones dnsProviderId={dnsProviderId} />
|
||||
</DnsPageTransition>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ dnsProviderId: string }>,
|
||||
) {
|
||||
const { req, res, params } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member" || !params?.dnsProviderId) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
dnsProviderId: params.dnsProviderId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,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 (
|
||||
<DnsPageTransition>
|
||||
<ShowDnsRecords dnsProviderId={dnsProviderId} zoneId={zoneId} />
|
||||
</DnsPageTransition>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<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 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 <T>(
|
||||
config: CloudflareConfig,
|
||||
path: string,
|
||||
@ -72,9 +100,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;
|
||||
}
|
||||
@ -92,7 +131,8 @@ export const cloudflareClient: DnsClient<CloudflareConfig> = {
|
||||
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<CloudflareConfig> = {
|
||||
body: JSON.stringify({
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
...splitPriority(record),
|
||||
...proxySettings(record),
|
||||
ttl: record.ttl ?? 1,
|
||||
}),
|
||||
},
|
||||
|
||||
@ -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 }],
|
||||
});
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user