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>
88 lines
6.2 KiB
JavaScript
88 lines
6.2 KiB
JavaScript
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules
|
||
// and the latest drops.
|
||
import { h, mount, table, badge, problemsList, time } from "../ui.js";
|
||
import { get, can } from "../api.js";
|
||
import { onLeave } from "../app.js";
|
||
import { describe, hits, humanCount } from "../widgets.js";
|
||
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||
import { bytes } from "./stats.js";
|
||
|
||
export async function render(el) {
|
||
const draw = async () => {
|
||
const st = await get("/status");
|
||
const config = can("config", false), logs = can("log", false);
|
||
const [rules, cs, log, tl, zones] = await Promise.all([
|
||
config ? get("/rules") : [],
|
||
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
||
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
|
||
logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
|
||
config ? get("/zones").catch(() => []) : [],
|
||
]);
|
||
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
|
||
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
|
||
const card = (label, value, sub, href) => {
|
||
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
|
||
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
|
||
};
|
||
const policyDrops = (c) => cs["p-" + c] ? humanCount(cs["p-" + c].packets) : "–";
|
||
mount(el,
|
||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
|
||
st.unconfigured ? h("div", { class: "msg info" }, "Nothing is configured yet, so Housecarl applies no ruleset. Write the rules that let you in (SSH, this web UI) first: the input policy drops what no rule accepts. Or ",
|
||
h("a", { href: "#/import" }, "import your ruleset"), ".") :
|
||
!st.applied ? h("div", { class: "msg err" }, "The ruleset isn't in force: ", st.apply_error) : null,
|
||
st.other_tables.length ? h("div", { class: "msg info" }, "Other tables in the ruleset: ", h("span", { class: "mono" }, st.other_tables.join(", ")),
|
||
". A packet must get past them too. Once Housecarl's ruleset replaces one, 'hc takeover' deletes it.") : null,
|
||
h("div", { class: "cards" },
|
||
card("Ruleset", st.applied ? "in force" : st.unconfigured ? "none yet" : "not in force", "table inet housecarl", null),
|
||
card("Rules", st.counts.rule, st.counts.zone + " zones · " + st.counts.set + " sets", "#/rules"),
|
||
card("NAT", st.counts.forward + st.counts.snat + st.counts.redirect, st.counts.forward + " port forwards", "#/nat"),
|
||
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
|
||
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
|
||
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
|
||
tl ? statsCharts(tl, zones) : null,
|
||
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
|
||
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
||
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
|
||
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ")) },
|
||
], st.interfaces.filter((i) => i.name !== "lo"))),
|
||
busiest.length ? h("div", { class: "panel" },
|
||
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
|
||
table([
|
||
{ label: "Chain", render: (r) => r.chain },
|
||
{ label: "Rule", render: (r) => r.raw || describe(r) },
|
||
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
|
||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||
{ label: "Comment", render: (r) => r.comment },
|
||
], busiest)) : null,
|
||
log.length ? h("div", { class: "panel" },
|
||
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
|
||
table([
|
||
{ label: "When", render: (e) => time(e.time) },
|
||
{ label: "Why", render: (e) => e.prefix },
|
||
{ label: "From", render: (e) => h("span", { class: "mono" }, e.source) },
|
||
{ label: "To", render: (e) => h("span", { class: "mono" }, e.destination, e.destination_port ? ":" + e.destination_port : "") },
|
||
{ label: "Protocol", render: (e) => e.protocol },
|
||
], log)) : null);
|
||
};
|
||
await draw();
|
||
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
|
||
onLeave(() => clearInterval(t));
|
||
}
|
||
|
||
// statsCharts: the last 24 hours' traffic with the internet and drops,
|
||
// per hour (the Statistics page has the rest).
|
||
function statsCharts(tl, zones) {
|
||
const ext = new Set(zones.filter((z) => z.external).map((z) => z.name));
|
||
const step = stepSeconds[tl.step];
|
||
const total = (prefix) => tl.times.map((_, i) => tl.series.filter((x) => x.key.startsWith(prefix) && ext.has(x.key.slice(prefix.length))).reduce((a, x) => a + x.values[i], 0));
|
||
const traffic = [{ label: "Downloaded", values: total("zrx:"), slot: 1 }, { label: "Uploaded", values: total("ztx:"), slot: 2 }];
|
||
const drops = [{ label: "Dropped", values: (tl.series.find((x) => x.key === "drops") || { values: tl.times.map(() => 0) }).values, slot: 2 }];
|
||
const gutter = { left: 64, right: 8 };
|
||
return h("div", { class: "chart-grid" },
|
||
ext.size ? chartCard("Internet traffic", "bytes per hour from and to external zones, last 24 hours",
|
||
lineChart({ series: traffic, times: tl.times, step, area: true, height: 150, endLabels: false, gutter, format: bytes }),
|
||
timeTable(traffic, tl.times, step, bytes), { legend: legend(traffic) }) : null,
|
||
chartCard("Dropped packets", h("span", null, "per hour, last 24 hours · ", h("a", { href: "#/stats" }, "Statistics")),
|
||
lineChart({ series: drops, times: tl.times, step, area: true, height: 150, endLabels: false, gutter }), timeTable(drops, tl.times, step)));
|
||
}
|