import { useState } from 'react' import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' const { Text } = Typography interface Zone { id: number name: string zone_type: 'local' | 'forward' description?: string | null managed_by: string forward_to?: string | null active: boolean } interface DNSRecord { id: number zone_id: number name: string record_type: string value: string ttl: number active: boolean } interface Settings { listen_addresses: string listen_port: number upstream_forwards: string access_acl: string dnssec: boolean qname_minimisation: boolean cache_min_ttl: number cache_max_ttl: number } const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA'] async function listZones(): Promise { const r = await apiClient.get('/dns/zones') if (!isEnvelope(r.data)) return [] return (r.data.data as { zones?: Zone[] }).zones ?? [] } async function listRecords(zoneID: number): Promise { const r = await apiClient.get(`/dns/zones/${zoneID}/records`) if (!isEnvelope(r.data)) return [] return (r.data.data as { records?: DNSRecord[] }).records ?? [] } async function getSettings(): Promise { const r = await apiClient.get('/dns/settings') return isEnvelope(r.data) ? (r.data.data as Settings) : null } interface SystemIface { ifname: string addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }> } async function listSystemInterfaces(): Promise { const r = await apiClient.get('/system/interfaces') if (!isEnvelope(r.data)) return [] return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? [] } interface ServiceStatus { label: string; unit: string; active: boolean; state: string } export default function DNSPage() { const { t } = useTranslation() const { data: services } = useQuery({ queryKey: ['system', 'services'], queryFn: async () => { const r = await apiClient.get('/system/services') return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : [] }, refetchInterval: 30_000, }) const unbound = services?.find(s => s.unit === 'unbound.service' || s.unit === 'unbound') return (
} title={t('dns.title')} subtitle={t('dns.intro')} /> : } color={unbound.active ? 'green' : 'red'} > unbound {unbound.state} )} items={[ { key: 'zones', label: {t('dns.tabs.zones')}, children: }, { key: 'stats', label: {t('dns.tabs.stats')}, children: }, { key: 'settings', label: {t('dns.tabs.settings')}, children: }, ]} />
) } // ── Zones tab ────────────────────────────────────────────────── function ZonesTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['dns', 'zones'], queryFn: listZones }) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const [recordsZone, setRecordsZone] = useState(null) const upsert = useMutation({ mutationFn: async (v: Zone) => { if (editing) return (await apiClient.put(`/dns/zones/${editing.id}`, v)).data return (await apiClient.post('/dns/zones', v)).data }, onSuccess: () => { message.success(t('common.save')) setEditing(null); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/dns/zones/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: Zone; checked: boolean }) => { const { id: _id, ...body } = row await apiClient.put(`/dns/zones/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) }, onError: (e: Error) => message.error(e.message), }) const cols: ColumnsType = [ { title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, { title: t('dns.zone.type'), dataIndex: 'zone_type', key: 'zone_type', render: (s: string) => {s} }, { title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to', render: (v?: string | null) => v ? {v} : '—' }, { title: t('dns.zone.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' }, { title: t('common.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: Zone) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( {row.zone_type === 'local' && ( )} { setEditing(row) form.setFieldsValue(row) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('dns.zone.deleteConfirm', { name: row.name })} /> ), }, ] const openZoneCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ zone_type: 'local', active: true } as Zone) } return ( <> } emptyContent={ } title={t('dns.zone.emptyTitle')} description={t('dns.zone.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false); form.resetFields() }} onOk={() => { void form.submit() }} confirmLoading={upsert.isPending} width={580} destroyOnClose >
upsert.mutate(v)}> )}
setRecordsZone(null)} /> ) } // ── Records drawer ──────────────────────────────────────────── interface RecordsDrawerProps { zone: Zone | null onClose: () => void } function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const open = zone !== null const zoneID = zone?.id ?? 0 const { data, isLoading } = useQuery({ queryKey: ['dns', 'records', zoneID], queryFn: () => listRecords(zoneID), enabled: open, }) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const upsert = useMutation({ mutationFn: async (v: DNSRecord) => { if (editing) return (await apiClient.put(`/dns/records/${editing.id}`, v)).data return (await apiClient.post(`/dns/zones/${zoneID}/records`, v)).data }, onSuccess: () => { message.success(t('common.save')) setEditing(null); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/dns/records/${id}`) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) }, onError: (e: Error) => message.error(e.message), }) const quickToggle = useMutation({ mutationFn: async ({ id, row, checked }: { id: number; row: DNSRecord; checked: boolean }) => { const { id: _id, ...body } = row await apiClient.put(`/dns/records/${id}`, { ...body, active: checked }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) }, onError: (e: Error) => message.error(e.message), }) const cols: ColumnsType = [ { title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, { title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => {s} }, { title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => {s} }, { title: t('dns.record.ttl'), dataIndex: 'ttl', key: 'ttl', width: 80 }, { title: t('common.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: DNSRecord) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( { setEditing(row) form.setFieldsValue(row) }} onDelete={() => del.mutate(row.id)} deleteConfirm={t('dns.record.deleteConfirm', { name: row.name })} /> ), }, ] const openRecordCreate = () => { setCreating(true); form.resetFields() form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord) } return ( {t('dns.record.drawerTitle')} {zone.name} )} destroyOnClose > } emptyContent={ } title={t('dns.record.emptyTitle')} description={t('dns.record.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false); form.resetFields() }} onOk={() => { void form.submit() }} confirmLoading={upsert.isPending} width={580} destroyOnClose >
upsert.mutate(v)}>
) } // ── Settings tab ────────────────────────────────────────────── // Settings-Form-Shape unterscheidet sich vom Wire-Shape: listen_addresses // ist im UI ein Array (Multi-Select), wird beim Save zur Komma-CSV. interface SettingsForm extends Omit { listen_addresses: string[] } function SettingsTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const { data, isLoading } = useQuery({ queryKey: ['dns', 'settings'], queryFn: getSettings }) const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces }) const [form] = Form.useForm() // Multi-Select-Optionen: alle IPv4/IPv6 die der Kernel kennt + Spezial- // Werte (0.0.0.0 = alle IPv4, :: = alle IPv6, 127.0.0.1 / ::1 = lo). // Mode "tags" damit der Operator notfalls auch eine IP eintippen kann // die der Kernel noch nicht meldet (z.B. eine geplante VIP). const ipOptions: { value: string; label: string }[] = [] ipOptions.push({ value: '0.0.0.0', label: `0.0.0.0 — ${t('dns.settings.allIPv4')}` }) ipOptions.push({ value: '::', label: `:: — ${t('dns.settings.allIPv6')}` }) ipOptions.push({ value: '127.0.0.1', label: `127.0.0.1 — ${t('dns.settings.loopback')} IPv4` }) ipOptions.push({ value: '::1', label: `::1 — ${t('dns.settings.loopback')} IPv6` }) for (const i of sys ?? []) { if (i.ifname === 'lo') continue for (const a of i.addr_info ?? []) { ipOptions.push({ value: a.local, label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`, }) } } const initial: SettingsForm | undefined = data ? { ...data, listen_addresses: data.listen_addresses .split(',') .map(s => s.trim()) .filter(Boolean), } : undefined const save = useMutation({ mutationFn: async (v: SettingsForm) => { const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') } return (await apiClient.put('/dns/settings', body)).data }, onSuccess: () => { message.success(t('common.save')) void qc.invalidateQueries({ queryKey: ['dns', 'settings'] }) }, onError: (e: Error) => message.error(e.message), }) const flushCache = useMutation({ mutationFn: async () => { await apiClient.post('/dns/flush-cache') }, onSuccess: () => message.success(t('dns.settings.flushCacheOk')), onError: (e: Error) => message.error(t('dns.settings.flushCacheFailed') + ': ' + e.message), }) if (isLoading) return null return (
save.mutate(v)} style={{ maxWidth: 720 }} > s.trim()).filter(Boolean).find(s => !IPCIDR.test(s)) return bad ? Promise.reject(new Error(t('dns.settings.accessACLInvalid'))) : Promise.resolve() }, }, ]} > ({ validator(_, val) { const minTTL = getFieldValue('cache_min_ttl') as number | undefined if (val != null && minTTL != null && val < minTTL) { return Promise.reject(new Error(t('dns.settings.cacheTTLError'))) } return Promise.resolve() }, }), ]} > ) } // ── Stats tab ────────────────────────────────────────────────── interface UnboundStats { total_queries: number cache_hits: number cache_miss: number cache_hit_pct: number recursive_replies: number prefetch: number rate_limited: number rrset_cache_bytes: number msg_cache_bytes: number tcp_usage: number unwanted: number } function fmtBytes(n: number): string { if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB' if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } function StatsTab() { const { t } = useTranslation() const { data, isFetching, refetch, error } = useQuery({ queryKey: ['dns', 'stats'], queryFn: async () => { const r = await apiClient.get('/dns/stats') if (!isEnvelope(r.data)) return null return r.data.data as { stats: UnboundStats; error?: string } }, refetchInterval: 30_000, }) const stats = data?.stats const apiErr = data?.error return ( {t('dns.statsCard.title')}} extra={} > {(error || apiErr) && ( )} {!stats ? ( {t('dns.statsCard.empty')} ) : ( {t('dns.statsCard.sinceRestart')} {stats.rate_limited > 0 && ( )} {stats.unwanted > 0 && ( )}
{t('dns.statsCard.cacheHitPct')}: {stats.cache_hit_pct.toFixed(1)}% = 70 ? '#16a34a' : stats.cache_hit_pct >= 40 ? '#d48806' : '#dc2626'} size="small" style={{ marginTop: 4 }} />
)}
) }