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; }