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

+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)))),