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,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) => (