// 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"; 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) })), 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) => "rule #" + k.slice(1), href: () => "#/rules" })) : 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:*" }), ]); 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; }