From f6615fd27dff56cba165a28a6ffeba0951ebdd76 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 25 May 2026 13:20:07 +0200 Subject: [PATCH] feat(dns): Resolver stats tab + NAT rule move buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/dns.go | 68 +++++++++++ management-ui/src/i18n/locales/de/common.json | 18 ++- management-ui/src/i18n/locales/en/common.json | 18 ++- management-ui/src/pages/DNS/index.tsx | 114 +++++++++++++++++- management-ui/src/pages/Firewall/NATRules.tsx | 39 +++++- 9 files changed, 255 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index b02d7a2..bcce8b7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.102 +1.1.103 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 71b60c3..e80df40 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.102" +var version = "1.1.103" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 4852091..fec8c1a 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.102" +var version = "1.1.103" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index b863ae5..c6b5d24 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.102" +var version = "1.1.103" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/dns.go b/internal/handlers/dns.go index bf0a704..e787f77 100644 --- a/internal/handlers/dns.go +++ b/internal/handlers/dns.go @@ -5,6 +5,8 @@ import ( "errors" "log/slog" "os/exec" + "strconv" + "strings" "github.com/gin-gonic/gin" @@ -55,6 +57,7 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) { g.GET("/settings", h.GetSettings) g.PUT("/settings", h.UpdateSettings) + g.GET("/stats", h.Stats) g.POST("/flush-cache", h.FlushCache) } @@ -325,6 +328,71 @@ func validateZone(z *models.DNSZone) error { return nil } +// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`. +// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für +// wiederholte Aufrufe aus dem UI. +func (h *DNSHandler) Stats(c *gin.Context) { + out, err := exec.Command("unbound-control", "stats_noreset").Output() + if err != nil { + response.OK(c, gin.H{ + "error": "unbound-control nicht verfügbar: " + err.Error(), + }) + return + } + response.OK(c, gin.H{"stats": parseUnboundStats(string(out))}) +} + +type unboundStats struct { + TotalQueries int64 `json:"total_queries"` + CacheHits int64 `json:"cache_hits"` + CacheMiss int64 `json:"cache_miss"` + CacheHitPct float64 `json:"cache_hit_pct"` + RecursiveReplies int64 `json:"recursive_replies"` + Prefetch int64 `json:"prefetch"` + RateLimited int64 `json:"rate_limited"` + RRSetCacheBytes int64 `json:"rrset_cache_bytes"` + MsgCacheBytes int64 `json:"msg_cache_bytes"` + TCPUsage int64 `json:"tcp_usage"` + Unwanted int64 `json:"unwanted"` +} + +func parseUnboundStats(out string) unboundStats { + s := unboundStats{} + for _, line := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + switch strings.TrimSpace(k) { + case "total.num.queries": + s.TotalQueries = n + case "total.num.cachehits": + s.CacheHits = n + case "total.num.cachemiss": + s.CacheMiss = n + case "total.num.recursivereplies": + s.RecursiveReplies = n + case "total.num.prefetch": + s.Prefetch = n + case "total.num.queries_ip_ratelimited": + s.RateLimited = n + case "mem.cache.rrset": + s.RRSetCacheBytes = n + case "mem.cache.message": + s.MsgCacheBytes = n + case "total.tcpusage": + s.TCPUsage = n + case "unwanted.queries": + s.Unwanted = n + } + } + if s.TotalQueries > 0 { + s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100 + } + return s +} + func validateRecord(r *models.DNSRecord) error { if r.Name == "" { return errors.New("name required") diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 94e17a1..af66b6e 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -883,7 +883,23 @@ "dns": { "title": "DNS (Unbound)", "intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.", - "tabs": { "zones": "Zonen", "settings": "Resolver-Settings" }, + "tabs": { "zones": "Zonen", "settings": "Resolver-Settings", "stats": "Resolver-Stats" }, + "statsCard": { + "title": "Resolver-Statistiken (unbound-control stats_noreset)", + "totalQueries": "Abfragen gesamt", + "cacheHits": "Cache-Treffer", + "cacheMiss": "Cache-Miss", + "cacheHitPct": "Cache-Trefferrate", + "recursiveReplies": "Rekursive Antworten", + "prefetch": "Prefetch", + "rateLimited": "Rate-Limited", + "rrsetCacheBytes": "RRset-Cache", + "msgCacheBytes": "Msg-Cache", + "tcpUsage": "TCP-Verbindungen", + "unwanted": "Unerwünschte Anfragen", + "empty": "Keine Statistiken — läuft unbound?", + "sinceRestart": "Seit letztem Neustart" + }, "zone": { "name": "Zone-Name", "nameExtra": "FQDN ohne führenden/abschließenden Punkt — z.B. internal.netcell-it.de", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 16d271d..ef8b3ee 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -883,7 +883,23 @@ "dns": { "title": "DNS (Unbound)", "intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.", - "tabs": { "zones": "Zones", "settings": "Resolver settings" }, + "tabs": { "zones": "Zones", "settings": "Resolver settings", "stats": "Resolver stats" }, + "statsCard": { + "title": "Resolver statistics (unbound-control stats_noreset)", + "totalQueries": "Total queries", + "cacheHits": "Cache hits", + "cacheMiss": "Cache miss", + "cacheHitPct": "Cache hit rate", + "recursiveReplies": "Recursive replies", + "prefetch": "Prefetch", + "rateLimited": "Rate-limited", + "rrsetCacheBytes": "RRset cache", + "msgCacheBytes": "Msg cache", + "tcpUsage": "TCP connections", + "unwanted": "Unwanted queries", + "empty": "No stats available — is unbound running?", + "sinceRestart": "Since last restart" + }, "zone": { "name": "Zone name", "nameExtra": "FQDN without leading/trailing dot — e.g. internal.netcell-it.de", diff --git a/management-ui/src/pages/DNS/index.tsx b/management-ui/src/pages/DNS/index.tsx index 116e689..4e50601 100644 --- a/management-ui/src/pages/DNS/index.tsx +++ b/management-ui/src/pages/DNS/index.tsx @@ -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: {t('dns.tabs.zones')}, children: }, + { key: 'stats', label: {t('dns.tabs.stats')}, children: }, { key: 'settings', label: {t('dns.tabs.settings')}, children: }, ]} /> @@ -566,3 +567,112 @@ function SettingsTab() { ) } + +// ── 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 }} + /> +
+ + + + + + + + +
+ )} +
+ ) +} diff --git a/management-ui/src/pages/Firewall/NATRules.tsx b/management-ui/src/pages/Firewall/NATRules.tsx index 1b674a9..03a0956 100644 --- a/management-ui/src/pages/Firewall/NATRules.tsx +++ b/management-ui/src/pages/Firewall/NATRules.tsx @@ -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 {r.out_zone ?? '?'}-iface IP 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 ( + + +