From fc390eb57cc2517b2184b9e46925e62366c289d6 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Mon, 19 Oct 2026 13:23:28 +0100 Subject: [PATCH] Rule dialog: the less used parts behind "+" buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new rule window shows chain, zones, addresses, services, family, action, log and comment; connection state, time window, rate limit, quota, connection limit and raw nftables open with "+" buttons (and close with ✕, which unsets them). Editing a rule opens what it uses. Group addresses appears only when a per-address limit, quota or connection limit is set. Clearer help for connection state and status. Co-Authored-By: Claude Opus 5.5 --- internal/web/static/assets/pages/rules.js | 85 ++++++++++++++++++----- internal/web/static/assets/style.css | 3 + 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 7638af5..3260dfc 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -190,27 +190,77 @@ export async function ruleDialog(r, chain) { const comment = input({ value: r.comment || "" }); const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled); const err = h("div"); + + // The parts most rules don't need are behind "+" buttons: open when the + // rule uses them, closed (and unset) with ✕. + const adders = h("div", { class: "adders" }); + const sections = {}; + const section = (key, label, open, fields) => { + const nodes = fields.flat().filter(Boolean); + const sec = { open }; + const add = h("button", { type: "button", class: "small", onclick: () => show(true) }, "+ " + label); + const close = h("button", { type: "button", class: "small close-section", title: "Remove " + label.toLowerCase(), onclick: () => show(false) }, "✕"); + nodes[0].append(" ", close); + const show = (on) => { + sec.open = on; + for (const n of nodes) n.classList.toggle("hidden", !on); + add.classList.toggle("hidden", on); + if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); } + updateGroup(); + }; + adders.append(add, " "); + sections[key] = sec; + sec.nodes = nodes; + sec.init = () => show(open); + return nodes; + }; + const stateFields = section("state", "Connection state", (r.ct_state || []).length > 0 || (r.ct_status || []).length > 0, [ + field("Connection state", states, "new: a connection's first packet; established: its later ones; related: packets that belong to another connection (ICMP errors, FTP data); invalid: what connection tracking can't place. Empty: any."), + field("Connection status", statuses, "dnat: the connection was port forwarded; snat: its source was translated. Empty: any."), + ]); + const timeFields = section("time", "Time window", (r.days || []).length > 0 || !!r.hour_from, [ + field("Days", days), + field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight. In the firewall's local time."), + ]); + const rateFields = section("rate", "Rate limit", !!r.limit, [ + field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over), + "In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."), + ]); + const quotaFields = section("quota", "Quota", !!r.quota, [ + field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver), + "An allowance of traffic; Housecarl keeps the count across changes and restarts."), + ]); + const connFields = section("conn", "Connection limit", !!r.conn_limit, [ + field("Connection limit", conn, "Match a source that already has more than this many connections."), + ]); + const rawFields = section("raw", "Write it in nftables", !!r.raw, [ + field("Raw nftables", raw, "Instead of the matches above: one nftables rule, for what Housecarl can't say. Checked by nftables."), + ]); + // Grouping only means something with a per-address limit, quota or + // connection limit. + const groupFields = field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6), + "Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses).").filter(Boolean); + function updateGroup() { + if (!sections.raw) return; // still building + const on = (sections.rate.open && limitKey.value !== "") || (sections.quota.open && quotaKey.value !== "") || sections.conn.open; + for (const n of groupFields) n.classList.toggle("hidden", !on); + } + limitKey.addEventListener("change", updateGroup); + quotaKey.addEventListener("change", updateGroup); + const body = h("div", null, h("div", { class: "form-grid" }, field("Chain", ch), ...match.flat(), field("Action", h("div", null, action, " ", rejectWith)), - field("Connection state", states), - field("Connection status", statuses, "dnat: the connection was port forwarded."), - field("Days", days), - field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight."), - field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over), - "Empty: no limit. In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."), - field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver), - "Empty: none. An allowance of traffic; Housecarl keeps the count across changes and restarts."), - field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6), - "Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses)."), - field("Connection limit", conn, "Match a source that already has more than this many connections."), + ...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields, + h("label"), adders, field("Log", h("div", null, log, logPrefix)), - field("Raw nftables", raw, "Instead of all the above: one nftables rule, for what Housecarl can't say. Checked by nftables."), field("Comment", comment), r.id ? [h("label"), disabled] : null), err); + for (const sec of Object.values(sections)) sec.init(); + updateGroup(); await modal(r.id ? "Edit rule" : "Add rule", body, [ { label: "Cancel", value: null }, { label: "Check", onclick: () => save(true) }, @@ -219,15 +269,18 @@ export async function ruleDialog(r, chain) { async function save(dry) { mount(err); + const on = (k) => sections[k].open; const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "", - ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(), - limit: rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, + ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [], + days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "", + limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, per_source: limitKey.value === "source", per_destination: limitKey.value === "destination" } : null, - quota: quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked, + quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked, per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination", reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null, group_v4: parseInt(group4.value || "0", 10), group_v6: parseInt(group6.value || "0", 10), - conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() }; + conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, log: log.input.checked, log_prefix: logPrefix.value.trim(), + raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() }; if (r.id) v.disabled = disabled.input.checked; try { await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined }); diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 2e5b5c5..80ef38f 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -409,3 +409,6 @@ input.wide { width: 100%; } .diff .hunk { background: var(--hunk); color: var(--accent); } .diff .file { font-weight: 700; padding-top: 6px; } select.inline { width: auto; display: inline-block; } +/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */ +.adders { display: flex; flex-wrap: wrap; gap: 6px; } +button.close-section { padding: 0 6px; margin-left: 4px; font-size: 11px; line-height: 1.5; }