- New zone dialog: choose Domain or Reverse zone. Reverse zones are created from a network and their name is generated; both kinds show the exact zone name and flag likely mistakes (addresses or URLs as names, host-like names, duplicates in the view, parent/child zones, networks off the octet/nibble boundaries) before anything is submitted. - Record form: clears every field but the type after an add; shows the record exactly as it will be stored and warns about unqualified names. In reverse zones it takes an address, defaults to PTR, suggests host names of A/AAAA records with that address and warns about a second PTR; the record list shows addresses. - Address match lists (options, view match lists, ACL elements) offer the existing ACLs, TSIG keys and built-ins as one-click suggestions and flag unknown names. - Server: in reverse zones a record can be named by its address, and a PTR target like nas.example.com without the final dot is read as the full host name (a bare label is refused). Warnings noticed while parsing record data reach the result again. - Fixes: brand link hover underline, the dashboard's "New zone" link opening the dialog twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
379 lines
22 KiB
JavaScript
379 lines
22 KiB
JavaScript
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";
|
|
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.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");
|
|
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" });
|
|
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();
|
|
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));
|
|
}
|