diff --git a/CHANGELOG.md b/CHANGELOG.md index adf7f24..aa9af64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 narrows it further), and the latest answers. "NXDOMAIN in external" → 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`. - **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 diff --git a/docs/api.md b/docs/api.md index a34abef..4e78a7a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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/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/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/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 55c19e1..984ab8f 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -215,9 +215,16 @@ which shows, for what you clicked: 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 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 -it), a view, a record type, a result, blocked or not; it suggests values -from what is shown. Filters combine (this +filter bar to type filters in: clients, names, views, record types, +results, blocked or not; it suggests values from what is shown. + +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 can be widened to the names below it (`domain=`). The Statistics page's view switches apply here too. diff --git a/internal/stats/explore.go b/internal/stats/explore.go index 111f382..f1fce73 100644 --- a/internal/stats/explore.go +++ b/internal/stats/explore.go @@ -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 diff --git a/internal/stats/explore_test.go b/internal/stats/explore_test.go index ba91fcc..7fce71f 100644 --- a/internal/stats/explore_test.go +++ b/internal/stats/explore_test.go @@ -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) diff --git a/internal/web/static/assets/charts.js b/internal/web/static/assets/charts.js index a0cdaee..0624035 100644 --- a/internal/web/static/assets/charts.js +++ b/internal/web/static/assets/charts.js @@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) { // barList shows ranked entries as labelled horizontal bars in one hue // (one series: no legend, the title names it). Values are always printed, // 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); // Entries may be in a natural order (e.g. latency buckets), not by size. 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) + "%)" }, h("div", { class: "bar-text" }, 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) + "%" } }))); })); } diff --git a/internal/web/static/assets/pages/explore.js b/internal/web/static/assets/pages/explore.js index 0a53a06..3098cb6 100644 --- a/internal/web/static/assets/pages/explore.js +++ b/internal/web/static/assets/pages/explore.js @@ -2,7 +2,7 @@ // 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, input, select, checkbox } from "../ui.js"; +import { h, mount, fmtTime, errorBox, badge, backLink, input } from "../ui.js"; import { get } from "../api.js"; import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.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). export function exploreHref(filters, since) { 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); 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) { +// 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 = {}; - 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 with_ = (more) => exploreHref({ ...f, ...more }, since); - const without = (k) => { const g = { ...f }; delete g[k]; return exploreHref(g, since); }; + const go = (g) => 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. 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(",") : ""; let ex; try { - ex = await get("/stats/explore", { ...f, since, views: views || undefined }); + ex = await get("/stats/explore", { ...q, since, views: views || undefined }); } 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)); 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); parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" }, h("span", { class: "muted" }, "Range"), @@ -59,8 +82,8 @@ export async function render(el, params, query) { 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)); + nx && !includes(f, "rcode").length ? tile("NXDOMAIN", compact(nx.count), pct(nx.count / Math.max(1, ex.total)) + " of them") : 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 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 ? 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. + // A breakdown for each kind the filter leaves open: click a value to add + // it, "hide" to leave it out. 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 pinned = (k) => includes(f, d).includes(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." })); + 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)); - parts.push(recentPanel(ex, with_)); + parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v)))); mount(el, parts); } -// filterBar types filters in: a client, a name (or it and the names below -// it), a view, a type, a result, blocked or not. It starts from the current -// filters and suggests values from what is shown. +// filterBar types filters in. Each field takes several values, comma +// separated (any of them), !value to leave one out, and names *.domain for +// 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) { 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 name = input({ value: f.name || f.domain || "", placeholder: "nas.adbraun.lol", class: "mono", list: "ex-names", style: { width: "220px" } }); - const below = checkbox("and below", !!f.domain); - const view = select([["", "any view"], ...views.map((v) => [v, v])], f.view || ""); - const type = input({ value: f.type || "", placeholder: "any type", class: "mono", list: "ex-types", style: { width: "110px" } }); - const rcode = select([["", "any result"], "NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"], f.rcode || ""); - 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 || ""); - 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 field = (k, placeholder, width, list) => input({ value: (f[k] || []).join(", "), placeholder, class: "mono", list, style: { width } }); + const client = field("client", "any client", "190px", "ex-clients"); + const name = field("name", "nas.adbraun.lol, *.apple.com", "250px", "ex-names"); + const view = field("view", "any view", "130px", "ex-views"); + const type = field("type", "any type", "110px", "ex-types"); + const rcode = field("rcode", "any result", "130px", "ex-rcodes"); + const blocked = field("blocked", "yes, no or a list", "140px", "ex-blocked"); const apply = () => { - const n = name.value.trim().toLowerCase().replace(/\.$/, ""); - const g = { client: client.value.trim(), view: view.value, type: type.value.trim().toUpperCase(), rcode: rcode.value, blocked: blocked.value }; - if (n) g[below.input.checked ? "domain" : "name"] = n; + const g = { client: split(client.value), name: split(name.value).map((v) => v.toLowerCase().replace(/\.$/, "")), view: split(view.value), + type: split(type.value).map((v) => v.toUpperCase()), rcode: split(rcode.value).map((v) => v.toUpperCase()), blocked: split(blocked.value) }; location.hash = exploreHref(g, since); }; 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"), - 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 })))); - return h("div", { class: "panel explore-filter-panel" }, form); + sugg("ex-clients", ex && ex.by.client), sugg("ex-names", ex && ex.by.name), sugg("ex-views", views.map((v) => ({ key: v }))), + 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. -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" }, "✕"))); +// head is the title and a chip per filter value, each with a ✕. +function head(f, go, since, ex) { + const chips = []; + for (const k of kinds) for (const v of f[k] || []) { + const val = plain(v); + chips.push(h("span", { class: "chip filter-chip" + (isNot(v) ? " not" : "") }, + 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 = []; - 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")); + const names = includes(f, "name"); + if (names.length === 1) { + 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" }, 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."), + 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, 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(" "); + if (!anyFilter(f)) return "Explore the answers"; + const inc = (k) => includes(f, k); + const or = (vs, fmt = (x) => x) => vs.map(fmt).join(" or "); + const clients = inc("client"); + const who = clients.length === 1 && ex && ex.client_name ? ex.client_name : or(clients); + 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"]; - if (f.client) parts.push("for " + who); - if (f.view) parts.push("in view " + f.view); + if (clients.length) parts.push("for " + who); + if (inc("view").length) parts.push("in view " + or(inc("view"))); return parts.join(" "); } // recentPanel lists the latest answers, every cell a link. -function recentPanel(ex, with_) { +function recentPanel(ex, add) { 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"), 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)))), diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 4d14843..c6fa914 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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 label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; } .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; }