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

+3 -1
View File
@@ -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
+5 -1
View File
@@ -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.
+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" },
+3 -1
View File
@@ -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;
+5
View File
@@ -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; }