// 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, input } 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]]; // exploreRange is the longest range here of at most secs ("1h" at least). export function exploreRange(secs) { let out = "1h"; for (const [k, s] of ranges) if (s <= secs) out = k; return out; } // exploreHref is the explore page for these filters (and range). export function exploreHref(filters, since) { const q = new URLSearchParams(); for (const k of keys) { const v = Array.isArray(filters[k]) ? filters[k].join(",") : filters[k]; if (v) q.set(k, v); } 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); // A filter is, per kind, a list of values: any of them, and !VALUE for // "not this". Names: *.example.com is it and the names below it. const kinds = ["client", "name", "view", "type", "rcode", "blocked"]; const split = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => x.trim()).filter(Boolean); function parse(query) { const f = {}; for (const k of kinds) f[k] = split(query.get(k)); for (const d of split(query.get("domain"))) f.name.push(d.startsWith("!") ? "!*." + d.slice(1).replace(/^\*\./, "") : "*." + d.replace(/^\*\./, "")); return f; } const plain = (v) => v.replace(/^!/, ""); const isNot = (v) => v.startsWith("!"); function addTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== "!" + v && x !== v).concat(v); return g; } function notTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v && x !== "!" + v).concat("!" + v); return g; } function dropFrom(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v); return g; } function copy(f) { const g = {}; for (const k of kinds) g[k] = [...(f[k] || [])]; return g; } const includes = (f, k) => (f[k] || []).filter((v) => !isNot(v)); const anyFilter = (f) => kinds.some((k) => (f[k] || []).length); let generation = 0; export async function render(el, params, query) { const gen = ++generation; const hash = location.hash; const f = parse(query); const since = query.get("since") || "24h"; const go = (g) => exploreHref(g, since); const q = {}; for (const k of kinds) if (f[k].length) q[k] = f[k].join(","); // The Statistics page's view switches apply here too. const off = viewsOff(); 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", { ...q, since, views: views || undefined }); } catch (e) { mount(el, head(f, go, 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, go, since, ex), filterBar(f, since, ex, allViews)]; const maxSecs = ex.retention_seconds || 7 * 86400; 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 && !includes(f, "rcode").length ? tile("NXDOMAIN", compact(nx.count), pct(nx.count / Math.max(1, ex.total)) + " of them") : null, includes(f, "client").length !== 1 && 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 kind the filter leaves open: click a value to add // it, "hide" to leave it out. const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => { const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k; const pinned = (k) => includes(f, d).includes(k); return h("div", { class: "card chart-card" }, h("h3", null, title), barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, label, empty: d === "blocked" ? "Nothing blocked." : "None.", href: (k) => (pinned(k) ? null : go(addTo(f, d, k))), extra: (k) => h("a", { class: "bar-hide", href: go(notTo(f, d, k)), title: "Leave " + label(k) + " out" }, "hide") })); }); if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists)); parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v)))); if (gen !== generation) return; // the user moved on el.classList.remove("refreshing"); mount(el, parts); scheduleRefresh(el, params, query, gen, hash); } // scheduleRefresh redraws the page a minute later with the same filters, // so the graphs move along; not while a filter is being typed in. function scheduleRefresh(el, params, query, gen, hash) { setTimeout(() => { if (gen !== generation || location.hash !== hash || !el.isConnected) return; const form = el.querySelector("form.explore-filters"); if (form && (form.contains(document.activeElement) || form.dataset.dirty)) { scheduleRefresh(el, params, query, gen, hash); // try again later return; } el.classList.add("refreshing"); render(el, params, query).catch(() => el.classList.remove("refreshing")); }, 60_000); } // filterBar types filters in. Each field takes several values, comma // separated (any of them), !value to leave one out, and names *.domain for // it and the names below it. 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 field = (k, placeholder, width, list) => input({ value: (f[k] || []).join(", "), placeholder, class: "mono", list, style: { width } }); const client = field("client", "any client", "190px", "ex-clients"); const name = field("name", "nas.adbraun.lol, *.apple.com", "250px", "ex-names"); const view = field("view", "any view", "130px", "ex-views"); const type = field("type", "any type", "110px", "ex-types"); const rcode = field("rcode", "any result", "130px", "ex-rcodes"); const blocked = field("blocked", "yes, no or a list", "140px", "ex-blocked"); const apply = () => { const g = { client: split(client.value), name: split(name.value).map((v) => v.toLowerCase().replace(/\.$/, "")), view: split(view.value), type: split(type.value).map((v) => v.toUpperCase()), rcode: split(rcode.value).map((v) => v.toUpperCase()), blocked: split(blocked.value) }; location.hash = exploreHref(g, since); }; const form = h("form", { class: "explore-filters", onsubmit: (e) => { e.preventDefault(); apply(); }, oninput: () => { form.dataset.dirty = "1"; } }, h("label", null, "Client ", client), h("label", null, "Name ", name), h("label", null, "View ", view), h("label", null, "Type ", type), h("label", null, "Result ", rcode), h("label", null, "Blocked ", 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-views", views.map((v) => ({ key: v }))), sugg("ex-types", (ex && ex.by.type) || ["A", "AAAA", "PTR", "MX", "TXT", "HTTPS", "SRV"].map((k) => ({ key: k }))), sugg("ex-rcodes", ["NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"].map((k) => ({ key: k }))), sugg("ex-blocked", [{ key: "yes" }, { key: "no" }, ...((ex && ex.by.blocked) || [])])); return h("div", { class: "panel explore-filter-panel" }, form, h("div", { class: "muted small" }, "Several values: comma separated, any of them. !value leaves one out (!10.0.0.250, !*.apple.com). *.example.com is it and the names below it.")); } // head is the title and a chip per filter value, each with a ✕. function head(f, go, since, ex) { const chips = []; for (const k of kinds) for (const v of f[k] || []) { const val = plain(v); chips.push(h("span", { class: "chip filter-chip" + (isNot(v) ? " not" : "") }, h("span", { class: "muted" }, (isNot(v) ? "not " : "") + filterWords[k] + " "), h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(val) : val), k === "client" && !isNot(v) && ex && ex.client_name && includes(f, "client").length === 1 ? h("span", { class: "muted" }, " " + ex.client_name) : null, " ", h("a", { href: go(dropFrom(f, k, v)), title: "Take it out", class: "chip-x", "aria-label": "Remove " + filterWords[k] + " " + val }, "✕"))); } const extra = []; const names = includes(f, "name"); if (names.length === 1) { const n = names[0], bare = n.replace(/^\*\./, ""); if (!n.startsWith("*.")) extra.push(h("a", { href: go(addTo(dropFrom(f, "name", n), "name", "*." + n)) }, "this name and the names below it")); if (includes(f, "blocked").length || (ex && ex.blocked)) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(bare) }, "why is it blocked?")); extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(bare) }, "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 value to add it, \"hide\" to leave it out; ✕ takes it out of the filter."), 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) { if (!anyFilter(f)) return "Explore the answers"; const inc = (k) => includes(f, k); const or = (vs, fmt = (x) => x) => vs.map(fmt).join(" or "); const clients = inc("client"); const who = clients.length === 1 && ex && ex.client_name ? ex.client_name : or(clients); const onlyClient = clients.length && kinds.every((k) => k === "client" || !(f[k] || []).length); if (onlyClient && clients.length === 1) return "Client " + who; const what = [or(inc("rcode")), or(inc("type")), inc("blocked").length ? "blocked" + (inc("blocked")[0] !== "yes" ? " by " + or(inc("blocked"), blockedLabel) : "") : "", or(inc("name"), (n) => (n.startsWith("*.") ? n.slice(2) + " and below" : n))].filter(Boolean).join(" "); const parts = [what || "Answers"]; if (clients.length) parts.push("for " + who); if (inc("view").length) parts.push("in view " + or(inc("view"))); return parts.join(" "); } // recentPanel lists the latest answers, every cell a link. function recentPanel(ex, add) { if (!ex.recent.length) return null; const a = (text, more, cls) => { const [k, v] = Object.entries(more)[0]; return h("a", { href: add(k, v), 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"; }