Explore: several values per filter, !value to leave one out, *.domain
Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8c7760fc8
commit
e94d3634b6
8 files changed
+246
-86
No files matched your search
+97
-25
@@ -11,7 +11,10 @@ import (
|
||||
// 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.
|
||||
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||
// field may hold several values, comma separated: any of them; a value
|
||||
// written !VALUE excludes it. A name *.example.com is it and the names
|
||||
// below it.
|
||||
type ExploreFilter struct {
|
||||
Client string // a client key, as the top lists show it
|
||||
Name string // exactly this name
|
||||
@@ -58,33 +61,96 @@ type ExplorePoint struct {
|
||||
// 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.
|
||||
// values splits a filter field into the values it must be one of and the
|
||||
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
|
||||
func values(field string, norm func(string) string) (in, out []string) {
|
||||
for _, v := range strings.Split(field, ",") {
|
||||
v = strings.TrimSpace(v)
|
||||
not := strings.HasPrefix(v, "!")
|
||||
v = norm(strings.TrimSpace(strings.TrimPrefix(v, "!")))
|
||||
switch {
|
||||
case v == "":
|
||||
case not:
|
||||
out = append(out, v)
|
||||
default:
|
||||
in = append(in, v)
|
||||
}
|
||||
}
|
||||
return in, out
|
||||
}
|
||||
|
||||
// nameValues are the name filter's values: Name's, and Domain's as
|
||||
// "*.DOMAIN" (the name and the names below it).
|
||||
func (f ExploreFilter) nameValues() (in, out []string) {
|
||||
norm := func(v string) string { return strings.ToLower(strings.TrimSuffix(v, ".")) }
|
||||
in, out = values(f.Name, norm)
|
||||
din, dout := values(f.Domain, norm)
|
||||
for _, d := range din {
|
||||
in = append(in, "*."+strings.TrimPrefix(d, "*."))
|
||||
}
|
||||
for _, d := range dout {
|
||||
out = append(out, "*."+strings.TrimPrefix(d, "*."))
|
||||
}
|
||||
return in, out
|
||||
}
|
||||
|
||||
// fieldValues returns a dimension's filter values.
|
||||
func (f ExploreFilter) fieldValues(dim string) (in, out []string) {
|
||||
same := func(v string) string { return v }
|
||||
upper := strings.ToUpper
|
||||
switch dim {
|
||||
case "client":
|
||||
return values(f.Client, same)
|
||||
case "name":
|
||||
return f.nameValues()
|
||||
case "view":
|
||||
return values(f.View, same)
|
||||
case "type":
|
||||
return values(f.Type, upper)
|
||||
case "rcode":
|
||||
return values(f.RCode, upper)
|
||||
case "blocked":
|
||||
return values(f.Blocked, same)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// cond is the SQL condition for one value of a dimension.
|
||||
func cond(dim, v string) (string, []any) {
|
||||
switch {
|
||||
case dim == "name" && strings.HasPrefix(v, "*."):
|
||||
d := strings.TrimPrefix(v, "*.")
|
||||
return "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))", []any{d, d, d, "." + d}
|
||||
case dim == "blocked" && v == "yes":
|
||||
return "blocked != ''", nil
|
||||
case dim == "blocked" && v == "no":
|
||||
return "blocked = ''", nil
|
||||
}
|
||||
return dim + " = ?", []any{v}
|
||||
}
|
||||
|
||||
// where builds the SQL condition and arguments for a filter: within a
|
||||
// dimension any of its values, and none of its !values; the dimensions
|
||||
// together.
|
||||
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)
|
||||
for _, dim := range Dimensions {
|
||||
in, out := f.fieldValues(dim)
|
||||
if len(in) > 0 {
|
||||
var or []string
|
||||
for _, v := range in {
|
||||
c, a := cond(dim, v)
|
||||
or = append(or, c)
|
||||
args = append(args, a...)
|
||||
}
|
||||
conds = append(conds, "("+strings.Join(or, " OR ")+")")
|
||||
}
|
||||
for _, v := range out {
|
||||
c, a := cond(dim, v)
|
||||
conds = append(conds, "NOT "+c)
|
||||
args = append(args, a...)
|
||||
}
|
||||
}
|
||||
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)), ",")
|
||||
@@ -143,7 +209,13 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
|
||||
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"}
|
||||
// A dimension is broken down unless the filter pins it to one value
|
||||
// (several: how they compare; !values leave the rest to see).
|
||||
open := map[string]bool{}
|
||||
for _, dim := range Dimensions {
|
||||
in, _ := f.fieldValues(dim)
|
||||
open[dim] = len(in) != 1 || (dim == "blocked" && in[0] == "yes") || (dim == "name" && strings.HasPrefix(in[0], "*."))
|
||||
}
|
||||
for _, dim := range Dimensions {
|
||||
if !open[dim] {
|
||||
continue
|
||||
|
||||
@@ -84,6 +84,39 @@ func TestExplore(t *testing.T) {
|
||||
t.Errorf("internal NXDOMAIN: %d", ex.Total)
|
||||
}
|
||||
|
||||
// Several values: any of them; !values: none of them.
|
||||
total := func(f ExploreFilter) int64 {
|
||||
t.Helper()
|
||||
ex, err := q.Explore(ctx, f, 10, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ex.Total
|
||||
}
|
||||
for _, c := range []struct {
|
||||
f ExploreFilter
|
||||
want int64
|
||||
}{
|
||||
{ExploreFilter{Client: "10.0.0.5, 198.51.100.7"}, 6},
|
||||
{ExploreFilter{Client: "!10.0.0.5"}, 2},
|
||||
{ExploreFilter{RCode: "NXDOMAIN,noerror"}, 6},
|
||||
{ExploreFilter{Name: "*.adbraun.lol"}, 5},
|
||||
{ExploreFilter{Name: "!*.adbraun.lol"}, 1},
|
||||
{ExploreFilter{Name: "www.adbraun.lol,nas.adbraun.lol"}, 2},
|
||||
{ExploreFilter{Name: "*.adbraun.lol, !typo.adbraun.lol", Client: "!198.51.100.7"}, 2},
|
||||
{ExploreFilter{Blocked: "!hagezi"}, 5},
|
||||
{ExploreFilter{Domain: "adbraun.lol", Name: "!nope.adbraun.lol"}, 3},
|
||||
} {
|
||||
if got := total(c.f); got != c.want {
|
||||
t.Errorf("%+v: %d answers, want %d", c.f, got, c.want)
|
||||
}
|
||||
}
|
||||
// Two clients: their breakdown shows them side by side.
|
||||
ex, _ = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5,198.51.100.7"}, 10, 50)
|
||||
if len(ex.By["client"]) != 2 {
|
||||
t.Errorf("two clients' breakdown %+v", ex.By["client"])
|
||||
}
|
||||
|
||||
// Pruning: past the retention they go.
|
||||
clk.t = clk.t.Add(8 * 24 * time.Hour)
|
||||
c.prune(ctx)
|
||||
|
||||
Reference in new issue
Block a user