Files
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00

89 lines
5.6 KiB
JavaScript

// Address sets, and dynamic sets' entries.
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
import { get, api, change, can } from "../api.js";
import { rerender } from "../app.js";
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
export async function render(el) {
const sets = await get("/sets");
const write = can("config", true);
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Address sets"), h("div", { class: "muted" }, "Named lists of IPv4 and IPv6 addresses, prefixes and ranges, mixed. A dynamic set takes entries at run time, each with a timeout (a captive portal's signed-in clients).")),
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => setDialog(null) }, "Add set")) : null),
h("div", { class: "panel" }, table([
{ label: "Name", render: (s) => h("strong", null, s.name) },
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
{ label: "Comment", render: (s) => s.comment },
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
}
async function del(s) {
if (!await confirmDialog("Delete set", "Delete set " + s.name + "?", "Delete", "danger")) return;
try { await change("DELETE", "/sets/" + s.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
}
async function setDialog(s) {
s = s || { entries: [] };
const name = input({ value: s.name || "", placeholder: "trusted" });
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
// A dynamic set has no configured entries; only it has a timeout.
const entryFields = field("Entries", list).filter(Boolean);
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
const showKind = () => {
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
};
dynamic.input.addEventListener("change", showKind);
showKind();
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
const comment = input({ value: s.comment || "" });
const err = h("div");
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [
{ label: "Cancel", value: null },
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
mount(err);
const t = parseSeconds(timeout.value);
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
try {
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true, sticky: true });
}
// entries shows and changes a dynamic set's entries.
async function entries(s) {
const body = h("div");
const write = can("sets", true);
const draw = async () => {
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
const addr = input({ placeholder: "192.168.1.50 or 2001:db8:1::50", class: "mono" });
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
mount(body,
table([
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.address) },
{ label: "Expires in", render: (e) => e.timeout ? fmtSeconds(e.timeout) : "never" },
write ? { label: "", render: (e) => h("button", { class: "small", onclick: async () => {
try { await api("DELETE", "/sets/" + encodeURIComponent(s.name) + "/elements/" + encodeURIComponent(e.address)); draw(); } catch (x) { toast(x.message, "err"); }
} }, "Remove") } : null,
].filter(Boolean), es, { empty: "No entries." }),
write ? h("div", { class: "filter-row" }, addr, t, h("button", { class: "primary small", onclick: async () => {
const secs = parseSeconds(t.value);
if (secs === null) { toast("Write the timeout as 1h, 30m or 1d", "err"); return; }
try { await api("POST", "/sets/" + encodeURIComponent(s.name) + "/elements", { body: { address: addr.value.trim(), timeout: secs } }); draw(); } catch (x) { toast(x.message, "err"); }
} }, "Add")) : null);
};
await draw();
await modal("Entries of " + s.name, body, [{ label: "Close", value: null }], { wide: true });
}