Rule dialog: the less used parts behind "+" buttons

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 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 13:23:28 +01:00
1 parent 09504e5555
commit fc390eb57c
2 files changed
+72 -16

No files matched your search

+69 -16
View File
@@ -190,27 +190,77 @@ export async function ruleDialog(r, chain) {
const comment = input({ value: r.comment || "" }); const comment = input({ value: r.comment || "" });
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled); const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
const err = h("div"); 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, const body = h("div", null,
h("div", { class: "form-grid" }, h("div", { class: "form-grid" },
field("Chain", ch), field("Chain", ch),
...match.flat(), ...match.flat(),
field("Action", h("div", null, action, " ", rejectWith)), field("Action", h("div", null, action, " ", rejectWith)),
field("Connection state", states), ...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
field("Connection status", statuses, "dnat: the connection was port forwarded."), h("label"), adders,
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."),
field("Log", h("div", null, log, logPrefix)), 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), field("Comment", comment),
r.id ? [h("label"), disabled] : null), r.id ? [h("label"), disabled] : null),
err); err);
for (const sec of Object.values(sections)) sec.init();
updateGroup();
await modal(r.id ? "Edit rule" : "Add rule", body, [ await modal(r.id ? "Edit rule" : "Add rule", body, [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: "Check", onclick: () => save(true) }, { label: "Check", onclick: () => save(true) },
@@ -219,15 +269,18 @@ export async function ruleDialog(r, chain) {
async function save(dry) { async function save(dry) {
mount(err); 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 : "", 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(), ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [],
limit: rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, 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, 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", per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null, 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), 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; if (r.id) v.disabled = disabled.input.checked;
try { try {
await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined }); await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined });
+3
View File
@@ -409,3 +409,6 @@ input.wide { width: 100%; }
.diff .hunk { background: var(--hunk); color: var(--accent); } .diff .hunk { background: var(--hunk); color: var(--accent); }
.diff .file { font-weight: 700; padding-top: 6px; } .diff .file { font-weight: 700; padding-top: 6px; }
select.inline { width: auto; display: inline-block; } 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; }