Files
autoglue/ui/src/pages/dns-page.tsx
allanice001 0f562ac5f4 ui fixes
2026-01-06 17:05:52 +00:00

1106 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <CheckCircle2 className="h-4 w-4 text-emerald-600" />
case "provisioning":
return <Loader2 className="h-4 w-4 animate-spin text-blue-600" />
case "failed":
return <AlertTriangle className="h-4 w-4 text-red-600" />
default:
return <Circle className="text-muted-foreground h-4 w-4" />
}
}
const StatusBadge = ({ s }: { s?: string }) => (
<Badge
variant={s === "failed" ? "destructive" : s === "ready" ? "default" : "secondary"}
className="gap-1"
title={s}
>
{statusIcon(s)}
<span className="capitalize">{s ?? "pending"}</span>
</Badge>
)
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.credential_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 ----------
// IMPORTANT (Zod v4):
// - `.partial()` cannot be used on object schemas containing refinements/effects.
// Your schemas contain effects via `.transform(...)` and refinements via `.superRefine(...)` / `.refine(...)`,
// so we define UPDATE schemas explicitly instead of using `.partial()`.
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<typeof createDomainSchema>
// Update: all optional; replicate the normalization safely
const updateDomainSchema = z.object({
domain_name: z
.string()
.min(1, "Domain is required")
.max(253)
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
.optional(),
credential_id: z.string().uuid("Pick a credential").optional(),
zone_id: z
.string()
.optional()
.or(z.literal(""))
.transform((v) => (v ? v.trim() : undefined)),
})
type UpdateDomainValues = z.input<typeof updateDomainSchema>
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<typeof createRecordSchema>
// Update: all optional. Only enforce "values required"/"CNAME exactly one" if valuesCsv is present.
// Only validate ttl if present (ttlSchema already optional).
const updateRecordSchema = z
.object({
name: z
.string()
.min(1, "Name required")
.max(253)
.transform((s) => s.trim().replace(/\.$/, "").toLowerCase())
.optional(),
type: z.enum(rrtypes as [string, ...string[]]).optional(),
ttl: ttlSchema,
valuesCsv: z.string().optional(),
})
.superRefine((vals, ctx) => {
const hasValues = typeof vals.valuesCsv !== "undefined"
if (!hasValues) return
const arr = parseCommaList(vals.valuesCsv ?? "")
if (arr.length === 0) {
ctx.addIssue({
code: "custom",
path: ["valuesCsv"],
message: "At least one value is required",
})
}
// We can only enforce CNAME rule if `type` is provided in patch (or you can enforce always if you want).
if (vals.type === "CNAME" && arr.length !== 1) {
ctx.addIssue({
code: "custom",
path: ["valuesCsv"],
message: "CNAME requires exactly one value",
})
}
})
type UpdateRecordValues = z.input<typeof updateRecordSchema>
// ---------- main ----------
export const DnsPage = () => {
const [filter, setFilter] = useState("")
const [selected, setSelected] = useState<DtoDomainResponse | null>(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<DtoRecordSetResponse | null>(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]
.filter(Boolean)
.map((x) => String(x).toLowerCase())
.some((s) => s.includes(f))
)
}, [domainsQ.data, filter])
// ---- mutations: domains ----
const createDomainForm = useForm<CreateDomainValues>({
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<UpdateDomainValues>({
resolver: zodResolver(updateDomainSchema),
defaultValues: {},
})
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)
}
// Build PATCH body (dont send empty strings)
const buildUpdateDomainBody = (vals: UpdateDomainValues): DtoUpdateDomainRequest => {
const body: any = {}
if (typeof vals.domain_name !== "undefined") body.domain_name = vals.domain_name
if (typeof vals.credential_id !== "undefined" && vals.credential_id !== "")
body.credential_id = vals.credential_id
if (typeof vals.zone_id !== "undefined" && vals.zone_id !== "") body.zone_id = vals.zone_id
return body as DtoUpdateDomainRequest
}
const updateDomainMut = useMutation({
mutationFn: (vals: UpdateDomainValues) => {
if (!selected) throw new Error("No domain selected")
return dnsApi.updateDomain(selected.id!, buildUpdateDomainBody(vals))
},
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<CreateRecordValues>({
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 isnt 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,
...(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<UpdateRecordValues>({
resolver: zodResolver(updateRecordSchema),
defaultValues: {},
})
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 (typeof vals.name !== "undefined") body.name = vals.name
if (typeof vals.type !== "undefined") body.type = vals.type
if (typeof vals.ttl !== "undefined") body.ttl = vals.ttl as unknown as number | undefined
if (typeof 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 (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold">DNS</h1>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search domains…"
className="w-64 pl-8"
/>
</div>
<Dialog open={createDomOpen} onOpenChange={setCreateDomOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateDomOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Domain
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Domain</DialogTitle>
</DialogHeader>
<Form {...createDomainForm}>
<form
className="space-y-4 pt-2"
onSubmit={createDomainForm.handleSubmit((v) => createDomainMut.mutate(v))}
>
<FormField
control={createDomainForm.control}
name="domain_name"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* CREDENTIAL SELECT (Create) */}
<FormField
control={createDomainForm.control}
name="credential_id"
render={({ field }) => (
<FormItem>
<FormLabel>Route53 Credential</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={credentialQ.isLoading || (r53Credentials?.length ?? 0) === 0}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
credentialQ.isLoading
? "Loading…"
: (r53Credentials?.length ?? 0) === 0
? "No Route53 credentials found"
: "Select credential"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{(r53Credentials ?? []).map((c) => (
<SelectItem key={c.id} value={c.id!}>
{credLabel(c)}
</SelectItem>
))}
</SelectContent>
</Select>
{credentialQ.error && (
<p className="text-destructive text-xs">Failed to load credentials.</p>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createDomainForm.control}
name="zone_id"
render={({ field }) => (
<FormItem>
<FormLabel>Zone ID (optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/hostedzone/Z123…" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateDomOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createDomainMut.isPending}>
{createDomainMut.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
{/* domains panel */}
<div>
<Card className="p-3 md:col-span-5">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold">Domains</div>
{domainsQ.isFetching && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
<div className="max-h-[60vh] overflow-auto rounded-md border">
<table className="min-w-full text-sm">
<thead className="bg-muted/40 text-xs tracking-wide uppercase">
<tr>
<th className="px-3 py-2 text-left">Domain</th>
<th className="px-3 py-2 text-left">Zone</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{(filteredDomains ?? []).map((d) => (
<tr
key={d.id}
className={`hover:bg-muted/30 border-t ${
selected?.id === d.id ? "bg-muted/40" : ""
}`}
onClick={() => setSelected(d)}
>
<td className="cursor-pointer px-3 py-2 font-medium">{d.domain_name}</td>
<td className="px-3 py-2">{d.zone_id || "—"}</td>
<td className="px-3 py-2">
<StatusBadge s={d.status} />
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-2">
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
openEditDomain(d)
}}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {d.domain_name}?</AlertDialogTitle>
<AlertDialogDescription>
This deletes the domain metadata. External DNS records are not
touched.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteDomainMut.mutate(d.id!)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</td>
</tr>
))}
{(!filteredDomains || filteredDomains.length === 0) && (
<tr>
<td colSpan={4} className="text-muted-foreground px-3 py-8 text-center">
No domains yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
<div>
{/* records panel */}
<Card className="p-3 md:col-span-7">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-semibold">
Records {selected ? `${selected.domain_name}` : ""}
</div>
<div className="flex items-center gap-2">
<StatusBadge s={selected?.status} />
<Dialog open={createRecOpen} onOpenChange={setCreateRecOpen}>
<DialogTrigger asChild>
<Button disabled={!selected}>
<Plus className="mr-2 h-4 w-4" />
Add Record
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Add Record</DialogTitle>
</DialogHeader>
<Form {...createRecForm}>
<form
className="space-y-4 pt-2"
onSubmit={createRecForm.handleSubmit((v) => createRecordMut.mutate(v))}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField
control={createRecForm.control}
name="name"
render={({ field }) => (
<FormItem className="md:col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="endpoint (or @)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createRecForm.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value as string}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{rrtypes.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createRecForm.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (sec, optional)</FormLabel>
<FormControl>
<Input
type="number"
value={field.value as number | undefined}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : Number(e.target.value)
)
}
placeholder="300"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createRecForm.control}
name="valuesCsv"
render={({ field }) => (
<FormItem>
<FormLabel>Values (comma-separated)</FormLabel>
<FormControl>
<Textarea
{...field}
rows={3}
placeholder='e.g. 10.0.30.1, 10.0.30.2 or "v=spf1 ~all"'
className="font-mono"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button
variant="outline"
type="button"
onClick={() => setCreateRecOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={createRecordMut.isPending}>
{createRecordMut.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="overflow-x-auto rounded-md border">
{recordsQ.isLoading && (
<div className="flex items-center gap-2 p-4">
<Loader2 className="h-4 w-4 animate-spin" /> Loading records
</div>
)}
{!recordsQ.isLoading && (
<table className="min-w-full text-sm">
<thead className="bg-muted/40 text-xs tracking-wide uppercase">
<tr>
<th className="px-3 py-2 text-left">Name</th>
<th className="px-3 py-2 text-left">Type</th>
<th className="px-3 py-2 text-left">TTL</th>
<th className="px-3 py-2 text-left">Values</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Owner</th>
<th className="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{(recordsQ.data ?? []).map((r) => {
const values = (r.values as any) || []
return (
<tr key={r.id} className="border-t">
<td className="px-3 py-2 font-medium">{r.name || "@"}</td>
<td className="px-3 py-2">{r.type}</td>
<td className="px-3 py-2">{r.ttl ?? "—"}</td>
<td className="px-3 py-2">
<div className="max-w-[420px] truncate" title={(values || []).join(", ")}>
{(values || []).join(", ")}
</div>
</td>
<td className="px-3 py-2">
<StatusBadge s={r.status} />
</td>
<td className="px-3 py-2">{r.owner}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-2">
<Button size="icon" variant="ghost" onClick={() => openEditRecord(r)}>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {r.name || "@"} {r.type}?
</AlertDialogTitle>
<AlertDialogDescription>
This removes the record set from your project. Your worker does
not delete it from the DNS provider right now.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteRecordMut.mutate(r.id!)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEditRecord(r)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteRecordMut.mutate(r.id!)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
{(!recordsQ.data || recordsQ.data.length === 0) && (
<tr>
<td colSpan={7} className="text-muted-foreground px-3 py-8 text-center">
{selected
? "No records yet — add one."
: "Select a domain to view records."}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</Card>
</div>
{/* Edit Domain Dialog */}
<Dialog open={editDomOpen} onOpenChange={setEditDomOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit Domain</DialogTitle>
</DialogHeader>
<Form {...editDomainForm}>
<form
className="space-y-4 pt-2"
onSubmit={editDomainForm.handleSubmit((v) => updateDomainMut.mutate(v))}
>
<FormField
control={editDomainForm.control}
name="domain_name"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* CREDENTIAL SELECT (Edit) */}
<FormField
control={editDomainForm.control}
name="credential_id"
render={({ field }) => (
<FormItem>
<FormLabel>Route53 Credential</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? ""}
disabled={credentialQ.isLoading || (r53Credentials?.length ?? 0) === 0}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
credentialQ.isLoading
? "Loading…"
: (r53Credentials?.length ?? 0) === 0
? "No Route53 credentials found"
: "Select credential"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{(r53Credentials ?? []).map((c) => (
<SelectItem key={c.id} value={c.id!}>
{credLabel(c)}
</SelectItem>
))}
</SelectContent>
</Select>
{credentialQ.error && (
<p className="text-destructive text-xs">Failed to load credentials.</p>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editDomainForm.control}
name="zone_id"
render={({ field }) => (
<FormItem>
<FormLabel>Zone ID</FormLabel>
<FormControl>
<Input {...field} placeholder="/hostedzone/Z123…" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button variant="outline" type="button" onClick={() => setEditDomOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateDomainMut.isPending}>
{updateDomainMut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit Record Dialog */}
<Dialog open={editRecOpen} onOpenChange={setEditRecOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Edit Record</DialogTitle>
</DialogHeader>
<Form {...editRecForm}>
<form
className="space-y-4 pt-2"
onSubmit={editRecForm.handleSubmit((v) => updateRecordMut.mutate(v))}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField
control={editRecForm.control}
name="name"
render={({ field }) => (
<FormItem className="md:col-span-1">
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editRecForm.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select onValueChange={field.onChange} value={field.value as string}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{rrtypes.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editRecForm.control}
name="ttl"
render={({ field }) => (
<FormItem>
<FormLabel>TTL (sec, optional)</FormLabel>
<FormControl>
<Input
type="number"
value={field.value as number | undefined}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : Number(e.target.value)
)
}
placeholder="300"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editRecForm.control}
name="valuesCsv"
render={({ field }) => (
<FormItem>
<FormLabel>Values (comma-separated)</FormLabel>
<FormControl>
<Textarea {...field} rows={3} className="font-mono" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button variant="outline" type="button" onClick={() => setEditRecOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateRecordMut.isPending}>
{updateRecordMut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)
}