Files
abrown38andClaude Opus 5.5 7d23fb13ed Quotas: usage as a share, and their statistics per rule
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>
2026-10-21 13:55:43 +01:00

108 lines
7.9 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.
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules,
// the quotas and the latest drops.
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
import { get, can } from "../api.js";
import { onLeave } from "../app.js";
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named, pct } 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, quotas] = 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(() => []) : [],
logs ? get("/quotas").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) => h("a", { href: "#/rules?chain=" + r.chain + "&rule=" + r.id }, "#" + r.id + " ", 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,
quotas.length ? quotaPanel(quotas, await lookupNames(quotas.map((q) => (q.addresses || [])[0]).filter(Boolean).map((a) => a.address))) : 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));
}
// quotaPanel lists every rule's quota with what it used: the total, or
// for a per-address quota the busiest address (the rest are on the rule).
function quotaPanel(quotas, names) {
const perAddress = (q) => q.quota.per_source || q.quota.per_destination;
const used = (q) => perAddress(q) ? ((q.addresses || [])[0] || { used: 0 }).used : q.used;
return h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")),
table([
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
{ label: "Used", render: (q) => perAddress(q)
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q)) + " (" + pct(used(q), q.quota.bytes) + ")"] : ""]
: fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" },
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
], quotas));
}
// 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)));
}