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 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 19:18:33 +01:00
1 parent 566c07cbc7
commit 21ddced1ca
8 files changed
+31 -8

No files matched your search

+7
View File
@@ -8,6 +8,11 @@ All notable changes to Housecarl are documented here. The format follows
### Added ### 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** - **A clearer zone matrix.** A cell reads allow, block, or **partly**
(some new connections let through, the rest not), with the exceptions (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 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 between its interfaces passes the firewall). The details list each
rule's hits. API: `summary`, `allowed`, `blocked`, `open`, rule's hits. API: `summary`, `allowed`, `blocked`, `open`,
`same_zone`, `note`, `hits`, and `short` and `hits` per exception. `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): - **Shadow mode** (`shadow.enabled` in housecarl.yaml; docs/shadow.md):
Housecarl's ruleset is loaded beside the firewall you have but blocks, Housecarl's ruleset is loaded beside the firewall you have but blocks,
rejects and translates nothing. Drops and rejects are counted and accept rejects and translates nothing. Drops and rejects are counted and accept
@@ -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")), h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
table([ table([
{ label: "Chain", render: (r) => r.chain }, { 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: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Hits", render: (r) => hits(cs, "r" + r.id) },
{ label: "Comment", render: (r) => r.comment }, { label: "Comment", render: (r) => r.comment },
+1 -1
View File
@@ -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("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("tbody", null, es.map((e) => h("tr", null,
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), 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), 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" }, 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)), h("td", { class: "mono" }, addr(e.source, e.source_port)), h("td", { class: "mono" }, addr(e.destination, e.destination_port)),
+1 -1
View File
@@ -32,7 +32,7 @@ export async function render(el) {
} }
function detail(c) { 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 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 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]; const summary = { allowed: "all allowed", blocked: "all blocked", partly: "partly allowed" }[c.summary];
+16 -3
View File
@@ -24,7 +24,14 @@ const builtins = {
}; };
export async function render(el, params, query) { 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()]); 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. // As nftables checks them: the rules that count traffic first.
const rules = [...all.filter(everyPacket), ...all.filter((r) => !everyPacket(r))]; 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" }, h("div", { class: "panel" },
builtinsRow(chain, settings, cs), builtinsRow(chain, settings, cs),
table([ 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)] { 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])] }, : [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
{ label: "Action", render: actionBadge }, { label: "Action", render: actionBadge },
@@ -59,10 +67,15 @@ export async function render(el, params, query) {
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"), h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
]; ];
} } : null, } } : 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, 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"), h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null))); 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 // everyPacket: checked before established connections are accepted (as
+1 -1
View File
@@ -180,7 +180,7 @@ function rangeRow(since) {
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the // ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
// built-ins under Settings). // built-ins under Settings).
function ruleHref(mark) { 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("b-") || mark.startsWith("p-")) return "#/settings";
if (mark.startsWith("f")) return "#/nat"; if (mark.startsWith("f")) return "#/nat";
return null; return null;
+1 -1
View File
@@ -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("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("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", { 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)), : s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
h("td", { class: "small" }, s.verdict)))))); h("td", { class: "small" }, s.verdict))))));
} }
+3
View File
@@ -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; } .matrix-n { margin-left: 6px; font-weight: 400; font-size: 12px; }
td.matrix-partly { background: var(--warn-soft); color: var(--text); } td.matrix-partly { background: var(--warn-soft); color: var(--text); }
td.matrix-same { opacity: 0.55; } 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; } .matrix-open { display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px; }
td.matrix-none { color: var(--muted); } td.matrix-none { color: var(--muted); }
tr.matrix-rest td { border-top: 2px solid var(--border); } tr.matrix-rest td { border-top: 2px solid var(--border); }