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>
108 lines
7.8 KiB
JavaScript
108 lines
7.8 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 } 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))] : ""]
|
||
: fmtBytes(q.used) },
|
||
{ 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)));
|
||
}
|