// 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) => 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, 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))); }