From 21ddced1ca5aa48f2c902cc9a27a1f9797a0241a Mon Sep 17 00:00:00 2001 From: abrown38 Date: Tue, 20 Oct 2026 19:18:33 +0100 Subject: [PATCH] Web UI: links to a rule open it, highlighted; rule IDs shown A rule named in the matrix, the log, the tracer, statistics or the dashboard opens the Rules page on its chain, scrolled to it and highlighted (#/rules?rule=ID; the chain is looked up when the link has none). The Rules page shows each rule's ID beside its order. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ internal/web/static/assets/pages/dashboard.js | 2 +- internal/web/static/assets/pages/log.js | 2 +- internal/web/static/assets/pages/matrix.js | 2 +- internal/web/static/assets/pages/rules.js | 19 ++++++++++++++++--- internal/web/static/assets/pages/stats.js | 2 +- internal/web/static/assets/pages/trace.js | 2 +- internal/web/static/assets/style.css | 3 +++ 8 files changed, 31 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b8687..e9c03eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- **Links to a rule open it.** A rule named in the matrix, the log, the + tracer, statistics or the dashboard's busiest rules opens the Rules + page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`). + The Rules page shows each rule's ID (`#410`, as `hc` and labels name + it) beside its order. - **A clearer zone matrix.** A cell reads allow, block, or **partly** (some new connections let through, the rest not), with the exceptions counted by what they do (`+3 -1` instead of `+4`), and what they let @@ -17,6 +22,8 @@ All notable changes to Housecarl are documented here. The format follows between its interfaces passes the firewall). The details list each rule's hits. API: `summary`, `allowed`, `blocked`, `open`, `same_zone`, `note`, `hits`, and `short` and `hits` per exception. + Raw rules that are a match and then accept, drop or reject (without + interfaces) are read instead of shown as unknown. - **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md): Housecarl's ruleset is loaded beside the firewall you have but blocks, rejects and translates nothing. Drops and rejects are counted and accept diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index 373a26a..d7d9041 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -49,7 +49,7 @@ export async function render(el) { 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: "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 }, diff --git a/internal/web/static/assets/pages/log.js b/internal/web/static/assets/pages/log.js index 2948dde..03ad5fe 100644 --- a/internal/web/static/assets/pages/log.js +++ b/internal/web/static/assets/pages/log.js @@ -16,7 +16,7 @@ export async function render(el, params, query) { h("thead", null, h("tr", null, ["Time", "Why", "In", "Out", "From", "To", "Protocol"].map((t) => h("th", null, t)))), h("tbody", null, es.map((e) => h("tr", null, h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), - h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules", title: "rule " + e.mark.slice(1) }, e.prefix) : e.prefix, + h("td", null, e.mark && e.mark.startsWith("r") ? h("a", { href: "#/rules?rule=" + e.mark.slice(1), title: "rule #" + e.mark.slice(1) }, e.prefix) : e.prefix, e.shadow && /^(drop|reject)$/.test(e.verdict) ? [" ", badge("would " + e.verdict, "warn")] : null), h("td", { class: "mono" }, e.in || ""), h("td", { class: "mono" }, e.out || ""), h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)), diff --git a/internal/web/static/assets/pages/matrix.js b/internal/web/static/assets/pages/matrix.js index 6094532..e7ad888 100644 --- a/internal/web/static/assets/pages/matrix.js +++ b/internal/web/static/assets/pages/matrix.js @@ -32,7 +32,7 @@ export async function render(el) { } function detail(c) { - const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label; + const ruleLink = (id, chain, label) => id ? h("a", { href: "#/rules?chain=" + chain + "&rule=" + id, onclick: () => document.querySelector(".modal-back") && document.querySelector(".modal-back").remove() }, label) : label; const v = (verdict) => badge(verdict, { allowed: "ok", blocked: "err", logged: "", unknown: "warn" }[verdict]); const hits = (n) => h("td", { class: "small num" }, n === undefined || n === null ? "–" : n.toLocaleString()); const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary]; diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index bbbbe01..5ae1058 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -24,7 +24,14 @@ const builtins = { }; export async function render(el, params, query) { - const chain = query.get("chain") || "input"; + // ?rule=ID (from the matrix, the log, the tracer, statistics): show + // that rule, in its chain, highlighted. + const target = Number(query.get("rule")) || 0; + let chain = query.get("chain"); + if (!chain && target) { + try { chain = (await get("/rules/" + target)).chain; } catch { /* gone: the default chain */ } + } + chain = chain || "input"; const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]); // As nftables checks them: the rules that count traffic first. const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))]; @@ -40,7 +47,8 @@ export async function render(el, params, query) { h("div", { class: "panel" }, builtinsRow(chain, settings, cs), table([ - { label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, + { label: "Order", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) }, + { label: "ID", render: (r) => r.id ? h("span", { class: "muted mono small" }, "#" + r.id) : "" }, { label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)] : [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] }, { label: "Action", render: actionBadge }, @@ -59,10 +67,15 @@ export async function render(el, params, query) { h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"), ]; } } : null, - ].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }), + ].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => rowClass(r) + (target && r.id === target ? " row-target" : "") }), rules.some(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null, h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"), policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); + if (target) { + const row = el.querySelector("tr.row-target"); + if (row) requestAnimationFrame(() => row.scrollIntoView({ block: "center" })); + else el.querySelector(".tabs").after(h("div", { class: "msg warn" }, "There is no rule #" + target + " (any more).")); + } } // everyPacket: checked before established connections are accepted (as diff --git a/internal/web/static/assets/pages/stats.js b/internal/web/static/assets/pages/stats.js index 66ca70e..6bd1c01 100644 --- a/internal/web/static/assets/pages/stats.js +++ b/internal/web/static/assets/pages/stats.js @@ -180,7 +180,7 @@ function rangeRow(since) { // ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the // built-ins under Settings). function ruleHref(mark) { - if (/^r\d/.test(mark)) return "#/rules"; + if (/^r\d+$/.test(mark)) return "#/rules?rule=" + mark.slice(1); if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings"; if (mark.startsWith("f")) return "#/nat"; return null; diff --git a/internal/web/static/assets/pages/trace.js b/internal/web/static/assets/pages/trace.js index 3c67690..0cee346 100644 --- a/internal/web/static/assets/pages/trace.js +++ b/internal/web/static/assets/pages/trace.js @@ -60,7 +60,7 @@ export async function render(el, params, query) { h("strong", null, p.would_drop.label || [p.would_drop.chain, p.would_drop.mark].filter(Boolean).join(" "))) : null, h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) || /\[would (drop|reject)\]/.test(s.rule || "") ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" }, h("td", { class: "muted small" }, s.table + " " + s.chain), - h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label) + h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain + "&rule=" + s.mark.slice(1).split("-")[0] }, s.label) : s.label) : s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)), h("td", { class: "small" }, s.verdict)))))); } diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 4c58de0..7a11ecb 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -433,6 +433,9 @@ td.matrix-blocked { background: var(--del); color: var(--del-text); } .matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; } td.matrix-partly { background: var(--warn-soft); color: var(--text); } td.matrix-same { opacity: 0.55; } +/* The rule a link pointed at (?rule=ID). */ +tr.row-target td { background: var(--accent-soft); font-weight: 600; } +tr.row-target td:first-child { box-shadow: inset 3px 0 0 var(--accent); } .matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; } td.matrix-none { color: var(--muted); } tr.matrix-rest td { border-top: 2px solid var(--border); }