jarld downloads blocklists (Adblock, hosts or plain domains), checks them daily and reloads named only when a list changed; your own block and allow rules take precedence, and the most specific rule wins. Lists and rules apply per view (default: every view that offers recursion). Web UI Blocking page with name checks, jarl blocklist and jarl block, the /blocking API, blocked counts in the statistics. Database schema 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
11 KiB
JavaScript
190 lines
11 KiB
JavaScript
import { h, mount, fmtTime, errorBox } from "../ui.js";
|
||
import { get } from "../api.js";
|
||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds } from "../charts.js";
|
||
|
||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||
|
||
// Failed answers keep the same color whatever the range: color follows
|
||
// the result code, never its rank.
|
||
const failures = [
|
||
{ key: "rcode:NXDOMAIN", label: "NXDOMAIN", slot: 1 },
|
||
{ key: "rcode:SERVFAIL", label: "SERVFAIL", slot: 2 },
|
||
{ key: "rcode:REFUSED", label: "REFUSED", slot: 3 },
|
||
];
|
||
|
||
const rttOrder = ["<10ms", "10-100ms", "100-500ms", "500-800ms", "800-1600ms", ">1600ms"];
|
||
|
||
let generation = 0;
|
||
|
||
export async function render(el, params, query) {
|
||
const gen = ++generation;
|
||
const want = query && query.get ? query.get("since") : null;
|
||
const since = rangeSecs[want] ? want : "24h";
|
||
const status = await get("/stats/status");
|
||
const head = h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||
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")));
|
||
if (!status.enabled) {
|
||
mount(el, head, disabledPanel());
|
||
return;
|
||
}
|
||
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
|
||
let data;
|
||
try {
|
||
data = await loadAll(since, heatSince, status.query_log);
|
||
} catch (e) {
|
||
mount(el, head, errorBox(e));
|
||
return;
|
||
}
|
||
if (gen !== generation) return; // the user moved on
|
||
const { summary: s, heat } = data;
|
||
// The newest bucket is still filling up; drawing it would look like a
|
||
// sudden drop in traffic.
|
||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||
const step = stepSeconds[timeline.step];
|
||
const parts = [head, filterRow(since)];
|
||
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
|
||
|
||
// Headline numbers.
|
||
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
|
||
parts.push(h("div", { class: "cards" },
|
||
tile("Queries", compact(s.queries), rangeLabel),
|
||
tile("Per second", s.avg_qps < 10 ? s.avg_qps.toFixed(2) : Math.round(s.avg_qps).toLocaleString(), "on average"),
|
||
s.peak ? tile("Busiest " + s.peak.per, compact(s.peak.queries), fmtTime(s.peak.time)) : null,
|
||
s.nxdomain_ratio != null ? tile("NXDOMAIN", pct(s.nxdomain_ratio), "of queries named no existing name") : null,
|
||
s.cache_hit_ratio != null ? tile("Cache hits", pct(s.cache_hit_ratio), "of recursive queries answered from cache") : null,
|
||
s.blocked_ratio != null ? tile("Blocked", pct(s.blocked_ratio), compact(s.blocked) + " queries, by your rules and blocklists") : null));
|
||
|
||
// Volume over time: one series, so no legend box; the title names it.
|
||
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
|
||
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
|
||
parts.push(chartCard("Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
|
||
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step), { wide: true }));
|
||
|
||
// Failed answers: three fixed series, legend plus end labels.
|
||
const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values }));
|
||
const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0));
|
||
parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed",
|
||
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step] }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
|
||
anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null,
|
||
{ wide: true, legend: anyFailures ? legend(fSeries) : null }));
|
||
|
||
// When is the server busy?
|
||
parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
||
heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true }));
|
||
|
||
// Who asks for what.
|
||
if (status.query_log) {
|
||
const t = data.tops;
|
||
parts.push(h("div", { class: "chart-grid" },
|
||
listCard("Most queried names", "", barList(t.name, { href: (k) => "#/search?q=" + encodeURIComponent(k) })),
|
||
listCard("Busiest clients", "", barList(t.client)),
|
||
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })),
|
||
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })),
|
||
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail)) : null,
|
||
t.blocked.length ? listCard("Most blocked names", "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: (k) => "#/blocking?check=" + encodeURIComponent(k) })) : null));
|
||
} else {
|
||
parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl."));
|
||
}
|
||
|
||
// Breakdowns.
|
||
const b = data.breakdowns;
|
||
const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean);
|
||
parts.push(h("div", { class: "chart-grid" },
|
||
listCard("Record types", "", barList(b.qtype, { total: s.queries })),
|
||
listCard("Result codes", "", barList(b.rcode, { total: s.queries })),
|
||
b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel })) : null,
|
||
b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null,
|
||
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
||
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
||
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
|
||
el.classList.remove("refreshing");
|
||
mount(el, parts);
|
||
scheduleRefresh(el, params, query, gen);
|
||
}
|
||
|
||
async function loadAll(since, heatSince, queryLog) {
|
||
const p = {
|
||
summary: get("/stats/summary", { since, top: 0 }),
|
||
timeline: get("/stats/timeline", { since, key: "queries" }),
|
||
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
|
||
heat: get("/stats/heatmap", { since: heatSince }),
|
||
};
|
||
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
|
||
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
|
||
if (queryLog) {
|
||
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 });
|
||
}
|
||
const keys = Object.keys(p);
|
||
const vals = await Promise.all(Object.values(p));
|
||
const out = { breakdowns: {}, tops: {} };
|
||
keys.forEach((k, i) => {
|
||
if (k.startsWith("b_")) out.breakdowns[k.slice(2)] = vals[i];
|
||
else if (k.startsWith("t_")) out.tops[k.slice(2)] = vals[i];
|
||
else out[k] = vals[i];
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// scheduleRefresh reloads the page's data every minute while it is shown,
|
||
// keeping the current render (dimmed) until the new one is ready.
|
||
function scheduleRefresh(el, params, query, gen) {
|
||
setTimeout(() => {
|
||
if (gen !== generation || !location.hash.startsWith("#/stats") || !el.isConnected) return;
|
||
el.classList.add("refreshing");
|
||
render(el, params, query).catch(() => el.classList.remove("refreshing"));
|
||
}, 60_000);
|
||
}
|
||
|
||
// complete drops the last bucket of a timeline if it hasn't ended yet.
|
||
function complete(tl) {
|
||
const step = stepSeconds[tl.step];
|
||
const n = tl.times.length;
|
||
if (n > 1 && tl.times[n - 1] + step > Date.now() / 1000) {
|
||
return { ...tl, times: tl.times.slice(0, -1), series: tl.series.map((x) => ({ ...x, values: x.values.slice(0, -1) })) };
|
||
}
|
||
return tl;
|
||
}
|
||
|
||
function filterRow(since) {
|
||
return h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||
h("span", { class: "muted" }, "Range"),
|
||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
||
}
|
||
|
||
function tile(label, value, sub) {
|
||
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
|
||
}
|
||
|
||
function listCard(title, subtitle, body) {
|
||
return h("section", { class: "panel" }, h("h2", null, title), subtitle ? h("div", { class: "muted small list-sub" }, subtitle) : null, body);
|
||
}
|
||
|
||
function pct(r) {
|
||
const p = r * 100;
|
||
return (p < 10 ? p.toFixed(1) : Math.round(p)) + "%";
|
||
}
|
||
|
||
function stepName(step) {
|
||
return { "1m": "minute", "5m": "5 minutes", "15m": "15 minutes", "1h": "hour", "6h": "6 hours", "1d": "day" }[step] || step;
|
||
}
|
||
|
||
function viewLabel(k) {
|
||
return k === "_default" ? "(no view)" : k;
|
||
}
|
||
|
||
function disabledPanel() {
|
||
return h("div", { class: "panel" },
|
||
h("h2", null, "Turn on query statistics"),
|
||
h("p", null, "Jarl can show how many queries named answers over time, the busiest hours, the most queried names and the clients asking most. " +
|
||
"It is off by default. To turn it on, add this to /etc/jarl/jarl.yaml on the server and restart jarl (sudo systemctl restart jarl):"),
|
||
h("pre", { class: "code" }, "stats:\n enabled: true"),
|
||
h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " +
|
||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||
}
|