Files
jarl/internal/web/static/assets/charts.js
T
abrown38andClaude Opus 5.5 3b7bee67f8 Add optional query statistics
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>
2026-10-07 20:21:34 +00:00

334 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Small SVG charts for the statistics page: a line/area chart with a
// crosshair tooltip, a weekday x hour heatmap, and bar lists. No library;
// colors come from CSS custom properties (--series-N, --heat-N) so light
// and dark themes are handled in style.css. All text is inserted as text,
// never as markup.
import { h } from "./ui.js";
const NS = "http://www.w3.org/2000/svg";
function svg(tag, attrs, ...children) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v !== undefined && v !== null && v !== false) el.setAttribute(k, v);
}
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
// compact formats a count: 1,234 / 12.9K / 4.2M.
export function compact(n) {
const a = Math.abs(n);
if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, "") + "B";
if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
if (a >= 1e4) return (n / 1e3).toFixed(a >= 1e5 ? 0 : 1).replace(/\.0$/, "") + "K";
return Math.round(n).toLocaleString();
}
// niceTicks returns 0..max in clean steps (1, 2 or 5 x 10^k), at most n+1 ticks.
export function niceTicks(max, n = 4) {
if (max <= 0) return [0, 1];
const raw = max / n;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw);
const ticks = [];
for (let v = 0; v < max + step * 0.999; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
return ticks;
}
const MIN = 60, HOUR = 3600, DAY = 86400;
const tickSteps = [5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY];
// timeTicks picks local-time-aligned ticks between t0 and t1 (Unix seconds).
function timeTicks(t0, t1, maxTicks) {
const span = t1 - t0;
const step = tickSteps.find((s) => span / s <= maxTicks) || 30 * DAY;
const out = [];
const d = new Date(t0 * 1000);
if (step >= DAY) {
d.setHours(0, 0, 0, 0);
} else {
d.setSeconds(0, 0);
const mins = d.getHours() * 60 + d.getMinutes();
const stepMin = step / 60;
d.setHours(0, Math.floor(mins / stepMin) * stepMin, 0, 0);
}
for (let t = d.getTime() / 1000; t <= t1; ) {
if (t >= t0) out.push(t);
if (step >= DAY) {
const nd = new Date(t * 1000);
nd.setDate(nd.getDate() + step / DAY);
t = nd.getTime() / 1000;
} else t += step;
}
return { ticks: out, step };
}
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const pad = (n) => String(n).padStart(2, "0");
function tickLabel(t, step) {
const d = new Date(t * 1000);
if (step >= DAY) return step >= 7 * DAY ? months[d.getMonth()] + " " + d.getDate() : weekdays[d.getDay()] + " " + d.getDate();
const hm = pad(d.getHours()) + ":" + pad(d.getMinutes());
return d.getHours() === 0 && d.getMinutes() === 0 ? weekdays[d.getDay()] + " " + d.getDate() : hm;
}
// longTime describes a bucket for tooltips and tables.
export function bucketLabel(t, stepSecs) {
const d = new Date(t * 1000);
const day = weekdays[d.getDay()] + " " + d.getDate() + " " + months[d.getMonth()];
if (stepSecs >= DAY) return day;
const end = new Date((t + stepSecs) * 1000);
const hm = (x) => pad(x.getHours()) + ":" + pad(x.getMinutes());
return day + ", " + hm(d) + (stepSecs > MIN ? "–" + hm(end) : "");
}
export const stepSeconds = { "1m": MIN, "5m": 5 * MIN, "15m": 15 * MIN, "1h": HOUR, "6h": 6 * HOUR, "1d": DAY };
// tooltip is one floating readout per chart.
function tooltip() {
const el = h("div", { class: "chart-tip", role: "status" });
el.hidden = true;
return el;
}
// chartCard wraps a chart with its title and a table-view toggle (the
// accessible twin of every chart).
export function chartCard(title, subtitle, chart, tableView, opts = {}) {
const body = h("div", { class: "chart-body" }, chart);
const tableBox = h("div", { class: "chart-table" }, tableView);
tableBox.hidden = true;
const toggle = h("button", { class: "small link", type: "button", "aria-pressed": "false" }, "Table");
toggle.addEventListener("click", () => {
const showTable = tableBox.hidden;
tableBox.hidden = !showTable;
body.hidden = showTable;
toggle.textContent = showTable ? "Chart" : "Table";
toggle.setAttribute("aria-pressed", String(showTable));
});
return h("figure", { class: "panel chart-card" + (opts.wide ? " wide" : "") },
h("figcaption", { class: "chart-head" },
h("div", { class: "grow" }, h("h2", null, title), subtitle ? h("div", { class: "muted small" }, subtitle) : null),
opts.legend || null,
tableView ? toggle : null),
body, tableBox);
}
// legend lists series with a short line key (the mark the series uses).
export function legend(series) {
return h("ul", { class: "chart-legend" }, series.map((s) =>
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
}
// lineChart draws series over time. series: [{label, values, slot}] (slot
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
// seconds. With area, the first series gets a 10% wash under its line.
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true }) {
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
const tip = tooltip();
root.append(tip);
let width = 0, focusIdx = -1, geom = null;
function draw() {
const w = root.clientWidth;
if (!w || w === width) return;
width = w;
root.querySelector("svg")?.remove();
const max = Math.max(1, ...series.flatMap((s) => s.values));
const yTicks = niceTicks(max, 4);
const yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => compact(t).length)) * 7 + 10;
const endW = endLabels && series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
const m = { top: 10, right: endW, bottom: 24, left: labelW };
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
const t0 = times[0], t1 = times[times.length - 1] + step;
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
const cx = (i) => x(times[i] + step / 2); // the middle of a bucket
const y = (v) => m.top + ph - (v / yMax) * ph;
const s = svg("svg", { width: w, height, viewBox: `0 0 ${w} ${height}`, role: "img", "aria-hidden": "true" });
// Grid and axes.
for (const t of yTicks) {
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, compact(t)));
}
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
for (const t of ticks) {
s.append(svg("text", { class: "tick", x: x(t), y: height - 6, "text-anchor": "middle" }, tickLabel(t, tstep)));
}
// Series.
series.forEach((sr, k) => {
const pts = sr.values.map((v, i) => [cx(i), y(v)]);
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join("");
if (area && k === 0 && pts.length) {
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
}
s.append(svg("path", { class: "line s" + sr.slot, d }));
if (pts.length === 1) s.append(svg("circle", { class: "dot s" + sr.slot, cx: pts[0][0], cy: pts[0][1], r: 4 }));
});
// Direct end labels when there is room and they don't collide.
if (m.right > 8) {
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values[sr.values.length - 1] || 0) })).sort((a, b) => a.y - b.y);
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
}
}
// Crosshair (drawn on hover/focus).
const cross = svg("line", { class: "crosshair", y1: m.top, y2: m.top + ph, visibility: "hidden" });
const dots = series.map((sr) => svg("circle", { class: "dot s" + sr.slot, r: 4, visibility: "hidden" }));
s.append(cross, ...dots);
// The hit area is the whole plot: readers aim at a time, not a line.
const hit = svg("rect", { class: "hit", x: m.left, y: m.top, width: pw, height: ph });
s.append(hit);
root.prepend(s);
geom = { cx, y, m, pw, ph, cross, dots, hit };
hit.addEventListener("pointermove", (e) => {
const r = s.getBoundingClientRect();
const px = e.clientX - r.left;
const i = Math.round(((px - m.left) / pw) * (times.length) - 0.5);
show(Math.max(0, Math.min(times.length - 1, i)));
});
hit.addEventListener("pointerleave", hide);
if (focusIdx >= 0) show(focusIdx);
}
function show(i) {
if (!geom) return;
focusIdx = i;
const xpos = geom.cx(i);
geom.cross.setAttribute("x1", xpos);
geom.cross.setAttribute("x2", xpos);
geom.cross.setAttribute("visibility", "visible");
series.forEach((sr, k) => {
geom.dots[k].setAttribute("cx", xpos);
geom.dots[k].setAttribute("cy", geom.y(sr.values[i]));
geom.dots[k].setAttribute("visibility", "visible");
});
tip.replaceChildren(
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
...series.map((sr) => h("div", { class: "tip-row" },
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i].toLocaleString()), " ", h("span", { class: "muted" }, sr.label))));
tip.hidden = false;
// Keep the readout inside the chart, on the far side of the pointer.
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
tip.style.left = Math.max(0, left) + "px";
tip.style.top = geom.m.top + "px";
}
function hide() {
if (!geom) return;
geom.cross.setAttribute("visibility", "hidden");
geom.dots.forEach((d) => d.setAttribute("visibility", "hidden"));
tip.hidden = true;
focusIdx = -1;
}
// Keyboard: arrows move the crosshair, as hover does.
root.addEventListener("focus", () => show(focusIdx >= 0 ? focusIdx : times.length - 1));
root.addEventListener("blur", hide);
root.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
show(Math.max(0, Math.min(times.length - 1, (focusIdx < 0 ? times.length - 1 : focusIdx) + (e.key === "ArrowLeft" ? -1 : 1))));
}
});
new ResizeObserver(draw).observe(root);
return root;
}
// timeTable is the table view of a line chart (busiest buckets first is
// not useful here: keep time order, newest first).
export function timeTable(series, times, step) {
const rows = times.map((t, i) => ({ t, i })).reverse();
return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
series.map((s) => h("td", { class: "num" }, s.values[r.i].toLocaleString())))))));
}
// heatmap draws average activity by weekday (rows, Monday first) and hour
// (columns) with a sequential one-hue ramp; cells without data stay empty.
export function heatmap(hm, unit) {
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let peak = 0;
hm.avg.forEach((row) => row.forEach((v) => { peak = Math.max(peak, v); }));
const steps = 7;
const level = (v) => (peak <= 0 ? 0 : Math.min(steps - 1, Math.floor((v / peak) * steps)));
const tip = tooltip();
const grid = h("div", { class: "heatmap", role: "grid", "aria-label": "Average " + unit + " per hour by weekday and hour of day" });
grid.append(h("div", { class: "hm-corner" }));
for (let hr = 0; hr < 24; hr++) grid.append(h("div", { class: "hm-col" }, hr % 3 === 0 ? pad(hr) : ""));
days.forEach((day, d) => {
grid.append(h("div", { class: "hm-row" }, day));
for (let hr = 0; hr < 24; hr++) {
const v = hm.avg[d][hr];
const n = hm.samples[d][hr];
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const showTip = () => {
tip.textContent = text;
tip.hidden = false;
const r = cell.getBoundingClientRect(), pr = grid.getBoundingClientRect();
tip.style.left = Math.max(0, Math.min(pr.width - tip.offsetWidth, r.left - pr.left - tip.offsetWidth / 2 + r.width / 2)) + "px";
tip.style.top = (r.bottom - pr.top + 6) + "px";
};
cell.addEventListener("pointerenter", showTip);
cell.addEventListener("focus", showTip);
cell.addEventListener("pointerleave", () => { tip.hidden = true; });
cell.addEventListener("blur", () => { tip.hidden = true; });
grid.append(cell);
}
});
// Arrow keys move between cells.
grid.tabIndex = 0;
let cur = -1;
const cells = () => [...grid.querySelectorAll(".hm-cell")];
grid.addEventListener("keydown", (e) => {
const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -24, ArrowDown: 24 }[e.key];
if (delta === undefined) return;
e.preventDefault();
cur = Math.max(0, Math.min(7 * 24 - 1, (cur < 0 ? 0 : cur) + delta));
cells()[cur].focus();
});
const scale = h("div", { class: "hm-scale" },
h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data"));
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
}
export function heatmapTable(hm, unit) {
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Hour"), days.map((d) => h("th", { class: "num" }, d)))),
h("tbody", null, Array.from({ length: 24 }, (_, hr) => h("tr", null, h("td", null, pad(hr) + ":00"),
days.map((_, d) => h("td", { class: "num" }, hm.avg[d][hr] < 0 ? "–" : Math.round(hm.avg[d][hr]).toLocaleString())))))),
h("div", { class: "muted small" }, "Average " + unit + " per hour."));
}
// 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 } = {}) {
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));
const sum = total || entries.reduce((a, e) => a + e.count, 0);
return h("ol", { class: "bar-list" }, entries.map((e) => {
const share = sum ? (e.count / sum) * 100 : 0;
const name = label(e.key);
const link = href ? href(e.key) : null;
return h("li", { title: name + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, name) : h("span", { class: "bar-label" }, name),
h("span", { class: "bar-value" }, 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) + "%" } })));
}));
}