Reverse lookups (internal/names, POST /names) put a name beside each inside address in connections, the log, quota usage, the dashboard's quotas and the statistics' devices and blocked sources, in the web UI and hc (--no-names to leave them out). Only inside addresses are looked up: an inside zone's networks, and private ones outside the external zones'. Names are cached (names.cache, 10m) and never stored; names.resolver picks the DNS server. Also: hc stats top devices listed each device twice; the dashboard's quotas named the rule's ID twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
14 KiB
JavaScript
217 lines
14 KiB
JavaScript
// 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) })),
|
||
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:*" }),
|
||
]);
|
||
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;
|
||
}
|