Statistics per view: toggle views on the Statistics page

Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 19:29:33 +01:00
1 parent ead749dd3d
commit 12820b17bb
13 files changed
+526 -61

No files matched your search

+22 -8
View File
@@ -1,6 +1,7 @@
package server
import (
"errors"
"net/http"
"net/netip"
"path/filepath"
@@ -14,14 +15,24 @@ import (
"jarl/internal/stats"
)
// statsQuerier checks permission and that statistics are enabled.
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
// statsQuerier checks permission and that statistics are enabled. With
// ?views=internal,vps the figures are for those views only.
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
return nil, err
}
if s.statsQ == nil {
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
}
var views []string
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
if v = strings.TrimSpace(v); v != "" {
views = append(views, v)
}
}
if len(views) > 0 {
return s.statsQ.ForViews(views), nil
}
return s.statsQ, nil
}
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
}
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
return err
}
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
if errors.Is(err, stats.ErrNotPerView) {
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
}
if err != nil {
return badRequest("%v", err)
}
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
// limits (rndc status), and the space left on the file systems Jarl and
// named write to.
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if _, err := s.statsQuerier(p); err != nil {
if _, err := s.statsQuerier(r, p); err != nil {
return err
}
out := map[string]any{"server": s.cfg.Stats.Server()}
+14 -1
View File
@@ -35,7 +35,10 @@ func TestStatsAPI(t *testing.T) {
minute := now.Unix() - now.Unix()%60 - 120
hour := minute - minute%3600
b := &stats.Batch{}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
"answer:authoritative": 40}
b.AddCounters(stats.ResMinute, minute, vals)
b.AddCounters(stats.ResHour, hour, vals)
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
@@ -47,6 +50,16 @@ func TestStatsAPI(t *testing.T) {
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
// Some views only.
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
t.Errorf("external summary = %v", sum)
}
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
t.Errorf("rcodes of both views = %v", b)
}
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
t.Errorf("answers per view = %d %s", status, out)
}
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
t.Errorf("status = %v", st)
}