Statistics: traffic, drops, devices and connections over time

internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.

Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).

GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 22:27:48 +01:00
1 parent 6bb82e86f5
commit cf30715c5a
37 files changed
+3652 -18

No files matched your search

+351
View File
@@ -0,0 +1,351 @@
// 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)));
}
// endLabelWidth is the room lineChart needs for the end labels of series.
export function endLabelWidth(series) {
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
}
// 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.
// gutter {left, right} sets the least room for the axis labels and the end
// labels: charts shown one above the other pass the same gutter, so their
// time axes line up. format formats values (axis and readout); a null
// value is a gap in the line (no data for that time).
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
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).filter((v) => v != null));
const yTicks = niceTicks(max, 4);
const yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
const endW = endLabels ? endLabelWidth(series) : 8;
const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
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" }, format(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) => {
// A gap (null) starts a new piece of line.
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
if (area && k === 0 && pts.length && pts.every(Boolean)) {
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 }));
// A point without neighbours has no line: show it as a dot.
pts.forEach((p, i) => {
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
});
});
// 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.findLast((v) => v != null) || 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] ?? 0));
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "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] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", 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, format) {
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
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" }, cell(s.values[r.i]))))))));
}
// 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 ? "hm-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 hm-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.
// extra(key), when given, adds something after an entry's label (e.g. a
// link that excludes it).
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) {
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;
// Entries with a name (a rule's label): the name, then the key.
const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name;
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(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) + "%" } })));
}));
}