What a quota used shows its percentage of the allowance on the Rules page (line and dialog), the dashboard and in hc quotas. Statistics' Quotas card links each entry to its rule, named, without the share of the total; per-address quotas are in it at last: their quota:rN series is the sum of their addresses. 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) => (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;
|
||
}
|