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:
1 parent
56c18551b2
commit
d94e4cf6a8
3 files changed
+91
-5
No files matched your search
@@ -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<string | null>(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() {
|
||||
<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">
|
||||
{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="min-w-56 flex-1">
|
||||
<Input label="Zone apex" value={draft.name} disabled={active !== null} onChange={(event) => setDraft({ ...draft, name: event.currentTarget.value })} />
|
||||
|
||||
+13
-1
@@ -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() {
|
||||
</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"
|
||||
|
||||
Reference in new issue
Block a user