Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
3.8 KiB
JavaScript
70 lines
3.8 KiB
JavaScript
// History: every configuration change, with undo.
|
||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||
import { get, change, can } 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.", "Undo")) return;
|
||
try {
|
||
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 === "lease_time" || k === "decline_hold") 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);
|
||
}
|