import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } 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"; import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js"; let renderSeq = 0; export async function render(el, params, query) { const id = params.id; const [zone, views] = await Promise.all([get("/zones/" + id, draftQuery()), get("/views")]); const view = views.find((v) => v.id === zone.view_id); 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, ["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null, ].filter(Boolean); const body = h("div"); const head = h("div", { class: "page-head" }, h("div", { class: "grow" }, backLink("Zones", lastList.zones || "#/zones"), h("h1", { class: "mono" }, zone.display_name), h("div", { class: "muted" }, badge(zone.type, "accent"), " ", zone.pending ? [pendingBadge(zone.pending), " "] : null, zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : 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"))); const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label))); // The header and tabs show at once. Below them, switching tabs keeps the // previous tab's content (faded) until the new one is loaded, rather // than blanking the page; opening the zone shows "Loading…" there. const seq = ++renderSeq; const prev = el.querySelector(`:scope > .zone-body[data-zone="${id}"]`); const holder = prev || h("div", { class: "zone-body" }, h("div", { class: "muted" }, "Loading…")); holder.classList.add("stale"); body.className = "zone-body"; body.dataset.zone = id; mount(el, head, tabBar, holder); 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); else if (tab === "servers") await serversTab(body, zone); // A later click (another tab) has taken over the page. if (seq !== renderSeq || !holder.isConnected) return; holder.replaceWith(body); const first = body.querySelector("[data-autofocus]"); if (first) first.focus(); } // ---- name servers: serials on the other servers, and the delegation const nsStatus = { self: ["this server", ""], in_sync: ["in sync", "ok"], answered: ["answers", "ok"], behind: [null, "warn"], ahead: [null, "warn"], not_authoritative: ["doesn't serve the zone", "err"], refused: ["refused", "err"], unreachable: ["no answer", "err"], no_route: ["no route from here", ""], no_address: ["no address", "err"], error: ["error", "err"], }; function nsBadge(r) { let [text, kind] = nsStatus[r.status] || [r.status, "err"]; if (r.status === "behind") text = "behind by " + -r.diff; if (r.status === "ahead") text = "ahead by " + r.diff; return [badge(text, kind), r.error && r.status !== "unreachable" ? h("div", { class: "muted small" }, r.error) : null]; } function nsTable(servers, withSource) { return table([ { label: "Server", render: (r) => r.name ? display(r.name) : h("span", { class: "muted" }, "–"), tdClass: "mono" }, { label: "Address", render: (r) => (r.address || "–") + (r.port ? " port " + r.port : ""), tdClass: "mono small" }, withSource ? { label: "From", render: (r) => r.source, tdClass: "small" } : null, { label: "Status", render: nsBadge }, { label: "Serial", render: (r) => r.serial ?? "–", tdClass: "mono" }, { label: "Time", render: (r) => r.rtt_ms ? r.rtt_ms + " ms" : "", tdClass: "small muted" }, ].filter(Boolean), servers, { empty: "No other name servers to ask." }); } async function serversTab(el, zone) { const serials = h("div"); const loadSerials = async () => { mount(serials, h("div", { class: "muted" }, "Asking the name servers…")); try { const r = await get("/zones/" + zone.id + "/serials"); const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source]; mount(serials, h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null), nsTable(r.servers, true), r.notes.map((n) => h("p", { class: "muted small" }, n)), h("p", { class: "muted small" }, "Checked ", time(r.checked_at), ".")); } catch (err) { mount(serials, errorBox(err)); } }; const parts = [h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"), h("button", { class: "small", onclick: loadSerials }, "Check again")), h("p", { class: "muted small" }, zone.type === "primary" ? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet." : "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."), serials)]; if (zone.type === "primary") { const out = h("div"); const resolver = input({ placeholder: "the server's own" }); const run = async (e) => { e.preventDefault(); mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…")); try { const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {}; mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q))); } catch (err) { mount(out, errorBox(err)); } }; parts.push(h("div", { class: "panel" }, h("h2", null, "Delegation from the parent zone"), h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."), h("form", { class: "inline-form", onsubmit: run }, h("div", { class: "f" }, h("label", null, "Resolver (optional)"), resolver), h("div", { class: "f" }, h("label", null, " "), h("button", { type: "submit" }, "Check delegation"))), out)); } mount(el, parts); await loadSerials(); } function delegationReport(r) { const lvl = { ok: "ok", info: "", warning: "warn", error: "err" }; const parents = {}; for (const p of r.parent_servers) { const say = p.status === "delegated" ? "delegate to " + p.ns.map(display).join(", ") : p.status === "not_delegated" ? "no delegation" : p.status + (p.error ? ": " + p.error : ""); (parents[say] = parents[say] || []).push(display(p.name)); } return [ r.parent ? h("p", null, "Parent zone ", h("strong", { class: "mono" }, display(r.parent)), h("span", { class: "muted" }, " (found through " + r.resolver + ")")) : null, Object.keys(parents).length ? h("ul", { class: "small" }, Object.entries(parents).map(([say, who]) => h("li", null, who.length > 1 ? who[0] + " and " + (who.length - 1) + " more: " : who[0] + ": ", say))) : null, h("div", null, r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))), r.glue.length ? table([ { label: "Glue for", render: (g) => display(g.name), tdClass: "mono" }, { label: "Parent", render: (g) => g.parent.join(", ") || "none", tdClass: "mono small" }, { label: "Zone", render: (g) => g.jarl.join(", ") || "none", tdClass: "mono small" }, { label: "", render: (g) => g.match ? badge("matches", "ok") : badge("differs", "warn") }, ], r.glue) : null, r.servers.length ? [h("h3", null, "Delegated name servers"), nsTable(r.servers, false)] : null, r.notes.map((n) => h("p", { class: "muted small" }, n)), ]; } 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"); const zoneName = zone.name.replace(/\.$/, ""); const reverse = isReverseZone(zone.name); const rinfo = reverse ? reverseInfo(zone.name) : null; // Add form. In reverse zones records are named by address. const fName = input({ placeholder: reverse && rinfo ? rinfo.example : "@ or www", style: { width: reverse ? "220px" : "180px" }, class: "mono", autocomplete: "off", spellcheck: "false", "data-autofocus": true }); const fType = select(types.map((t) => [t.type, t.type + (t.common ? "" : "")]), reverse ? "PTR" : "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 willAdd = h("div", { class: "will-add", "aria-live": "polite" }); const suggest = h("div", { class: "aml-suggest" }); const showHint = () => { const t = typeInfo[fType.value] || {}; fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : 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(); preview(); }); showHint(); // ownerOf shows what the Name field becomes. const ownerOf = (v) => { v = v.trim(); if (reverse) { const r = ptrOwner(v || "@", zone.name); if (!v || v === "@") return { fqdn: zoneName }; return r; } if (!v || v === "@") return { fqdn: zoneName }; if (v.endsWith(".")) return { fqdn: v.replace(/\.$/, "").toLowerCase() }; const low = v.toLowerCase(); if (low === zoneName || low.endsWith("." + zoneName)) return { fqdn: low, note: "\"" + v + "\" already ends with the zone name, so it is read as " + low + " (end it with a dot to be explicit)" }; return { fqdn: low + "." + zoneName }; }; // Which word of the data is a domain name, per type. const targetIndex = { CNAME: 0, DNAME: 0, NS: 0, PTR: 0, MX: 1, SRV: 3, KX: 1, AFSDB: 1 }; const dataOf = (type, data) => { const words = data.trim().split(/\s+/); const i = targetIndex[type]; if (i === undefined || words.length <= i || !words[i]) return { text: data.trim() }; const w = words[i]; let target, note, error; if (w === "@") target = zoneName + "."; else if (w.endsWith(".")) target = w; else if (reverse && type === "PTR") { if (w.includes(".")) target = w + "."; else error = "give the host's full name, e.g. " + w + ".adbraun.lol (" + w + " alone would mean " + w + "." + zoneName + ")"; } else { target = w + "." + zoneName + "."; if (w.includes(".")) note = "\"" + w + "\" has no final dot, so it means " + target + "; write " + w + ". if you meant the name outside this zone"; } if (error) return { error }; words[i] = target; return { text: words.join(" "), note }; }; let lookupTimer = null, lookupFor = ""; const preview = () => { const n = fName.value, d = fData.value; if (!n.trim() && !d.trim()) { mount(willAdd); mount(suggest); return; } const o = ownerOf(n); const dd = d.trim() ? dataOf(fType.value, d) : { text: "…" }; const lines = []; if (o.error) lines.push(h("div", { class: "msg err small" }, o.error)); if (dd.error) lines.push(h("div", { class: "msg err small" }, dd.error)); if (o.note) lines.push(h("div", { class: "msg warn small" }, o.note)); if (dd.note) lines.push(h("div", { class: "msg warn small" }, dd.note)); if (reverse && o.fqdn && fType.value === "PTR") { const existing = current.filter((r) => r.type === "PTR" && r.name.replace(/\.$/, "") === o.fqdn && r.pending !== "delete"); if (existing.length) lines.push(h("div", { class: "msg warn small" }, "this address already has a PTR record (" + existing.map((r) => r.data).join(", ") + "); a second one gives it two names")); } mount(willAdd, o.fqdn && !o.error ? h("div", null, h("span", { class: "muted" }, "Will add: "), h("code", null, o.fqdn + ". " + (fTTL.value !== "" ? fTTL.value + " " : "") + fType.value + " " + (dd.text || "")), reverse && addressOf(o.fqdn) ? h("span", { class: "muted" }, " (address " + addressOf(o.fqdn) + ")") : null) : null, lines); // In reverse zones, offer the names that already point to the address. const addr = reverse && o.fqdn ? addressOf(o.fqdn) : ""; if (addr !== lookupFor) { lookupFor = addr; mount(suggest); clearTimeout(lookupTimer); if (addr && fType.value === "PTR") lookupTimer = setTimeout(async () => { const hits = await get("/search", { q: addr }).catch(() => []); if (lookupFor !== addr) return; const names = [...new Set(hits.filter((x) => x.kind === "record").map((x) => x.label.split(" ")).filter((l) => (l[1] === "A" || l[1] === "AAAA") && l[2] === addr).map((l) => l[0]))]; mount(suggest, names.length ? [h("span", { class: "muted small" }, "Hosts with this address:"), names.map((nm) => h("button", { type: "button", class: "chip", onclick: () => { fData.value = nm.replace(/\.$/, "") + "."; preview(); fData.focus(); } }, nm))] : h("span", { class: "muted small" }, "No A/AAAA record points to " + addr + " yet. Tip: adding the A record with \"PTR\" ticked creates this PTR for you.")); }, 250); } }; for (const f of [fName, fData, fTTL]) f.addEventListener("input", preview); const add = async (e) => { e.preventDefault(); mount(err); const o = ownerOf(fName.value); const dd = dataOf(fType.value, fData.value); if (o.error || dd.error) { mount(err, h("div", { class: "msg err" }, o.error || dd.error)); (o.error ? fName : fData).focus(); return; } const body = { name: reverse && o.relative ? o.relative : 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)); // Start the next record from scratch (the type stays, as records // are often added several of a kind at a time). fName.value = ""; fData.value = ""; fTTL.value = ""; fComment.value = ""; lookupFor = ""; preview(); await redraw(); fName.focus(); } catch (ex) { mount(err, errorBox(ex)); } }; const form = h("form", { class: "inline-form", onsubmit: add }, h("div", { class: "f" }, h("label", null, reverse ? "Address" : "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) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : 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" }, reverse && rinfo ? h("div", { class: "muted small", style: { marginBottom: "6px" } }, "Reverse zone for " + rinfo.network + ": name records by address (e.g. " + rinfo.example + ") and give the host name as data.") : null, form, willAdd, suggest, 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). " + (reverse ? "Names are the last part of the address within " + zone.display_name + "." : "Names are relative to " + zone.display_name + "; end a name with '.' to make it absolute."))), list)); draw(); } // ---- 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)); }