diff --git a/CHANGELOG.md b/CHANGELOG.md index a67144b..c66f1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,17 @@ All notable changes to Jarl are documented here. The format follows - Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`, and a **Compare** button on each record. +- **Exploring the statistics**: jarld keeps every answer for a while + (`stats.retention.queries`, default 7 days), and every client, name, + view, record type, result and blocklist on the Statistics page leads to + an explore page for it: answers over time, the top clients, names, + views, types, results and blocklists among them (each a link that + narrows it further), and the latest answers. "NXDOMAIN in external" → + the clients asking → everything one of them asks. `GET /stats/explore`. +- **Storage** on the dashboard: the size of Jarl's databases (and how many + answers the query log holds), and per disk Jarl uses a bar of how full it + is and how much of it the databases take. + ### Changed - **Compare views** and `jarl zone diff` are rebuilt around the view @@ -83,6 +94,8 @@ All notable changes to Jarl are documented here. The format follows starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo jarld backup`). Nothing changes until you turn `auto_ptr` on for a zone. +- The statistics database gets a new version too (the query log); 0.5 + can't open it afterwards either. It starts empty and fills from then on. ## [0.5.2] - 2026-10-12 diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index 53b5f05..915bd2f 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -151,7 +151,8 @@ func statsOptions(cfg *config.Config) stats.Options { sc := cfg.Stats o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN, RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day), - Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig} + RetainQueries: time.Duration(sc.Retention.Queries), + Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig} for _, p := range sc.ExcludeClients { if pf, err := config.ParsePrefix(p); err == nil { o.Exclude = append(o.Exclude, pf.Masked()) diff --git a/docs/api.md b/docs/api.md index 4b160f2..ffde06b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -565,6 +565,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but | GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` | | GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) | | GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data | +| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=` | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log | | GET | `/stats/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` | diff --git a/docs/stats.md b/docs/stats.md index e933792..cec2a3a 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -70,6 +70,7 @@ stats: minute: 7d # per-minute counters hour: 90d # hourly counters and top lists day: 730d # daily counters and top lists + queries: 7d # every answer, for exploring (0: none) top_n: 100 # names and clients kept per hour and per day anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48) exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe @@ -192,6 +193,37 @@ jarl stats summary --since 30d -o json # export (also -o yaml) `operator` roles include it, `dns-editor` doesn't. Statistics show client addresses and what they look up, so grant it with care. +## Exploring + +The counters and top lists say how much, not who asked for what: "100 +NXDOMAIN answers in the external view" can't tell you which clients asked. +For that, jarld also keeps every answer for a while, the **query log**: time, +client, view, name, record type, result, and the blocklist (or your rules) +that blocked it. It is in the statistics database, from the response log +named writes anyway, and answers older than `retention.queries` (default +7 days) are deleted every hour; `0` keeps none. + +With it, every client, name, view, record type, result and blocklist on the +Statistics page is a link to the **explore** page (`#/stats/explore`), +which shows, for what you clicked: + +* the answers over time (and how many were blocked); +* the top clients, names, views, record types, results and blocklists + among them, each a link that narrows the filter further; +* the latest 100 answers, every cell a link. + +So "external, NXDOMAIN" leads to the clients asking, a client to everything +it asks, one of its names to who else asks for it. Filters combine (this +client, in this view, blocked only) and each has a ✕ to take it out. A name +can be widened to the names below it (`domain=`). The Statistics page's +view switches apply here too. + +`anonymize_clients` and `exclude_clients` apply to the query log as to +everything else. Its size: each answer takes about 150 to 200 bytes with +its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a +day about 250 MB. The dashboard's Storage panel shows the databases' size +and how many answers the query log holds. + ## Views With views, the Statistics page has a toggle per view (and **All** and diff --git a/internal/api/types.go b/internal/api/types.go index 1346238..3ce4f5e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -277,6 +277,35 @@ type StatusResponse struct { // EncryptedDNS is the last check of DNS over TLS and HTTPS, when it // is set up (for those who may see options). EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"` + // Storage is the size of Jarl's databases and how full the disks Jarl + // uses are (administrators only). + Storage *Storage `json:"storage,omitempty"` +} + +// Storage is the size of Jarl's databases and the disks Jarl uses. +type Storage struct { + Databases []Database `json:"databases"` + Total int64 `json:"total"` // the databases' bytes + Disks []Disk `json:"disks"` +} + +// Database is one of Jarl's databases: its file with its -wal and -shm. +type Database struct { + Label string `json:"label"` + Path string `json:"path"` + Bytes int64 `json:"bytes"` + // Rows is how many answers the query log holds (statistics only). + QueryLogRows *int64 `json:"query_log_rows,omitempty"` +} + +// Disk is a file system Jarl uses: what of Jarl's it holds, its size, +// what is free, and how much of it the databases take. +type Disk struct { + Path string `json:"path"` + Holds []string `json:"holds"` + Total int64 `json:"total"` + Free int64 `json:"free"` + Databases int64 `json:"databases"` } // CertSummary is what the dashboard reports about certificates. diff --git a/internal/config/config.go b/internal/config/config.go index f70afde..3c9c8f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -395,6 +395,9 @@ type StatsRetention struct { Minute Duration `yaml:"minute"` Hour Duration `yaml:"hour"` Day Duration `yaml:"day"` + // Queries is how long the query log keeps every answer, for exploring + // (docs/stats.md#exploring); 0 keeps none. + Queries Duration `yaml:"queries"` } // Duration is a time.Duration written as "15s" or "12h" in YAML. Days @@ -461,7 +464,7 @@ func Default() *Config { LogFile: "/var/log/named/jarl-responses.log", LogSize: "20m", LogVersions: 2, - Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}, + Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)}, TopN: 100, }, ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"}, @@ -604,6 +607,9 @@ func (s *Stats) check() error { if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) { return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d") } + if r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) { + return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log") + } if s.TopN < 10 || s.TopN > 10000 { return fmt.Errorf("top_n must be between 10 and 10000") } @@ -754,6 +760,7 @@ stats: minute: 7d # per-minute counters hour: 90d # hourly counters and top lists day: 730d # daily counters and top lists + queries: 7d # every answer, to explore (who asked what); 0: none top_n: 100 # names and clients kept per hour and per day anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring diff --git a/internal/server/h_changes.go b/internal/server/h_changes.go index d944f8d..4f1195d 100644 --- a/internal/server/h_changes.go +++ b/internal/server/h_changes.go @@ -285,6 +285,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa resp.Backup = s.backupStatus(r.Context(), now) resp.TOTP = s.totpOverview(r.Context()) resp.Certificates = s.certSummary(r.Context()) + resp.Storage = s.storage(r.Context()) } if p.Can(auth.Action{Resource: auth.ResOptions}) { if h := s.encryptedDNSHealth(); h != nil { diff --git a/internal/server/h_stats.go b/internal/server/h_stats.go index 96d3a4e..79aabf4 100644 --- a/internal/server/h_stats.go +++ b/internal/server/h_stats.go @@ -286,3 +286,59 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri writeJSON(w, http.StatusOK, out) return nil } + +// statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode= +// &blocked=&since=24h explores the query log (docs/stats.md#exploring): +// totals, a series, the top values of each dimension left open, and the +// latest answers, with client host names. +func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { + q, err := s.statsQuerier(r, p) + if err != nil { + return err + } + since, err := statsSince(r) + if err != nil { + return err + } + v := r.URL.Query() + f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"), + Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since} + ex, err := q.Explore(r.Context(), f, 15, 100) + if errors.Is(err, stats.ErrNoQueryLog) { + return &httpError{http.StatusNotFound, "query_log_off", err.Error()} + } + if err != nil { + return err + } + if err := s.nameClients(r, p, ex.By["client"]); err != nil { + return err + } + // The recent answers' clients, named once each. + var es []stats.Entry + seen := map[string]int{} + for _, row := range ex.Recent { + if _, ok := seen[row.Client]; !ok { + seen[row.Client] = len(es) + es = append(es, stats.Entry{Key: row.Client}) + } + } + if err := s.nameClients(r, p, es); err != nil { + return err + } + for i := range ex.Recent { + ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name + } + // The filter's client, named too. + out := struct { + *stats.Exploration + ClientName string `json:"client_name,omitempty"` + }{Exploration: ex} + if f.Client != "" { + one := []stats.Entry{{Key: f.Client}} + if err := s.nameClients(r, p, one); err == nil { + out.ClientName = one[0].Name + } + } + writeJSON(w, http.StatusOK, out) + return nil +} diff --git a/internal/server/h_storage.go b/internal/server/h_storage.go new file mode 100644 index 0000000..012445a --- /dev/null +++ b/internal/server/h_storage.go @@ -0,0 +1,72 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "slices" + + "jarl/internal/api" + "jarl/internal/stats" +) + +// storage reports the size of Jarl's databases and how full the disks +// Jarl uses are (the dashboard's Storage card). +func (s *Server) storage(ctx context.Context) *api.Storage { + out := &api.Storage{Databases: []api.Database{}, Disks: []api.Disk{}} + dbFile := func(label, path string) *api.Database { + if path == "" || path == ":memory:" { + return nil + } + d := api.Database{Label: label, Path: path} + for _, suffix := range []string{"", "-wal", "-shm"} { + if fi, err := os.Stat(path + suffix); err == nil { + d.Bytes += fi.Size() + } + } + out.Databases = append(out.Databases, d) + out.Total += d.Bytes + return &out.Databases[len(out.Databases)-1] + } + labels := []string{} + dirs := map[string]string{} + add := func(label, dir string) { + if dir != "" { + labels = append(labels, label) + dirs[label] = dir + } + } + const jarlDB, statsDB = "Jarl's database", "statistics database" + if p := s.store.Path(); p != "" { + dbFile(jarlDB, p) + add(jarlDB, filepath.Dir(p)) + } + if s.cfg.Stats != nil { + if p := s.cfg.Stats.Store().Path(); p != "" && p != ":memory:" { + if d := dbFile(statsDB, p); d != nil && s.cfg.Stats.Options().RetainQueries > 0 { + if n, err := s.cfg.Stats.Store().QueryLogSize(ctx); err == nil { + d.QueryLogRows = &n + } + } + add(statsDB, filepath.Dir(p)) + } + if lf := s.cfg.Stats.Options().LogFile; lf != "" { + add("named's logs", filepath.Dir(lf)) + } + } + paths := s.eng.Config().Paths + add("zone files", paths.ZoneDir) + add("named.conf", paths.ConfigDir) + add("Jarl's cache and blocklists", paths.CacheDir) + add("backups", s.cfg.BackupDir) + for _, d := range stats.DiskUsage(labels, dirs) { + disk := api.Disk{Path: d.Path, Holds: d.Holds, Total: d.Total, Free: d.Free} + for _, db := range out.Databases { + if slices.Contains(d.Holds, db.Label) { + disk.Databases += db.Bytes + } + } + out.Disks = append(out.Disks, disk) + } + return out +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 432ccec..684c7ab 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -195,6 +195,7 @@ func (s *Server) routes() { m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap)) m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown)) m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer)) + m.HandleFunc("GET "+p+"/stats/explore", s.authed(s.statsExplore)) m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path) }) diff --git a/internal/server/stats_test.go b/internal/server/stats_test.go index 57450cc..8aa6145 100644 --- a/internal/server/stats_test.go +++ b/internal/server/stats_test.go @@ -121,3 +121,58 @@ func TestStatsAPI(t *testing.T) { vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string) e.do("GET", "/stats/summary", vtok, nil, 200) } + +func TestStatsExploreAPI(t *testing.T) { + ctx := context.Background() + ss, err := stats.OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer ss.Close() + now := time.Now().Unix() + b := &stats.Batch{} + b.AddQueries([]stats.QueryRow{ + {Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}, + {Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"}, + {Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"}, + }) + if err := ss.Commit(ctx, b); err != nil { + t.Fatal(err) + } + opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour} + c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) + e := newAPIEnv(t, c) + e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201) + + ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200) + if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 { + t.Errorf("NXDOMAIN: %v", ex) + } + clients := ex["by"].(map[string]any)["client"].([]any) + if len(clients) != 2 { + t.Fatalf("clients %v", clients) + } + named := false + for _, c := range clients { + m := c.(map[string]any) + named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com" + } + if !named { + t.Errorf("client names %v", clients) + } + ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200) + recent := ex["recent"].([]any) + if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" { + t.Errorf("laptop: %v", ex) + } + if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 { + t.Errorf("laptop in external only: %v", ex["total"]) + } + + // The dashboard reports the databases' size. + st := e.do("GET", "/status", e.token, nil, 200) + sg, ok := st["storage"].(map[string]any) + if !ok || len(sg["databases"].([]any)) == 0 { + t.Errorf("storage %v", st["storage"]) + } +} diff --git a/internal/stats/collector.go b/internal/stats/collector.go index d052fb4..f2e0026 100644 --- a/internal/stats/collector.go +++ b/internal/stats/collector.go @@ -56,9 +56,12 @@ type Options struct { RetainMinute time.Duration RetainHour time.Duration RetainDay time.Duration - Anonymize bool - Exclude []netip.Prefix - Location *time.Location // for days and log timestamps (default: local time) + // RetainQueries is how long the query log keeps every answer (0: no + // query log; it needs QueryLog). + RetainQueries time.Duration + Anonymize bool + Exclude []netip.Prefix + Location *time.Location // for days and log timestamps (default: local time) // MainConfig is named's main configuration file; it tells named's // process apart from other named processes (for its memory and CPU). MainConfig string @@ -87,9 +90,10 @@ type Status struct { Warnings []string `json:"warnings"` FirstData *time.Time `json:"first_data,omitempty"` Retention struct { - Minute string `json:"minute"` - Hour string `json:"hour"` - Day string `json:"day"` + Minute string `json:"minute"` + Hour string `json:"hour"` + Day string `json:"day"` + Queries string `json:"queries"` // the query log ("" when off) } `json:"retention"` } @@ -119,9 +123,11 @@ type Collector struct { recentL []int64 // response log lines per minute, last 10 minutes linesMin int64 ensureAt time.Time - // rewritten holds queries named blocked (client#port|name) until their - // response line arrives. - rewritten map[string]bool + // rewritten holds queries named blocked (client#port|name), and what + // blocked them, until their response line arrives. + rewritten map[string]string + // pending are answers for the query log, written at the next flush. + pending []QueryRow // viewsSince is set once per-view counters exist (meta views_since). viewsSince bool viewsSinceAt int64 @@ -137,6 +143,10 @@ type Collector struct { // maxKeys bounds the per-hour maps; rarely seen keys are dropped first. const maxKeys = 200_000 +// maxPending bounds the answers waiting for the query log between flushes +// (a minute); past it they are counted but not logged. +const maxPending = 500_000 + // NewCollector creates a collector. Run starts it. func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector { if opts.Location == nil { @@ -154,6 +164,9 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) c.status.Retention.Minute = humanDuration(opts.RetainMinute) c.status.Retention.Hour = humanDuration(opts.RetainHour) c.status.Retention.Day = humanDuration(opts.RetainDay) + if opts.QueryLog && opts.RetainQueries > 0 { + c.status.Retention.Queries = humanDuration(opts.RetainQueries) + } return c } @@ -362,7 +375,8 @@ func (c *Collector) handleLine(line string) { } return } - blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name] + blockedBy := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name] + blocked := blockedBy != "" if blocked { delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name) } @@ -405,6 +419,9 @@ func (c *Collector) handleLine(line string) { } add(KindName, ev.Name) add(KindClient, client) + if c.opts.RetainQueries > 0 && len(c.pending) < maxPending { + c.pending = append(c.pending, QueryRow{Time: ev.Time.Unix(), Client: client, View: view, Name: ev.Name, Type: ev.Type, RCode: ev.RCode, Blocked: blockedBy}) + } switch { case blocked: // Counted by handleRewrite; not a name that doesn't exist. @@ -445,9 +462,9 @@ func (c *Collector) handleRewrite(rw Rewrite) { if c.rewritten == nil || len(c.rewritten) > 10_000 { // Rewrites whose response line never came (named only logs // responses while response logging is on) don't pile up. - c.rewritten = map[string]bool{} + c.rewritten = map[string]string{} } - c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true + c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = policySource(rw.Via) minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time) view := rw.View if view == "" { @@ -639,6 +656,8 @@ func (c *Collector) flush(ctx context.Context, final bool) { pos, _ := json.Marshal(c.tail.Position()) b.SetMeta("tail", string(pos)) } + b.AddQueries(c.pending) + c.pending = nil c.updateWarnings() c.mu.Unlock() @@ -737,6 +756,17 @@ func (c *Collector) prune(ctx context.Context) { c.log.Debug("statistics: pruned", "resolution", res, "rows", n) } } + // The query log keeps answers for RetainQueries; with no query log, + // none at all (it may have been turned off). + keep := c.opts.RetainQueries + if !c.opts.QueryLog { + keep = 0 + } + if n, err := c.store.PruneQueries(ctx, now-int64(keep.Seconds())); err != nil { + c.log.Warn("statistics: pruning the query log failed", "err", err) + } else if n > 0 { + c.log.Debug("statistics: pruned the query log", "rows", n) + } } func humanDuration(d time.Duration) string { diff --git a/internal/stats/explore.go b/internal/stats/explore.go new file mode 100644 index 0000000..111f382 --- /dev/null +++ b/internal/stats/explore.go @@ -0,0 +1,199 @@ +package stats + +import ( + "context" + "fmt" + "strings" + "time" +) + +// Exploring the query log (docs/stats.md#exploring): every answer of the +// last days (stats.query_retention), filtered by any mix of client, name, +// view, type, result and blocking, broken down by each of them. + +// ExploreFilter narrows the query log. Empty fields don't filter. +type ExploreFilter struct { + Client string // a client key, as the top lists show it + Name string // exactly this name + Domain string // this name or any below it + View string + Type string + RCode string + // Blocked is "yes" (blocked by anything), "no", or a blocklist's name + // ("rules" for your own rules). + Blocked string + Since time.Duration // how far back (default 24h) +} + +// Dimensions are what the query log breaks down by, in the order shown. +var Dimensions = []string{"client", "name", "view", "type", "rcode", "blocked"} + +// Exploration is what the query log says for a filter. +type Exploration struct { + From time.Time `json:"from"` + To time.Time `json:"to"` + Total int64 `json:"total"` + // Blocked counts the blocked answers among them. + Blocked int64 `json:"blocked"` + // Series are the answers (and blocked ones) per Step seconds. + Step int64 `json:"step"` + Series []ExplorePoint `json:"series"` + // By are the top values of each dimension the filter leaves open. + By map[string][]Entry `json:"by"` + // Recent are the latest answers. + Recent []QueryRow `json:"recent"` + // Oldest is the first answer the query log still has. + Oldest *time.Time `json:"oldest,omitempty"` + // Retention is how long it keeps them. + Retention string `json:"retention"` +} + +// ExplorePoint is one step of the series. +type ExplorePoint struct { + Time int64 `json:"t"` + Count int64 `json:"count"` + Blocked int64 `json:"blocked"` +} + +// ErrNoQueryLog is returned when the query log is off. +var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)") + +// where builds the SQL condition and arguments for a filter. +func (q *Querier) where(f ExploreFilter, from int64) (string, []any) { + conds := []string{"ts >= ?"} + args := []any{from} + eq := func(col, v string) { + if v != "" { + conds = append(conds, col+" = ?") + args = append(args, v) + } + } + eq("client", f.Client) + eq("name", strings.ToLower(strings.TrimSuffix(f.Name, "."))) + eq("view", f.View) + eq("type", strings.ToUpper(f.Type)) + eq("rcode", strings.ToUpper(f.RCode)) + if d := strings.ToLower(strings.TrimSuffix(f.Domain, ".")); d != "" { + conds = append(conds, "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))") + args = append(args, d, d, d, "."+d) + } + switch f.Blocked { + case "": + case "yes": + conds = append(conds, "blocked != ''") + case "no": + conds = append(conds, "blocked = ''") + default: + eq("blocked", f.Blocked) + } + if len(q.Views) > 0 { + marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",") + conds = append(conds, "view IN ("+marks+")") + for _, v := range q.Views { + args = append(args, v) + } + } + return strings.Join(conds, " AND "), args +} + +// Explore answers a filter: totals, a series, the top values of each open +// dimension (topN each) and the latest answers (recent of them). +func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int) (*Exploration, error) { + if !q.Opts.QueryLog || q.Opts.RetainQueries <= 0 { + return nil, ErrNoQueryLog + } + now := q.Now() + since := f.Since + if since <= 0 { + since = 24 * time.Hour + } + if since > q.Opts.RetainQueries { + since = q.Opts.RetainQueries + } + from := now.Add(-since).Unix() + out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{}, + Retention: humanDuration(q.Opts.RetainQueries)} + cond, args := q.where(f, from) + db := q.Store.db + + if err := db.QueryRowContext(ctx, `SELECT count(*), coalesce(sum(blocked != ''), 0) FROM queries WHERE `+cond, args...).Scan(&out.Total, &out.Blocked); err != nil { + return nil, err + } + // The series: about 100 steps over the range, in whole minutes. + step := int64(since.Seconds()) / 100 + step = max(60, step-step%60) + out.Step = step + rows, err := db.QueryContext(ctx, `SELECT ts - ts % ? AS t, count(*), sum(blocked != '') FROM queries WHERE `+cond+` GROUP BY t ORDER BY t`, append([]any{step}, args...)...) + if err != nil { + return nil, err + } + got := map[int64]ExplorePoint{} + for rows.Next() { + var p ExplorePoint + if err := rows.Scan(&p.Time, &p.Count, &p.Blocked); err != nil { + rows.Close() + return nil, err + } + got[p.Time] = p + } + rows.Close() + for t := from - from%step; t <= now.Unix(); t += step { + p := got[t] + p.Time = t + out.Series = append(out.Series, p) + } + + open := map[string]bool{"client": f.Client == "", "name": f.Name == "", "view": f.View == "", "type": f.Type == "", "rcode": f.RCode == "", "blocked": f.Blocked == "" || f.Blocked == "yes"} + for _, dim := range Dimensions { + if !open[dim] { + continue + } + c := cond + if dim == "blocked" { + c += " AND blocked != ''" + } + rows, err := db.QueryContext(ctx, `SELECT `+dim+`, count(*) AS n FROM queries WHERE `+c+` GROUP BY `+dim+` ORDER BY n DESC, `+dim+` LIMIT ?`, append(args, topN)...) + if err != nil { + return nil, err + } + es := []Entry{} + for rows.Next() { + var e Entry + if err := rows.Scan(&e.Key, &e.Count); err != nil { + rows.Close() + return nil, err + } + es = append(es, e) + } + rows.Close() + out.By[dim] = es + } + + rows, err = db.QueryContext(ctx, `SELECT ts, client, view, name, type, rcode, blocked FROM queries WHERE `+cond+` ORDER BY ts DESC, rowid DESC LIMIT ?`, append(args, recent)...) + if err != nil { + return nil, err + } + for rows.Next() { + var r QueryRow + if err := rows.Scan(&r.Time, &r.Client, &r.View, &r.Name, &r.Type, &r.RCode, &r.Blocked); err != nil { + rows.Close() + return nil, err + } + out.Recent = append(out.Recent, r) + } + rows.Close() + + var oldest *int64 + if err := db.QueryRowContext(ctx, `SELECT min(ts) FROM queries`).Scan(&oldest); err == nil && oldest != nil { + t := time.Unix(*oldest, 0) + out.Oldest = &t + } + return out, nil +} + +// QueryLogSize returns how many answers the query log has. +func (s *Store) QueryLogSize(ctx context.Context) (int64, error) { + var n int64 + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM queries`).Scan(&n) + return n, err +} diff --git a/internal/stats/explore_test.go b/internal/stats/explore_test.go new file mode 100644 index 0000000..ba91fcc --- /dev/null +++ b/internal/stats/explore_test.go @@ -0,0 +1,98 @@ +package stats + +import ( + "context" + "fmt" + "io" + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestExplore(t *testing.T) { + ctx := context.Background() + loc := time.UTC + store, err := OpenStore(filepath.Join(t.TempDir(), "stats.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + clk := &clock{time.Date(2026, 10, 13, 12, 0, 30, 0, loc)} + opts := Options{QueryLog: true, LogFile: "unused", StatisticsChannel: "unused", TopN: 10, RetainMinute: time.Hour, RetainHour: time.Hour, RetainDay: time.Hour, + RetainQueries: 7 * 24 * time.Hour, Location: loc} + c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil))) + c.now = clk.now + at := clk.t.Add(-10 * time.Minute) + // The phone: lookups, an NXDOMAIN, a blocked tracker. + c.HandleLine(logLine(at, "10.0.0.5", "internal", "www.adbraun.lol", "A", "NOERROR")) + c.HandleLine(logLine(at, "10.0.0.5", "internal", "nas.adbraun.lol", "AAAA", "NOERROR")) + c.HandleLine(logLine(at, "10.0.0.5", "internal", "typo.adbraun.lol", "A", "NXDOMAIN")) + c.HandleLine(fmt.Sprintf("%s client @0x7f0000000000 10.0.0.5#5353 (t.tracker.example): view internal: rpz QNAME NXDOMAIN rewrite t.tracker.example/A/IN via t.tracker.example.hagezi.blocklist.jarl.invalid\n", at.Format(logTimeLayout))) + c.HandleLine(logLine(at, "10.0.0.5", "internal", "t.tracker.example", "A", "NXDOMAIN")) + // A scanner in the external view, NXDOMAIN twice. + for i := 0; i < 2; i++ { + c.HandleLine(logLine(at, "198.51.100.7", "external", "nope.adbraun.lol", "A", "NXDOMAIN")) + } + c.flush(ctx, true) + + q := &Querier{Store: store, Opts: opts, Now: clk.now} + ex, err := q.Explore(ctx, ExploreFilter{View: "external", RCode: "NXDOMAIN"}, 10, 50) + if err != nil { + t.Fatal(err) + } + if ex.Total != 2 || len(ex.By["client"]) != 1 || ex.By["client"][0].Key != "198.51.100.7" || ex.By["view"] != nil { + t.Errorf("external NXDOMAIN: %+v", ex) + } + // What else does the phone do? + ex, err = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5"}, 10, 50) + if err != nil { + t.Fatal(err) + } + if ex.Total != 4 || ex.Blocked != 1 || len(ex.Recent) != 4 { + t.Errorf("phone: total %d blocked %d recent %d", ex.Total, ex.Blocked, len(ex.Recent)) + } + if b := ex.By["blocked"]; len(b) != 1 || b[0].Key != "hagezi" || b[0].Count != 1 { + t.Errorf("blocked by %+v", b) + } + if r := ex.By["rcode"]; len(r) != 2 || r[0].Key != "NOERROR" || r[0].Count != 2 { + t.Errorf("rcode %+v", r) + } + var sum int64 + for _, p := range ex.Series { + sum += p.Count + } + if sum != 4 || ex.Step != 60*int64(24*60/100) { + t.Errorf("series sum %d step %d", sum, ex.Step) + } + // A domain and what's below it. + ex, _ = q.Explore(ctx, ExploreFilter{Domain: "adbraun.lol"}, 10, 50) + if ex.Total != 5 { + t.Errorf("domain adbraun.lol: %d", ex.Total) + } + ex, _ = q.Explore(ctx, ExploreFilter{Name: "nas.adbraun.lol", Type: "aaaa"}, 10, 50) + if ex.Total != 1 || ex.Recent[0].Client != "10.0.0.5" { + t.Errorf("nas AAAA: %+v", ex) + } + ex, _ = q.Explore(ctx, ExploreFilter{Blocked: "no", Client: "10.0.0.5"}, 10, 50) + if ex.Total != 3 { + t.Errorf("not blocked: %d", ex.Total) + } + // Limited to a view (the Statistics page's view switches). + ex, _ = q.ForViews([]string{"internal"}).Explore(ctx, ExploreFilter{RCode: "NXDOMAIN"}, 10, 50) + if ex.Total != 2 { + t.Errorf("internal NXDOMAIN: %d", ex.Total) + } + + // Pruning: past the retention they go. + clk.t = clk.t.Add(8 * 24 * time.Hour) + c.prune(ctx) + if n, _ := store.QueryLogSize(ctx); n != 0 { + t.Errorf("after pruning: %d rows", n) + } + // Off: an error that says how to turn it on. + q.Opts.RetainQueries = 0 + if _, err := q.Explore(ctx, ExploreFilter{}, 10, 50); err != ErrNoQueryLog { + t.Errorf("off: %v", err) + } +} diff --git a/internal/stats/store.go b/internal/stats/store.go index 96227aa..2e2d6df 100644 --- a/internal/stats/store.go +++ b/internal/stats/store.go @@ -45,6 +45,21 @@ var schema = []string{ PRIMARY KEY (res, ts, kind, key) ) WITHOUT ROWID; CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`, + // 2: the query log, every answer for a while (stats.query_retention), + // for drilling down: what a client asked, who asked for a name + // (docs/stats.md#exploring). + `CREATE TABLE queries ( + ts INTEGER NOT NULL, + client TEXT NOT NULL, + view TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + rcode TEXT NOT NULL, + blocked TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX queries_ts ON queries (ts); + CREATE INDEX queries_client ON queries (client, ts); + CREATE INDEX queries_name ON queries (name, ts);`, } // OpenStore opens (creating if needed) the statistics database. Use @@ -123,8 +138,25 @@ type Batch struct { counters []counterRow tops []topsWrite meta map[string]string + queries []QueryRow } +// QueryRow is one answer in the query log. +type QueryRow struct { + Time int64 `json:"ts"` // Unix seconds + Client string `json:"client"` + View string `json:"view"` // "_default" without views + Name string `json:"name"` + Type string `json:"type"` + RCode string `json:"rcode"` + Blocked string `json:"blocked,omitempty"` // the blocklist (or "rules") that blocked it + // ClientName is the client's host name (set by the API). + ClientName string `json:"client_name,omitempty"` +} + +// AddQueries adds answers to the query log. +func (b *Batch) AddQueries(rows []QueryRow) { b.queries = append(b.queries, rows...) } + type counterRow struct { res string ts int64 @@ -220,9 +252,39 @@ func (s *Store) Commit(ctx context.Context, b *Batch) error { return err } } + if len(b.queries) > 0 { + st, err := tx.PrepareContext(ctx, `INSERT INTO queries (ts, client, view, name, type, rcode, blocked) VALUES (?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return err + } + for _, q := range b.queries { + if _, err := st.ExecContext(ctx, q.Time, q.Client, q.View, q.Name, q.Type, q.RCode, q.Blocked); err != nil { + st.Close() + return err + } + } + st.Close() + } return tx.Commit() } +// PruneQueries deletes the query log's answers older than before (Unix +// time), a slice at a time so the database isn't locked for long. +func (s *Store) PruneQueries(ctx context.Context, before int64) (int64, error) { + var n int64 + for { + r, err := s.db.ExecContext(ctx, `DELETE FROM queries WHERE rowid IN (SELECT rowid FROM queries WHERE ts < ? LIMIT 50000)`, before) + if err != nil { + return n, err + } + c, _ := r.RowsAffected() + n += c + if c < 50000 { + return n, nil + } + } +} + // Meta returns a stored value ("" if unset). func (s *Store) Meta(ctx context.Context, key string) (string, error) { var v string diff --git a/internal/store/store.go b/internal/store/store.go index 13dc921..66219da 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -29,6 +29,15 @@ type Store struct { // DB exposes the underlying database (for backups and tests). func (s *Store) DB() *sql.DB { return s.db } +// Path returns the database file ("" in memory). +func (s *Store) Path() string { + var file string + if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil { + return "" + } + return file +} + // Open opens (creating if needed) the database at path and applies // migrations. Use ":memory:" for a throwaway in-memory database. func Open(path string) (*Store, error) { diff --git a/internal/web/static/assets/app.js b/internal/web/static/assets/app.js index dcd8923..7f6a251 100644 --- a/internal/web/static/assets/app.js +++ b/internal/web/static/assets/app.js @@ -17,6 +17,7 @@ import * as users from "./pages/users.js"; import * as account from "./pages/account.js"; import * as search from "./pages/search.js"; import * as stats from "./pages/stats.js"; +import * as explore from "./pages/explore.js"; import * as blocking from "./pages/blocking.js"; import * as exposure from "./pages/exposure.js"; import * as certificates from "./pages/certificates.js"; @@ -39,6 +40,7 @@ const routes = [ { pattern: /^account$/, page: account, nav: "account" }, { pattern: /^search$/, page: search, nav: "" }, { pattern: /^stats$/, page: stats, nav: "stats" }, + { pattern: /^stats\/explore$/, page: explore, nav: "stats" }, { pattern: /^blocking$/, page: blocking, nav: "blocking" }, { pattern: /^exposure$/, page: exposure, nav: "exposure" }, { pattern: /^certificates$/, page: certificates, nav: "certificates" }, diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index ac4a10d..84092f3 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -18,6 +18,7 @@ export async function render(el) { st.backup ? backupCard(st.backup) : null, exposure ? exposureCard(exposure) : null, st.certificates ? certCard(st.certificates) : null, + st.storage ? storageCard(st.storage) : null, st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"), h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")), h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null, @@ -100,6 +101,7 @@ export async function render(el) { h("h2", null, "Configuration checks"), real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."), problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null)); + if (st.storage) parts.push(storagePanel(st.storage)); if (s.paths) { parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" }, h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"), @@ -140,3 +142,44 @@ function certCard(c) { function card(label, value, sub) { return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null); } + +// storageCard: the databases' size, and the fullest disk Jarl uses. +function storageCard(sg) { + const fullest = sg.disks.reduce((a, d) => (!a || used(d) > used(a) ? d : a), null); + const value = h("span", null, bytes(sg.total)); + const sub = fullest ? [h("span", { class: used(fullest) > 0.9 ? "warn-text" : "" }, Math.round(used(fullest) * 100) + "% full"), " · " + fullest.path] : "databases"; + return h("a", { class: "card link", href: "#storage" , onclick: (e) => { e.preventDefault(); document.getElementById("storage")?.scrollIntoView({ behavior: "smooth" }); } }, + h("div", { class: "label" }, "Databases"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub)); +} + +// storagePanel shows each database's size and, per disk Jarl uses, a bar: +// the databases, everything else, and what is free. +function storagePanel(sg) { + const dbRows = sg.databases.map((d) => [h("dt", null, d.label), + h("dd", null, h("strong", null, bytes(d.bytes)), " ", h("span", { class: "mono small muted" }, d.path), + d.query_log_rows != null ? h("div", { class: "small muted" }, "the query log holds " + d.query_log_rows.toLocaleString() + " answers (stats.retention.queries)") : null)]); + return h("div", { class: "panel", id: "storage" }, + h("h2", null, "Storage"), + h("dl", { class: "kv" }, dbRows, h("dt", null, "Together"), h("dd", null, h("strong", null, bytes(sg.total)))), + sg.disks.map((d) => { + const other = Math.max(0, d.total - d.free - d.databases); + const pctOf = (n) => (d.total ? (n / d.total) * 100 : 0) + "%"; + const u = used(d); + return h("div", { class: "disk" }, + h("div", { class: "disk-head" }, h("span", { class: "mono" }, d.path), h("span", { class: "muted small" }, " · " + d.holds.join(", ")), + h("span", { class: "grow" }), h("span", { class: u > 0.9 ? "warn-text" : "muted" }, bytes(d.free) + " free of " + bytes(d.total) + " (" + Math.round(u * 100) + "% used)")), + h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used, Jarl's databases " + bytes(d.databases) }, + d.databases ? h("div", { class: "seg db", title: "Jarl's databases: " + bytes(d.databases), style: { width: pctOf(d.databases) } }) : null, + h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), title: "Everything else: " + bytes(other), style: { width: pctOf(other) } })), + d.databases ? h("div", { class: "small muted" }, h("span", { class: "swatch db" }), " Jarl's databases " + bytes(d.databases), " ", h("span", { class: "swatch other" }), " everything else") : null); + })); +} + +function used(d) { return d.total ? (d.total - d.free) / d.total : 0; } + +function bytes(n) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + " " + units[i]; +} diff --git a/internal/web/static/assets/pages/explore.js b/internal/web/static/assets/pages/explore.js new file mode 100644 index 0000000..8bad809 --- /dev/null +++ b/internal/web/static/assets/pages/explore.js @@ -0,0 +1,156 @@ +// Exploring the query log (docs/stats.md#exploring): the answers of the +// last days, filtered by any mix of client, name, view, record type, result +// and blocking. Every value is a link that adds it to the filter, so a +// client leads to its names, a name to the clients asking for it, and so on. +import { h, mount, fmtTime, errorBox, badge, backLink } from "../ui.js"; +import { get } from "../api.js"; +import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js"; +import { viewsOff } from "./stats.js"; + +const dims = [ + ["client", "Clients"], ["name", "Names"], ["view", "Views"], + ["type", "Record types"], ["rcode", "Results"], ["blocked", "Blocked by"], +]; +// What a filter is called, in the chips and the title. +const filterWords = { client: "client", name: "name", domain: "domain", view: "view", type: "type", rcode: "result", blocked: "blocked" }; +const keys = Object.keys(filterWords); +const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]]; + +// exploreHref is the explore page for these filters (and range). +export function exploreHref(filters, since) { + const q = new URLSearchParams(); + for (const k of keys) if (filters[k]) q.set(k, filters[k]); + if (since && since !== "24h") q.set("since", since); + return "#/stats/explore" + (q.toString() ? "?" + q : ""); +} + +const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k); + +export async function render(el, params, query) { + const f = {}; + for (const k of keys) if (query.get(k)) f[k] = query.get(k); + const since = query.get("since") || "24h"; + const with_ = (more) => exploreHref({ ...f, ...more }, since); + const without = (k) => { const g = { ...f }; delete g[k]; return exploreHref(g, since); }; + + // The Statistics page's view switches apply here too. + const off = viewsOff(); + let views = ""; + if (off.size) { + const all = (await get("/views").catch(() => [])).map((v) => v.name); + views = all.filter((v) => !off.has(v)).join(","); + } + let ex; + try { + ex = await get("/stats/explore", { ...f, since, views: views || undefined }); + } catch (e) { + mount(el, head(f, without, since, null), e.code === "query_log_off" + ? h("div", { class: "msg info" }, e.message) : errorBox(e)); + return; + } + + const parts = [head(f, without, since, ex)]; + const maxSecs = retentionSecs(ex.retention); + parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" }, + h("span", { class: "muted" }, "Range"), + h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) => + h("a", { href: exploreHref(f, k), class: k === since ? "active" : "" }, k))), + h("span", { class: "muted small" }, " · the query log keeps " + ex.retention + (ex.oldest ? ", since " + fmtTime(ex.oldest) : "")), + off.size ? h("span", { class: "muted small" }, " · views switched off on the Statistics page are left out") : null)); + + const nx = (ex.by.rcode || []).find((e) => e.key === "NXDOMAIN"); + parts.push(h("div", { class: "cards" }, + tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)), + tile("Blocked", compact(ex.blocked), ex.total ? pct(ex.blocked / ex.total) + " of them" : ""), + nx && !f.rcode ? tile("NXDOMAIN", compact(nx.count), pct(nx.count / Math.max(1, ex.total)) + " of them") : null, + !f.client && ex.by.client ? tile("Clients", (ex.by.client.length >= 15 ? "15+" : String(ex.by.client.length)), "asking") : null)); + + const times = ex.series.map((p) => p.t); + const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }]; + if (ex.blocked) series.push({ label: "Blocked", values: ex.series.map((p) => p.blocked), slot: 2 }); + parts.push(chartCard(ex.blocked ? "Answers and blocked" : "Answers", "per " + stepWord(ex.step), + ex.total ? lineChart({ series, times, step: ex.step, area: true }) : h("div", { class: "empty" }, "No answers match."), + ex.total ? timeTable(series, times, ex.step) : null, { wide: true, legend: series.length > 1 ? legend(series) : null })); + + // A breakdown for each dimension the filter leaves open: click a value + // to add it. + const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => { + const href = (k) => with_(d === "blocked" ? { blocked: k } : { [d]: k }); + const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k; + return h("div", { class: "card chart-card" }, h("h3", null, title), + barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, href, label, empty: d === "blocked" ? "Nothing blocked." : "None." })); + }); + if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists)); + + parts.push(recentPanel(ex, with_)); + mount(el, parts); +} + +// head is the title and the filter chips, each with a ✕ to take it out. +function head(f, without, since, ex) { + const chips = keys.filter((k) => f[k]).map((k) => h("span", { class: "chip filter-chip" }, + h("span", { class: "muted" }, filterWords[k] + " "), + h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(f[k]) : f[k]), + k === "client" && ex && ex.client_name ? h("span", { class: "muted" }, " " + ex.client_name) : null, + " ", h("a", { href: without(k), title: "Take it out", class: "chip-x", "aria-label": "Remove the " + filterWords[k] + " filter" }, "✕"))); + const extra = []; + if (f.name && !f.domain) extra.push(h("a", { href: exploreHref({ ...f, name: undefined, domain: f.name }, since) }, "this name and the names below it")); + if ((f.name || f.domain) && (f.blocked === "yes" || (ex && ex.blocked))) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(f.name || f.domain) }, "why is it blocked?")); + if (f.name || f.domain) extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(f.name || f.domain) }, "find it in your zones")); + return h("div", { class: "page-head" }, + h("div", { class: "grow" }, + backLink("Statistics", "#/stats"), + h("h1", null, title(f, ex)), + h("div", { class: "muted" }, "Every answer in the query log that matches. Click a client, name, view, type or result to narrow it down; ✕ takes a filter out."), + chips.length ? h("div", { class: "chips filter-chips" }, chips, + h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null, + extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null)); +} + +function title(f, ex) { + const n = Object.keys(f).length; + if (!n) return "Explore the answers"; + const who = f.client ? (ex && ex.client_name ? ex.client_name : f.client) : ""; + if (f.client && n === 1) return "Client " + who; + const what = [f.rcode, f.type, f.blocked ? "blocked" + (f.blocked !== "yes" ? " by " + blockedLabel(f.blocked) : "") : "", + f.name || (f.domain ? f.domain + " and below" : "")].filter(Boolean).join(" "); + const parts = [what || "Answers"]; + if (f.client) parts.push("for " + who); + if (f.view) parts.push("in view " + f.view); + return parts.join(" "); +} + +// recentPanel lists the latest answers, every cell a link. +function recentPanel(ex, with_) { + if (!ex.recent.length) return null; + const a = (text, more, cls) => h("a", { href: with_(more), class: cls || "" }, text); + return h("div", { class: "panel" }, h("h2", null, "Latest answers"), + h("div", { class: "table-wrap" }, h("table", { class: "recent-queries" }, + h("thead", null, h("tr", null, ["Time", "Client", "View", "Name", "Type", "Result"].map((t) => h("th", null, t)))), + h("tbody", null, ex.recent.map((r) => h("tr", null, + h("td", { class: "small muted nowrap" }, fmtTime(new Date(r.ts * 1000).toISOString())), + h("td", { class: "nowrap" }, a(r.client_name || r.client, { client: r.client }, "mono"), r.client_name ? h("span", { class: "muted small" }, " " + r.client) : null), + h("td", null, a(r.view === "_default" ? "–" : r.view, { view: r.view })), + h("td", { class: "oneline-cell" }, a(r.name, { name: r.name }, "mono")), + h("td", null, a(r.type, { type: r.type })), + h("td", null, r.blocked ? a(badge("blocked: " + blockedLabel(r.blocked), "warn"), { blocked: r.blocked }) : a(r.rcode, { rcode: r.rcode }, r.rcode === "NOERROR" ? "" : "warn-text"))))))), + ex.recent.length >= 100 ? h("div", { class: "muted small" }, "The latest 100: narrow the filter to see others.") : null); +} + +function tile(label, value, sub) { + return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub)); +} + +function pct(r) { return (r * 100).toFixed(r < 0.1 ? 1 : 0) + "%"; } + +function stepWord(s) { + if (s % 86400 === 0) return s === 86400 ? "day" : s / 86400 + " days"; + if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours"; + return s === 60 ? "minute" : s / 60 + " minutes"; +} + +function retentionSecs(r) { + const m = /^(\d+)([dhm])$/.exec(r || ""); + if (!m) return 7 * 86400; + return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]]; +} diff --git a/internal/web/static/assets/pages/stats.js b/internal/web/static/assets/pages/stats.js index 634e29f..74aad01 100644 --- a/internal/web/static/assets/pages/stats.js +++ b/internal/web/static/assets/pages/stats.js @@ -2,6 +2,7 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js"; import { get } from "../api.js"; import { rerender } from "../app.js"; import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js"; +import { exploreHref } from "./explore.js"; const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]]; const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 }; @@ -21,7 +22,7 @@ let generation = 0; // The views shown are remembered per browser: the ones switched off, so a // new view shows up switched on. const offKey = "jarl.stats.views.off"; -function viewsOff() { +export function viewsOff() { try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); } } function saveViewsOff(set) { @@ -114,17 +115,20 @@ export async function render(el, params, query) { parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1], heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true })); - // Who asks for what. + // Who asks for what. With the query log, every value leads to the + // explore page: a client to what it asked, a name to who asked. + const ql = !!(status.retention || {}).queries; + const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null); if (status.query_log) { const t = data.tops; parts.push(h("div", { class: "chart-grid" }, - listCard("Most queried names", "", barList(t.name, { href: (k) => "#/search?q=" + encodeURIComponent(k) })), - listCard("Busiest clients", "", barList(t.client)), - listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })), - listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })), - t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail)) : null, - t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client)) : null, - t.blocked.length ? listCard("Most blocked names", "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: (k) => "#/blocking?check=" + encodeURIComponent(k) })) : null)); + listCard("Most queried names", ql ? "click one to see who asks for it" : "", barList(t.name, { href: ex((k) => ({ name: k })) || ((k) => "#/search?q=" + encodeURIComponent(k)) })), + listCard("Busiest clients", ql ? "click one to see what it asks" : "", barList(t.client, { href: ex((k) => ({ client: k })) })), + listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ name: k, rcode: "NXDOMAIN" })) })), + listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ client: k, rcode: "REFUSED" })) })), + t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail, { href: ex((k) => ({ name: k, rcode: "SERVFAIL" })) })) : null, + t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client, { href: ex((k) => ({ client: k, blocked: "yes" })) })) : null, + t.blocked.length ? listCard("Most blocked names", ql ? "click a name to see who asks for it, and why it is blocked" : "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: ex((k) => ({ name: k, blocked: "yes" })) || ((k) => "#/blocking?check=" + encodeURIComponent(k)) })) : null)); } else { parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl.")); } @@ -133,13 +137,13 @@ export async function render(el, params, query) { const b = data.breakdowns; const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean); parts.push(h("div", { class: "chart-grid" }, - listCard("Record types", "", barList(b.qtype, { total: s.queries })), - listCard("Result codes", "", barList(b.rcode, { total: s.queries })), - b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel })) : null, + listCard("Record types", "", barList(b.qtype, { total: s.queries, href: ex((k) => ({ type: k })) })), + listCard("Result codes", ql ? "click one to see who gets it, for what" : "", barList(b.rcode, { total: s.queries, href: ex((k) => ({ rcode: k })) })), + b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel, href: ex((k) => ({ view: k })) })) : null, b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null, b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null, rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null, - b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null)); + b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: ex((k) => ({ blocked: k })) || (() => "#/blocking") })) : null)); parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel)); el.classList.remove("refreshing"); @@ -302,3 +306,9 @@ function disabledPanel() { h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " + "(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md.")); } + +// exploreSince is the Statistics range for the explore page: the query log +// keeps days, not months. +function exploreSince(since) { + return rangeSecs[since] > 7 * 86400 ? "7d" : since; +} diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 271047e..60f3449 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -595,3 +595,21 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; } td.addr-views { width: 100%; } .view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; } .view-chips label.chip { white-space: nowrap; font-size: 0.9em; } + +/* Explore: the filter chips with a ✕ each, and the latest answers. */ +.filter-chips { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } +.chip.filter-chip { display: inline-flex; gap: 2px; align-items: center; padding: 2px 6px 2px 10px; border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); font-size: 13px; } +.chip.filter-chip a.chip-x { text-decoration: none; color: var(--muted); padding: 0 4px; } +.chip.filter-chip a.chip-x:hover { color: var(--err, #c00); } +table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Dashboard storage: a bar per disk. */ +.disk { margin-top: 14px; } +.disk-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; margin-bottom: 4px; } +.disk-head .grow { flex: 1; } +.disk-bar { display: flex; height: 10px; border-radius: 5px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); } +.disk-bar .seg.db { background: var(--accent); } +.disk-bar .seg.other { background: var(--muted); opacity: 0.45; } +.disk-bar .seg.other.full { background: var(--warn); opacity: 0.8; } +.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; } +.swatch.db { background: var(--accent); } +.swatch.other { background: var(--muted); opacity: 0.45; }