Explore: a filter bar, and Explore the answers on the Statistics page

The explore page has a filter bar to type filters in (client, name or it and below, view, type, result, blocked), starting from the current filters and suggesting values from what is shown, so taking every filter out no longer means going back. The Statistics page opens it with Explore the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 18:58:45 +01:00
1 parent 5443e95897
commit a8c7760fc8
5 files changed
+47 -11

No files matched your search

+31 -8
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 } from "../ui.js";
import { h, mount, fmtTime, errorBox, badge, backLink, input, select, checkbox } from "../ui.js";
import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
import { viewsOff } from "./stats.js";
@@ -35,21 +35,18 @@ export async function render(el, params, query) {
// The Statistics page's view switches apply here too.
const off = viewsOff();
let views = "";
if (off.size) {
const all = (await get("/views").catch(() => [])).map((v) => v.name);
views = all.filter((v) => !off.has(v)).join(",");
}
const allViews = (await get("/views").catch(() => [])).map((v) => v.name);
const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : "";
let ex;
try {
ex = await get("/stats/explore", { ...f, since, views: views || undefined });
} catch (e) {
mount(el, head(f, without, since, null), e.code === "query_log_off"
mount(el, head(f, without, 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)];
const parts = [head(f, without, 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"),
@@ -86,6 +83,32 @@ export async function render(el, params, query) {
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.
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 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;
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("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);
}
// 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" },