import { useEffect, useMemo, useState } from "react" import { credentialsApi } from "@/api/credentials" import { dnsApi } from "@/api/dns" import type { DtoCreateDomainRequest, DtoCreateRecordSetRequest, DtoCredentialOut, DtoDomainResponse, DtoRecordSetResponse, DtoUpdateDomainRequest, DtoUpdateRecordSetRequest, } from "@/sdk" import { zodResolver } from "@hookform/resolvers/zod" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { AlertTriangle, CheckCircle2, Circle, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2, } from "lucide-react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Form, FormControl, 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" // ---------- helpers ---------- const statusIcon = (s?: string) => { switch (s) { case "ready": return case "provisioning": return case "failed": return default: return } } const StatusBadge = ({ s }: { s?: string }) => ( {statusIcon(s)} {s ?? "pending"} ) const parseCommaList = (v: string) => v .split(",") .map((s) => s.trim()) .filter(Boolean) const joinCommaList = (arr?: string[] | null) => (arr && arr.length ? arr.join(",") : "") const rrtypes = ["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"] const isR53 = (c: DtoCredentialOut) => c.provider === "aws" && c.scope_kind === "service" && (() => { const s = (c as any).scope try { const obj = typeof s === "string" ? JSON.parse(s) : s || {} return obj?.service === "route53" } catch { return false } })() const credLabel = (c: DtoCredentialOut) => { const bits = [c.name || "Unnamed", c.account_id, c.region].filter(Boolean) return bits.join(" · ") } // ---------- zod schemas ---------- const createDomainSchema = z.object({ domain_name: z .string() .min(1, "Domain is required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()), credential_id: z.string().uuid("Pick a credential"), zone_id: z .string() .optional() .or(z.literal("")) .transform((v) => (v ? v.trim() : undefined)), }) type CreateDomainValues = z.input const updateDomainSchema = createDomainSchema.partial() type UpdateDomainValues = z.infer const ttlSchema = z .union([ z.number(), z .string() .regex(/^\d+$/) .transform((s) => Number(s)), ]) .optional() .refine((v) => v === undefined || (v >= 1 && v <= 86400), { message: "TTL must be between 1 and 86400", }) const createRecordSchema = z .object({ name: z .string() .min(1, "Name required") .max(253) .transform((s) => s.trim().replace(/\.$/, "").toLowerCase()), type: z.enum(rrtypes as [string, ...string[]]), ttl: ttlSchema, valuesCsv: z.string().optional(), }) .superRefine((vals, ctx) => { const arr = parseCommaList(vals.valuesCsv ?? "") if (arr.length === 0) { ctx.addIssue({ code: "custom", message: "At least one value is required" }) } if (vals.type === "CNAME" && arr.length !== 1) { ctx.addIssue({ code: "custom", message: "CNAME requires exactly one value" }) } }) type CreateRecordValues = z.input const updateRecordSchema = createRecordSchema.partial() type UpdateRecordValues = z.input // ---------- main ---------- export const DnsPage = () => { const [filter, setFilter] = useState("") const [selected, setSelected] = useState(null) const [createDomOpen, setCreateDomOpen] = useState(false) const [editDomOpen, setEditDomOpen] = useState(false) const [createRecOpen, setCreateRecOpen] = useState(false) const [editRecOpen, setEditRecOpen] = useState(false) const [editingRecord, setEditingRecord] = useState(null) const qc = useQueryClient() // ---- queries ---- const domainsQ = useQuery({ queryKey: ["dns", "domains"], queryFn: () => dnsApi.listDomains(), }) const recordsQ = useQuery({ queryKey: ["dns", "records", selected?.id], queryFn: async () => { if (!selected) return [] return await dnsApi.listRecordSetsByDomain(selected.id as string) }, enabled: !!selected?.id, }) const credentialQ = useQuery({ queryKey: ["credentials", "r53"], queryFn: () => credentialsApi.listCredentials(), }) const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data]) useEffect(() => { if (!selected && domainsQ.data && domainsQ.data.length) { setSelected(domainsQ.data[0]!) } }, [domainsQ.data, selected]) const filteredDomains = useMemo(() => { const list: DtoDomainResponse[] = domainsQ.data ?? [] if (!filter.trim()) return list const f = filter.toLowerCase() return list.filter((d) => [d.domain_name, d.zone_id, d.status, d.domain_name] .filter(Boolean) .map((x) => String(x).toLowerCase()) .some((s) => s.includes(f)) ) }, [domainsQ.data, filter]) // ---- mutations: domains ---- const createDomainForm = useForm({ resolver: zodResolver(createDomainSchema), defaultValues: { domain_name: "", credential_id: "", zone_id: "", }, }) const createDomainMut = useMutation({ mutationFn: (v: CreateDomainValues) => dnsApi.createDomain(v as unknown as DtoCreateDomainRequest), onSuccess: async (d) => { toast.success("Domain created") setCreateDomOpen(false) createDomainForm.reset() await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) setSelected(d as DtoDomainResponse) }, onError: (e: any) => toast.error("Failed to create domain", { description: e?.message ?? "Unknown error" }), }) const editDomainForm = useForm({ resolver: zodResolver(updateDomainSchema), }) const openEditDomain = (d: DtoDomainResponse) => { setSelected(d) editDomainForm.reset({ domain_name: d.domain_name, credential_id: d.credential_id, zone_id: d.zone_id || "", }) setEditDomOpen(true) } const updateDomainMut = useMutation({ mutationFn: (vals: UpdateDomainValues) => { if (!selected) throw new Error("No domain selected") return dnsApi.updateDomain(selected.id!, vals as unknown as DtoUpdateDomainRequest) }, onSuccess: async () => { toast.success("Domain updated") setEditDomOpen(false) await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to update domain", { description: e?.message ?? "Unknown error" }), }) const deleteDomainMut = useMutation({ mutationFn: (id: string) => dnsApi.deleteDomain(id), onSuccess: async () => { toast.success("Domain deleted") await qc.invalidateQueries({ queryKey: ["dns", "domains"] }) setSelected(null) }, onError: (e: any) => toast.error("Failed to delete domain", { description: e?.message ?? "Unknown error" }), }) // ---- mutations: record sets ---- const createRecForm = useForm({ resolver: zodResolver(createRecordSchema), defaultValues: { name: "", type: "A", ttl: 300, valuesCsv: "", }, }) const explainError = (e: any) => { const msg: string = e?.response?.data?.error || e?.message || "Unknown error" if (msg.includes("ownership_conflict")) { return "Ownership conflict: this (name,type) exists but isn’t owned by autoglue." } if (msg.includes("already_exists")) { return "A record with this (name,type) already exists. Use Edit instead." } return msg } const createRecordMut = useMutation({ mutationFn: async (vals: CreateRecordValues) => { if (!selected) throw new Error("No domain selected") const body: DtoCreateRecordSetRequest = { name: vals.name, type: vals.type, // omit ttl when empty/undefined ...(vals.ttl ? { ttl: vals.ttl as unknown as number } : {}), values: parseCommaList(vals.valuesCsv ?? ""), } return dnsApi.createRecordSetsByDomain(selected.id!, body) }, onSuccess: async () => { toast.success("Record set created") setCreateRecOpen(false) createRecForm.reset() await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to create record set", { description: explainError(e) }), }) const editRecForm = useForm({ resolver: zodResolver(updateRecordSchema), }) const openEditRecord = (r: DtoRecordSetResponse) => { setEditingRecord(r) const values = (r.values as any) || [] editRecForm.reset({ name: r.name, type: r.type, ttl: r.ttl ? Number(r.ttl) : undefined, valuesCsv: joinCommaList(values), }) setEditRecOpen(true) } const updateRecordMut = useMutation({ mutationFn: async (vals: UpdateRecordValues) => { if (!editingRecord) throw new Error("No record selected") const body: DtoUpdateRecordSetRequest = {} if (vals.name !== undefined) body.name = vals.name if (vals.type !== undefined) body.type = vals.type if (vals.ttl !== undefined && vals.ttl !== null) { // if blank string came through it would have been filtered; when undefined, omit body.ttl = vals.ttl as unknown as number | undefined } if (vals.valuesCsv !== undefined) { body.values = parseCommaList(vals.valuesCsv) } return dnsApi.updateRecordSetsByDomain(editingRecord.id!, body) }, onSuccess: async () => { toast.success("Record set updated") setEditRecOpen(false) setEditingRecord(null) await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to update record set", { description: explainError(e) }), }) const deleteRecordMut = useMutation({ mutationFn: (id: string) => dnsApi.deleteRecordSetsByDomain(id), onSuccess: async () => { toast.success("Record set deleted") await qc.invalidateQueries({ queryKey: ["dns", "records", selected?.id] }) }, onError: (e: any) => toast.error("Failed to delete record set", { description: e?.message ?? "Unknown error" }), }) // ---------- UI ---------- return (

DNS

setFilter(e.target.value)} placeholder="Search domains…" className="w-64 pl-8" />
Add Domain
createDomainMut.mutate(v))} > ( Domain )} /> {/* CREDENTIAL SELECT (Create) */} ( Route53 Credential {credentialQ.error && (

Failed to load credentials.

)}
)} /> ( Zone ID (optional) )} />
{/* domains panel */}
Domains
{domainsQ.isFetching && }
{(filteredDomains ?? []).map((d) => ( setSelected(d)} > ))} {(!filteredDomains || filteredDomains.length === 0) && ( )}
Domain Zone Status Actions
{d.domain_name} {d.zone_id || "—"}
Delete “{d.domain_name}”? This deletes the domain metadata. External DNS records are not touched. Cancel deleteDomainMut.mutate(d.id!)} > Delete
No domains yet.
{/* records panel */}
Records {selected ? `— ${selected.domain_name}` : ""}
Add Record
createRecordMut.mutate(v))} >
( Name )} /> ( Type )} /> ( TTL (sec, optional) field.onChange( e.target.value === "" ? undefined : Number(e.target.value) ) } placeholder="300" /> )} />
( Values (comma-separated)