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>
352 lines
17 KiB
JavaScript
352 lines
17 KiB
JavaScript
// 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) + "%" } })));
|
||
}));
|
||
}
|