jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore. The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
8.9 KiB
JavaScript
157 lines
8.9 KiB
JavaScript
// Exploring the query log (docs/stats.md#exploring): the answers of the
|
||
// 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 { get } from "../api.js";
|
||
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
|
||
import { viewsOff } from "./stats.js";
|
||
|
||
const dims = [
|
||
["client", "Clients"], ["name", "Names"], ["view", "Views"],
|
||
["type", "Record types"], ["rcode", "Results"], ["blocked", "Blocked by"],
|
||
];
|
||
// What a filter is called, in the chips and the title.
|
||
const filterWords = { client: "client", name: "name", domain: "domain", view: "view", type: "type", rcode: "result", blocked: "blocked" };
|
||
const keys = Object.keys(filterWords);
|
||
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 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]);
|
||
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) {
|
||
const f = {};
|
||
for (const k of keys) if (query.get(k)) f[k] = query.get(k);
|
||
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); };
|
||
|
||
// 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(",");
|
||
}
|
||
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"
|
||
? h("div", { class: "msg info" }, e.message) : errorBox(e));
|
||
return;
|
||
}
|
||
|
||
const parts = [head(f, without, since, ex)];
|
||
const maxSecs = retentionSecs(ex.retention);
|
||
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||
h("span", { class: "muted" }, "Range"),
|
||
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||
h("a", { href: exploreHref(f, k), class: k === since ? "active" : "" }, k))),
|
||
h("span", { class: "muted small" }, " · the query log keeps " + ex.retention + (ex.oldest ? ", since " + fmtTime(ex.oldest) : "")),
|
||
off.size ? h("span", { class: "muted small" }, " · views switched off on the Statistics page are left out") : null));
|
||
|
||
const nx = (ex.by.rcode || []).find((e) => e.key === "NXDOMAIN");
|
||
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));
|
||
|
||
const times = ex.series.map((p) => p.t);
|
||
const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }];
|
||
if (ex.blocked) series.push({ label: "Blocked", values: ex.series.map((p) => p.blocked), slot: 2 });
|
||
parts.push(chartCard(ex.blocked ? "Answers and blocked" : "Answers", "per " + stepWord(ex.step),
|
||
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.
|
||
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;
|
||
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." }));
|
||
});
|
||
if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists));
|
||
|
||
parts.push(recentPanel(ex, with_));
|
||
mount(el, parts);
|
||
}
|
||
|
||
// 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" }, "✕")));
|
||
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"));
|
||
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."),
|
||
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(" ");
|
||
const parts = [what || "Answers"];
|
||
if (f.client) parts.push("for " + who);
|
||
if (f.view) parts.push("in view " + f.view);
|
||
return parts.join(" ");
|
||
}
|
||
|
||
// recentPanel lists the latest answers, every cell a link.
|
||
function recentPanel(ex, with_) {
|
||
if (!ex.recent.length) return null;
|
||
const a = (text, more, cls) => h("a", { href: with_(more), 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)))),
|
||
h("tbody", null, ex.recent.map((r) => h("tr", null,
|
||
h("td", { class: "small muted nowrap" }, fmtTime(new Date(r.ts * 1000).toISOString())),
|
||
h("td", { class: "nowrap" }, a(r.client_name || r.client, { client: r.client }, "mono"), r.client_name ? h("span", { class: "muted small" }, " " + r.client) : null),
|
||
h("td", null, a(r.view === "_default" ? "–" : r.view, { view: r.view })),
|
||
h("td", { class: "oneline-cell" }, a(r.name, { name: r.name }, "mono")),
|
||
h("td", null, a(r.type, { type: r.type })),
|
||
h("td", null, r.blocked ? a(badge("blocked: " + blockedLabel(r.blocked), "warn"), { blocked: r.blocked }) : a(r.rcode, { rcode: r.rcode }, r.rcode === "NOERROR" ? "" : "warn-text"))))))),
|
||
ex.recent.length >= 100 ? h("div", { class: "muted small" }, "The latest 100: narrow the filter to see others.") : null);
|
||
}
|
||
|
||
function tile(label, value, sub) {
|
||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||
}
|
||
|
||
function pct(r) { return (r * 100).toFixed(r < 0.1 ? 1 : 0) + "%"; }
|
||
|
||
function stepWord(s) {
|
||
if (s % 86400 === 0) return s === 86400 ? "day" : s / 86400 + " days";
|
||
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||
return s === 60 ? "minute" : s / 60 + " minutes";
|
||
}
|
||
|
||
function retentionSecs(r) {
|
||
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||
if (!m) return 7 * 86400;
|
||
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
||
}
|