Backend: validateSettings() prüft vor dem Reload-Trigger ob upstream_forwards gültige IPs (inkl. @port), access_acl gültige IPs/CIDRs und listen_addresses gültige IPs sind; cache_max_ttl ≥ cache_min_ttl. Frontend: Pattern-Validatoren auf upstream_forwards + access_acl; TTL- Kreuzvalidierung mit dependencies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
722 lines
27 KiB
TypeScript
722 lines
27 KiB
TypeScript
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<Zone[]> {
|
|
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<DNSRecord[]> {
|
|
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<Settings | null> {
|
|
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<SystemIface[]> {
|
|
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 (
|
|
<div>
|
|
<PageHeader
|
|
icon={<GlobalOutlined />}
|
|
title={t('dns.title')}
|
|
subtitle={t('dns.intro')}
|
|
/>
|
|
<Tabs
|
|
defaultActiveKey="zones"
|
|
tabBarExtraContent={unbound && (
|
|
<Tag
|
|
icon={unbound.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
|
color={unbound.active ? 'green' : 'red'}
|
|
>
|
|
<Space size={4}>
|
|
<span>unbound</span>
|
|
<span style={{ fontWeight: 400, opacity: 0.85 }}>{unbound.state}</span>
|
|
</Space>
|
|
</Tag>
|
|
)}
|
|
items={[
|
|
{ key: 'zones', label: <span><NodeIndexOutlined /> {t('dns.tabs.zones')}</span>, children: <ZonesTab /> },
|
|
{ key: 'stats', label: <span><BarChartOutlined /> {t('dns.tabs.stats')}</span>, children: <StatsTab /> },
|
|
{ key: 'settings', label: <span><SettingOutlined /> {t('dns.tabs.settings')}</span>, children: <SettingsTab /> },
|
|
]}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 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<Zone | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<Zone>()
|
|
|
|
const [recordsZone, setRecordsZone] = useState<Zone | null>(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<Zone> = [
|
|
{ title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
|
{ title: t('dns.zone.type'), dataIndex: 'zone_type', key: 'zone_type',
|
|
render: (s: string) => <Tag color={s === 'local' ? 'blue' : 'purple'}>{s}</Tag> },
|
|
{ title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to',
|
|
render: (v?: string | null) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '—' },
|
|
{ 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) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('common.actions'), key: 'actions',
|
|
render: (_, row) => (
|
|
<Space size={4}>
|
|
{row.zone_type === 'local' && (
|
|
<Button size="small" type="text" onClick={() => setRecordsZone(row)}>
|
|
{t('dns.zone.records')}
|
|
</Button>
|
|
)}
|
|
<ActionButtons
|
|
onEdit={() => {
|
|
setEditing(row)
|
|
form.setFieldsValue(row)
|
|
}}
|
|
onDelete={() => del.mutate(row.id)}
|
|
deleteConfirm={t('dns.zone.deleteConfirm', { name: row.name })}
|
|
/>
|
|
</Space>
|
|
),
|
|
},
|
|
]
|
|
|
|
const openZoneCreate = () => {
|
|
setCreating(true); form.resetFields()
|
|
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DataTable
|
|
rowKey="id"
|
|
loading={isLoading}
|
|
dataSource={data ?? []}
|
|
columns={cols}
|
|
extraActions={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openZoneCreate}>
|
|
{t('dns.zone.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
emptyContent={
|
|
<EmptyState
|
|
icon={<GlobalOutlined />}
|
|
title={t('dns.zone.emptyTitle')}
|
|
description={t('dns.zone.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openZoneCreate}>
|
|
{t('dns.zone.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? t('dns.zone.edit') : t('dns.zone.add')}
|
|
open={editing !== null || creating}
|
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={upsert.isPending}
|
|
width={580}
|
|
destroyOnClose
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
|
<Form.Item label={t('dns.zone.name')} name="name" rules={[{ required: true }]}
|
|
extra={t('dns.zone.nameExtra')}>
|
|
<Input placeholder="internal.netcell-it.de" />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.zone.type')} name="zone_type" rules={[{ required: true }]}>
|
|
<Select options={[
|
|
{ value: 'local', label: t('dns.zone.typeLocal') },
|
|
{ value: 'forward', label: t('dns.zone.typeForward') },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item noStyle shouldUpdate={(p, c) => p.zone_type !== c.zone_type}>
|
|
{({ getFieldValue }) => getFieldValue('zone_type') === 'forward' && (
|
|
<Form.Item label={t('dns.zone.forwardTo')} name="forward_to" rules={[{ required: true }]}
|
|
extra={t('dns.zone.forwardToExtra')}>
|
|
<Input placeholder="10.0.0.53, 8.8.8.8" />
|
|
</Form.Item>
|
|
)}
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.zone.description')} name="description">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<RecordsDrawer zone={recordsZone} onClose={() => 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<DNSRecord | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<DNSRecord>()
|
|
|
|
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<DNSRecord> = [
|
|
{ title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
|
{ title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => <Tag>{s}</Tag> },
|
|
{ title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
|
|
{ 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) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: t('common.actions'), key: 'actions',
|
|
render: (_, row) => (
|
|
<ActionButtons
|
|
onEdit={() => {
|
|
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 (
|
|
<Drawer
|
|
open={open}
|
|
onClose={onClose}
|
|
width={920}
|
|
title={zone && (
|
|
<Space>
|
|
<span>{t('dns.record.drawerTitle')}</span>
|
|
<Tag color="blue"><code>{zone.name}</code></Tag>
|
|
</Space>
|
|
)}
|
|
destroyOnClose
|
|
>
|
|
<DataTable
|
|
rowKey="id"
|
|
loading={isLoading}
|
|
dataSource={data ?? []}
|
|
columns={cols}
|
|
extraActions={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openRecordCreate}>
|
|
{t('dns.record.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
emptyContent={
|
|
<EmptyState
|
|
icon={<NodeIndexOutlined />}
|
|
title={t('dns.record.emptyTitle')}
|
|
description={t('dns.record.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openRecordCreate}>
|
|
{t('dns.record.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? t('dns.record.edit') : t('dns.record.add')}
|
|
open={editing !== null || creating}
|
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={upsert.isPending}
|
|
width={580}
|
|
destroyOnClose
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
|
<Form.Item label={t('dns.record.name')} name="name" rules={[{ required: true }]}
|
|
extra={t('dns.record.nameExtra')}>
|
|
<Input placeholder="mailcow oder mailcow.internal.netcell-it.de." />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.record.type')} name="record_type" rules={[{ required: true }]}>
|
|
<Select options={RECORD_TYPES.map(r => ({ value: r, label: r }))} />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.record.value')} name="value" rules={[{ required: true }]}
|
|
extra={t('dns.record.valueExtra')}>
|
|
<Input placeholder="10.10.20.5" />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.record.ttl')} name="ttl">
|
|
<InputNumber min={30} max={86400} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
// ── 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<Settings, 'listen_addresses'> {
|
|
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<SettingsForm>()
|
|
|
|
// 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 (
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
initialValues={initial}
|
|
onFinish={(v) => save.mutate(v)}
|
|
style={{ maxWidth: 720 }}
|
|
>
|
|
<Alert type="info" showIcon className="mb-12" message={t('dns.settings.intro')} />
|
|
<Form.Item
|
|
label={t('dns.settings.listenAddresses')}
|
|
name="listen_addresses"
|
|
rules={[{ required: true, type: 'array', min: 1, message: t('dns.settings.listenAddressesRequired') }]}
|
|
extra={t('dns.settings.listenAddressesExtra')}
|
|
>
|
|
<Select
|
|
mode="tags"
|
|
placeholder={t('dns.settings.listenAddressesPlaceholder')}
|
|
options={ipOptions}
|
|
showSearch
|
|
optionFilterProp="value"
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.settings.listenPort')} name="listen_port" rules={[{ required: true }]}>
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('dns.settings.upstreamForwards')}
|
|
name="upstream_forwards"
|
|
extra={t('dns.settings.upstreamForwardsExtra')}
|
|
rules={[
|
|
{ required: true },
|
|
{
|
|
validator(_, val: string) {
|
|
if (!val) return Promise.resolve()
|
|
const IP = /^(\d{1,3}\.){3}\d{1,3}(@\d{1,5})?$|^[0-9a-fA-F:]+(@\d{1,5})?$/
|
|
const bad = val.split(',').map(s => s.trim()).filter(Boolean).find(s => !IP.test(s))
|
|
return bad ? Promise.reject(new Error(t('dns.settings.upstreamForwardsInvalid'))) : Promise.resolve()
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input placeholder="1.1.1.1, 9.9.9.9" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('dns.settings.accessACL')}
|
|
name="access_acl"
|
|
extra={t('dns.settings.accessACLExtra')}
|
|
rules={[
|
|
{ required: true },
|
|
{
|
|
validator(_, val: string) {
|
|
if (!val) return Promise.resolve()
|
|
const IPCIDR = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$|^[0-9a-fA-F:]+(\/\d{1,3})?$/
|
|
const bad = val.split(',').map(s => s.trim()).filter(Boolean).find(s => !IPCIDR.test(s))
|
|
return bad ? Promise.reject(new Error(t('dns.settings.accessACLInvalid'))) : Promise.resolve()
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.settings.dnssec')} name="dnssec" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
<Space>
|
|
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
|
|
<InputNumber min={0} style={{ width: 120 }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('dns.settings.cacheMax')}
|
|
name="cache_max_ttl"
|
|
dependencies={['cache_min_ttl']}
|
|
rules={[
|
|
({ getFieldValue }) => ({
|
|
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()
|
|
},
|
|
}),
|
|
]}
|
|
>
|
|
<InputNumber min={60} style={{ width: 120 }} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item>
|
|
<Space>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" htmlType="submit" disabled={isViewer} loading={save.isPending}>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Tooltip>
|
|
<Tooltip title={t('dns.settings.flushCacheTooltip')}>
|
|
<Button
|
|
icon={<ClearOutlined />}
|
|
loading={flushCache.isPending}
|
|
onClick={() => flushCache.mutate()}
|
|
>
|
|
{t('dns.settings.flushCacheBtn')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<Card
|
|
size="small"
|
|
title={<><BarChartOutlined /> {t('dns.statsCard.title')}</>}
|
|
extra={<Button size="small" icon={<ReloadOutlined />} loading={isFetching} onClick={() => refetch()}>{t('common.refresh')}</Button>}
|
|
>
|
|
{(error || apiErr) && (
|
|
<Alert type="warning" showIcon message={apiErr ?? String(error)} className="mb-12" />
|
|
)}
|
|
{!stats ? (
|
|
<Typography.Text type="secondary">{t('dns.statsCard.empty')}</Typography.Text>
|
|
) : (
|
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
|
{t('dns.statsCard.sinceRestart')}
|
|
</Typography.Text>
|
|
<Row gutter={[12, 12]}>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.totalQueries')} value={stats.total_queries.toLocaleString()} />
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.cacheHits')} value={stats.cache_hits.toLocaleString()} valueStyle={{ color: '#16a34a' }} />
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.cacheMiss')} value={stats.cache_miss.toLocaleString()} />
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.recursiveReplies')} value={stats.recursive_replies.toLocaleString()} />
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.prefetch')} value={stats.prefetch.toLocaleString()} />
|
|
</Col>
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.tcpUsage')} value={stats.tcp_usage} />
|
|
</Col>
|
|
{stats.rate_limited > 0 && (
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.rateLimited')} value={stats.rate_limited.toLocaleString()} valueStyle={{ color: '#d48806' }} />
|
|
</Col>
|
|
)}
|
|
{stats.unwanted > 0 && (
|
|
<Col xs={12} sm={8} md={6}>
|
|
<Statistic title={t('dns.statsCard.unwanted')} value={stats.unwanted.toLocaleString()} valueStyle={{ color: '#dc2626' }} />
|
|
</Col>
|
|
)}
|
|
</Row>
|
|
<div>
|
|
<Typography.Text style={{ fontSize: 12 }}>
|
|
{t('dns.statsCard.cacheHitPct')}: <strong>{stats.cache_hit_pct.toFixed(1)}%</strong>
|
|
</Typography.Text>
|
|
<Progress
|
|
percent={Math.round(stats.cache_hit_pct)}
|
|
strokeColor={stats.cache_hit_pct >= 70 ? '#16a34a' : stats.cache_hit_pct >= 40 ? '#d48806' : '#dc2626'}
|
|
size="small"
|
|
style={{ marginTop: 4 }}
|
|
/>
|
|
</div>
|
|
<Row gutter={[12, 12]}>
|
|
<Col xs={12} sm={8}>
|
|
<Statistic title={t('dns.statsCard.rrsetCacheBytes')} value={fmtBytes(stats.rrset_cache_bytes)} />
|
|
</Col>
|
|
<Col xs={12} sm={8}>
|
|
<Statistic title={t('dns.statsCard.msgCacheBytes')} value={fmtBytes(stats.msg_cache_bytes)} />
|
|
</Col>
|
|
</Row>
|
|
</Space>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|