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
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
+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/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}]` |
+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
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.
+97 -25
View File
@@ -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
+33
View File
@@ -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)
+4 -2
View File
@@ -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) + "%" } })));
}));
}
+95 -54
View File
@@ -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)))),
+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 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; }