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>
108 lines
7.9 KiB
JavaScript
108 lines
7.9 KiB
JavaScript
// 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)));
|
||
}
|