mroe ui #2

Merged
mats merged 1 commits from feat/ui-fix into main 2026-09-28 12:38:58 +02:00
13 changed files with 542 additions and 26 deletions
Showing only changes of commit b2cd7d4b91 - Show all commits

No files matched your search

Binary file not shown.
+23 -23
View File
@@ -1,6 +1,9 @@
{
"root": "/workspace/repos/git.k6n.net/mats/fancy-dns",
"recent_files": [
"internal/qlog/qlog_test.go",
"internal/app/app.go",
"web/src/pages/Zones.tsx",
"internal/cluster/cluster_test.go",
"README.md",
"internal/server/shutdown_test.go",
@@ -23,34 +26,13 @@
"internal/blocklist/parse.go",
"internal/app/memory.go",
"internal/api/auth.go",
"internal/app/app.go",
"internal/api/api.go",
"internal/zonefile/zonefile_test.go",
"internal/engine/pins_test.go",
"internal/engine/harness_test.go",
"internal/metrics/metrics.go",
"internal/qlog/counts.go",
"internal/metrics/metrics_test.go"
"internal/metrics/metrics.go"
],
"queries": [
{
"query": "func Load|KnownFields|yaml.NewDecoder|func Marshal",
"command": "grep",
"at": "2026-09-27T16:22:55.810381382Z",
"focus_path": "internal/config/config.go"
},
{
"query": "NSID|OutcomeLocal|ActionAllow|ActionLocal",
"command": "grep",
"at": "2026-09-27T16:22:55.85487649Z",
"focus_path": "internal/engine/engine_test.go"
},
{
"query": "^func Test",
"command": "grep",
"at": "2026-09-27T16:23:07.872849833Z",
"focus_path": "internal/config/size_test.go"
},
{
"query": "Outcome|func Record\\(|local",
"command": "grep",
@@ -329,8 +311,26 @@
"query": "(?i)qtype|match|count|can_write|types\\.ts|hand-written",
"command": "grep",
"at": "2026-09-27T17:14:02.764934975Z"
},
{
"query": "(?i)name:|zone:",
"command": "grep",
"at": "2026-09-28T08:14:05.218032007Z",
"focus_path": "web/src/pages/Zones.tsx"
},
{
"query": "func \\(a Action\\) String|StringToType|func \\(s \\*Server\\) request\\(",
"command": "grep",
"at": "2026-09-28T08:14:17.238295025Z",
"focus_path": "internal/app/app.go"
},
{
"query": "Action|type Action",
"command": "grep",
"at": "2026-09-28T08:15:04.827470149Z",
"focus_path": "internal/qlog/qlog_test.go"
}
],
"ast_pins": [],
"updated_at": "2026-09-27T17:14:02.764941775Z"
"updated_at": "2026-09-28T08:15:04.827473319Z"
}
+1
View File
@@ -543,6 +543,7 @@ is on.
| GET | `/api/domains?q=&action=&list=&page=&per_page=` | — | page the compiled rules |
| GET | `/api/domains/check?name=…` | — | explain how a name would be treated (the fastest "why isn't this blocked" tool) |
| POST | `/api/domains` | yes | add an inline rule |
| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request |
| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule |
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, the managed directory, whether it is writable, the writable record types, and why the store is stale (`error`) |
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
+4
View File
@@ -65,6 +65,9 @@ type Hooks struct {
ToggleBlocklist func(name string, enabled bool) error
// DomainCheck explains how a name would be treated.
DomainCheck func(name string) (any, error)
// Resolve runs one query through the live pipeline and reports the
// outcome, so the console can test the server it is talking to.
Resolve func(ctx context.Context, name, qtype string) (any, error)
// ListDomains pages the compiled rules.
ListDomains func(q DomainQuery) (any, error)
// AddDomain adds an inline rule and persists it.
@@ -286,6 +289,7 @@ func (s *Server) routes(cfg *config.Config) *http.ServeMux {
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
mux.Handle("POST "+base+"dns/test", s.authed(s.handleDNSTest, false))
mux.Handle("GET "+base+"local-zones", s.authed(s.handleLocalZones, false))
mux.Handle("PUT "+base+"local-zones/{name}", s.authed(s.handleLocalZonePut, true))
mux.Handle("DELETE "+base+"local-zones/{name}", s.authed(s.handleLocalZoneDelete, true))
+31
View File
@@ -177,6 +177,37 @@ func (s *Server) handleDomainCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, v)
}
// handleDNSTest answers one query through the live pipeline. It is the
// console's "is my DNS actually answering" button: the answer comes from the
// same engine the listeners use, so what it shows is what a client gets, and
// a query error is reported inside the payload rather than as a status code —
// a failed lookup is a result here, not a failed request.
func (s *Server) handleDNSTest(w http.ResponseWriter, r *http.Request) {
if s.hooks.Resolve == nil {
writeError(w, http.StatusNotImplemented, "dns test not available")
return
}
var in struct {
Name string `json:"name"`
QType string `json:"type"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
name := strings.TrimSpace(in.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
v, err := s.hooks.Resolve(r.Context(), name, in.QType)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"name": name, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, v)
}
func (s *Server) handleLocalZones(w http.ResponseWriter, r *http.Request) {
if s.hooks.LocalZones == nil {
writeError(w, http.StatusNotImplemented, "local zone editor is unavailable")
+58 -1
View File
@@ -2,7 +2,9 @@ package app
import (
"context"
"errors"
"fmt"
"net/netip"
"sort"
"strings"
"sync"
@@ -13,6 +15,7 @@ import (
"git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/engine"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
@@ -35,6 +38,7 @@ func (a *App) hooks() api.Hooks {
RefreshBlocklists: a.refreshBlocklists,
ToggleBlocklist: a.toggleBlocklist,
DomainCheck: a.domainCheck,
Resolve: a.resolveTest,
ListDomains: a.listDomains,
AddDomain: a.addDomain,
DeleteDomain: a.deleteDomain,
@@ -398,7 +402,60 @@ func (a *App) domainCheck(name string) (any, error) {
return out, nil
}
// listDomains pages the compiled rules. Walking a million-entry map on every
// resolveTest runs one query through the live pipeline. It goes through the
// engine rather than straight to an upstream: a test that skipped the
// pipeline would report a healthy upstream while the blocklist, the ACL or the
// local zones were what a client actually hit.
func (a *App) resolveTest(ctx context.Context, name, qtype string) (any, error) {
if a.engine == nil {
return nil, errors.New("the query engine is not running")
}
qt := dns.TypeA
if qtype = strings.TrimSpace(qtype); qtype != "" {
var ok bool
qt, ok = dns.StringToType[strings.ToUpper(qtype)]
if !ok {
return nil, fmt.Errorf("unknown record type %q", qtype)
}
}
q := new(dns.Msg)
q.SetQuestion(dns.Fqdn(name), qt)
q.RecursionDesired = true
res := a.engine.Handle(ctx, engine.Request{
Msg: q,
Remote: netip.AddrFrom4([4]byte{127, 0, 0, 1}),
Transport: "api",
Start: time.Now(),
})
out := map[string]any{
"name": dns.Fqdn(name),
"type": dns.TypeToString[qt],
"rcode": "SERVFAIL",
"action": string(res.Action),
"duration_ms": res.Duration.Milliseconds(),
"upstream": res.Upstream,
"group": res.Group,
"cache": res.CacheKind,
"local": res.Local,
"truncated": res.Truncated,
"blocked": res.BlockSource != "",
"block_source": res.BlockSource,
"answers": []string{},
}
if res.Msg == nil {
out["error"] = "no response"
return out, nil
}
out["rcode"] = dns.RcodeToString[res.Msg.Rcode]
answers := make([]string, 0, len(res.Msg.Answer))
for _, rr := range res.Msg.Answer {
answers = append(answers, strings.TrimSpace(rr.String()))
}
out["answers"] = answers
return out, nil
}
// listDomains pages the compiled rules.
// request would be too slow, so the filter runs over a bounded sample unless
// the query is a prefix/exact lookup, which the LRU can answer directly.
func (a *App) listDomains(q api.DomainQuery) (any, error) {
+51
View File
@@ -608,6 +608,57 @@ func TestDomainAddInvalid(t *testing.T) {
// TestACLUpdateRoundTrip checks the clients editor's write path and that a
// saved group is enforced immediately.
// The console's "Test" button goes through the live pipeline, so this pins
// the whole path: a name the fake upstream answers must come back with the
// answer records, and one it does not must come back as a result (a 200 with
// an rcode) rather than as a failed request.
func TestDNSTestEndpoint(t *testing.T) {
h := newHarness(t, nil)
code, body := h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"A"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test = %d: %s", code, body)
}
var hit struct {
Name string `json:"name"`
Type string `json:"type"`
RCode string `json:"rcode"`
Action string `json:"action"`
Answers []string `json:"answers"`
}
if err := json.Unmarshal(body, &hit); err != nil {
t.Fatal(err)
}
if hit.Name != "www.example.test." || hit.Type != "A" {
t.Errorf("canonical name = %q type = %q", hit.Name, hit.Type)
}
if hit.RCode != "NOERROR" {
t.Fatalf("rcode = %q, want NOERROR: %s", hit.RCode, body)
}
if len(hit.Answers) != 1 || !strings.Contains(hit.Answers[0], "203.0.113.7") {
t.Errorf("answers = %v, want the upstream A record", hit.Answers)
}
// A name the upstream does not hold is still a successful test.
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"nothing.example.com","type":"A"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test (miss) = %d: %s", code, body)
}
if !strings.Contains(string(body), "NXDOMAIN") {
t.Errorf("a miss is not reported as a result: %s", body)
}
// An unknown record type is a bad request the caller can fix, so it comes
// back as an error in the payload rather than as a bogus lookup.
code, body = h.do(http.MethodPost, "/api/dns/test", `{"name":"www.example.test","type":"NOTATYPE"}`)
if code != http.StatusOK {
t.Fatalf("POST /api/dns/test (bad type) = %d: %s", code, body)
}
if !strings.Contains(string(body), "error") {
t.Errorf("a bad type is not reported: %s", body)
}
}
func TestACLUpdateRoundTrip(t *testing.T) {
h := newHarness(t, nil)
// Write a deny group through the API.
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-BFc4v6ED.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CYPXMp15.css">
<script type="module" crossorigin src="/assets/index-CzcgOHn-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FxcuHhfL.css">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -20,6 +20,7 @@ import Domains from './pages/Domains.tsx'
import Login from './pages/Login.tsx'
import Logs from './pages/Logs.tsx'
import LocalZones from './pages/LocalZones.tsx'
import Lookup from './pages/Lookup.tsx'
import NotFound from './pages/NotFound.tsx'
import Settings from './pages/Settings.tsx'
import Upstreams from './pages/Upstreams.tsx'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/" element={<Dashboard />} />
<Route path="/blocklists" element={<Blocklists />} />
<Route path="/domains" element={<Domains />} />
<Route path="/lookup" element={<Lookup />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/zones" element={<Zones />} />
<Route path="/clients" element={<Clients />} />
+2
View File
@@ -15,6 +15,7 @@ import {
Network,
PanelLeftClose,
ScrollText,
Search,
Settings,
Users,
X,
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/lookup', label: 'Lookup', icon: Search },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/zones', label: 'Zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users },
+9
View File
@@ -18,6 +18,7 @@ import type {
DomainCheckResponse,
DomainFilters,
DomainsResponse,
DnsTestResponse,
FlushCacheResponse,
LoginInput,
LoginResponse,
@@ -278,6 +279,14 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
/** Runs one query through the live pipeline, the console's connectivity test. */
export function testDns(name: string, qtype: string): Promise<DnsTestResponse> {
return apiFetch<DnsTestResponse>('/api/dns/test', {
method: 'POST',
body: JSON.stringify({ name, type: qtype }),
})
}
export function getZones(): Promise<ZonesResponse> {
return apiFetch<ZonesResponse>('/api/zones')
}
+24
View File
@@ -315,6 +315,30 @@ export interface DomainCheckError {
export type DomainCheckResponse = DomainCheckMatch | DomainCheckMiss | DomainCheckError
// --- POST /api/dns/test -----------------------------------------------------
/**
* The result of one query through the live pipeline. A failed lookup is a
* 200 with an rcode and no answers, so `error` only appears when the query
* never ran (bad name, engine down).
*/
export interface DnsTestResponse {
name: string
type: string
rcode: string
action: LogAction | string
answers: string[]
duration_ms: number
upstream: string
group: string
cache: string
local: boolean
truncated: boolean
blocked: boolean
block_source: string
error?: string
}
// --- GET /api/clients ------------------------------------------------------
export interface ClientMatch {
+335
View File
@@ -0,0 +1,335 @@
/**
* Lookup — the console's dig, and the place a domain's config is copied from.
*
* A test goes through the live pipeline (blocklists, ACL, local zones, cache,
* upstream), so it answers the question an operator actually has: "would a
* client pointed at this server get an answer for this name". The entries list
* is the same query over the record types a domain is normally configured
* with, and every entry can be turned into a local-zone override from there.
*/
import { useState } from 'react'
import type { ReactNode } from 'react'
import { Plus, Search } from 'lucide-react'
import { addZoneRecord, testDns } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type { DnsTestResponse } from '../lib/types'
import { useAsyncAction } from '../hooks/useApi'
import {
Badge,
Button,
Card,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Table,
useToast,
} from '../components/ui'
import { PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
/** The record types a "list the entries of this domain" lookup asks for. */
const ENTRY_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA']
const TEST_TYPES = ['A', 'AAAA', 'TXT', 'MX', 'NS', 'SOA', 'CNAME', 'PTR', 'ANY']
interface Entry {
key: string
name: string
type: string
ttl: number
value: string
}
/**
* Splits one rendered answer into its parts. The server sends the record as it
* appears in a zone file — `name ttl class type rdata` — so the rdata is
* whatever follows the fourth field and keeps its spaces (a TXT value, a
* quoted MX preference and exchange).
*/
function parseEntry(answer: string, type: string, index: number): Entry | null {
const parts = answer.trim().split(/\s+/)
if (parts.length < 5) return null
const ttl = Number.parseInt(parts[1], 10)
return {
key: `${parts[0]}-${type}-${index}`,
name: parts[0],
type: parts[3].toUpperCase(),
ttl: Number.isFinite(ttl) ? ttl : 0,
value: parts.slice(4).join(' '),
}
}
/**
* The apex to offer as the override zone. There is no public-suffix list in the
* binary, so this takes the last two labels: for `www.example.com` that is
* `example.com`, and the dialog opens with the field editable because
* `example.co.uk` needs three.
*/
function defaultZone(name: string): string {
return name.replace(/\.$/, '').split('.').slice(-2).join('.')
}
function rcodeTone(rcode: string): 'default' | 'ok' | 'warn' | 'danger' {
if (rcode === 'NOERROR') return 'ok'
if (rcode === 'NXDOMAIN') return 'warn'
return 'danger'
}
export default function Lookup() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const [name, setName] = useState('')
const [qtype, setQtype] = useState('A')
const [result, setResult] = useState<DnsTestResponse | null>(null)
const [entries, setEntries] = useState<Entry[]>([])
const [listed, setListed] = useState('')
const [override, setOverride] = useState<Entry | null>(null)
const [zone, setZone] = useState('')
const [relName, setRelName] = useState('')
const [ttl, setTtl] = useState('')
const test = useAsyncAction(async (target: string, type: string) => testDns(target, type))
const write = useAsyncAction(async (input: { zone: string; name: string; type: string; ttl: number; value: string }) =>
addZoneRecord({
zone: input.zone,
name: input.name,
type: input.type,
ttl: input.ttl,
value: input.value,
}),
)
const trimmed = name.trim()
const runTest = async (): Promise<void> => {
if (trimmed === '') return
const res = await test.run(trimmed, qtype)
if (res === null) {
toast({ title: test.error ?? 'the test query failed', tone: 'danger' })
return
}
setResult(res)
}
const listEntries = async (): Promise<void> => {
if (trimmed === '') return
const results = await Promise.all(ENTRY_TYPES.map((t) => testDns(trimmed, t)))
const found: Entry[] = []
for (const [i, res] of results.entries()) {
if (res.error !== undefined) continue
for (const [j, answer] of res.answers.entries()) {
const entry = parseEntry(answer, ENTRY_TYPES[i], j)
if (entry !== null) found.push(entry)
}
}
setEntries(found)
setListed(trimmed)
toast({
title: found.length === 0 ? `no records for ${trimmed}` : `${found.length} record${found.length === 1 ? '' : 's'} for ${trimmed}`,
tone: found.length === 0 ? 'warn' : 'ok',
})
}
const openOverride = (entry: Entry): void => {
const apex = defaultZone(entry.name)
setOverride(entry)
setZone(apex)
setRelName(entry.name === `${apex}.` || entry.name === apex ? '@' : entry.name.replace(/\.$/, '').slice(0, -(apex.length + 1)))
setTtl(entry.ttl > 0 ? String(entry.ttl) : '300')
}
const saveOverride = async (): Promise<void> => {
const target = override
if (target === null) return
const res = await write.run({
zone: zone.trim(),
name: relName.trim() === '' ? '@' : relName.trim(),
type: target.type,
ttl: Number.parseInt(ttl, 10) || target.ttl,
value: target.value,
})
if (res === null) {
toast({ title: write.error ?? 'the server rejected that record', tone: 'danger' })
return
}
setOverride(null)
toast({ title: `override created in ${res.zone}`, description: `${target.type} ${target.name}`, tone: 'ok' })
}
const columns = [
{ key: 'name', header: 'Name', width: '40%' },
{ key: 'type', header: 'Type', width: '10%' },
{ key: 'value', header: 'Value', width: '38%' },
{ key: 'ttl', header: 'TTL', width: '6%', align: 'right' as const },
{ key: 'override', header: '', width: '6%', align: 'right' as const },
]
const rows: Row[] = entries.map((entry) => ({
__key: entry.key,
name: <span className="block max-w-[280px] truncate font-mono text-xs" title={entry.name}>{entry.name}</span>,
type: <Badge tone="info">{entry.type}</Badge>,
value: <span className="block max-w-[320px] truncate font-mono text-xs" title={entry.value}>{entry.value}</span>,
ttl: <span className="font-mono text-xs">{entry.ttl || '—'}</span>,
override: (
<IconButton
icon={Plus}
label={`create an override for ${entry.name} ${entry.type}`}
variant="ghost"
disabled={!canWrite}
onClick={() => openOverride(entry)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Lookup"
description="Test the server, list a domain's records, and copy one into a local zone as an override."
/>
<Card title="Test a name" subtitle="Resolved through the live pipeline, exactly as a client would see it.">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto_auto]">
<Input
label="Name"
placeholder="example.com or www.example.com"
icon={Search}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runTest()
}}
autoComplete="off"
spellCheck={false}
/>
<Select
label="Type"
value={qtype}
onChange={(e) => setQtype(e.target.value)}
options={TEST_TYPES.map((t) => ({ value: t, label: t }))}
/>
<div className="flex items-end">
<Button onClick={() => void runTest()} loading={test.pending} disabled={trimmed === ''}>
Test
</Button>
</div>
</div>
{result ? (
<div className="mt-4 space-y-2 border-t border-slate-200 pt-4 dark:border-slate-800">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={rcodeTone(result.rcode)}>{result.rcode}</Badge>
<Badge tone="muted">{result.type}</Badge>
<Badge tone={result.blocked ? 'danger' : 'default'}>{result.action}</Badge>
{result.local ? <Badge tone="ok">local zone</Badge> : null}
{result.cache === 'hit' || result.cache === 'stale' ? <Badge tone="info">cache {result.cache}</Badge> : null}
{result.truncated ? <Badge tone="warn">truncated</Badge> : null}
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{result.duration_ms} ms{result.upstream ? ` · ${result.upstream}` : ''}
</span>
</div>
{result.error ? (
<p className="text-sm text-red-700 dark:text-red-300">{result.error}</p>
) : null}
{result.block_source ? (
<p className="text-sm text-slate-600 dark:text-slate-400">Blocked by {result.block_source}</p>
) : null}
{result.answers.length > 0 ? (
<ul className="space-y-1">
{result.answers.map((answer) => (
<li key={answer} className="break-all font-mono text-xs">{answer}</li>
))}
</ul>
) : result.error === undefined ? (
<p className="text-sm text-slate-500 dark:text-slate-400">No answer records.</p>
) : null}
</div>
) : null}
</Card>
<Card
title="Domain entries"
subtitle={listed === '' ? undefined : `Records for ${listed}`}
padded={false}
actions={
<Button variant="secondary" onClick={() => void listEntries()} loading={test.pending} disabled={trimmed === ''}>
List entries
</Button>
}
>
<div className="overflow-x-auto">
<Table
columns={columns}
rows={rows}
rowKey={(row) => String(row.__key)}
empty={
<EmptyState
title={listed === '' ? 'No domain looked up yet' : 'No records found'}
description="Enter a name above and list its entries. Every entry can become a local-zone override."
/>
}
/>
</div>
</Card>
<Dialog
open={override !== null}
onClose={() => setOverride(null)}
title="Create an override"
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
Cancel
</Button>
<Button onClick={() => void saveOverride()} loading={write.pending} disabled={!canWrite}>
Create override
</Button>
</div>
}
>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Zone"
hint="The apex to make authoritative."
value={zone}
onChange={(e) => setZone(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Name"
hint="Relative to the zone; @ is the apex."
value={relName}
onChange={(e) => setRelName(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Type"
value={override?.type ?? ''}
onChange={() => undefined}
disabled
/>
<Input
label="TTL"
value={ttl}
onChange={(e) => setTtl(e.target.value)}
inputMode="numeric"
/>
</div>
<div className="rounded-md bg-slate-100 px-3 py-2 font-mono text-xs break-all dark:bg-slate-800">
{override?.name} {override?.ttl} IN {override?.type} {override?.value}
</div>
</div>
</Dialog>
</div>
)
}