Add a Zones console: create and manage local zones, records and delegations

The console could not see local zones at all. `blocklist.local_zones` compiled
a store that nothing read back: there was no API for a zone, its records, its
delegations or the file a record came from, and no way to change one without
editing YAML and reloading. The Domains page is the blocklist's inline rules,
not local data, so nothing in the console covered the half of the server that
answers authoritatively from disk. There is now a Zones page and the endpoints
behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`,
`POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`.

**The console owns one directory and nothing else.** `blocklist.managed_zones`
holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the
records added there, and it is compiled by the same `localzone.Load` as the
operator's files — so a record added from the console is served on the next
query, with no reload and no restart. Files in `blocklist.local_zones` are
listed and shown read-only and are never rewritten; the key defaults to empty,
so a deployment that never sets it gets a zone browser and no zone writes.
Which file a record came from is now part of the store (`zone.files`, keyed by
the index the store already uses), so the snapshot can say "console" or "your
file" per record; the query path does not read it.

**The writer proves its own output.** A record is validated by the project's own
master-file parser, so `www` means `www.<zone>` and a bare CNAME target is
completed exactly as it would be in a zone file; the file is rendered, and the
rendering is re-parsed and compared with the records it meant to write before
the file is renamed into place. A writer bug that dropped a record would
otherwise present as a zone that silently stopped answering. A zone that cannot
answer is refused rather than created: no SOA means no NXDOMAIN or NODATA
authority, and no apex NS means nothing is delegated and no server is named. The
SOA's primary follows the apex NS set — a zone whose SOA names a server outside
its own NS set hands out a referral no resolver can follow — and the serial
moves on every write.

**A delegation takes the names under it, so a record below a cut is refused.**
The store answers those names as delegated without reading the zone file, which
would make the record data that looks alive and never answers. `DELETE
/api/zones?zone=` exists for the same reason from the other side: a zone the
console created is a real zone, so it says NXDOMAIN for what it does not hold,
and deleting a record is not what gives a name back to upstream. It is
refused for a zone that also has records outside the managed directory, because
removing that file would remove none of the zone.

**Two bugs the work turned up.** `engine.applyConfig` published local-record
metrics only when `blocklist.local_zones` was non-empty, so a zone the console
created was served but never counted; the gate is now the store's own record
count. And the record-value renderer fell back to `RR.String()` for every type
it did not know, which is the whole line rather than the RDATA — it wrote a
name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would
have fed that back as a record value on the next edit.

Every write recompiles the store and flushes the cache. Which names changed is
not knowable from the API, and a cached upstream answer for a name the zone now
answers is simply wrong, so the flush is blunt on purpose.

The wire shapes are type aliases in `internal/api` rather than copies, so there
is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every
level with a record and a delegation actually present — an empty list only
proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only
default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added
through the API is answered authoritatively on the next query, a cut stops the
parent answering below it, and deleting the zone hands the name back.

Verified against a running instance built from this tree: records and
wildcards answer with `aa`, relative names are qualified, a record below a cut
is refused with a message that says why, and the file on disk is a zone file
an operator can read. The console page is typechecked and built; the browser was
not available in this round, so it has not been looked at.
This commit is contained in:
developer committed 2026-09-28 07:45:16 +00:00
1 parent 32f940b763
commit 504512f247
25 files changed
+4064 -56

No files matched your search

+2
View File
@@ -23,6 +23,7 @@ import LocalZones from './pages/LocalZones.tsx'
import NotFound from './pages/NotFound.tsx'
import Settings from './pages/Settings.tsx'
import Upstreams from './pages/Upstreams.tsx'
import Zones from './pages/Zones.tsx'
const THEME_KEY = 'fancydns.theme'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/blocklists" element={<Blocklists />} />
<Route path="/domains" element={<Domains />} />
<Route path="/local-zones" element={<LocalZones />} />
<Route path="/zones" element={<Zones />} />
<Route path="/clients" element={<Clients />} />
<Route path="/upstreams" element={<Upstreams />} />
<Route path="/logs" element={<Logs />} />
+2
View File
@@ -7,6 +7,7 @@ import {
FileCode2,
Globe,
LayoutDashboard,
Layers,
ListChecks,
Lock,
LogOut,
@@ -39,6 +40,7 @@ const NAV: NavItem[] = [
{ to: '/blocklists', label: 'Blocklists', icon: ListChecks },
{ to: '/domains', label: 'Domains', icon: Globe },
{ to: '/local-zones', label: 'Local zones', icon: FileCode2 },
{ to: '/zones', label: 'Zones', icon: Layers },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/upstreams', label: 'Upstreams', icon: Network },
{ to: '/logs', label: 'Logs', icon: ScrollText },
+51
View File
@@ -38,6 +38,10 @@ import type {
UpstreamsResponse,
ValidateResponse,
Whoami,
ZoneDelegationInput,
ZoneRecordInput,
ZonesResponse,
ZoneWriteResponse,
} from './types.ts'
const TOKEN_KEY = 'fancydns.token'
@@ -274,6 +278,53 @@ export function checkDomain(name: string): Promise<DomainCheckResponse> {
return apiFetch<DomainCheckResponse>(apiUrl('/api/domains/check', { name }))
}
export function getZones(): Promise<ZonesResponse> {
return apiFetch<ZonesResponse>('/api/zones')
}
export function addZoneRecord(input: ZoneRecordInput): Promise<ZoneWriteResponse> {
return apiFetch<ZoneWriteResponse>('/api/zones/records', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function putZoneRecord(input: ZoneRecordInput): Promise<ZoneWriteResponse> {
return apiFetch<ZoneWriteResponse>('/api/zones/records', {
method: 'PUT',
body: JSON.stringify(input),
})
}
export function deleteZoneRecord(
zone: string,
name: string,
type: string,
value?: string,
): Promise<OkResponse> {
return apiFetch<OkResponse>(
apiUrl('/api/zones/records', { zone, name, type, value }),
{ method: 'DELETE' },
)
}
export function addZoneDelegation(input: ZoneDelegationInput): Promise<ZoneWriteResponse> {
return apiFetch<ZoneWriteResponse>('/api/zones/delegations', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function deleteZoneDelegation(zone: string, name: string): Promise<OkResponse> {
return apiFetch<OkResponse>(apiUrl('/api/zones/delegations', { zone, name }), {
method: 'DELETE',
})
}
export function deleteZone(zone: string): Promise<OkResponse> {
return apiFetch<OkResponse>(apiUrl('/api/zones', { zone }), { method: 'DELETE' })
}
export function getClients(): Promise<ClientsResponse> {
return apiFetch<ClientsResponse>('/api/clients')
}
+77
View File
@@ -620,3 +620,80 @@ export interface AddDomainInput {
source?: string
content?: string
}
// --- Local zones -----------------------------------------------------------
/**
* One authoritative record. `value` is presentation-format RDATA with every
* domain name absolute, so it can be pasted back into the form unchanged.
*/
export interface ZoneRecord {
name: string
type: string
ttl: number
value: string
/** True when the owner was written `*.name`. */
wildcard: boolean
/** True for an NS record below the apex: the cut. */
delegation: boolean
/** True when the console may rewrite this record. */
managed: boolean
/** The master file the record was parsed from. */
source: string
}
/** A cut in a zone: a name below the apex handed to other servers. */
export interface ZoneDelegation {
name: string
targets: string[]
managed: boolean
}
export interface Zone {
origin: string
serial: number
soa: string
default_ttl: number
records: ZoneRecord[]
delegations: ZoneDelegation[]
record_count: number
/** True when every record in the zone is the console's to rewrite. */
managed: boolean
files: string[]
}
export interface ZonesResponse {
zones: Zone[]
files: string[]
/** The directory the API may rewrite, `''` when writes are disabled. */
managed_dir: string
writable: boolean
/** The record types the write endpoints accept, from the server. */
types: string[]
records: number
built_at: string
/** Why the store is stale, `''` when it is current. */
error: string
}
export interface ZoneRecordInput {
zone: string
name: string
type: string
/** 0 means the zone's default TTL. */
ttl: number
value: string
}
export interface ZoneDelegationInput {
zone: string
name: string
targets: string[]
ttl: number
}
export interface ZoneWriteResponse {
ok: boolean
zone: string
serial: number
}
+834
View File
@@ -0,0 +1,834 @@
/**
* Zones — the local zones this resolver answers for itself, and the
* delegations that hand a name to another server.
*
* The console only ever writes the managed zone directory, so a record from an
* operator's master file is shown and left alone: it carries no edit controls,
* and the API would refuse it anyway. Everything the page does is a
* request-then-refetch, because every write recompiles the zone store and the
* serial in the answer is the cheapest way to see that the file moved.
*/
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import {
addZoneDelegation,
addZoneRecord,
deleteZone,
deleteZoneDelegation,
deleteZoneRecord,
getZones,
putZoneRecord,
} from '../lib/api'
import { useWhoami } from '../lib/session.ts'
import type {
Zone,
ZoneDelegation,
ZoneDelegationInput,
ZoneRecord,
ZoneRecordInput,
} from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { useDebounced } from '../hooks/useDebounced'
import { formatNumber, formatRelative, toLines } from '../lib/format'
import {
Badge,
Button,
Card,
ConfirmDialog,
Dialog,
EmptyState,
IconButton,
Input,
Select,
Table,
Tabs,
Textarea,
useToast,
} from '../components/ui'
import type { ToastOptions } from '../components/ui'
import { PageError, PageHeader } from '../components/layout'
type Row = Record<string, ReactNode>
function notify(
toast: (options: ToastOptions) => void,
kind: 'ok' | 'fail',
message: string,
): void {
toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' })
}
/** One hint per type, so the value field says what belongs in it. */
const VALUE_HINTS: Record<string, string> = {
A: 'An IPv4 address, e.g. 10.0.0.5',
AAAA: 'An IPv6 address, e.g. 2001:db8::5',
CNAME: 'The target name. A name without a trailing dot is completed with the zone.',
MX: 'Preference and exchange, e.g. 10 mail.example.com. — a bare exchange name is completed with the zone',
NS: 'A nameserver name. A bare name is completed with the zone',
PTR: 'The name this address maps back to',
SRV: 'Priority, weight, port and target, e.g. 0 1 443 target.example.com.',
TXT: 'One or more quoted strings, e.g. "v=spf1 -all"',
CAA: 'Flag, tag and value, e.g. 0 issue "letsencrypt.org"',
}
function valueHint(type: string): string {
return VALUE_HINTS[type] ?? 'The RDATA, exactly as a zone file spells it'
}
function typeTone(type: string): 'default' | 'ok' | 'warn' | 'danger' | 'info' | 'muted' {
switch (type) {
case 'A':
case 'AAAA':
return 'ok'
case 'CNAME':
return 'info'
case 'NS':
return 'warn'
case 'TXT':
case 'SPF':
return 'muted'
case 'SRV':
case 'MX':
return 'default'
default:
return 'default'
}
}
function matches(record: ZoneRecord, q: string): boolean {
if (q === '') return true
return (
record.name.toLowerCase().includes(q) ||
record.type.toLowerCase().includes(q) ||
record.value.toLowerCase().includes(q)
)
}
/** The record form's state, for both adding and editing. */
type RecordForm = {
zone: string
name: string
type: string
ttl: string
value: string
}
const NEW_ZONE = '__new__'
export default function Zones() {
const { toast } = useToast()
const { canWrite } = useWhoami()
const { data, error, loading, refetch } = useApi(getZones, [])
const [origin, setOrigin] = useState('')
const [tab, setTab] = useState('records')
const [search, setSearch] = useState('')
const q = useDebounced(search, 200)
const [recordOpen, setRecordOpen] = useState(false)
const [editing, setEditing] = useState<ZoneRecord | null>(null)
const [form, setForm] = useState<RecordForm | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const [cutOpen, setCutOpen] = useState(false)
const [cutZone, setCutZone] = useState<string>('')
const [newZone, setNewZone] = useState<string>('')
const [cutName, setCutName] = useState<string>('')
const [cutTargets, setCutTargets] = useState<string>('')
const [pendingRecord, setPendingRecord] = useState<ZoneRecord | null>(null)
const [pendingCut, setPendingCut] = useState<ZoneDelegation | null>(null)
const [pendingZone, setPendingZone] = useState<Zone | null>(null)
const zones = useMemo(() => data?.zones ?? [], [data])
// A zone is selected as soon as there is one, and the selection follows a
// zone that has gone away rather than pointing at nothing.
useEffect(() => {
if (zones.length === 0) {
setOrigin('')
return
}
if (!zones.some((zone) => zone.origin === origin)) setOrigin(zones[0].origin)
}, [zones, origin])
const zone = useMemo(
() => zones.find((z) => z.origin === origin) ?? null,
[zones, origin],
)
const writable = data?.writable === true
const canEdit = canWrite && writable
const saveRecord = useAsyncAction(async (input: ZoneRecordInput) =>
editing ? putZoneRecord(input) : addZoneRecord(input),
)
const dropRecord = useAsyncAction(async (target: { zone: string; record: ZoneRecord }) =>
deleteZoneRecord(target.zone, target.record.name, target.record.type, target.record.value),
)
const makeCut = useAsyncAction(async (input: ZoneDelegationInput) => addZoneDelegation(input))
const dropCut = useAsyncAction(async (name: string) =>
deleteZoneDelegation(zone?.origin ?? '', name),
)
const dropZone = useAsyncAction(async (target: string) => deleteZone(target))
const recordLines = useMemo(() => toLines(cutTargets), [cutTargets])
const targets = useMemo(
() =>
cutTargets
.split(/[\n,]+/)
.map((t) => t.trim())
.filter((t) => t !== ''),
[cutTargets],
)
const openNew = (): void => {
setEditing(null)
setForm({ zone: origin, name: '', type: data?.types[0] ?? 'A', ttl: '', value: '' })
setFormError(null)
setRecordOpen(true)
}
const openEdit = (record: ZoneRecord): void => {
setEditing(record)
setForm({
zone: zone?.origin ?? '',
name: record.name,
type: record.type,
ttl: String(record.ttl),
value: record.value,
})
setFormError(null)
setRecordOpen(true)
}
const submitRecord = async (): Promise<void> => {
if (form === null) return
setFormError(null)
const zoneName = form.zone === NEW_ZONE ? newZone.trim() : form.zone
if (zoneName === '') {
setFormError('name the zone this record belongs to')
return
}
if (form.name.trim() === '') {
setFormError('a record needs an owner name')
return
}
if (form.value.trim() === '') {
setFormError(`a ${form.type} record needs a value`)
return
}
const res = await saveRecord.run({
zone: zoneName,
name: form.name.trim(),
type: form.type,
ttl: Number(form.ttl) || 0,
value: form.value.trim(),
})
if (res === null) {
const msg = saveRecord.error ?? 'the server rejected that record'
setFormError(msg)
notify(toast, 'fail', msg)
return
}
notify(toast, 'ok', `${editing ? 'replaced' : 'added'} ${form.name.trim()} ${form.type}`)
setOrigin(zoneName)
setRecordOpen(false)
setForm(null)
refetch()
}
const submitCut = async (): Promise<void> => {
setFormError(null)
const zoneName = cutZone === NEW_ZONE ? newZone.trim() : cutZone
if (zoneName === '') {
setFormError('name the zone to delegate from')
return
}
if (cutName.trim() === '') {
setFormError('name the zone being delegated')
return
}
if (targets.length === 0) {
setFormError('a delegation needs at least one nameserver')
return
}
const res = await makeCut.run({ zone: zoneName, name: cutName.trim(), targets, ttl: 0 })
if (res === null) {
const msg = makeCut.error ?? 'the server rejected that delegation'
setFormError(msg)
notify(toast, 'fail', msg)
return
}
notify(toast, 'ok', `delegated ${cutName.trim()}`)
setOrigin(zoneName)
setCutOpen(false)
setCutName('')
setCutTargets('')
refetch()
}
const confirmRecord = async (): Promise<void> => {
const target = pendingRecord
const zoneName = zone?.origin ?? ''
if (target === null) return
const res = await dropRecord.run({ zone: zoneName, record: target })
if (res === null) {
notify(toast, 'fail', dropRecord.error ?? `could not delete ${target.name}`)
return
}
setPendingRecord(null)
notify(toast, 'ok', `deleted ${target.name} ${target.type}`)
refetch()
}
const confirmCut = async (): Promise<void> => {
const target = pendingCut
if (target === null) return
const res = await dropCut.run(target.name)
if (res === null) {
notify(toast, 'fail', dropCut.error ?? `could not remove the delegation for ${target.name}`)
return
}
setPendingCut(null)
notify(toast, 'ok', `${target.name} answers authoritatively again`)
refetch()
}
const confirmZone = async (): Promise<void> => {
const target = pendingZone
if (target === null) return
const res = await dropZone.run(target.origin)
if (res === null) {
notify(toast, 'fail', dropZone.error ?? `could not delete ${target.origin}`)
return
}
setPendingZone(null)
notify(toast, 'ok', `deleted ${target.origin}`)
refetch()
}
if (error) return <PageError error={error} onRetry={refetch} />
const records = (zone?.records ?? []).filter((record) => matches(record, q.trim().toLowerCase()))
const delegations = zone?.delegations ?? []
const noWriteReason = !canWrite
? 'This API key is read-only'
: !writable
? 'No writable managed zone directory'
: undefined
const recordColumns = [
{ key: 'name', header: 'Name', width: '32%' },
{ key: 'type', header: 'Type', width: '8%' },
{ key: 'value', header: 'Value', width: '34%' },
{ key: 'ttl', header: 'TTL', width: '8%', align: 'right' as const },
{ key: 'source', header: 'File', width: '14%' },
{ key: 'actions', header: '', width: '4%', align: 'right' as const },
]
const recordRows: Row[] = records.map((record, i) => ({
__key: `${record.name}-${record.type}-${record.value}-${i}`,
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={record.name}>
{record.name}
{record.wildcard ? <span className="ml-1 text-fg-subtle">(wildcard)</span> : null}
</span>
),
type: <Badge tone={typeTone(record.type)}>{record.type}</Badge>,
value: (
<span className="block max-w-[360px] truncate font-mono text-xs" title={record.value}>
{record.value}
</span>
),
ttl: <span className="font-mono text-xs">{formatNumber(record.ttl)}</span>,
source: (
<span
className="block max-w-[200px] truncate text-xs text-fg-subtle"
title={record.source}
>
{record.source || '—'}
</span>
),
actions: (
<div className="flex items-center justify-end gap-0.5">
{record.managed ? (
<>
<IconButton
icon={Pencil}
label={`edit ${record.name} ${record.type}`}
variant="ghost"
disabled={!canEdit}
onClick={() => openEdit(record)}
/>
<IconButton
icon={Trash2}
label={`delete ${record.name} ${record.type}`}
variant="ghost"
disabled={!canEdit}
onClick={() => setPendingRecord(record)}
/>
</>
) : (
<span className="text-[10px] text-fg-subtle" title="This record is in a master file, not the managed directory">
read-only
</span>
)}
</div>
),
}))
const cutRows: Row[] = delegations.map((cut, i) => ({
__key: `${cut.name}-${i}`,
name: (
<span className="block max-w-[320px] truncate font-mono text-xs" title={cut.name}>
{cut.name}
</span>
),
targets: (
<span className="block max-w-[420px] truncate font-mono text-xs">
{cut.targets.join(', ')}
</span>
),
managed: cut.managed ? '—' : 'read-only',
actions: (
<IconButton
icon={Trash2}
label={`remove the delegation for ${cut.name}`}
variant="ghost"
disabled={!canEdit || !cut.managed}
onClick={() => setPendingCut(cut)}
/>
),
}))
return (
<div className="space-y-4">
<PageHeader
title="Zones"
description="Local zones this resolver answers for itself, and the names it delegates elsewhere."
actions={
<div className="flex flex-wrap gap-2">
<Button
icon={Waypoints}
onClick={() => {
setCutZone(zone?.origin ?? NEW_ZONE)
setNewZone('')
setFormError(null)
setCutOpen(true)
}}
disabled={!canEdit}
title={noWriteReason}
>
Delegate
</Button>
<Button icon={Plus} onClick={openNew} disabled={!canEdit} title={noWriteReason}>
Add record
</Button>
</div>
}
/>
{data !== null && data.managed_dir === '' ? (
<Notice tone="info" title="Zones are read-only here">
Set <code className="font-mono">blocklist.managed_zones</code> to a writable directory and
this page can create zones, add records and delegate names. The zones below are
compiled from <code className="font-mono">blocklist.local_zones</code> and are not
rewritten by the console.
</Notice>
) : null}
{data !== null && data.managed_dir !== '' && !data.writable ? (
<Notice tone="warn" title="The managed zone directory is not writable">
<code className="font-mono">{data.managed_dir}</code> exists but the daemon cannot
write to it, so the console is read-only. The zone files themselves are fine.
</Notice>
) : null}
{data !== null && data.error !== '' ? (
<Notice tone="danger" title="The zone store is stale">
{data.error}. The last good version is still being served.
</Notice>
) : null}
{zones.length === 0 ? (
<Card>
<EmptyState
title="No local zones"
description={
data?.managed_dir === ''
? 'Add a zone with blocklist.local_zones, or set blocklist.managed_zones and create one from here.'
: 'Create one from here: adding a record to a zone that does not exist yet starts it.'
}
/>
</Card>
) : (
<>
<Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Select
label="Zone"
value={origin}
onChange={(e) => setOrigin(e.target.value)}
options={zones.map((z) => ({ value: z.origin, label: z.origin }))}
/>
<div className="flex items-end">
<div className="w-full text-xs text-fg-muted">
<p>
{formatNumber(zone?.record_count ?? 0)} record
{zone?.record_count === 1 ? '' : 's'}
{zone !== null && zone.serial > 0 ? ` · serial ${zone.serial}` : ''}
</p>
<p className="text-fg-subtle">
{zone?.managed === true
? 'Managed by this console'
: `From ${zone?.files.length ?? 0} file${zone?.files.length === 1 ? '' : 's'}`}
{data !== null ? ` · loaded ${formatRelative(data.built_at)}` : ''}
</p>
</div>
</div>
<div className="flex items-end justify-end gap-2">
{zone !== null && zone.managed ? (
<Button
variant="ghost"
icon={Trash2}
onClick={() => setPendingZone(zone)}
disabled={!canEdit}
title={noWriteReason}
>
Delete zone
</Button>
) : null}
</div>
</div>
</Card>
<Card padded={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-line p-3">
<Tabs
tabs={[
{ id: 'records', label: 'Records', count: records.length, icon: List },
{
id: 'delegations',
label: 'Delegations',
count: delegations.length,
icon: Waypoints,
},
]}
value={tab}
onChange={setTab}
/>
{tab === 'records' ? (
<div className="w-full sm:w-72">
<Input
label="Search"
placeholder="www, 10.0.0., MX"
icon={Search}
value={search}
onChange={(e) => setSearch(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
) : (
<p className="text-xs text-fg-muted">
A delegated name, and everything under it, is answered by the servers named
here — not by this resolver.
</p>
)}
</div>
<div className="p-3">
{tab === 'records' ? (
<Table
columns={recordColumns}
rows={recordRows}
rowKey={(row) => String(row.__key)}
loading={loading}
empty={
<EmptyState
title={q === '' ? 'This zone has no records' : 'No records match'}
description={
q === ''
? 'Add one with the button above.'
: 'Change the search, or clear it to see the whole zone.'
}
/>
}
/>
) : (
<Table
columns={[
{ key: 'name', header: 'Delegated name', width: '36%' },
{ key: 'targets', header: 'Nameservers', width: '52%' },
{ key: 'managed', header: 'Source', width: '8%' },
{ key: 'actions', header: '', width: '4%', align: 'right' },
]}
rows={cutRows}
rowKey={(row) => String(row.__key)}
empty={
<EmptyState
title="Nothing is delegated"
description="A delegation hands a name, and everything under it, to other servers."
/>
}
/>
)}
</div>
</Card>
</>
)}
<Dialog
open={recordOpen}
onClose={() => {
setRecordOpen(false)
setForm(null)
}}
title={editing ? `Edit ${editing.name} ${editing.type}` : 'Add a record'}
description={
editing
? 'Saving replaces every record of this name and type in this zone.'
: 'A record that does not exist yet in this zone is added; the same name and type is added to the RRset.'
}
footer={
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => setRecordOpen(false)}
disabled={saveRecord.pending}
>
Cancel
</Button>
<Button
onClick={() => void submitRecord()}
loading={saveRecord.pending}
disabled={form === null || !canEdit}
>
{editing ? 'Save record' : 'Add record'}
</Button>
</div>
}
>
{form !== null ? (
<div className="space-y-3">
{formError !== null ? <ErrorBox>{formError}</ErrorBox> : null}
<ZonePicker
zones={zones}
value={form.zone}
newZone={newZone}
onPick={(value) => setForm({ ...form, zone: value })}
onNewZone={setNewZone}
fixed={editing !== null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
placeholder="www or *.www"
hint="Relative to the zone: www means www.{zone}. A leading *. makes it a wildcard."
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
autoComplete="off"
spellCheck={false}
disabled={editing !== null}
/>
<Select
label="Type"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value })}
options={(data?.types ?? ['A']).map((t) => ({ value: t, label: t }))}
/>
</div>
<Input
label="Value"
placeholder={valueHint(form.type)}
hint={valueHint(form.type)}
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
autoComplete="off"
spellCheck={false}
/>
<Input
label="TTL"
placeholder={zone?.default_ttl ? String(zone.default_ttl) : '300'}
hint="Seconds. Empty means the zone's default."
value={form.ttl}
onChange={(e) => setForm({ ...form, ttl: e.target.value.replace(/[^0-9]/g, '') })}
inputMode="numeric"
/>
</div>
) : null}
</Dialog>
<Dialog
open={cutOpen}
onClose={() => setCutOpen(false)}
title="Delegate a zone"
description="The names below the delegated name stop being answered here, except for the delegation itself."
footer={
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setCutOpen(false)} disabled={makeCut.pending}>
Cancel
</Button>
<Button onClick={() => void submitCut()} loading={makeCut.pending} disabled={!canEdit}>
Delegate
</Button>
</div>
}
>
<div className="space-y-3">
{formError !== null ? <ErrorBox>{formError}</ErrorBox> : null}
<ZonePicker
zones={zones}
value={cutZone}
newZone={newZone}
onPick={setCutZone}
onNewZone={setNewZone}
/>
<Input
label="Delegated name"
placeholder="sub.example.com."
hint="A name below the zone's apex. The apex is the zone's own server list, not a delegation."
value={cutName}
onChange={(e) => setCutName(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Textarea
label="Nameservers"
hint={`One per line, or comma separated. ${recordLines.length} line${recordLines.length === 1 ? '' : 's'}, ${targets.length} name${targets.length === 1 ? '' : 's'}.`}
monospace
rows={4}
value={cutTargets}
onChange={(e) => setCutTargets(e.target.value)}
placeholder={'ns1.sub.example.com.\nns2.sub.example.com.'}
spellCheck={false}
/>
</div>
</Dialog>
<ConfirmDialog
open={pendingRecord !== null}
onClose={() => setPendingRecord(null)}
onConfirm={() => void confirmRecord()}
title="Delete this record?"
message={
pendingRecord
? `${pendingRecord.name} ${pendingRecord.type} ${pendingRecord.value} will be removed from the zone file.`
: ''
}
confirmLabel="Delete"
destructive
pending={dropRecord.pending}
/>
<ConfirmDialog
open={pendingCut !== null}
onClose={() => setPendingCut(null)}
onConfirm={() => void confirmCut()}
title="Remove this delegation?"
message={
pendingCut
? `${pendingCut.name} answers authoritatively from here again, and names under it get an NXDOMAIN from this zone.`
: ''
}
confirmLabel="Remove"
destructive
pending={dropCut.pending}
/>
<ConfirmDialog
open={pendingZone !== null}
onClose={() => setPendingZone(null)}
onConfirm={() => void confirmZone()}
title="Delete this zone?"
message={
pendingZone
? `${pendingZone.origin} stops being served here, and its names go back to whatever answered for them before. Its zone file is deleted.`
: ''
}
confirmLabel="Delete zone"
destructive
pending={dropZone.pending}
/>
</div>
)
}
function Notice({
tone,
title,
children,
}: {
tone: 'info' | 'warn' | 'danger'
title: string
children: ReactNode
}) {
const tones = {
info: 'border-sky-500/40 bg-sky-500/10 text-fg',
warn: 'border-warn/40 bg-warn-soft text-fg',
danger: 'border-red-500/40 bg-red-500/10 text-fg',
} as const
return (
<p className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-xs ${tones[tone]}`}>
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-fg-muted" aria-hidden="true" />
<span>
<span className="font-semibold">{title}. </span>
{children}
</span>
</p>
)
}
function ErrorBox({ children }: { children: ReactNode }) {
return (
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{children}
</p>
)
}
/**
* Picks an existing zone or a new apex. A typo inside an existing zone name
* would silently create a second zone, so the two are separate choices: the
* select picks the apex, and a new one is typed out.
*/
function ZonePicker({
zones,
value,
newZone,
onPick,
onNewZone,
fixed = false,
}: {
zones: Zone[]
value: string
newZone: string
onPick: (value: string) => void
onNewZone: (value: string) => void
fixed?: boolean
}) {
return (
<div className="space-y-3">
<Select
label="Zone"
value={value}
onChange={(e) => onPick(e.target.value)}
disabled={fixed}
options={[
...zones.map((z) => ({ value: z.origin, label: z.origin })),
{ value: NEW_ZONE, label: 'New zone…' },
]}
/>
{value === NEW_ZONE ? (
<Input
label="New zone apex"
placeholder="home.example.com."
hint="The zone is created with this name as its apex. Its NS record at the apex is taken from the delegation's nameservers."
value={newZone}
onChange={(e) => onNewZone(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
) : null}
</div>
)
}