Files
fancy-dns/web/src/pages/Zones.tsx
T
developer d94e4cf6a8
build / build-push (push) Successful in 32s
Give the zone-file editor something to look at
A textarea holding a master file is a text box: it says nothing about what the
resolver will answer for the zone, and a typo in a record is invisible until a
client sees the wrong answer. `/api/zones` already knows — it is compiled from
the same files — so Local zones now shows the records the daemon has actually
compiled out of the selected file, with the SOA serial and the number of
delegations, above the editor that writes it. It is the same list the Zones
page shows, which is the point: the two pages are two views of one thing, not
two ways of guessing.

The Zones page gains the way back: a zone that comes from a master file rather
than the managed directory has an "Edit the file" button that goes to Local
zones, so a read-only row is a link rather than a dead end.
2026-09-28 08:13:06 +00:00

847 lines
28 KiB
TypeScript

/**
* 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 { useNavigate } from 'react-router-dom'
import { AlertTriangle, FileCode2, 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 navigate = useNavigate()
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={FileCode2}
onClick={() => navigate('/local-zones')}
title="This zone comes from a master file, not the managed directory. Local zones edits that file."
>
Edit the file
</Button>
) : null}
{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>
)
}