From d94e4cf6a8e83df9498f3c40d692ef9587990f73 Mon Sep 17 00:00:00 2001 From: developer Date: Mon, 28 Sep 2026 08:13:06 +0000 Subject: [PATCH] Give the zone-file editor something to look at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/web/dist/index.html | 4 +- web/src/pages/LocalZones.tsx | 78 +++++++++++++++++++++++++++++++++++- web/src/pages/Zones.tsx | 14 ++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/internal/web/dist/index.html b/internal/web/dist/index.html index 09247ac..ed9d371 100644 --- a/internal/web/dist/index.html +++ b/internal/web/dist/index.html @@ -5,8 +5,8 @@ web - - + +
diff --git a/web/src/pages/LocalZones.tsx b/web/src/pages/LocalZones.tsx index eda8ebe..5d51a20 100644 --- a/web/src/pages/LocalZones.tsx +++ b/web/src/pages/LocalZones.tsx @@ -3,10 +3,20 @@ import { useEffect, useMemo, useState } from '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 type { ZoneRecord } from '../lib/types' 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' 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 { toast } = useToast() 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(null) const [draft, setDraft] = useState<{ name: string; content: string } | null>(null) const [creating, setCreating] = useState(false) @@ -35,6 +49,14 @@ export default function LocalZones() { const zones = data?.zones ?? [] 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(() => { if (selected === null && zones.length > 0 && !creating && draft === null) setSelected(zones[0].name) }, [creating, draft, selected, zones]) @@ -141,6 +163,58 @@ export default function LocalZones() {

Choose a zone or create one to add records.

) : (
+ {served === null ? ( +

+ The daemon has not compiled a zone called{' '} + {draft.name}. — the file may be invalid, or + it may be a name the resolver does not serve. The errors are on the status page. +

+ ) : ( +
+

+ + {served.record_count} record{served.record_count === 1 ? '' : 's'} served + {' '} + 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. +

+
+ ({ + __key: `${record.name}-${record.type}-${record.value}-${i}`, + name: ( + + {record.name} + {record.wildcard ? ( + wildcard + ) : null} + + ), + type: {record.type}, + value: ( + + {record.value} + + ), + ttl: {record.ttl}, + managed: record.managed ? 'console' : 'file', + }))} + rowKey={(row) => String(row.__key)} + /> + + + )}
setDraft({ ...draft, name: event.currentTarget.value })} /> diff --git a/web/src/pages/Zones.tsx b/web/src/pages/Zones.tsx index 818568c..754da2e 100644 --- a/web/src/pages/Zones.tsx +++ b/web/src/pages/Zones.tsx @@ -11,7 +11,8 @@ import { useEffect, useMemo, useState } 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 { addZoneDelegation, @@ -121,6 +122,7 @@ 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('') @@ -486,6 +488,16 @@ export default function Zones() {
+ {zone !== null && !zone.managed ? ( + + ) : null} {zone !== null && zone.managed ? (