Files
vibedns/internal/app/stats.go
T
2026-08-16 21:18:45 -05:00

306 lines
9.0 KiB
Go

package app
import (
"context"
"net/netip"
"strings"
"time"
"github.com/owen/vibedns/internal/auditlog"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/database"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
)
// Dashboard is everything the overview page and /api/v1/stats report.
type Dashboard struct {
Uptime time.Duration `json:"uptime"`
UptimeText string `json:"uptime_text"`
StartedAt time.Time `json:"started_at"`
Hostname string `json:"hostname"`
Version string `json:"version"`
TotalQueries int64 `json:"total_queries"`
QueriesPerSec float64 `json:"queries_per_second"`
Authoritative int64 `json:"authoritative_queries"`
Recursive int64 `json:"recursive_queries"`
Blocked int64 `json:"blocked_queries"`
Refused int64 `json:"refused_queries"`
RateLimited int64 `json:"ratelimited_queries"`
Errors int64 `json:"errors"`
AvgQueryMS float64 `json:"avg_query_ms"`
AvgResolverMS float64 `json:"avg_resolver_ms"`
BlockRate float64 `json:"block_rate"`
CacheHits int64 `json:"cache_hits"`
CacheMisses int64 `json:"cache_misses"`
CacheHitRate float64 `json:"cache_hit_rate"`
CacheEntries int `json:"cache_entries"`
CacheBytes int64 `json:"cache_bytes"`
CacheEnabled bool `json:"cache_enabled"`
Zones int `json:"zones"`
Records int `json:"records"`
Blacklists int `json:"blacklists"`
BlacklistDomains int `json:"blacklist_domains"`
Allowlists int `json:"allowlists"`
AllowlistDomains int `json:"allowlist_domains"`
Networks int `json:"networks"`
Policies int `json:"policies"`
QueriesByType []metrics.LabelValue `json:"queries_by_type"`
QueriesByRcode []metrics.LabelValue `json:"queries_by_rcode"`
QueriesBySource []metrics.LabelValue `json:"queries_by_source"`
TopDomains []database.NameCount `json:"top_domains"`
TopBlocked []database.NameCount `json:"top_blocked"`
TopClients []database.NameCount `json:"top_clients"`
Activity []database.TimeBucket `json:"activity"`
Upstreams []resolver.Status `json:"upstreams"`
RateLimit ratelimit.Stats `json:"rate_limit"`
QueryLog QueryLogStatus `json:"query_log"`
Recent []models.QueryLogEntry `json:"recent"`
RecursionEnabled bool `json:"recursion_enabled"`
DNSRunning bool `json:"dns_running"`
QueryLogEnabled bool `json:"query_log_enabled"`
}
// QueryLogStatus summarises the query log for the dashboard.
type QueryLogStatus struct {
Enabled bool `json:"enabled"`
Rows int64 `json:"rows"`
Written int64 `json:"written"`
Dropped int64 `json:"dropped"`
}
// activityWindow is how far back the dashboard chart looks.
const activityWindow = 24 * time.Hour
// activityBuckets is how many points the chart plots.
const activityBuckets = 48
// Dashboard assembles the overview data.
//
// The counters come from memory; only the top-N lists and the activity chart
// touch SQLite, and they are read-only aggregate queries over an indexed
// timestamp column.
func (a *App) Dashboard(ctx context.Context, topN int) (*Dashboard, error) {
if topN <= 0 {
topN = 10
}
m := a.Metrics
snap := a.Snapshot()
cs := a.Cache.Stats()
d := &Dashboard{
Uptime: a.Uptime(),
UptimeText: FormatDuration(a.Uptime()),
StartedAt: a.StartedAt(),
Hostname: Hostname(),
TotalQueries: m.QueriesTotal.Load(),
QueriesPerSec: m.QueriesPerSecond(),
Authoritative: m.Authoritative.Load(),
Recursive: m.Recursive.Load(),
Blocked: m.Blocked.Load(),
Refused: m.Refused.Load(),
RateLimited: m.RateLimited.Load(),
Errors: m.Errors.Load(),
AvgQueryMS: m.AvgQueryMS(),
AvgResolverMS: m.AvgResolverMS(),
CacheHits: m.CacheHits.Load(),
CacheMisses: m.CacheMisses.Load(),
CacheHitRate: m.CacheHitRate(),
CacheEntries: cs.Entries,
CacheBytes: cs.Bytes,
CacheEnabled: cs.Enabled,
Zones: snap.ZoneCount,
Records: snap.RecordCount,
BlacklistDomains: snap.BlacklistDomains,
AllowlistDomains: snap.AllowlistDomains,
Networks: snap.NetworkCount,
QueriesByType: m.ByType(),
QueriesByRcode: m.ByRcode(),
QueriesBySource: m.BySource(),
Upstreams: a.Resolver.Statuses(),
RateLimit: a.Limiter.Stats(),
RecursionEnabled: snap.Settings.DNS.Recursion,
DNSRunning: a.DNS.Running(),
QueryLogEnabled: snap.Settings.QueryLog.Enabled,
}
if d.TotalQueries > 0 {
d.BlockRate = float64(d.Blocked) / float64(d.TotalQueries) * 100
}
// Zone and record counts from the database include disabled objects, which
// the operator still wants to see on the dashboard.
if zones, records, err := a.DB.CountZonesAndRecords(ctx); err == nil {
d.Zones, d.Records = zones, records
}
if bl, bd, al, ad, err := a.DB.CountDomainLists(ctx); err == nil {
d.Blacklists, d.BlacklistDomains = bl, bd
d.Allowlists, d.AllowlistDomains = al, ad
}
if policies, err := a.DB.Policies(ctx); err == nil {
d.Policies = len(policies)
}
qs := a.QueryLog.Stats()
d.QueryLog = QueryLogStatus{Enabled: qs.Enabled, Written: qs.Written, Dropped: qs.Dropped}
if n, err := a.DB.QueryLogCount(ctx); err == nil {
d.QueryLog.Rows = n
}
// Query-log derived panels are only meaningful when logging is on.
if snap.Settings.QueryLog.Enabled {
since := time.Now().Add(-activityWindow)
if v, err := a.DB.TopQueried(ctx, since, topN); err == nil {
d.TopDomains = v
}
if v, err := a.DB.TopBlocked(ctx, since, topN); err == nil {
d.TopBlocked = v
}
if v, err := a.DB.TopClients(ctx, since, topN); err == nil {
d.TopClients = v
}
if v, err := a.DB.ActivityBuckets(ctx, since, activityWindow/activityBuckets, activityBuckets); err == nil {
d.Activity = v
}
if v, _, err := a.DB.QueryLogs(ctx, database.QueryLogFilter{Limit: 15}); err == nil {
d.Recent = v
}
}
return d, nil
}
// QueryLogs searches the query log.
func (a *App) QueryLogs(ctx context.Context, f database.QueryLogFilter) ([]models.QueryLogEntry, int, error) {
entries, total, err := a.DB.QueryLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The query log could not be loaded.")
}
return entries, total, nil
}
// ClearQueryLog empties the query log.
func (a *App) ClearQueryLog(ctx context.Context, actor auditlog.Actor) (int64, error) {
n, err := a.DB.TruncateQueryLogs(ctx)
if err != nil {
return 0, Internal(err, "The query log could not be cleared.")
}
a.Audit.Record(ctx, actor, "querylog.clear", auditlog.ObjectQueryLog, "", "query log",
auditlog.Changes("rows", formatInt(n)))
return n, nil
}
// AuditLogs searches the audit log.
func (a *App) AuditLogs(ctx context.Context, f database.AuditFilter) ([]models.AuditEntry, int, error) {
entries, total, err := a.DB.AuditLogs(ctx, f)
if err != nil {
return nil, 0, Internal(err, "The audit log could not be loaded.")
}
return entries, total, nil
}
// DatabaseStats reports database size and row counts.
func (a *App) DatabaseStats(ctx context.Context) (database.Stats, error) {
s, err := a.DB.Stats(ctx)
if err != nil {
return s, Internal(err, "Database statistics could not be read.")
}
return s, nil
}
// CacheStatsView bundles cache counters with its configuration for the UI.
type CacheStatsView struct {
cache.Stats
MinTTL int `json:"min_ttl"`
MaxTTL int `json:"max_ttl"`
NegativeTTL int `json:"negative_ttl"`
ServeStale bool `json:"serve_stale"`
StaleTTL int `json:"stale_ttl"`
Prefetch bool `json:"prefetch"`
}
// CacheView returns cache counters together with the active configuration.
func (a *App) CacheView() CacheStatsView {
s := a.Settings().Cache
return CacheStatsView{
Stats: a.Cache.Stats(),
MinTTL: s.MinTTL,
MaxTTL: s.MaxTTL,
NegativeTTL: s.NegativeTTL,
ServeStale: s.ServeStale,
StaleTTL: s.StaleTTL,
Prefetch: s.Prefetch,
}
}
// FormatDuration renders a duration the way an operator reads uptime.
func FormatDuration(d time.Duration) string {
if d < time.Minute {
return formatInt(int64(d.Seconds())) + "s"
}
days := int64(d.Hours()) / 24
hours := int64(d.Hours()) % 24
mins := int64(d.Minutes()) % 60
var parts []string
if days > 0 {
parts = append(parts, formatInt(days)+"d")
}
if hours > 0 {
parts = append(parts, formatInt(hours)+"h")
}
if mins > 0 || len(parts) == 0 {
parts = append(parts, formatInt(mins)+"m")
}
return strings.Join(parts, " ")
}
func formatInt(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var buf [24]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// netipAddr parses a client address for the lookup tool, defaulting to
// loopback when the field is left blank.
func netipAddr(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.MustParseAddr("127.0.0.1"), true
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}