initial commit
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user