Files
housecarl/internal/web/static/assets/charts.js
T
abrown38andClaude Opus 5.5 cf30715c5a 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>
2026-10-19 22:27:48 +01:00

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