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.")); }