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

@@ -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")