Files
housecarl/internal/web/static/assets/pages/history.js
T
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

71 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}