The console now works as the cluster's editor, without the monitoring pages. Web: Blocklists gets Add/Edit/Remove for BlockList objects (and shows ones the resolver has not compiled yet); Clients marks cluster groups and locks config-file groups and the default policy; Upstreams becomes a read-only health view when the config cannot be written; Zones and Domains lose the managed-directory and list copy; the Local zones page and its API calls are removed; nav is Status, Blocklists, Domains, Lookup, Address redirects, Zones, Clients, Upstreams, Settings. Console: a change to a group the resolver's config file defines is refused with the reason instead of being skipped silently, and /api/clients says what the console can and cannot edit. Server: isFileWritable called a read-only existing file (a mounted ConfigMap) writable, and api.allow_config_write was not reflected in the writable flag nor checked by the upstream/ACL edit path. Both fixed; tests pin them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
840 lines
27 KiB
TypeScript
840 lines
27 KiB
TypeScript
/**
|
|
* Zones — the local zones this resolver answers for itself, and the
|
|
* delegations that hand a name to another server.
|
|
*
|
|
* In the cluster a zone is a DNSZone object and the console edits it; a record
|
|
* from a master file in the resolver's own config 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: a write changes the object, the resolver
|
|
* re-renders the zone, and the serial in the answer is the cheapest way to see
|
|
* that it 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)
|
|
setNewZone('')
|
|
setForm({
|
|
zone: origin || NEW_ZONE,
|
|
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(
|
|
form.zone === NEW_ZONE
|
|
? 'enter the new zone apex'
|
|
: 'choose 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
|
|
? 'Zones are edited from the console, not from the resolver itself'
|
|
: 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 in the resolver's config, not in a DNSZone object">
|
|
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="Records and delegations for the zones this resolver answers for itself."
|
|
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.writable ? (
|
|
<Notice tone="info" title="Zones are read-only here">
|
|
This is the resolver's own view. Zones from master files named in{' '}
|
|
<code className="font-mono">blocklist.local_zones</code> are edited on disk; zones kept as
|
|
DNSZone objects are edited from the console.
|
|
</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={
|
|
writable
|
|
? 'Create one from here: adding a record to a zone that does not exist yet starts it.'
|
|
: 'Add a master file to blocklist.local_zones, or create a zone from the console.'
|
|
}
|
|
/>
|
|
</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
|
|
? 'A DNSZone object'
|
|
: `From ${zone?.files.length ?? 0} master file${zone?.files.length === 1 ? '' : 's'} (read-only)`}
|
|
{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>
|
|
)
|
|
}
|