feat(dns): Resolver stats tab + NAT rule move buttons

- GET /dns/stats via unbound-control stats_noreset: total queries,
  cache hits/miss, hit-rate progress bar, recursive replies, prefetch,
  rate-limited, unwanted, RRset/msg cache memory
- DNS page: new "Resolver stats" tab with 30s auto-refresh
- NAT rules: ↑↓ move buttons (same pattern as firewall rules 1.1.102)

v1.1.103

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-25 13:20:07 +02:00
parent 844f9ddc83
commit f6615fd27d
9 changed files with 255 additions and 10 deletions

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
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 { CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
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'
@@ -109,6 +109,7 @@ export default function DNSPage() {
)}
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 /> },
]}
/>
@@ -566,3 +567,112 @@ function SettingsTab() {
</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>
)
}

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { BranchesOutlined } from '@ant-design/icons'
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -94,6 +94,20 @@ export default function NATRulesTab() {
onError: (e: Error) => message.error(e.message),
})
const sortedNAT = useMemo(
() => [...(data ?? [])].sort((a, b) => a.priority - b.priority),
[data],
)
const swapNAT = useMutation({
mutationFn: async ({ a, b }: { a: NATRule; b: NATRule }) => {
await apiClient.put(`/firewall/nat-rules/${a.id}`, { ...a, priority: b.priority })
await apiClient.put(`/firewall/nat-rules/${b.id}`, { ...b, priority: a.priority })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
onError: (e: Error) => message.error(e.message),
})
const renderTarget = (r: NATRule) => {
if (r.kind === 'masquerade') return <Tag color="gold">{r.out_zone ?? '?'}-iface IP</Tag>
if (!r.target_addr) return '—'
@@ -129,6 +143,27 @@ export default function NATRulesTab() {
/>
),
},
{
title: '', key: 'move', width: 64,
render: (_, row) => {
const idx = sortedNAT.findIndex(r => r.id === row.id)
const swapping = swapNAT.isPending
return (
<Space size={2}>
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
</Tooltip>
</Space>
)
},
},
{
title: t('common.edit'), key: 'actions',
render: (_, row) => (