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
- **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
@@ -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 },
+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("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)),
+1 -1
View File
@@ -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];
+16 -3
View File
@@ -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
+1 -1
View File
@@ -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;
+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("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))))));
}
+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; }
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); }