- 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>
71 lines
4.0 KiB
JavaScript
71 lines
4.0 KiB
JavaScript
// History: every configuration change, with undo.
|
||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||
import { get, change, can, state } from "../api.js";
|
||
import { rerender } from "../app.js";
|
||
|
||
export async function render(el, params) {
|
||
if (params.id) return entry(el, params.id);
|
||
const hs = await get("/history", { limit: 200 });
|
||
mount(el,
|
||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
|
||
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
|
||
h("div", { class: "panel" }, table([
|
||
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
|
||
{ label: "When", render: (e) => time(e.time) },
|
||
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
|
||
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
|
||
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
|
||
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
|
||
].filter(Boolean), hs, { empty: "No changes yet." })));
|
||
}
|
||
|
||
async function undo(e) {
|
||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too." + (!state.immediate ? " It is added to your pending changes, to review and apply." : ""), "Undo")) return;
|
||
try {
|
||
// Like any edit: staged for review, or applied at once in live mode.
|
||
await change("POST", "/history/" + e.id + "/revert");
|
||
rerender();
|
||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||
}
|
||
|
||
async function entry(el, id) {
|
||
const e = await get("/history/" + id);
|
||
mount(el,
|
||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
|
||
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
|
||
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
|
||
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
|
||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||
e.changes.map((c) => h("div", { class: "panel" },
|
||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||
fieldsTable(c))));
|
||
}
|
||
|
||
function action(c) {
|
||
if (!c.before) return "Created";
|
||
if (!c.after) return "Deleted";
|
||
return "Changed";
|
||
}
|
||
|
||
// fieldsTable lists the fields that differ (all of them for a create or
|
||
// delete).
|
||
function fieldsTable(c) {
|
||
const a = c.before || {}, b = c.after || {};
|
||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||
const show = (v, k) => {
|
||
if (v === undefined || v === "") return "–";
|
||
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
|
||
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
|
||
if (k === "probation_seconds") return v ? v + "s" : "default";
|
||
return typeof v === "string" ? v : JSON.stringify(v);
|
||
};
|
||
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
|
||
return table([
|
||
{ label: "Field", render: (k) => h("strong", null, k) },
|
||
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
|
||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||
].filter(Boolean), rows);
|
||
}
|