Files
abrown38andClaude Opus 5.5 ca5ac971e5 Keep built-ins and policies out of the busiest rules
The established built-in passes most of the traffic, so it topped the
statistics' busiest rules every time and made every rule's bar look
empty. The summary now lists rules and NAT objects in rules, and
built-ins and policies in builtins; the Statistics page and hc stats
show them as two lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:02:12 +01:00

218 lines
14 KiB
JavaScript
Raw Permalink 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.
// Statistics: traffic per zone, drops, blocked sources, devices,
// connections, quotas and the machine over time (docs/stats.md).
import { h, mount, fmtTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
import { withNames } from "../widgets.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
let generation = 0;
// The zone the traffic chart shows, kept while the page refreshes.
let shownZone = null;
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
? "Sampled every minute · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
: "Statistics are off")));
if (!status.enabled) {
mount(el, head, h("div", { class: "panel" },
h("p", null, "Housecarl keeps traffic per zone, drops, blocked sources, connections and quotas over time, in a database of its own. Turn it on in /etc/housecarl/housecarl.yaml and restart housecarl:"),
h("pre", { class: "code" }, "stats:\n enabled: true"),
h("p", { class: "muted" }, "See docs/stats.md.")));
return;
}
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : rangeSecs[since] > 90 * 86400 ? "90d" : since;
let d;
try {
d = await loadAll(since, heatSince);
} catch (e) {
mount(el, head, rangeRow(since), errorBox(e));
return;
}
if (gen !== generation) return; // the user moved on
const s = d.summary;
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
const parts = [head, rangeRow(since)];
if (status.sample_error) parts.push(h("div", { class: "msg warn" }, "The last sample failed: " + status.sample_error));
// Headline numbers: what came from and went to the external zones.
const ext = new Set(d.zones.filter((z) => z.external).map((z) => z.name));
const sum = (f) => s.zones.filter((z) => ext.has(z.name)).reduce((a, z) => a + f(z), 0);
const l = status.latest || {};
parts.push(h("div", { class: "cards" },
ext.size ? tile("Downloaded", bytes(sum((z) => z.received)), "from external zones, " + rangeLabel) : null,
ext.size ? tile("Uploaded", bytes(sum((z) => z.sent)), "to external zones") : null,
tile("Dropped", compact(s.drops), "packets: rules, built-ins and policies"),
s.conntrack_max ? tile("Connections", compact(s.peak_connections), "most at once (of " + compact(s.conntrack_max) + " allowed)" + (l.conntrack != null ? " · now " + compact(l.conntrack) : "")) : null));
// Traffic per zone: one zone at a time, received and sent.
const traffic = complete(d.traffic);
const step = stepSeconds[traffic.step];
const zoneNames = s.zones.map((z) => z.name);
if (!zoneNames.includes(shownZone)) shownZone = zoneNames.find((z) => ext.has(z)) || zoneNames[0] || null;
const trafficBox = h("div");
const drawTraffic = () => {
const pick = h("div", { class: "segmented" }, zoneNames.map((z) =>
h("button", { type: "button", class: z === shownZone ? "active" : "", onclick: () => { shownZone = z; drawTraffic(); } }, z)));
const series = [
{ label: "Received", values: values(traffic, "zrx:" + shownZone), slot: 1 },
{ label: "Sent", values: values(traffic, "ztx:" + shownZone), slot: 2 },
];
mount(trafficBox, chartCard("Traffic" + (shownZone ? ": " + shownZone : ""),
"bytes per " + stepName(traffic.step) + " received from and sent to the zone's interfaces, " + rangeLabel,
shownZone ? h("div", null, zoneNames.length > 1 ? h("div", { class: "filter-row" }, pick) : null,
lineChart({ series, times: traffic.times, step, area: true, gutter: { left: 64, right: endLabelWidth(series) }, format: bytes }))
: h("div", { class: "empty" }, "No traffic recorded yet: interfaces count once they are in a zone."),
shownZone ? timeTable(series, traffic.times, step, bytes) : null, { wide: true, legend: shownZone ? legend(series) : null }));
};
drawTraffic();
parts.push(trafficBox);
if (s.zones.length) {
parts.push(h("div", { class: "chart-grid" },
listCard("Received per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.received })), { format: bytes })),
listCard("Sent per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.sent })), { format: bytes }))));
}
// Drops over time, by chain, and when they happen.
const drops = complete(d.drops);
const dSeries = [{ label: "Dropped", values: values(drops, "drops"), slot: 2 }];
const anyDrops = dSeries[0].values.some((v) => v > 0);
parts.push(chartCard("Dropped packets", "per " + stepName(drops.step) + ", by rules, built-ins and policies that drop, " + rangeLabel,
anyDrops ? lineChart({ series: dSeries, times: drops.times, step: stepSeconds[drops.step], area: true, gutter: { left: 64, right: 8 } }) : h("div", { class: "empty" }, "Nothing dropped " + rangeLabel + "."),
anyDrops ? timeTable(dSeries, drops.times, stepSeconds[drops.step]) : null, { wide: true }));
parts.push(chartCard("When drops happen", "average dropped packets per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
heatmap(d.heat, "packets"), heatmapTable(d.heat, "packets"), { wide: true }));
// Who and what.
const unused = d.hits.filter((e) => e.count === 0 && e.key.startsWith("r"));
const devHint = h("div", { class: "empty" }, status.per_device
? "No traffic per device " + rangeLabel + "."
: ["Traffic per device is off. Turn it on under ", h("a", { href: "#/settings" }, "Settings"), " (it adds a counter for each inside address)."]);
parts.push(h("div", { class: "chart-grid" },
listCard("Busiest rules", "packets each counted, " + rangeLabel, barList(s.rules, { label: (k) => k, href: (k) => ruleHref(k), empty: "No rule counted anything " + rangeLabel + "." })),
listCard("Built-ins and policies", "packets each counted; 'established' passes the replies and the rest of every allowed connection", barList(s.builtins || [], { label: (k) => k, href: (k) => ruleHref(k) })),
listCard("Dropped, by chain", "early: antispoofing · invalid: packets conntrack can't place", barList(s.drops_by_chain, { empty: "Nothing dropped " + rangeLabel + "." })),
listCard("Blocked most", "sources of logged drops (the policies' with 'log drops' on, and rules that log)", barList(s.blocked_sources, { empty: "No logged drops " + rangeLabel + ".", href: (k) => "#/trace?address=" + encodeURIComponent(k) })),
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => (d.hits.find((x) => x.key === k) || {}).name || "rule #" + k.slice(1), href: ruleHref, share: false })) : null,
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
// Connections and the machine.
const g = complete(d.gauges);
const gStep = stepSeconds[g.step];
const gauge = (k, f = (v) => v) => values(g, k).map((v) => (v == null || v < 0 ? null : f(v)));
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
const cSeries = [{ label: "Tracked", values: gauge("g:conntrack"), slot: 1 }];
const mSeries = [{ label: "Host in use", values: gauge("g:mem"), slot: 1 }, { label: "housecarld", values: gauge("g:rss"), slot: 2 }];
const lSeries = [{ label: "Load", values: gauge("g:load", (v) => v / 100), slot: 1 }, { label: "housecarld CPU %", values: gauge("g:cpu", (v) => v / 10), slot: 2 }];
const gutter = { left: 64, right: Math.max(endLabelWidth(cSeries), endLabelWidth(mSeries), endLabelWidth(lSeries)) };
const empty = h("div", { class: "empty" }, "No samples yet: they are taken once a minute.");
parts.push(h("h2", { class: "section" }, "Connections and the machine"));
parts.push(chartCard("Tracked connections", "average per " + stepName(g.step) + (l.conntrack_max ? "; the kernel allows " + compact(l.conntrack_max) + " (nf_conntrack_max), and drops new connections beyond" : ""),
any(cSeries) ? lineChart({ series: cSeries, times: g.times, step: gStep, area: true, gutter }) : empty, any(cSeries) ? timeTable(cSeries, g.times, gStep) : null, { wide: true }));
parts.push(chartCard("Memory", "average per " + stepName(g.step) + (l.rss ? " · housecarld now " + bytes(l.rss) : ""),
any(mSeries) ? lineChart({ series: mSeries, times: g.times, step: gStep, gutter, format: bytes }) : empty.cloneNode(true), any(mSeries) ? timeTable(mSeries, g.times, gStep, bytes) : null, { wide: true, legend: legend(mSeries) }));
parts.push(chartCard("Load", "the host's 1-minute load average, and housecarld's CPU (percent of one CPU)",
any(lSeries) ? lineChart({ series: lSeries, times: g.times, step: gStep, gutter, format: (v) => (Math.round(v * 100) / 100).toString() }) : empty.cloneNode(true),
any(lSeries) ? timeTable(lSeries, g.times, gStep, (v) => v.toFixed(2)) : null, { wide: true, legend: legend(lSeries) }));
el.classList.remove("refreshing");
mount(el, parts);
scheduleRefresh(el, params, query, gen);
}
async function loadAll(since, heatSince) {
const [summary, zones, traffic, drops, heat, hits, gauges, quotas] = await Promise.all([
get("/stats/summary", { since, limit: 10 }),
get("/zones"),
get("/stats/timeline", { since, keys: "zrx:*,ztx:*" }),
get("/stats/timeline", { since, keys: "drops" }),
get("/stats/heatmap", { since: heatSince }),
get("/stats/hits", { since: "30d" }),
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
get("/stats/timeline", { since, keys: "quota:*" }),
]);
await withNames(summary.devices_down, summary.devices_up, summary.blocked_sources);
return { summary, zones, traffic, drops, heat, hits, gauges,
quotas: quotas.series.map((x) => ({ key: x.key.slice(6), count: x.values.reduce((a, v) => a + Math.max(v, 0), 0) })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count) };
}
function scheduleRefresh(el, params, query, gen) {
setTimeout(() => {
const hh = location.hash;
if (gen !== generation || !(hh === "#/stats" || hh.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:
// drawn, it would look like a sudden fall.
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 values(tl, key) {
return (tl.series.find((x) => x.key === key) || { values: tl.times.map(() => 0) }).values;
}
function rangeRow(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 === "365d" ? "1y" : k))));
}
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
// built-ins under Settings).
function ruleHref(mark) {
if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1);
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
if (mark.startsWith("f")) return "#/nat";
return null;
}
function connHref(addr) {
return "#/connections?q=" + encodeURIComponent(addr.replace(/::\/64$/, ":"));
}
// bytes formats a size in binary units.
export 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 stepName(step) {
return { "1m": "minute", "5m": "5 minutes", "15m": "15 minutes", "1h": "hour", "6h": "6 hours", "1d": "day" }[step] || step;
}