From a8c7760fc8f5a18d85f4894e7cddf8242dc100ff Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 13 Oct 2026 18:58:45 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 4 ++- docs/stats.md | 6 +++- internal/web/static/assets/pages/explore.js | 39 ++++++++++++++++----- internal/web/static/assets/pages/stats.js | 4 ++- internal/web/static/assets/style.css | 5 +++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b85f31e..adf7f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,9 @@ All notable changes to Jarl are documented here. The format follows an explore page for it: answers over time, the top clients, names, 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. `GET /stats/explore`. + the clients asking → everything one of them asks. A filter bar types + filters in, and **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 is and how much of it the databases take. The Statistics page shows the diff --git a/docs/stats.md b/docs/stats.md index 8a0e6d8..55c19e1 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -213,7 +213,11 @@ which shows, for what you clicked: * the latest 100 answers, every cell a link. So "external, NXDOMAIN" leads to the clients asking, a client to everything -it asks, one of its names to who else asks for it. Filters combine (this +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 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/web/static/assets/pages/explore.js b/internal/web/static/assets/pages/explore.js index 8bad809..0a53a06 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 } 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" }, diff --git a/internal/web/static/assets/pages/stats.js b/internal/web/static/assets/pages/stats.js index d048208..6893078 100644 --- a/internal/web/static/assets/pages/stats.js +++ b/internal/web/static/assets/pages/stats.js @@ -43,7 +43,9 @@ export async function render(el, params, query) { h("div", { class: "muted" }, status.enabled ? "From named's counters" + (status.query_log ? " and response log" : "") + " · data since " + (status.first_data ? fmtTime(status.first_data) : "–") + " · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day - : "Query statistics are off"))); + : "Query statistics are off")), + status.enabled && status.retention && status.retention.queries + ? h("div", { class: "actions" }, h("a", { class: "button", href: "#/stats/explore", title: "Every answer of the last " + status.retention.queries + ", by client, name, view, type and result" }, "Explore the answers")) : null); if (!status.enabled) { mount(el, head, disabledPanel()); return; diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index a060b37..4d14843 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -614,3 +614,8 @@ table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; ove .swatch.db { background: var(--accent); } .swatch.other { background: var(--muted); opacity: 0.45; } .card .disk-bar { margin: 6px 0 4px; height: 8px; } + +/* Explore: the filter bar. */ +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; }