build / build-push (push) Successful in 32s
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.
847 lines
28 KiB
TypeScript
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>
|
|
)
|
|
}
|