Explore: several values per filter, !value to leave one out, *.domain
Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8c7760fc8
commit
e94d3634b6
8 files changed
+246
-86
No files matched your search
@@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) {
|
||||
// barList shows ranked entries as labelled horizontal bars in one hue
|
||||
// (one series: no legend, the title names it). Values are always printed,
|
||||
// so the list is its own table view.
|
||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) {
|
||||
// extra(key), when given, adds something after an entry's label (e.g. a
|
||||
// link that excludes it).
|
||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra } = {}) {
|
||||
if (!entries.length) return h("div", { class: "empty" }, empty);
|
||||
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
||||
const peak = Math.max(1, ...entries.map((e) => e.count));
|
||||
@@ -343,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
||||
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
|
||||
h("div", { class: "bar-text" },
|
||||
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
||||
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||
}));
|
||||
}
|
||||
@@ -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, input, select, checkbox } from "../ui.js";
|
||||
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";
|
||||
@@ -19,19 +19,42 @@ const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 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]);
|
||||
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);
|
||||
|
||||
export async function render(el, params, query) {
|
||||
// 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 keys) if (query.get(k)) f[k] = query.get(k);
|
||||
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);
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const f = parse(query);
|
||||
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); };
|
||||
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();
|
||||
@@ -39,14 +62,14 @@ export async function render(el, params, query) {
|
||||
const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : "";
|
||||
let ex;
|
||||
try {
|
||||
ex = await get("/stats/explore", { ...f, since, views: views || undefined });
|
||||
ex = await get("/stats/explore", { ...q, since, views: views || undefined });
|
||||
} catch (e) {
|
||||
mount(el, head(f, without, since, null), filterBar(f, since, null, allViews), e.code === "query_log_off"
|
||||
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, without, since, ex), filterBar(f, since, ex, allViews)];
|
||||
const parts = [head(f, go, 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"),
|
||||
@@ -59,8 +82,8 @@ export async function render(el, params, query) {
|
||||
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));
|
||||
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 }];
|
||||
@@ -69,84 +92,102 @@ export async function render(el, params, query) {
|
||||
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.
|
||||
// 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 href = (k) => with_(d === "blocked" ? { blocked: k } : { [d]: k });
|
||||
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, href, label, empty: d === "blocked" ? "Nothing blocked." : "None." }));
|
||||
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, with_));
|
||||
parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v))));
|
||||
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.
|
||||
// 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 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 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 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;
|
||||
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(); } },
|
||||
h("label", null, "Client ", client), h("label", null, "Name ", name), below, view, h("label", null, "Type ", type), rcode, blocked,
|
||||
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-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);
|
||||
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 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" }, "✕")));
|
||||
// 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 = [];
|
||||
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"));
|
||||
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 client, name, view, type or result to narrow it down; ✕ takes a filter out."),
|
||||
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) {
|
||||
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(" ");
|
||||
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 (f.client) parts.push("for " + who);
|
||||
if (f.view) parts.push("in view " + f.view);
|
||||
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, with_) {
|
||||
function recentPanel(ex, add) {
|
||||
if (!ex.recent.length) return null;
|
||||
const a = (text, more, cls) => h("a", { href: with_(more), class: cls || "" }, text);
|
||||
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)))),
|
||||
|
||||
@@ -619,3 +619,6 @@ table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; ove
|
||||
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; }
|
||||
.chip.filter-chip.not strong { text-decoration: line-through; }
|
||||
.bar-list a.bar-hide { font-size: 11px; color: var(--muted); margin-right: 4px; text-decoration: none; opacity: 0.6; }
|
||||
.bar-list li:hover a.bar-hide, .bar-list a.bar-hide:focus { opacity: 1; text-decoration: underline; }
|
||||
Reference in new issue
Block a user