// 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); }