Files
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00

112 lines
6.3 KiB
JavaScript

// Changes: the pending changeset — its operations, the check, and the
// ruleset's diff — to apply together or discard. Other open changesets
// (from hc or the API) can be taken over here.
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, time, plural, badge } from "../ui.js";
import { api, state, useDraft, refreshDraft, can } from "../api.js";
import { rerender, applyDraft } from "../app.js";
export async function render(el) {
await refreshDraft();
const open = await api("GET", "/changesets", { query: { status: "open", limit: 50 } });
const d = state.draft;
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches the firewall until you apply it; then the whole changeset is checked by Housecarl and nftables and put in force in one step, as one entry in the history. Over the network it is on probation: confirm it, or it rolls back.")))];
if (state.immediate) {
parts.push(h("div", { class: "msg info" }, "Live edits are on (the switch at the top): edits are applied right away, not collected here."));
}
if (!d || !(d.ops || []).length) {
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit rules, NAT, objects or settings and they show up here.")));
} else {
parts.push(draftPanel(d));
}
const others = open.filter((c) => !d || c.id !== d.id);
if (others.length) {
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
h("p", { class: "muted small" }, "Made with hc or the API, by others, or earlier here. Switch to one to review and apply it."),
table([
{ label: "#", render: (c) => String(c.id) },
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
{ label: "Operations", render: (c) => String((c.ops || []).length) },
{ label: "By", render: (c) => [c.username, h("span", { class: "muted small" }, " via " + c.via)] },
{ label: "Updated", render: (c) => time(c.updated_at) },
can("config", true) ? { label: "", tdClass: "actions-cell", render: (c) => [
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
h("button", { class: "small danger", onclick: async () => {
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
try { await api("POST", "/changesets/" + c.id + "/discard"); } catch (e) { toast(e.message, "err"); }
rerender();
} }, "Discard")] } : null,
].filter(Boolean), others)));
}
mount(el, parts);
}
function draftPanel(d) {
const ops = d.ops || [];
const desc = input({ value: d.description || "", placeholder: "Describe this change (it becomes the history entry's description)", class: "wide" });
const preview = h("div", { class: "muted" }, "Checking…");
const result = h("div");
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
const removeOp = async (seq) => {
try {
useDraft(await api("DELETE", "/changesets/" + d.id + "/ops/" + seq));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const opsTable = table([
{ label: "#", render: (o) => String(o.seq) },
{ label: "Change", render: (o) => o.summary || o.op },
{ label: "Added", render: (o) => time(o.added_at) },
{ label: "", tdClass: "actions-cell", render: (o) => h("button", { class: "small", title: "Take this operation out of the changeset", onclick: () => removeOp(o.seq) }, "Remove") },
], ops);
// failedOp marks the operation that no longer applies, and offers to
// take it out.
const failedOp = (err) => {
const i = ops.findIndex((o) => o.seq === err.op);
const rows = opsTable.querySelectorAll("tbody tr");
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
rows[i].classList.add("op-failed");
return h("div", { class: "msg err" },
h("div", null, err.message),
h("div", { class: "form-actions" },
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
" ", h("span", { class: "muted small" }, "It is marked in the list above. Something it names changed since it was staged.")));
};
(async () => {
try {
const p = await api("GET", "/changesets/" + d.id + "/preview");
applyBtn.disabled = !p.valid;
mount(preview,
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
p.valid ? h("div", { class: "msg ok" }, "Valid: Housecarl's checks pass and nftables accepts the new ruleset.") : null,
!p.error && !p.valid ? h("div", { class: "msg info" }, "Together these operations change nothing.") : null,
problemsList(p.warnings),
h("h3", null, "The ruleset (/var/lib/housecarl/ruleset.nft)"),
p.valid ? diffView(p.diff) : h("div", { class: "muted" }, "No diff until the changeset can be applied."));
} catch (e) { mount(preview, errorBox(e)); }
})();
applyBtn.addEventListener("click", async () => {
mount(result);
if (desc.value !== (d.description || "")) {
try { await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); } catch { /* the apply says it */ }
}
const r = await applyDraft(applyBtn, d, desc.value);
if (r && r.history_id) location.hash = "#/history/" + r.history_id;
});
const discard = h("button", { class: "danger", onclick: async () => {
if (!(await confirmDialog("Discard changes", "Throw away all " + plural(ops.length, "pending change") + "?", "Discard", "danger"))) return;
try { await api("POST", "/changesets/" + d.id + "/discard"); } catch (e) { toast(e.message, "err"); }
useDraft(null);
rerender();
} }, "Discard all");
return h("div", null,
h("div", { class: "panel" },
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
desc,
h("div", { class: "form-actions" }, opsTable),
h("div", { class: "actions form-actions" }, applyBtn, discard),
result),
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
}