Off by default (stats in jarl.yaml). When enabled, Jarl adds a loopback statistics channel and a response log to named's configuration (merged into existing logging/statistics-channels statements) and switches response logging with rndc responselog, because the responselog option only counts when named starts. jarld polls named's counters every minute and tails the response log (resuming after restarts and across rotation). It stores aggregates, never individual queries, in a separate SQLite database: per-minute, hourly and daily counters (queries, result codes, record types, views, zones, cache hits, upstream latency, DNSSEC) and hourly/daily top lists (names, clients, NXDOMAIN and SERVFAIL names, refused clients), each with its own retention. Clients can be anonymized or excluded. - Web UI: Statistics page with KPI tiles, queries and failed answers over time, a weekday x hour heatmap, top lists and breakdowns; SVG charts with hover/keyboard readouts, a table view and light/dark palettes validated for colour-vision deficiency. - CLI: jarl stats status|summary|top|timeline|heatmap|breakdown. - API: /api/v1/stats/*, behind a new "stats" permission resource. - jarld brings named's configuration up to date with jarl.yaml at startup (engine.SyncConfig), never overwriting hand-edited files. - resync now refuses to overwrite drifted files without force, as documented. - tmpfiles.d creates /var/log/named (allowed by named's AppArmor profile on Debian 13 and Ubuntu). - The e2e harness runs named with -f like the Debian unit. - docs/stats.md, plus API, CLI, configuration, permissions and README updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
10 KiB
JavaScript
187 lines
10 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));
|
||
|
||
// 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));
|
||
} 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));
|
||
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"];
|
||
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
|
||
if (queryLog) {
|
||
for (const k of ["name", "client", "nxdomain", "refused", "servfail"]) 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."));
|
||
}
|