jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. 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 values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore. The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
3.6 KiB
Go
99 lines
3.6 KiB
Go
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)
|
|
}
|
|
}
|