Give the zone-file editor something to look at
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.
This commit is contained in:
developer committed 2026-09-28 08:13:06 +00:00
1 parent 56c18551b2
commit d94e4cf6a8
3 files changed
+91 -5

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>web</title>
<script type="module" crossorigin src="/assets/index-v8i27mX0.js"></script> <script type="module" crossorigin src="/assets/index-BFc4v6ED.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEx0udoi.css"> <link rel="stylesheet" crossorigin href="/assets/index-CYPXMp15.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+76 -2
View File
@@ -3,10 +3,20 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react' import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
import { deleteLocalZone, getLocalZones, saveLocalZone } from '../lib/api' import { deleteLocalZone, getLocalZones, getZones, saveLocalZone } from '../lib/api'
import { useWhoami } from '../lib/session.ts' import { useWhoami } from '../lib/session.ts'
import type { ZoneRecord } from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi' import { useApi, useAsyncAction } from '../hooks/useApi'
import { Button, Card, CardBody, Input, Textarea, useToast } from '../components/ui' import {
Badge,
Button,
Card,
CardBody,
Input,
Table,
Textarea,
useToast,
} from '../components/ui'
import { LoadingBlock, PageError, PageHeader } from '../components/layout' import { LoadingBlock, PageError, PageHeader } from '../components/layout'
const EXAMPLE = '$ORIGIN example.net.\n$TTL 300\n@ IN A 192.0.2.10\nwww IN CNAME @\n' const EXAMPLE = '$ORIGIN example.net.\n$TTL 300\n@ IN A 192.0.2.10\nwww IN CNAME @\n'
@@ -23,6 +33,10 @@ export default function LocalZones() {
const { canWrite } = useWhoami() const { canWrite } = useWhoami()
const { toast } = useToast() const { toast } = useToast()
const { data, error, loading, refetch } = useApi(() => getLocalZones(), []) const { data, error, loading, refetch } = useApi(() => getLocalZones(), [])
// What the daemon has actually compiled out of the selected file. A raw
// textarea says nothing about what a zone ends up serving, and guessing
// from $TTL and owner columns is a bad way to find a typo.
const { data: compiled } = useApi(getZones, [])
const [selected, setSelected] = useState<string | null>(null) const [selected, setSelected] = useState<string | null>(null)
const [draft, setDraft] = useState<{ name: string; content: string } | null>(null) const [draft, setDraft] = useState<{ name: string; content: string } | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@@ -35,6 +49,14 @@ export default function LocalZones() {
const zones = data?.zones ?? [] const zones = data?.zones ?? []
const active = useMemo(() => zones.find((zone) => zone.name === selected) ?? null, [zones, selected]) const active = useMemo(() => zones.find((zone) => zone.name === selected) ?? null, [zones, selected])
// The compiled view is keyed by the zone's canonical apex, which carries a
// trailing dot the file listing does not.
const served = useMemo(() => {
if (active === null || compiled === null) return null
const want = active.name.endsWith('.') ? active.name : `${active.name}.`
return compiled.zones.find((zone) => zone.origin === want) ?? null
}, [active, compiled])
useEffect(() => { useEffect(() => {
if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name) if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name)
}, [creating, draft, selected, zones]) }, [creating, draft, selected, zones])
@@ -141,6 +163,58 @@ export default function LocalZones() {
<CardBody><p className="text-sm text-fg-subtle">Choose a zone or create one to add records.</p></CardBody> <CardBody><p className="text-sm text-fg-subtle">Choose a zone or create one to add records.</p></CardBody>
) : ( ) : (
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
{served === null ? (
<p className="rounded-md border border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
The daemon has not compiled a zone called{' '}
<span className="font-mono">{draft.name}.</span> — the file may be invalid, or
it may be a name the resolver does not serve. The errors are on the status page.
</p>
) : (
<div className="rounded-lg border border-line">
<p className="border-b border-line bg-surface-2 px-3 py-2 text-xs text-fg-muted">
<span className="font-semibold text-fg">
{served.record_count} record{served.record_count === 1 ? '' : 's'} served
</span>{' '}
for this apex
{served.serial > 0 ? ` · SOA serial ${served.serial}` : ''}
{served.delegations.length > 0
? ` · ${served.delegations.length} delegation${served.delegations.length === 1 ? '' : 's'}`
: ''}
. This is what the resolver answers with, compiled from the file below.
</p>
<div className="p-2">
<Table
columns={[
{ key: 'name', header: 'Name', width: '40%' },
{ key: 'type', header: 'Type', width: '10%' },
{ key: 'value', header: 'Value', width: '34%' },
{ key: 'ttl', header: 'TTL', width: '10%', align: 'right' },
{ key: 'managed', header: 'Source', width: '6%' },
]}
rows={served.records.map((record: ZoneRecord, i) => ({
__key: `${record.name}-${record.type}-${record.value}-${i}`,
name: (
<span className="block max-w-[280px] truncate font-mono text-xs">
{record.name}
{record.wildcard ? (
<span className="ml-1 text-fg-subtle">wildcard</span>
) : null}
</span>
),
type: <Badge tone={record.delegation ? 'warn' : 'default'}>{record.type}</Badge>,
value: (
<span className="block max-w-[320px] truncate font-mono text-xs">
{record.value}
</span>
),
ttl: <span className="font-mono text-xs">{record.ttl}</span>,
managed: record.managed ? 'console' : 'file',
}))}
rowKey={(row) => String(row.__key)}
/>
</div>
</div>
)}
<div className="flex flex-wrap items-end justify-between gap-3"> <div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-56 flex-1"> <div className="min-w-56 flex-1">
<Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} /> <Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} />
+13 -1
View File
@@ -11,7 +11,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react' import { useNavigate } from 'react-router-dom'
import { AlertTriangle, FileCode2, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import { import {
addZoneDelegation, addZoneDelegation,
@@ -121,6 +122,7 @@ const NEW_ZONE = '__new__'
export default function Zones() { export default function Zones() {
const { toast } = useToast() const { toast } = useToast()
const { canWrite } = useWhoami() const { canWrite } = useWhoami()
const navigate = useNavigate()
const { data, error, loading, refetch } = useApi(getZones, []) const { data, error, loading, refetch } = useApi(getZones, [])
const [origin, setOrigin] = useState('') const [origin, setOrigin] = useState('')
@@ -486,6 +488,16 @@ export default function Zones() {
</div> </div>
</div> </div>
<div className="flex items-end justify-end gap-2"> <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 ? ( {zone !== null && zone.managed ? (
<Button <Button
variant="ghost" variant="ghost"