Cluster UI: Blocklists management, locked config-file items, read-only upstreams; honour allow_config_write

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>
This commit is contained in:
matsandClaude Sonnet 5.5 committed 2026-10-16 10:56:21 +02:00
1 parent 7d58b80583
commit 7fa48bcdf9
18 files changed
+726 -404

No files matched your search

+20 -38
View File
@@ -2,17 +2,17 @@
* 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.
* 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 { useNavigate } from 'react-router-dom'
import { AlertTriangle, FileCode2, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import { AlertTriangle, List, Pencil, Plus, Search, Trash2, Waypoints } from 'lucide-react'
import {
addZoneDelegation,
@@ -122,7 +122,6 @@ 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('')
@@ -329,7 +328,7 @@ export default function Zones() {
const noWriteReason = !canWrite
? 'This API key is read-only'
: !writable
? 'No writable managed zone directory'
? 'Zones are edited from the console, not from the resolver itself'
: undefined
const recordColumns = [
@@ -383,7 +382,7 @@ export default function Zones() {
/>
</>
) : (
<span className="text-[10px] text-fg-subtle" title="This record is in a master file, not the managed directory">
<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>
)}
@@ -418,8 +417,8 @@ export default function Zones() {
return (
<div className="space-y-4">
<PageHeader
title="Managed zones"
description="Manage records and delegations for console-owned authoritative zones. Use Zone files to edit master-file data."
title="Zones"
description="Records and delegations for the zones this resolver answers for itself."
actions={
<div className="flex flex-wrap gap-2">
<Button
@@ -442,18 +441,11 @@ export default function Zones() {
}
/>
{data !== null && data.managed_dir === '' ? (
{data !== null && !data.writable ? (
<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.
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 !== '' ? (
@@ -467,9 +459,9 @@ export default function Zones() {
<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.'
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>
@@ -492,23 +484,13 @@ export default function Zones() {
</p>
<p className="text-fg-subtle">
{zone?.managed === true
? 'Managed by this console'
: `From ${zone?.files.length ?? 0} file${zone?.files.length === 1 ? '' : 's'}`}
? '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={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"