// Changes: the pending changeset — its operations, the check, and the // ruleset's diff — to apply together or discard. Other open changesets // (from hc or the API) can be taken over here. import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, time, plural, badge } from "../ui.js"; import { api, state, useDraft, refreshDraft, can } from "../api.js"; import { rerender, applyDraft } from "../app.js"; export async function render(el) { await refreshDraft(); const open = await api("GET", "/changesets", { query: { status: "open", limit: 50 } }); const d = state.draft; const parts = [h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Pending changes"), h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches the firewall until you apply it; then the whole changeset is checked by Housecarl and nftables and put in force in one step, as one entry in the history. Over the network it is on probation: confirm it, or it rolls back.")))]; if (state.immediate) { parts.push(h("div", { class: "msg info" }, "Live edits are on (the switch at the top): edits are applied right away, not collected here.")); } if (!d || !(d.ops || []).length) { parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit rules, NAT, objects or settings and they show up here."))); } else { parts.push(draftPanel(d)); } const others = open.filter((c) => !d || c.id !== d.id); if (others.length) { parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"), h("p", { class: "muted small" }, "Made with hc or the API, by others, or earlier here. Switch to one to review and apply it."), table([ { label: "#", render: (c) => String(c.id) }, { label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") }, { label: "Operations", render: (c) => String((c.ops || []).length) }, { label: "By", render: (c) => [c.username, h("span", { class: "muted small" }, " via " + c.via)] }, { label: "Updated", render: (c) => time(c.updated_at) }, can("config", true) ? { label: "", tdClass: "actions-cell", render: (c) => [ h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ", h("button", { class: "small danger", onclick: async () => { if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return; try { await api("POST", "/changesets/" + c.id + "/discard"); } catch (e) { toast(e.message, "err"); } rerender(); } }, "Discard")] } : null, ].filter(Boolean), others))); } mount(el, parts); } function draftPanel(d) { const ops = d.ops || []; const desc = input({ value: d.description || "", placeholder: "Describe this change (it becomes the history entry's description)", class: "wide" }); const preview = h("div", { class: "muted" }, "Checking…"); const result = h("div"); const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes"); const removeOp = async (seq) => { try { useDraft(await api("DELETE", "/changesets/" + d.id + "/ops/" + seq)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }; const opsTable = table([ { label: "#", render: (o) => String(o.seq) }, { label: "Change", render: (o) => o.summary || o.op }, { label: "Added", render: (o) => time(o.added_at) }, { label: "", tdClass: "actions-cell", render: (o) => h("button", { class: "small", title: "Take this operation out of the changeset", onclick: () => removeOp(o.seq) }, "Remove") }, ], ops); // failedOp marks the operation that no longer applies, and offers to // take it out. const failedOp = (err) => { const i = ops.findIndex((o) => o.seq === err.op); const rows = opsTable.querySelectorAll("tbody tr"); if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err }); rows[i].classList.add("op-failed"); return h("div", { class: "msg err" }, h("div", null, err.message), h("div", { class: "form-actions" }, h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op), " ", h("span", { class: "muted small" }, "It is marked in the list above. Something it names changed since it was staged."))); }; (async () => { try { const p = await api("GET", "/changesets/" + d.id + "/preview"); applyBtn.disabled = !p.valid; mount(preview, p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null, p.valid ? h("div", { class: "msg ok" }, "Valid: Housecarl's checks pass and nftables accepts the new ruleset.") : null, !p.error && !p.valid ? h("div", { class: "msg info" }, "Together these operations change nothing.") : null, problemsList(p.warnings), h("h3", null, "The ruleset (/var/lib/housecarl/ruleset.nft)"), p.valid ? diffView(p.diff) : h("div", { class: "muted" }, "No diff until the changeset can be applied.")); } catch (e) { mount(preview, errorBox(e)); } })(); applyBtn.addEventListener("click", async () => { mount(result); if (desc.value !== (d.description || "")) { try { await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); } catch { /* the apply says it */ } } const r = await applyDraft(applyBtn, d, desc.value); if (r && r.history_id) location.hash = "#/history/" + r.history_id; }); const discard = h("button", { class: "danger", onclick: async () => { if (!(await confirmDialog("Discard changes", "Throw away all " + plural(ops.length, "pending change") + "?", "Discard", "danger"))) return; try { await api("POST", "/changesets/" + d.id + "/discard"); } catch (e) { toast(e.message, "err"); } useDraft(null); rerender(); } }, "Discard all"); return h("div", null, h("div", { class: "panel" }, h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")), desc, h("div", { class: "form-actions" }, opsTable), h("div", { class: "actions form-actions" }, applyBtn, discard), result), h("div", { class: "panel" }, h("h2", null, "Review"), preview)); }