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:
1 parent
7d58b80583
commit
7fa48bcdf9
18 files changed
+726
-404
No files matched your search
@@ -17,7 +17,6 @@ import Clients from './pages/Clients.tsx'
|
||||
import Dashboard from './pages/Dashboard.tsx'
|
||||
import Domains from './pages/Domains.tsx'
|
||||
import Login from './pages/Login.tsx'
|
||||
import LocalZones from './pages/LocalZones.tsx'
|
||||
import Lookup from './pages/Lookup.tsx'
|
||||
import NotFound from './pages/NotFound.tsx'
|
||||
import Overrides from './pages/Overrides.tsx'
|
||||
@@ -54,7 +53,6 @@ function AppRoutes() {
|
||||
<Route path="/domains" element={<Domains />} />
|
||||
<Route path="/lookup" element={<Lookup />} />
|
||||
<Route path="/overrides" element={<Overrides />} />
|
||||
<Route path="/local-zones" element={<LocalZones />} />
|
||||
<Route path="/zones" element={<Zones />} />
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
<Route path="/upstreams" element={<Upstreams />} />
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
FileCode2,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
@@ -41,8 +40,7 @@ const NAV: NavItem[] = [
|
||||
{ to: '/domains', label: 'Domains', icon: Globe },
|
||||
{ to: '/lookup', label: 'Lookup', icon: Search },
|
||||
{ to: '/overrides', label: 'Address redirects', icon: MapPin },
|
||||
{ to: '/local-zones', label: 'Zone files', icon: FileCode2 },
|
||||
{ to: '/zones', label: 'Managed zones', icon: Layers },
|
||||
{ to: '/zones', label: 'Zones', icon: Layers },
|
||||
{ to: '/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/upstreams', label: 'Upstreams', icon: Network },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||
|
||||
+28
-16
@@ -20,7 +20,6 @@ import type {
|
||||
FlushCacheResponse,
|
||||
LoginInput,
|
||||
LoginResponse,
|
||||
LocalZonesResponse,
|
||||
OkResponse,
|
||||
RefreshBlocklistsResponse,
|
||||
ReloadSettingsResponse,
|
||||
@@ -36,6 +35,8 @@ import type {
|
||||
ZoneRecordInput,
|
||||
ZonesResponse,
|
||||
ZoneWriteResponse,
|
||||
BlockListSpec,
|
||||
BlocklistSource,
|
||||
Links,
|
||||
StatusResponse,
|
||||
} from './types.ts'
|
||||
@@ -224,6 +225,32 @@ export function getLinks(): Promise<Links> {
|
||||
return apiFetch<Links>('/api/links')
|
||||
}
|
||||
|
||||
/**
|
||||
* The BlockList objects, or null when this server does not manage them: the
|
||||
* console does, a resolver running on its own does not (it answers 404), and
|
||||
* the page then shows the compiled lists without edit controls.
|
||||
*/
|
||||
export async function getBlocklistSources(): Promise<BlocklistSource[] | null> {
|
||||
try {
|
||||
const res = await apiFetch<{ sources: BlocklistSource[] | null }>('/api/blocklist-sources')
|
||||
return res.sources ?? []
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && [404, 405, 501].includes(err.status)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function putBlocklistSource(name: string, spec: BlockListSpec): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(spec),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteBlocklistSource(name: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(`/api/blocklist-sources/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getBlocklists(): Promise<BlocklistsResponse> {
|
||||
return apiFetch<BlocklistsResponse>('/api/blocklists')
|
||||
}
|
||||
@@ -366,21 +393,6 @@ export function getSettings(): Promise<SettingsResponse> {
|
||||
return apiFetch<SettingsResponse>('/api/settings')
|
||||
}
|
||||
|
||||
export function getLocalZones(): Promise<LocalZonesResponse> {
|
||||
return apiFetch<LocalZonesResponse>('/api/local-zones')
|
||||
}
|
||||
|
||||
export function saveLocalZone(name: string, content: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteLocalZone(name: string): Promise<OkResponse> {
|
||||
return apiFetch<OkResponse>(`/api/local-zones/${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function putSettings(yaml: string): Promise<SaveSettingsResponse> {
|
||||
return apiFetch<SaveSettingsResponse>('/api/settings', {
|
||||
method: 'PUT',
|
||||
|
||||
+27
-11
@@ -163,6 +163,26 @@ export interface BlocklistStats {
|
||||
sources_detail: Record<string, BlocklistSourceMeta>
|
||||
}
|
||||
|
||||
/** A BlockList object, as the console stores it (GET /api/blocklist-sources). */
|
||||
export interface BlockListSpec {
|
||||
url: string
|
||||
format?: string
|
||||
mirror?: string[]
|
||||
disabled?: boolean
|
||||
/** A duration such as "24h". */
|
||||
refresh?: string
|
||||
timeout?: string
|
||||
max_bytes?: number
|
||||
categories?: string[]
|
||||
comment?: string
|
||||
insecure_skip_tls_verify?: boolean
|
||||
}
|
||||
|
||||
export interface BlocklistSource {
|
||||
name: string
|
||||
spec: BlockListSpec
|
||||
}
|
||||
|
||||
export interface BlocklistsResponse {
|
||||
items: BlocklistItem[]
|
||||
stats: BlocklistStats
|
||||
@@ -291,6 +311,13 @@ export interface ClientsResponse {
|
||||
groups: ClientGroup[]
|
||||
default_policy: DefaultPolicy
|
||||
default_rate_limit: number
|
||||
/**
|
||||
* Set (to false) by the console, which edits ClientGroup objects and cannot
|
||||
* edit what the resolver's config file defines. Absent when the resolver
|
||||
* itself is the editor, which is when both are editable.
|
||||
*/
|
||||
default_policy_editable?: boolean
|
||||
file_groups_editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -442,17 +469,6 @@ export interface SettingsResponse {
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
// --- GET /api/local-zones -------------------------------------------------
|
||||
|
||||
export interface ZoneFile {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface LocalZonesResponse {
|
||||
zones: ZoneFile[]
|
||||
}
|
||||
|
||||
export interface SaveSettingsResponse {
|
||||
ok: boolean
|
||||
version: number
|
||||
|
||||
+312
-25
@@ -8,11 +8,19 @@
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
|
||||
import { AlertTriangle, Clock, Layers, Pencil, Plus, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react'
|
||||
|
||||
import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api'
|
||||
import {
|
||||
checkDomain,
|
||||
deleteBlocklistSource,
|
||||
getBlocklists,
|
||||
getBlocklistSources,
|
||||
putBlocklistSource,
|
||||
refreshBlocklists,
|
||||
toggleBlocklist,
|
||||
} from '../lib/api'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import type { BlocklistItem, BlocklistStats } from '../lib/types'
|
||||
import type { BlockListSpec, BlocklistItem, BlocklistSource, BlocklistStats } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
import { useAutoRefresh } from '../hooks/useAutoRefresh'
|
||||
import { useDebounced } from '../hooks/useDebounced'
|
||||
@@ -22,9 +30,12 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
StatCard,
|
||||
Switch,
|
||||
Table,
|
||||
@@ -62,6 +73,67 @@ const EMPTY_STATS: BlocklistStats = {
|
||||
sources_detail: {},
|
||||
}
|
||||
|
||||
const FORMATS = [
|
||||
{ value: 'auto', label: 'Detect from the content' },
|
||||
{ value: 'hosts', label: 'hosts file' },
|
||||
{ value: 'plain', label: 'One domain per line' },
|
||||
{ value: 'adblock', label: 'Adblock Plus' },
|
||||
{ value: 'rpz', label: 'RPZ zone' },
|
||||
]
|
||||
|
||||
/** The form for one BlockList. Fields the form does not show ride along in `extra`. */
|
||||
interface SourceDraft {
|
||||
isNew: boolean
|
||||
name: string
|
||||
url: string
|
||||
format: string
|
||||
refresh: string
|
||||
categories: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
extra: BlockListSpec
|
||||
}
|
||||
|
||||
const EMPTY_SOURCE: SourceDraft = {
|
||||
isNew: true,
|
||||
name: '',
|
||||
url: '',
|
||||
format: 'auto',
|
||||
refresh: '24h',
|
||||
categories: '',
|
||||
comment: '',
|
||||
enabled: true,
|
||||
extra: { url: '' },
|
||||
}
|
||||
|
||||
function draftFromSource(src: BlocklistSource): SourceDraft {
|
||||
const { spec } = src
|
||||
return {
|
||||
isNew: false,
|
||||
name: src.name,
|
||||
url: spec.url,
|
||||
format: spec.format || 'auto',
|
||||
refresh: spec.refresh ?? '',
|
||||
categories: (spec.categories ?? []).join(', '),
|
||||
comment: spec.comment ?? '',
|
||||
enabled: !spec.disabled,
|
||||
extra: spec,
|
||||
}
|
||||
}
|
||||
|
||||
function specFromDraft(d: SourceDraft): BlockListSpec {
|
||||
const categories = d.categories.split(',').map((c) => c.trim()).filter((c) => c !== '')
|
||||
return {
|
||||
...d.extra,
|
||||
url: d.url.trim(),
|
||||
format: d.format === 'auto' ? undefined : d.format,
|
||||
refresh: d.refresh.trim() || undefined,
|
||||
categories: categories.length > 0 ? categories : undefined,
|
||||
comment: d.comment.trim() || undefined,
|
||||
disabled: d.enabled ? undefined : true,
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 42): string {
|
||||
if (value.length <= max) return value
|
||||
return `${value.slice(0, max - 1)}…`
|
||||
@@ -71,7 +143,22 @@ export default function Blocklists() {
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useWhoami()
|
||||
const { data, error, loading, refetch } = useApi(() => getBlocklists(), [])
|
||||
useAutoRefresh(refetch, 15000)
|
||||
// The BlockList objects, when this server manages them (the console does).
|
||||
const sourcesApi = useApi(() => getBlocklistSources(), [])
|
||||
const managed = sourcesApi.data !== null && sourcesApi.data !== undefined
|
||||
const sources = useMemo<BlocklistSource[]>(() => sourcesApi.data ?? [], [sourcesApi.data])
|
||||
const canEditSources = managed && canWrite
|
||||
const refetchAll = (): void => {
|
||||
refetch()
|
||||
sourcesApi.refetch()
|
||||
}
|
||||
useAutoRefresh(refetchAll, 15000)
|
||||
|
||||
const [sourceDraft, setSourceDraft] = useState<SourceDraft | null>(null)
|
||||
const [sourceError, setSourceError] = useState<string | null>(null)
|
||||
const [pendingRemove, setPendingRemove] = useState<string | null>(null)
|
||||
const saveSource = useAsyncAction(async (d: SourceDraft) => putBlocklistSource(d.name.trim(), specFromDraft(d)))
|
||||
const removeSource = useAsyncAction(async (name: string) => deleteBlocklistSource(name))
|
||||
|
||||
const [overrides, setOverrides] = useState<Record<string, boolean>>({})
|
||||
const [pendingRows, setPendingRows] = useState<Record<string, boolean>>({})
|
||||
@@ -140,6 +227,35 @@ export default function Blocklists() {
|
||||
refetch()
|
||||
}
|
||||
|
||||
const submitSource = async (): Promise<void> => {
|
||||
if (!sourceDraft) return
|
||||
setSourceError(null)
|
||||
if (sourceDraft.name.trim() === '' || sourceDraft.url.trim() === '') {
|
||||
setSourceError('A list needs a name and a URL.')
|
||||
return
|
||||
}
|
||||
const res = await saveSource.run(sourceDraft)
|
||||
if (res === null) {
|
||||
setSourceError(saveSource.error ?? 'The server rejected that list.')
|
||||
return
|
||||
}
|
||||
notify(toast, 'ok', `${sourceDraft.isNew ? 'added' : 'saved'} ${sourceDraft.name.trim()}`)
|
||||
setSourceDraft(null)
|
||||
refetchAll()
|
||||
}
|
||||
|
||||
const confirmRemove = async (): Promise<void> => {
|
||||
if (pendingRemove === null) return
|
||||
const res = await removeSource.run(pendingRemove)
|
||||
if (res === null) {
|
||||
notify(toast, 'fail', removeSource.error ?? `could not remove ${pendingRemove}`)
|
||||
return
|
||||
}
|
||||
notify(toast, 'ok', `removed ${pendingRemove}`)
|
||||
setPendingRemove(null)
|
||||
refetchAll()
|
||||
}
|
||||
|
||||
if (error) return <PageError error={error} onRetry={refetch} />
|
||||
|
||||
const columns = [
|
||||
@@ -151,9 +267,11 @@ export default function Blocklists() {
|
||||
{ key: 'updated', header: 'Updated', width: '12%' },
|
||||
{ key: 'duration', header: 'Duration', width: '10%', align: 'right' as const },
|
||||
{ key: 'source', header: 'Source', width: '24%' },
|
||||
{ key: 'refresh', header: '', width: '5%', align: 'right' as const },
|
||||
{ key: 'refresh', header: '', width: managed ? '10%' : '5%', align: 'right' as const },
|
||||
]
|
||||
|
||||
const objectFor = (name: string): BlocklistSource | undefined => sources.find((x) => x.name === name)
|
||||
|
||||
const rows: Row[] = items.map((item) => {
|
||||
// Everything about a source except its name and parse stats lives under
|
||||
// meta; before that nested shape these columns read fields that never
|
||||
@@ -200,17 +318,77 @@ export default function Blocklists() {
|
||||
</span>
|
||||
),
|
||||
refresh: (
|
||||
<IconButton
|
||||
icon={RefreshCw}
|
||||
label={`refresh ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={busy || !canWrite}
|
||||
onClick={() => void onRefresh([item.name])}
|
||||
/>
|
||||
<span className="flex justify-end gap-0.5">
|
||||
<IconButton
|
||||
icon={RefreshCw}
|
||||
label={`refresh ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={busy || !canWrite}
|
||||
onClick={() => void onRefresh([item.name])}
|
||||
/>
|
||||
{objectFor(item.name) ? (
|
||||
<>
|
||||
<IconButton
|
||||
icon={Pencil}
|
||||
label={`edit ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={!canEditSources}
|
||||
onClick={() => {
|
||||
const src = objectFor(item.name)
|
||||
if (src) {
|
||||
setSourceError(null)
|
||||
setSourceDraft(draftFromSource(src))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
label={`remove ${item.name}`}
|
||||
variant="ghost"
|
||||
disabled={!canEditSources}
|
||||
onClick={() => setPendingRemove(item.name)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
// Lists that exist as objects but that the resolver has not compiled yet (just
|
||||
// added, or failing to download): shown, so a save is never invisible.
|
||||
for (const src of sources) {
|
||||
if (items.some((i) => i.name === src.name)) continue
|
||||
rows.push({
|
||||
__key: `pending-${src.name}`,
|
||||
name: (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{src.name}</span>
|
||||
<span className="truncate text-xs text-slate-500">waiting for the resolver</span>
|
||||
</span>
|
||||
),
|
||||
format: <Badge tone="muted">{src.spec.format || 'auto'}</Badge>,
|
||||
enabled: <Badge tone={src.spec.disabled ? 'muted' : 'default'}>{src.spec.disabled ? 'off' : 'on'}</Badge>,
|
||||
rules: <span className="font-mono text-xs">—</span>,
|
||||
size: <span className="font-mono text-xs">—</span>,
|
||||
updated: <span className="text-xs">—</span>,
|
||||
duration: <span className="font-mono text-xs">—</span>,
|
||||
source: (
|
||||
<span className="block max-w-[240px] truncate font-mono text-xs text-slate-500" title={src.spec.url}>
|
||||
{truncate(src.spec.url)}
|
||||
</span>
|
||||
),
|
||||
refresh: (
|
||||
<span className="flex justify-end gap-0.5">
|
||||
<IconButton icon={Pencil} label={`edit ${src.name}`} variant="ghost" disabled={!canEditSources}
|
||||
onClick={() => { setSourceError(null); setSourceDraft(draftFromSource(src)) }} />
|
||||
<IconButton icon={Trash2} label={`remove ${src.name}`} variant="ghost" disabled={!canEditSources}
|
||||
onClick={() => setPendingRemove(src.name)} />
|
||||
</span>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// The lookup answers 200 with an error string instead of a 4xx, so its three
|
||||
// shapes are told apart by their keys rather than by the status code.
|
||||
const lookupError = result !== null && 'error' in result ? result.error : null
|
||||
@@ -225,15 +403,31 @@ export default function Blocklists() {
|
||||
title="Blocklists"
|
||||
description="Compiled rule sources, their freshness and their health."
|
||||
actions={
|
||||
<Button
|
||||
icon={RefreshCw}
|
||||
onClick={() => void onRefresh()}
|
||||
loading={refresh.pending}
|
||||
disabled={!canWrite}
|
||||
title={canWrite ? undefined : 'This API key is read-only'}
|
||||
>
|
||||
Refresh all
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{managed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setSourceError(null)
|
||||
setSourceDraft(EMPTY_SOURCE)
|
||||
}}
|
||||
disabled={!canEditSources}
|
||||
title={canWrite ? undefined : 'This API key is read-only'}
|
||||
>
|
||||
Add list
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
icon={RefreshCw}
|
||||
onClick={() => void onRefresh()}
|
||||
loading={refresh.pending}
|
||||
disabled={!canWrite}
|
||||
title={canWrite ? undefined : 'This API key is read-only'}
|
||||
>
|
||||
Refresh all
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -338,17 +532,110 @@ export default function Blocklists() {
|
||||
empty={
|
||||
<EmptyState
|
||||
title="No blocklists configured"
|
||||
description="Add list sources in the configuration to compile rules."
|
||||
description={
|
||||
managed
|
||||
? 'Add a list above: a URL to a hosts file, an Adblock list or an RPZ zone.'
|
||||
: 'Add list sources in the configuration to compile rules.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Removing a list is a configuration change: delete its source in Settings, then refresh.
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{managed
|
||||
? 'Lists you add here are BlockList objects in the cluster. Lists named in the resolver\'s config file are shown but edited there. Turning a list off keeps it, and the resolver drops its rules.'
|
||||
: 'Lists come from the resolver\'s configuration; edit the config and reload to change them.'}
|
||||
</p>
|
||||
|
||||
<Dialog
|
||||
open={sourceDraft !== null}
|
||||
onClose={() => setSourceDraft(null)}
|
||||
title={sourceDraft?.isNew ? 'Add a blocklist' : `Edit ${sourceDraft?.name ?? ''}`}
|
||||
description="The resolver downloads the list, keeps a copy on its volume and refreshes it on the interval."
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setSourceDraft(null)} disabled={saveSource.pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void submitSource()} loading={saveSource.pending}>
|
||||
{sourceDraft?.isNew ? 'Add list' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{sourceDraft ? (
|
||||
<div className="space-y-3">
|
||||
{sourceError ? (
|
||||
<p className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">{sourceError}</p>
|
||||
) : null}
|
||||
<Input
|
||||
label="Name"
|
||||
hint="Lower-case letters, digits, '-' and '.'. It becomes the object's name and cannot be changed later."
|
||||
value={sourceDraft.name}
|
||||
disabled={!sourceDraft.isNew}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, name: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
label="URL"
|
||||
placeholder="https://example.com/hosts"
|
||||
hint="http or https."
|
||||
value={sourceDraft.url}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, url: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Format"
|
||||
value={sourceDraft.format}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, format: e.target.value })}
|
||||
options={FORMATS}
|
||||
/>
|
||||
<Input
|
||||
label="Refresh every"
|
||||
placeholder="24h"
|
||||
hint="Empty means the default, 24h."
|
||||
value={sourceDraft.refresh}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, refresh: e.target.value })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Categories"
|
||||
placeholder="ads, tracking"
|
||||
hint="Comma separated labels shown next to the rules."
|
||||
value={sourceDraft.categories}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, categories: e.target.value })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
label="Comment"
|
||||
value={sourceDraft.comment}
|
||||
onChange={(e) => setSourceDraft({ ...sourceDraft, comment: e.target.value })}
|
||||
/>
|
||||
<Switch
|
||||
checked={sourceDraft.enabled}
|
||||
onChange={(next) => setSourceDraft({ ...sourceDraft, enabled: next })}
|
||||
label={sourceDraft.enabled ? 'enabled' : 'disabled'}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
onClose={() => setPendingRemove(null)}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
title="Remove this blocklist?"
|
||||
message={pendingRemove ? `${pendingRemove} and its rules are dropped from the resolver.` : ''}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
pending={removeSource.pending}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -213,7 +213,21 @@ export default function Clients() {
|
||||
const { canWrite } = useWhoami()
|
||||
const { data, error, loading, refetch } = useApi(() => getClients(), [])
|
||||
|
||||
const groups = useMemo<ClientGroup[]>(() => data?.groups ?? [], [data])
|
||||
// 'history' rows are traffic counters for a name that is not a group (the
|
||||
// default bucket, or a group removed since); saving them back would try to
|
||||
// create them.
|
||||
const groups = useMemo<ClientGroup[]>(
|
||||
() => (data?.groups ?? []).filter((g) => g.source !== 'history'),
|
||||
[data],
|
||||
)
|
||||
const defaultEditable = data?.default_policy_editable !== false
|
||||
// A group the resolver's config file defines cannot be changed through the
|
||||
// console, which writes cluster objects; saying so beats a save that
|
||||
// appears to work.
|
||||
const isLocked = (g: ClientGroup): boolean =>
|
||||
g.source !== 'kubernetes' && data?.file_groups_editable === false
|
||||
const lockedReason = "Defined in the resolver's config file; edit it there"
|
||||
|
||||
const [defaultPolicy] = useState<string>('')
|
||||
const [defaultDraft, setDefaultDraft] = useState<string | null>(null)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
@@ -357,13 +371,13 @@ export default function Clients() {
|
||||
title="Default policy"
|
||||
subtitle={`Applied to clients that match no group. Default rate limit: ${
|
||||
formatNumber(data?.default_rate_limit ?? 0)
|
||||
} qps`}
|
||||
} qps${defaultEditable ? '' : ' · set in the resolver\'s config (acl.default_policy)'}`}
|
||||
>
|
||||
<div className="w-full sm:max-w-xs">
|
||||
<Select
|
||||
label="Policy"
|
||||
value={effectiveDefault}
|
||||
disabled={loading || save.pending || !canWrite}
|
||||
disabled={loading || save.pending || !canWrite || !defaultEditable}
|
||||
onChange={(e) => void saveDefault(e.target.value)}
|
||||
options={DEFAULT_POLICIES}
|
||||
/>
|
||||
@@ -397,6 +411,11 @@ export default function Clients() {
|
||||
<Badge tone={group.policy === 'deny' ? 'danger' : group.policy === 'block_only' ? 'warn' : 'ok'}>
|
||||
{group.policy}
|
||||
</Badge>
|
||||
{group.source === 'kubernetes' ? (
|
||||
<Badge tone="info">cluster</Badge>
|
||||
) : isLocked(group) ? (
|
||||
<Badge tone="muted">config file</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{group.comment ? (
|
||||
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">{group.comment}</p>
|
||||
@@ -417,13 +436,14 @@ export default function Clients() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => openEdit(i)} />
|
||||
<IconButton icon={Pencil} label={`edit ${group.name}`} variant="ghost" disabled={!canWrite || isLocked(group)} title={isLocked(group) ? lockedReason : undefined} onClick={() => openEdit(i)} />
|
||||
<IconButton icon={Copy} label={`duplicate ${group.name}`} variant="ghost" disabled={!canWrite} onClick={() => duplicate(i)} />
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
label={`delete ${group.name}`}
|
||||
variant="ghost"
|
||||
disabled={!canWrite}
|
||||
disabled={!canWrite || isLocked(group)}
|
||||
title={isLocked(group) ? lockedReason : undefined}
|
||||
onClick={() => setPendingDelete(i)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -343,14 +343,6 @@ export default function Domains() {
|
||||
onChange={(e) => setForm((f) => ({ ...f, action: e.target.value }))}
|
||||
options={ACTIONS.map((a) => ({ value: a.value, label: a.label }))}
|
||||
/>
|
||||
<Input
|
||||
label="List"
|
||||
placeholder="inline"
|
||||
hint="Optional group name, defaults to the inline list."
|
||||
value={form.list}
|
||||
onChange={(e) => setForm((f) => ({ ...f, list: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -380,7 +372,7 @@ export default function Domains() {
|
||||
title="Delete this rule?"
|
||||
message={
|
||||
pendingDelete
|
||||
? `${pendingDelete.name} (${pendingDelete.action}) will be removed and the change persisted to the configuration file.`
|
||||
? `${pendingDelete.name} (${pendingDelete.action}) will be removed from the DomainRule it belongs to.`
|
||||
: ''
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/** Edit console-managed authoritative zone files shared by DNS replicas. */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FileCode2, Plus, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
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 {
|
||||
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'
|
||||
|
||||
function validApex(value: string): boolean {
|
||||
const apex = value.trim().replace(/\.$/, '')
|
||||
if (apex === '' || apex.length > 242) return false
|
||||
return apex.split('.').every((label) =>
|
||||
label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label),
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
const [newName, setNewName] = useState('')
|
||||
const save = useAsyncAction(async (input: { name: string; content: string }) =>
|
||||
saveLocalZone(input.name, input.content),
|
||||
)
|
||||
const remove = useAsyncAction(async (name: string) => deleteLocalZone(name))
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (active !== null && draft === null) setDraft({ name: active.name, content: active.content })
|
||||
if (active === null && selected !== null && !creating) setDraft(null)
|
||||
}, [active, creating, draft, selected])
|
||||
|
||||
const startNew = (): void => {
|
||||
setSelected(null)
|
||||
setDraft(null)
|
||||
setNewName('')
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
const beginCreate = (): void => {
|
||||
const name = newName.trim().replace(/\.$/, '').toLowerCase()
|
||||
if (!validApex(name)) {
|
||||
toast({ title: 'Enter a valid DNS zone name', tone: 'danger' })
|
||||
return
|
||||
}
|
||||
if (zones.some((zone) => zone.name === name)) {
|
||||
toast({ title: 'That zone already exists', tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setSelected(null)
|
||||
setDraft({ name, content: `$ORIGIN ${name}.\n$TTL 300\n` })
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const onSave = async (): Promise<void> => {
|
||||
if (draft === null || !validApex(draft.name)) return
|
||||
const result = await save.run(draft)
|
||||
if (result === null) {
|
||||
toast({ title: 'Could not save zone', description: save.error ?? undefined, tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setSelected(draft.name.trim().replace(/\.$/, '').toLowerCase())
|
||||
setDraft(null)
|
||||
await refetch()
|
||||
toast({ title: 'Zone saved', description: 'The DNS pods will load the updated file.', tone: 'ok' })
|
||||
}
|
||||
|
||||
const onDelete = async (): Promise<void> => {
|
||||
if (selected === null) return
|
||||
if (!window.confirm(`Delete the ${selected} zone and all of its records?`)) return
|
||||
const result = await remove.run(selected)
|
||||
if (result === null) {
|
||||
toast({ title: 'Could not delete zone', description: remove.error ?? undefined, tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setSelected(null)
|
||||
setDraft(null)
|
||||
await refetch()
|
||||
toast({ title: 'Zone deleted', tone: 'ok' })
|
||||
}
|
||||
|
||||
if (loading) return <LoadingBlock />
|
||||
if (error !== null) return <PageError error={error} onRetry={() => void refetch()} />
|
||||
|
||||
const changed = draft !== null && (active === null || draft.name !== active.name || draft.content !== active.content)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Zone files"
|
||||
description="Edit master-file data for authoritative zones. These files are shared across replicas; names missing beneath a zone apex return NXDOMAIN. Use Managed zones for record-by-record editing."
|
||||
actions={<Button icon={Plus} onClick={startNew} disabled={!canWrite}>New zone</Button>}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(220px,0.7fr)_minmax(0,2fr)]">
|
||||
<Card title="Zones" subtitle={`${zones.length} master files`} padded={false}>
|
||||
<div className="space-y-1 p-2">
|
||||
{zones.length === 0 && <p className="px-3 py-5 text-sm text-fg-subtle">No local zones yet.</p>}
|
||||
{zones.map((zone) => (
|
||||
<button
|
||||
key={zone.name}
|
||||
type="button"
|
||||
onClick={() => { setCreating(false); setDraft(null); setSelected(zone.name) }}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${selected === zone.name ? 'bg-accent-soft text-accent' : 'text-fg-muted hover:bg-surface-2 hover:text-fg'}`}
|
||||
>
|
||||
<FileCode2 size={15} aria-hidden="true" />
|
||||
<span className="truncate font-mono">{zone.name}.</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={draft?.name ? `${draft.name}.zone` : 'Zone file'}
|
||||
subtitle="RFC 1035 master-file syntax. The zone apex is used as the default origin."
|
||||
padded={false}
|
||||
actions={selected !== null && draft !== null && canWrite ? (
|
||||
<Button variant="danger" icon={Trash2} onClick={() => void onDelete()} loading={remove.pending}>Delete zone</Button>
|
||||
) : undefined}
|
||||
>
|
||||
{creating ? (
|
||||
<CardBody className="space-y-3">
|
||||
<Input label="Zone apex" placeholder="example.net" value={newName} onChange={(event) => setNewName(event.currentTarget.value)} autoFocus />
|
||||
<Button variant="primary" icon={Plus} onClick={beginCreate}>Create zone</Button>
|
||||
</CardBody>
|
||||
) : draft === null ? (
|
||||
<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 })} />
|
||||
</div>
|
||||
<Button variant="primary" icon={Save} onClick={() => void onSave()} loading={save.pending} disabled={!canWrite || !changed || !validApex(draft.name)}>Save records</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
monospace
|
||||
rows={20}
|
||||
spellCheck={false}
|
||||
aria-label="DNS zone records"
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
|
||||
placeholder={EXAMPLE}
|
||||
className="min-h-[420px] font-mono text-xs leading-6"
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">Example: <code>@ IN A 192.0.2.10</code> or <code>www IN CNAME @</code>. Invalid records are rejected before saving.</p>
|
||||
{!canWrite && <p className="text-xs text-warn">Your account is read-only.</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ export default function Lookup() {
|
||||
open={override !== null}
|
||||
onClose={() => setOverride(null)}
|
||||
title="Create an override"
|
||||
description="The record is written to the console's managed zone, which makes that apex authoritative for the names it holds."
|
||||
description="The record is added to the DNSZone for that apex, which must already exist: a zone is authoritative for every name under it, so a new one is created from the Zones page with its own name servers."
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setOverride(null)} disabled={write.pending}>
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function Overrides() {
|
||||
|
||||
<Card
|
||||
title="Current redirects"
|
||||
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · source: managed:address-overrides`}
|
||||
subtitle={`${items.length} ${items.length === 1 ? 'domain' : 'domains'} · AddressOverride objects`}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { getUpstreams, putUpstreams } from '../lib/api'
|
||||
import { getStatus, getUpstreams, putUpstreams } from '../lib/api'
|
||||
import { useWhoami } from '../lib/session.ts'
|
||||
import type { UpstreamBootstrap, UpstreamPolicy, UpstreamProto, UpstreamStat } from '../lib/types'
|
||||
import { useApi, useAsyncAction } from '../hooks/useApi'
|
||||
@@ -173,8 +173,15 @@ function splitList(value: string): string[] {
|
||||
|
||||
export default function Upstreams() {
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useWhoami()
|
||||
const { canWrite: keyCanWrite } = useWhoami()
|
||||
const { data, error, loading, refetch } = useApi(() => getUpstreams(), [])
|
||||
// Upstreams are deployment configuration: in the cluster they come from the
|
||||
// resolver's config file, which is mounted read-only, so this page shows live
|
||||
// health and cannot save. The status endpoint says whether the file can be
|
||||
// written.
|
||||
const { data: status } = useApi(() => getStatus(), [])
|
||||
const configWritable = status?.config.writable !== false
|
||||
const canWrite = keyCanWrite && configWritable
|
||||
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
@@ -360,6 +367,16 @@ export default function Upstreams() {
|
||||
}
|
||||
/>
|
||||
|
||||
{!configWritable ? (
|
||||
<p className="rounded-md bg-sky-500/10 px-3 py-2 text-sm text-sky-800 dark:text-sky-200">
|
||||
Upstreams are part of the resolver's configuration (in Kubernetes, its ConfigMap), so this
|
||||
page shows their live health and cannot change them. Edit the config and reload.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* A disabled fieldset disables every control inside it, so a read-only
|
||||
page cannot build a draft it could never save. */}
|
||||
<fieldset disabled={!canWrite} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||
<Card title="Pool behaviour">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Select
|
||||
@@ -484,6 +501,8 @@ export default function Upstreams() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{working.servers.length} server{working.servers.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatNumber(working.servers.reduce((sum, s) => sum + s.weight, 0))} total weight ·
|
||||
|
||||
+20
-38
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user