Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
16 KiB
JavaScript
259 lines
16 KiB
JavaScript
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
||
import { get } from "../api.js";
|
||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } 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.
|
||
// With blocking, a second line shows how many of them were blocked.
|
||
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
|
||
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
|
||
const bl = timeline.series.find((x) => x.key === "blocked");
|
||
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
|
||
// 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));
|
||
// The two charts are one above the other: the same room for labels on
|
||
// both sides keeps their time axes in line.
|
||
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
|
||
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
|
||
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
|
||
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
|
||
|
||
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], gutter }) : 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_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client)) : 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));
|
||
|
||
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
||
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,blocked" }),
|
||
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
|
||
heat: get("/stats/heatmap", { since: heatSince }),
|
||
server: get("/stats/server").catch(() => null),
|
||
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
|
||
};
|
||
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", "blocked_client"]) 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))));
|
||
}
|
||
|
||
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
|
||
|
||
// serverSection shows named's process, cache, clients, the host and the
|
||
// disks now, and named's memory and CPU over time.
|
||
function serverSection(sv, gauges, rangeLabel) {
|
||
const srv = (sv && sv.server) || {};
|
||
const named = (sv && sv.named) || {};
|
||
const host = srv.host || {};
|
||
const out = [h("h2", { class: "section" }, "Server")];
|
||
const clients = (v, what) => {
|
||
// "now/soft/hard" or "now/limit".
|
||
const [now, ...limits] = (v || "").split("/");
|
||
return v ? [now, "of " + limits[limits.length - 1] + " allowed" + what] : null;
|
||
};
|
||
const rc = clients(named.recursive_clients, srv.recursion_highwater ? " · most at once: " + srv.recursion_highwater : "");
|
||
const tc = clients(named.tcp_clients, srv.tcp_highwater ? " · most at once: " + srv.tcp_highwater : "");
|
||
out.push(h("div", { class: "cards" },
|
||
tile("named memory", srv.rss ? bytes(srv.rss) : "–", srv.rss ? "resident · named counts " + bytes(srv.mem_in_use) + " · " + srv.threads + " threads" : "named's process isn't visible to jarld"),
|
||
tile("named CPU", srv.pid ? srv.cpu_percent.toFixed(1) + "%" : "–", "of one CPU, last minute" + (named.cpus ? " · " + named.cpus + " CPUs" : "")),
|
||
tile("Cache", compact(srv.cache_rrsets || 0) + " RRsets", bytes(srv.cache_mem || 0) + " of memory, all views"),
|
||
rc ? tile("Recursive clients", rc[0], rc[1]) : null,
|
||
tc ? tile("TCP clients", tc[0], tc[1]) : null,
|
||
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
|
||
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
|
||
...((sv && sv.disks) || []).map((d) => tile("Disk space", bytes(d.free) + " free", h("span", { title: d.path }, "of " + bytes(d.total) + ", for " + d.holds.join(", "))))));
|
||
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
|
||
// Gauges over time: averages, -1 where there was no sample.
|
||
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
|
||
const gStep = stepSeconds[gauges.step];
|
||
const memSeries = [
|
||
{ label: "Resident", values: series("g:named:rss"), slot: 1 },
|
||
{ label: "named's count", values: series("g:named:mem"), slot: 2 },
|
||
{ label: "Cache", values: series("g:cache:mem"), slot: 3 },
|
||
];
|
||
const cpuSeries = [{ label: "CPU", values: series("g:named:cpu").map((v) => (v == null ? null : v / 10)), slot: 1 }];
|
||
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
|
||
const gutter = { left: 64, right: endLabelWidth(memSeries) };
|
||
out.push(chartCard("named memory", "average per " + stepName(gauges.step) + ", " + rangeLabel + " · resident is what the system sees",
|
||
any(memSeries) ? lineChart({ series: memSeries, times: gauges.times, step: gStep, gutter, format: bytes }) : h("div", { class: "empty" }, "No samples yet: they are taken once a minute."),
|
||
any(memSeries) ? timeTable(memSeries, gauges.times, gStep, bytes) : null, { wide: true, legend: any(memSeries) ? legend(memSeries) : null }));
|
||
out.push(chartCard("named CPU", "percent of one CPU, average per " + stepName(gauges.step),
|
||
any(cpuSeries) ? lineChart({ series: cpuSeries, times: gauges.times, step: gStep, gutter, format: (v) => (Math.round(v * 10) / 10) + "%" }) : h("div", { class: "empty" }, "No samples yet."),
|
||
any(cpuSeries) ? timeTable(cpuSeries, gauges.times, gStep, (v) => v.toFixed(1) + "%") : null, { wide: true }));
|
||
return out;
|
||
}
|
||
|
||
// bytes formats a size in binary units.
|
||
function bytes(n) {
|
||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||
let i = 0;
|
||
while (n >= 1024 && i < units.length - 1) {
|
||
n /= 1024;
|
||
i++;
|
||
}
|
||
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
|
||
}
|
||
|
||
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."));
|
||
}
|