mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
1106 lines
41 KiB
TypeScript
1106 lines
41 KiB
TypeScript
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 (don’t 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 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,
|
||
...(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>
|
||
)
|
||
}
|