// History: every configuration change, with undo. import { h, mount, table, time, confirmDialog, modal, errorBox, badge, diffView } 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, d] = await Promise.all([get("/history/" + id), get("/history/" + id + "/diff").catch(() => null)]); 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))), d ? h("div", { class: "panel" }, h("h3", null, "Ruleset"), h("p", { class: "muted small" }, "What this change did to the ruleset, rendered by this version of Housecarl."), diffView(d.diff)) : null); } 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). An object within (settings' builtins) is listed by its own // fields, "builtins.drop_invalid", so only what changed shows. function fieldsTable(c) { const a = flatten(c.before || {}), b = flatten(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); } // flatten lifts the fields of objects within an object to dotted keys // ({builtins: {loopback: true}} → {"builtins.loopback": true}); lists // stay whole. function flatten(o, prefix = "", out = {}) { for (const [k, v] of Object.entries(o)) { if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length) flatten(v, prefix + k + ".", out); else out[prefix + k] = v; } return out; }