import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView } from "../ui.js"; import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js"; import { rerender } from "../app.js"; import { optionsEditor } from "./options.js"; import { emailOf, pendingBadge } from "./zones.js"; export async function render(el, params, query) { const id = params.id; const zone = await get("/zones/" + id, draftQuery()); const tab = params.tab || (zone.type === "primary" ? "records" : "settings"); const tabs = [ zone.type === "primary" ? ["records", "Records (" + zone.record_count + ")"] : null, ["settings", "Settings"], ["options", "Options"], zone.type === "primary" ? ["file", "Zone file"] : null, ].filter(Boolean); const body = h("div"); mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", { class: "mono" }, zone.display_name), h("div", { class: "muted" }, badge(zone.type, "accent"), " ", zone.pending ? [pendingBadge(zone.pending), " "] : null, zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null, zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))), h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))), body); if (tab === "records") await recordsTab(body, zone, query); else if (tab === "settings") await settingsTab(body, zone); else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" }); else if (tab === "file") await fileTab(body, zone); } async function deleteZone(zone) { const msg = zone.type === "primary" ? "Delete zone " + zone.display_name + " and its " + zone.record_count + " records? named stops serving it once applied." : "Delete zone " + zone.display_name + "?"; if (!(await confirmDialog("Delete zone", msg, "Delete zone", "danger"))) return; try { const r = await change("DELETE", "/zones/" + zone.id); toast(describeResult(r)); location.hash = "#/zones"; } catch (e) { toast(e.message, "err", 8000); } } // ---- records async function recordsTab(el, zone, query) { const [records, types] = await Promise.all([get("/zones/" + zone.id + "/records", draftQuery()), rrtypes()]); const typeInfo = Object.fromEntries(types.map((t) => [t.type, t])); const err = h("div"); // Add form const fName = input({ placeholder: "@ or www", style: { width: "180px" }, class: "mono" }); const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), "A", { style: { width: "110px" } }); const fTTL = input({ type: "number", placeholder: "default", min: 0, style: { width: "100px" } }); const fData = input({ class: "mono", placeholder: typeInfo.A.example }); const fComment = input({ placeholder: "optional", style: { width: "160px" } }); const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" }); const hint = h("div", { class: "hint" }); const showHint = () => { const t = typeInfo[fType.value] || {}; fData.placeholder = t.example || t.format || ""; mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null); fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA")); }; fType.addEventListener("change", showHint); showHint(); const add = async (e) => { e.preventDefault(); mount(err); const body = { name: fName.value.trim() || "@", type: fType.value, data: fData.value.trim() }; if (fTTL.value !== "") body.ttl = Number(fTTL.value); if (fComment.value.trim()) body.comment = fComment.value.trim(); if (fPTR.input.checked) body.ptr = true; try { const r = await change("POST", "/zones/" + zone.id + "/records", body); toast(describeResult(r)); (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); fData.value = ""; fComment.value = ""; await redraw(); fData.focus(); } catch (ex) { mount(err, errorBox(ex)); } }; const form = h("form", { class: "inline-form", onsubmit: add }, h("div", { class: "f" }, h("label", null, "Name"), fName), h("div", { class: "f" }, h("label", null, "Type"), fType), h("div", { class: "f" }, h("label", null, "TTL"), fTTL), h("div", { class: "f grow" }, h("label", null, "Data"), fData), h("div", { class: "f" }, h("label", null, "Comment"), fComment), h("div", { class: "f" }, h("label", null, " "), fPTR), h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record"))); // Filters and table const filter = input({ type: "search", placeholder: "Filter by name or data…", value: query.get("q") || "" }); const typeFilter = select([["", "All types"], ...[...new Set(records.map((r) => r.type))].sort()], ""); const list = h("div"); let editing = null; let current = records; const redraw = async () => { current = await get("/zones/" + zone.id + "/records", draftQuery()); draw(); }; const draw = () => { const q = filter.value.trim().toLowerCase(); const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value)); let prevName = null; mount(list, table([ { label: "Name", render: (r) => r.relative_name, tdClass: (r) => { const rep = r.name === prevName; prevName = r.name; return "name" + (rep ? " repeat" : ""); } }, { label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" }, { label: "Type", render: (r) => h("strong", null, r.type) }, { label: "Data", render: (r) => r.data, tdClass: "data" }, { label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" }, ], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); if (editing) mountEdit(); }; const del = async (r) => { const isAddr = r.type === "A" || r.type === "AAAA"; let ptr = false; if (isAddr) { const c = checkbox("Also delete the matching PTR record", true); const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]); if (ok !== true) return; ptr = c.input.checked; } else if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data, "Delete", "danger"))) return; try { const res = await change("DELETE", "/records/" + r.id, undefined, { query: ptr ? { ptr: 1 } : undefined }); toast(describeResult(res)); await redraw(); } catch (e) { toast(e.message, "err", 8000); } }; const edit = (r) => { editing = r; draw(); }; const mountEdit = () => { const r = editing; const eName = input({ value: r.relative_name, class: "mono", style: { width: "100%" } }); const eType = select(types.map((t) => t.type), r.type); const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } }); const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } }); const eComment = input({ value: r.comment || "", style: { width: "100%" } }); const ePTR = checkbox("update PTR", false); const eErr = h("div"); const save = async () => { const body = {}; const name = eName.value.trim() || "@"; if (name !== r.relative_name) body.name = name; if (eType.value !== r.type) body.type = eType.value; const shown = r.type === "TXT" && r.text !== undefined ? r.text : r.data; if (eData.value.trim() !== shown || body.type) body.data = eData.value.trim(); if (eTTL.value === "" && r.ttl !== undefined && r.ttl !== null) body.ttl = null; else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value); if (eComment.value !== (r.comment || "")) body.comment = eComment.value; if (ePTR.input.checked) body.ptr = true; if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; } try { const res = await change("PATCH", "/records/" + r.id, body); toast(describeResult(res)); (res.warnings || []).forEach((w) => toast(w, "warn", 8000)); editing = null; await redraw(); } catch (e) { mount(eErr, errorBox(e)); } }; const row = h("div", { class: "panel", style: { background: "var(--accent-soft)" } }, h("h3", null, "Edit record #" + r.id), h("div", { class: "inline-form" }, h("div", { class: "f" }, h("label", null, "Name"), eName), h("div", { class: "f" }, h("label", null, "Type"), eType), h("div", { class: "f" }, h("label", null, "TTL"), eTTL), h("div", { class: "f grow" }, h("label", null, "Data"), eData), h("div", { class: "f" }, h("label", null, "Comment"), eComment), (r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null, h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))), h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."), eErr); list.prepend(row); eData.focus(); }; filter.addEventListener("input", draw); typeFilter.addEventListener("change", draw); mount(el, h("div", { class: "panel" }, form, hint, err), h("div", { class: "panel" }, h("div", { class: "actions", style: { marginBottom: "8px" } }, filter, typeFilter, h("span", { class: "muted small" }, "Highlighted rows are pending changes (not live until applied). Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute.")), list)); draw(); fName.focus(); } // ---- settings async function settingsTab(el, zone) { const views = await get("/views"); const err = h("div"); const rows = []; const s = zone.soa || {}; const mname = input({ value: display(s.mname), class: "mono" }); const rname = input({ value: zone.rname_email || emailOf(s.rname) }); const refresh = input({ type: "number", value: s.refresh }); const retry = input({ type: "number", value: s.retry }); const expire = input({ type: "number", value: s.expire }); const minimum = input({ type: "number", value: s.minimum }); const ttl = input({ type: "number", value: zone.default_ttl }); const policy = select([["", "(server default)"], "date", "increment", "unixtime"], zone.serial_policy || ""); const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || "")); const file = input({ value: zone.file || "", class: "mono" }); const comment = textarea(zone.comment, { rows: 2 }); if (zone.type === "primary") { rows.push( field("Primary name server", mname, "SOA MNAME."), field("Contact", rname, "SOA RNAME, as an email address."), field("Refresh", refresh, "Seconds between secondaries checking for updates."), field("Retry", retry, "Seconds before a failed refresh is retried."), field("Expire", expire, "Seconds after which secondaries stop answering without a successful refresh."), field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."), field("Default TTL", ttl, "TTL for records that don't set their own."), field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes.")); } if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones).")); if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it.")); rows.push(field("Comment", comment)); const save = async () => { mount(err); const body = { comment: comment.value }; if (zone.type === "primary") { body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value }; body.default_ttl = +ttl.value; body.serial_policy = policy.value; } if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim(); if (views.length) body.view_id = view.value ? Number(view.value) : 0; try { const r = await change("PATCH", "/zones/" + zone.id, body); toast(describeResult(r)); rerender(); } catch (e) { mount(err, errorBox(e)); } }; mount(el, h("div", { class: "panel" }, h("div", { class: "form-grid" }, rows), err, h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: save }, "Save settings")))); } // ---- zone file async function fileTab(el, zone) { const text = await get("/zones/" + zone.id + "/file", draftQuery()); const imp = textarea("", { class: "mono", rows: 10, placeholder: "$TTL 3600\nwww IN A 192.0.2.10\n..." }); const mode = select([["merge", "merge: add records that are missing"], ["replace", "replace: make the zone equal to this text"]], "merge"); const useSOA = checkbox("Also take SOA timers and $TTL from the text", false); const out = h("div"); const run = async (dry) => { mount(out); try { const body = { text: imp.value, mode: mode.value, use_soa: useSOA.input.checked, dry_run: dry }; const r = dry ? await get_dry(zone, body) : await change("POST", "/zones/" + zone.id + "/import", body); if (dry) { mount(out, h("h3", null, "Preview"), diffViewOf(r)); } else { toast(describeResult(r)); (r.warnings || []).forEach((w) => toast(w, "warn", 8000)); rerender(); } } catch (e) { mount(out, errorBox(e)); } }; mount(el, h("div", { class: "panel" }, h("h2", null, "Generated zone file"), h("p", { class: "muted small" }, state.draft && !state.immediate ? "What Jarl will write for named once the pending changes are applied." : "This is exactly what Jarl writes for named."), h("pre", null, text)), h("div", { class: "panel" }, h("h2", null, "Import records from zone file text"), h("p", { class: "muted small" }, "Paste records in zone file format. Names are relative to " + zone.display_name + ". The SOA record is ignored unless you tick the box."), imp, h("div", { class: "actions", style: { marginTop: "8px" } }, mode, useSOA, h("button", { onclick: () => run(true) }, "Preview"), h("button", { class: "primary", onclick: () => run(false) }, "Import")), out)); } async function get_dry(zone, body) { return api("POST", "/zones/" + zone.id + "/import", { body: { ...body, dry_run: true } }); } function diffViewOf(r) { const res = r && r.result; if (!res) return h("div", { class: "muted" }, (r.warnings || []).join("; ") || "Nothing to import."); return h("div", null, (r.warnings || []).length ? h("div", { class: "msg warn" }, r.warnings.join("; ")) : null, diffView(res.diff)); }