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:
adamandClaude Opus 5.5 committed 2026-10-13 19:10:00 +01:00
1 parent a8c7760fc8
commit e94d3634b6
8 files changed
+246 -86

No files matched your search

+3 -1
View File
@@ -69,7 +69,9 @@ All notable changes to Jarl are documented here. The format follows
views, types, results and blocklists among them (each a link that views, types, results and blocklists among them (each a link that
narrows it further), and the latest answers. "NXDOMAIN in external" → narrows it further), and the latest answers. "NXDOMAIN in external" →
the clients asking → everything one of them asks. A filter bar types the clients asking → everything one of them asks. A filter bar types
filters in, and **Explore the answers** opens the page from the filters in, several values each (any of them), `!value` to leave one
out and `*.example.com` for a domain and below; **hide** on a list entry
leaves it out. **Explore the answers** opens the page from the
Statistics page. `GET /stats/explore`. Statistics page. `GET /stats/explore`.
- **Storage** on the dashboard: the size of Jarl's databases (and how many - **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 answers the query log holds), and per disk Jarl uses a bar of how full it
+1 -1
View File
@@ -565,7 +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/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/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/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/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | 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}], database: {path, bytes, query_log_rows}}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) | | 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}], database: {path, bytes, query_log_rows}}`; 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}]` | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
+10 -3
View File
@@ -215,9 +215,16 @@ which shows, for what you clicked:
So "external, NXDOMAIN" leads to the clients asking, a client to everything So "external, NXDOMAIN" leads to the clients asking, a client to everything
it asks, one of its names to who else asks for it. The page also opens it asks, one of its names to who else asks for it. The page also opens
from **Explore the answers** at the top of the Statistics page, and has a from **Explore the answers** at the top of the Statistics page, and has a
filter bar to type filters in: a client, a name (or it and the names below filter bar to type filters in: clients, names, views, record types,
it), a view, a record type, a result, blocked or not; it suggests values results, blocked or not; it suggests values from what is shown.
from what is shown. Filters combine (this
Each kind of filter takes several values, comma separated, and matches
any of them: `10.0.0.5, 10.0.0.7` is either client. `!value` leaves one
out (`!10.0.0.250` hides a monitoring probe), and `*.example.com` is the
name and every name below it, so `!*.apple.com` drops a chatty device's
noise. Kinds combine: this client, in this view, NXDOMAIN, not that name.
In the lists, clicking a value adds it and **hide** leaves it out; every
value has a chip with a ✕ to take it out again. Filters combine (this
client, in this view, blocked only) and each has a ✕ to take it out. A name 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 can be widened to the names below it (`domain=`). The Statistics page's
view switches apply here too. view switches apply here too.
+97 -25
View File
@@ -11,7 +11,10 @@ import (
// last days (stats.query_retention), filtered by any mix of client, name, // last days (stats.query_retention), filtered by any mix of client, name,
// view, type, result and blocking, broken down by each of them. // 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 { type ExploreFilter struct {
Client string // a client key, as the top lists show it Client string // a client key, as the top lists show it
Name string // exactly this name Name string // exactly this name
@@ -58,33 +61,96 @@ type ExplorePoint struct {
// ErrNoQueryLog is returned when the query log is off. // 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)") 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) { func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
conds := []string{"ts >= ?"} conds := []string{"ts >= ?"}
args := []any{from} args := []any{from}
eq := func(col, v string) { for _, dim := range Dimensions {
if v != "" { in, out := f.fieldValues(dim)
conds = append(conds, col+" = ?") if len(in) > 0 {
args = append(args, v) 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 { if len(q.Views) > 0 {
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",") 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) 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 { for _, dim := range Dimensions {
if !open[dim] { if !open[dim] {
continue continue
+33
View File
@@ -84,6 +84,39 @@ func TestExplore(t *testing.T) {
t.Errorf("internal NXDOMAIN: %d", ex.Total) 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. // Pruning: past the retention they go.
clk.t = clk.t.Add(8 * 24 * time.Hour) clk.t = clk.t.Add(8 * 24 * time.Hour)
c.prune(ctx) c.prune(ctx)
+4 -2
View File
@@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) {
// barList shows ranked entries as labelled horizontal bars in one hue // barList shows ranked entries as labelled horizontal bars in one hue
// (one series: no legend, the title names it). Values are always printed, // (one series: no legend, the title names it). Values are always printed,
// so the list is its own table view. // so the list is its own table view.
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) { // extra(key), when given, adds something after an entry's label (e.g. a
// link that excludes it).
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra } = {}) {
if (!entries.length) return h("div", { class: "empty" }, empty); if (!entries.length) return h("div", { class: "empty" }, empty);
// Entries may be in a natural order (e.g. latency buckets), not by size. // Entries may be in a natural order (e.g. latency buckets), not by size.
const peak = Math.max(1, ...entries.map((e) => e.count)); const peak = Math.max(1, ...entries.map((e) => e.count));
@@ -343,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" }, h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown), link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
})); }));
} }
+95 -54
View File
@@ -2,7 +2,7 @@
// last days, filtered by any mix of client, name, view, record type, result // 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 // 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. // client leads to its names, a name to the clients asking for it, and so on.
import { h, mount, fmtTime, errorBox, badge, backLink, input, select, checkbox } from "../ui.js"; import { h, mount, fmtTime, errorBox, badge, backLink, input } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js"; import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
import { viewsOff } from "./stats.js"; import { viewsOff } from "./stats.js";
@@ -19,19 +19,42 @@ const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400
// exploreHref is the explore page for these filters (and range). // exploreHref is the explore page for these filters (and range).
export function exploreHref(filters, since) { export function exploreHref(filters, since) {
const q = new URLSearchParams(); const q = new URLSearchParams();
for (const k of keys) if (filters[k]) q.set(k, filters[k]); for (const k of keys) {
const v = Array.isArray(filters[k]) ? filters[k].join(",") : filters[k];
if (v) q.set(k, v);
}
if (since && since !== "24h") q.set("since", since); if (since && since !== "24h") q.set("since", since);
return "#/stats/explore" + (q.toString() ? "?" + q : ""); return "#/stats/explore" + (q.toString() ? "?" + q : "");
} }
const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k); const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k);
export async function render(el, params, query) { // A filter is, per kind, a list of values: any of them, and !VALUE for
// "not this". Names: *.example.com is it and the names below it.
const kinds = ["client", "name", "view", "type", "rcode", "blocked"];
const split = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => x.trim()).filter(Boolean);
function parse(query) {
const f = {}; const f = {};
for (const k of keys) if (query.get(k)) f[k] = query.get(k); for (const k of kinds) f[k] = split(query.get(k));
for (const d of split(query.get("domain"))) f.name.push(d.startsWith("!") ? "!*." + d.slice(1).replace(/^\*\./, "") : "*." + d.replace(/^\*\./, ""));
return f;
}
const plain = (v) => v.replace(/^!/, "");
const isNot = (v) => v.startsWith("!");
function addTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== "!" + v && x !== v).concat(v); return g; }
function notTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v && x !== "!" + v).concat("!" + v); return g; }
function dropFrom(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v); return g; }
function copy(f) { const g = {}; for (const k of kinds) g[k] = [...(f[k] || [])]; return g; }
const includes = (f, k) => (f[k] || []).filter((v) => !isNot(v));
const anyFilter = (f) => kinds.some((k) => (f[k] || []).length);
export async function render(el, params, query) {
const f = parse(query);
const since = query.get("since") || "24h"; const since = query.get("since") || "24h";
const with_ = (more) => exploreHref({ ...f, ...more }, since); const go = (g) => exploreHref(g, since);
const without = (k) => { const g = { ...f }; delete g[k]; return exploreHref(g, since); }; const q = {};
for (const k of kinds) if (f[k].length) q[k] = f[k].join(",");
// The Statistics page's view switches apply here too. // The Statistics page's view switches apply here too.
const off = viewsOff(); const off = viewsOff();
@@ -39,14 +62,14 @@ export async function render(el, params, query) {
const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : ""; const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : "";
let ex; let ex;
try { try {
ex = await get("/stats/explore", { ...f, since, views: views || undefined }); ex = await get("/stats/explore", { ...q, since, views: views || undefined });
} catch (e) { } catch (e) {
mount(el, head(f, without, since, null), filterBar(f, since, null, allViews), e.code === "query_log_off" mount(el, head(f, go, since, null), filterBar(f, since, null, allViews), e.code === "query_log_off"
? h("div", { class: "msg info" }, e.message) : errorBox(e)); ? h("div", { class: "msg info" }, e.message) : errorBox(e));
return; return;
} }
const parts = [head(f, without, since, ex), filterBar(f, since, ex, allViews)]; const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
const maxSecs = retentionSecs(ex.retention); const maxSecs = retentionSecs(ex.retention);
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" }, parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"), h("span", { class: "muted" }, "Range"),
@@ -59,8 +82,8 @@ export async function render(el, params, query) {
parts.push(h("div", { class: "cards" }, parts.push(h("div", { class: "cards" },
tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)), tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)),
tile("Blocked", compact(ex.blocked), ex.total ? pct(ex.blocked / ex.total) + " of them" : ""), 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, nx && !includes(f, "rcode").length ? 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)); includes(f, "client").length !== 1 && 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 times = ex.series.map((p) => p.t);
const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }]; const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }];
@@ -69,84 +92,102 @@ export async function render(el, params, query) {
ex.total ? lineChart({ series, times, step: ex.step, area: true }) : h("div", { class: "empty" }, "No answers match."), 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 })); 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 // A breakdown for each kind the filter leaves open: click a value to add
// to add it. // it, "hide" to leave it out.
const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => { 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; const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k;
const pinned = (k) => includes(f, d).includes(k);
return h("div", { class: "card chart-card" }, h("h3", null, title), 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." })); barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, label, empty: d === "blocked" ? "Nothing blocked." : "None.",
href: (k) => (pinned(k) ? null : go(addTo(f, d, k))),
extra: (k) => h("a", { class: "bar-hide", href: go(notTo(f, d, k)), title: "Leave " + label(k) + " out" }, "hide") }));
}); });
if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists)); if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists));
parts.push(recentPanel(ex, with_)); parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v))));
mount(el, parts); mount(el, parts);
} }
// filterBar types filters in: a client, a name (or it and the names below // filterBar types filters in. Each field takes several values, comma
// it), a view, a type, a result, blocked or not. It starts from the current // separated (any of them), !value to leave one out, and names *.domain for
// filters and suggests values from what is shown. // it and the names below it. It starts from the current filters and
// suggests values from what is shown.
function filterBar(f, since, ex, views) { function filterBar(f, since, ex, views) {
const sugg = (id, entries) => h("datalist", { id }, (entries || []).map((e) => h("option", { value: e.key }, e.name || null))); const sugg = (id, entries) => h("datalist", { id }, (entries || []).map((e) => h("option", { value: e.key }, e.name || null)));
const client = input({ value: f.client || "", placeholder: "address", class: "mono", list: "ex-clients", style: { width: "170px" } }); const field = (k, placeholder, width, list) => input({ value: (f[k] || []).join(", "), placeholder, class: "mono", list, style: { width } });
const name = input({ value: f.name || f.domain || "", placeholder: "nas.adbraun.lol", class: "mono", list: "ex-names", style: { width: "220px" } }); const client = field("client", "any client", "190px", "ex-clients");
const below = checkbox("and below", !!f.domain); const name = field("name", "nas.adbraun.lol, *.apple.com", "250px", "ex-names");
const view = select([["", "any view"], ...views.map((v) => [v, v])], f.view || ""); const view = field("view", "any view", "130px", "ex-views");
const type = input({ value: f.type || "", placeholder: "any type", class: "mono", list: "ex-types", style: { width: "110px" } }); const type = field("type", "any type", "110px", "ex-types");
const rcode = select([["", "any result"], "NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"], f.rcode || ""); const rcode = field("rcode", "any result", "130px", "ex-rcodes");
const blocked = select([["", "blocked or not"], ["yes", "blocked"], ["no", "not blocked"], ...((ex && ex.by.blocked) || []).map((e) => [e.key, "blocked by " + blockedLabel(e.key)])], f.blocked || ""); const blocked = field("blocked", "yes, no or a list", "140px", "ex-blocked");
if (f.blocked && ![...blocked.options].some((o) => o.value === f.blocked)) blocked.append(h("option", { value: f.blocked, selected: true }, "blocked by " + blockedLabel(f.blocked)));
const apply = () => { const apply = () => {
const n = name.value.trim().toLowerCase().replace(/\.$/, ""); const g = { client: split(client.value), name: split(name.value).map((v) => v.toLowerCase().replace(/\.$/, "")), view: split(view.value),
const g = { client: client.value.trim(), view: view.value, type: type.value.trim().toUpperCase(), rcode: rcode.value, blocked: blocked.value }; type: split(type.value).map((v) => v.toUpperCase()), rcode: split(rcode.value).map((v) => v.toUpperCase()), blocked: split(blocked.value) };
if (n) g[below.input.checked ? "domain" : "name"] = n;
location.hash = exploreHref(g, since); location.hash = exploreHref(g, since);
}; };
const form = h("form", { class: "explore-filters", onsubmit: (e) => { e.preventDefault(); apply(); } }, const form = h("form", { class: "explore-filters", onsubmit: (e) => { e.preventDefault(); apply(); } },
h("label", null, "Client ", client), h("label", null, "Name ", name), below, view, h("label", null, "Type ", type), rcode, blocked, h("label", null, "Client ", client), h("label", null, "Name ", name), h("label", null, "View ", view),
h("label", null, "Type ", type), h("label", null, "Result ", rcode), h("label", null, "Blocked ", blocked),
h("button", { type: "submit", class: "primary small" }, "Show"), h("button", { type: "submit", class: "primary small" }, "Show"),
sugg("ex-clients", ex && ex.by.client), sugg("ex-names", ex && ex.by.name), sugg("ex-types", (ex && ex.by.type) || ["A", "AAAA", "PTR", "MX", "TXT", "HTTPS", "SRV"].map((k) => ({ key: k })))); sugg("ex-clients", ex && ex.by.client), sugg("ex-names", ex && ex.by.name), sugg("ex-views", views.map((v) => ({ key: v }))),
return h("div", { class: "panel explore-filter-panel" }, form); sugg("ex-types", (ex && ex.by.type) || ["A", "AAAA", "PTR", "MX", "TXT", "HTTPS", "SRV"].map((k) => ({ key: k }))),
sugg("ex-rcodes", ["NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"].map((k) => ({ key: k }))),
sugg("ex-blocked", [{ key: "yes" }, { key: "no" }, ...((ex && ex.by.blocked) || [])]));
return h("div", { class: "panel explore-filter-panel" }, form,
h("div", { class: "muted small" }, "Several values: comma separated, any of them. !value leaves one out (!10.0.0.250, !*.apple.com). *.example.com is it and the names below it."));
} }
// head is the title and the filter chips, each with a ✕ to take it out. // head is the title and a chip per filter value, each with a ✕.
function head(f, without, since, ex) { function head(f, go, since, ex) {
const chips = keys.filter((k) => f[k]).map((k) => h("span", { class: "chip filter-chip" }, const chips = [];
h("span", { class: "muted" }, filterWords[k] + " "), for (const k of kinds) for (const v of f[k] || []) {
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(f[k]) : f[k]), const val = plain(v);
k === "client" && ex && ex.client_name ? h("span", { class: "muted" }, " " + ex.client_name) : null, chips.push(h("span", { class: "chip filter-chip" + (isNot(v) ? " not" : "") },
" ", h("a", { href: without(k), title: "Take it out", class: "chip-x", "aria-label": "Remove the " + filterWords[k] + " filter" }, "✕"))); h("span", { class: "muted" }, (isNot(v) ? "not " : "") + filterWords[k] + " "),
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(val) : val),
k === "client" && !isNot(v) && ex && ex.client_name && includes(f, "client").length === 1 ? h("span", { class: "muted" }, " " + ex.client_name) : null,
" ", h("a", { href: go(dropFrom(f, k, v)), title: "Take it out", class: "chip-x", "aria-label": "Remove " + filterWords[k] + " " + val }, "✕")));
}
const extra = []; 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")); const names = includes(f, "name");
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 (names.length === 1) {
if (f.name || f.domain) extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(f.name || f.domain) }, "find it in your zones")); const n = names[0], bare = n.replace(/^\*\./, "");
if (!n.startsWith("*.")) extra.push(h("a", { href: go(addTo(dropFrom(f, "name", n), "name", "*." + n)) }, "this name and the names below it"));
if (includes(f, "blocked").length || (ex && ex.blocked)) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(bare) }, "why is it blocked?"));
extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(bare) }, "find it in your zones"));
}
return h("div", { class: "page-head" }, return h("div", { class: "page-head" },
h("div", { class: "grow" }, h("div", { class: "grow" },
backLink("Statistics", "#/stats"), backLink("Statistics", "#/stats"),
h("h1", null, title(f, ex)), 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."), h("div", { class: "muted" }, "Every answer in the query log that matches. Click a value to add it, \"hide\" to leave it out; ✕ takes it out of the filter."),
chips.length ? h("div", { class: "chips filter-chips" }, chips, chips.length ? h("div", { class: "chips filter-chips" }, chips,
h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null, h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null,
extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null)); extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null));
} }
function title(f, ex) { function title(f, ex) {
const n = Object.keys(f).length; if (!anyFilter(f)) return "Explore the answers";
if (!n) return "Explore the answers"; const inc = (k) => includes(f, k);
const who = f.client ? (ex && ex.client_name ? ex.client_name : f.client) : ""; const or = (vs, fmt = (x) => x) => vs.map(fmt).join(" or ");
if (f.client && n === 1) return "Client " + who; const clients = inc("client");
const what = [f.rcode, f.type, f.blocked ? "blocked" + (f.blocked !== "yes" ? " by " + blockedLabel(f.blocked) : "") : "", const who = clients.length === 1 && ex && ex.client_name ? ex.client_name : or(clients);
f.name || (f.domain ? f.domain + " and below" : "")].filter(Boolean).join(" "); const onlyClient = clients.length && kinds.every((k) => k === "client" || !(f[k] || []).length);
if (onlyClient && clients.length === 1) return "Client " + who;
const what = [or(inc("rcode")), or(inc("type")),
inc("blocked").length ? "blocked" + (inc("blocked")[0] !== "yes" ? " by " + or(inc("blocked"), blockedLabel) : "") : "",
or(inc("name"), (n) => (n.startsWith("*.") ? n.slice(2) + " and below" : n))].filter(Boolean).join(" ");
const parts = [what || "Answers"]; const parts = [what || "Answers"];
if (f.client) parts.push("for " + who); if (clients.length) parts.push("for " + who);
if (f.view) parts.push("in view " + f.view); if (inc("view").length) parts.push("in view " + or(inc("view")));
return parts.join(" "); return parts.join(" ");
} }
// recentPanel lists the latest answers, every cell a link. // recentPanel lists the latest answers, every cell a link.
function recentPanel(ex, with_) { function recentPanel(ex, add) {
if (!ex.recent.length) return null; if (!ex.recent.length) return null;
const a = (text, more, cls) => h("a", { href: with_(more), class: cls || "" }, text); const a = (text, more, cls) => { const [k, v] = Object.entries(more)[0]; return h("a", { href: add(k, v), class: cls || "" }, text); };
return h("div", { class: "panel" }, h("h2", null, "Latest answers"), return h("div", { class: "panel" }, h("h2", null, "Latest answers"),
h("div", { class: "table-wrap" }, h("table", { class: "recent-queries" }, 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("thead", null, h("tr", null, ["Time", "Client", "View", "Name", "Type", "Result"].map((t) => h("th", null, t)))),
+3
View File
@@ -619,3 +619,6 @@ table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; ove
form.explore-filters { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; } form.explore-filters { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
form.explore-filters label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; } form.explore-filters label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; }
.explore-filter-panel { padding-top: 10px; padding-bottom: 10px; } .explore-filter-panel { padding-top: 10px; padding-bottom: 10px; }
.chip.filter-chip.not strong { text-decoration: line-through; }
.bar-list a.bar-hide { font-size: 11px; color: var(--muted); margin-right: 4px; text-decoration: none; opacity: 0.6; }
.bar-list li:hover a.bar-hide, .bar-list a.bar-hide:focus { opacity: 1; text-decoration: underline; }