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 }