The view shown in the zone list and on the zone page now links to that
view. The zone page and changeset page get breadcrumbs, and the zone list
keeps its filters in the address so going back restores them. Views can
be given a colour (Views > Edit, `jarl view color`, PUT /views/{id}/color)
so zones of the same view are easy to spot; the colour is UI-only, kept
outside changesets, and needs no deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
483 lines
28 KiB
JavaScript
483 lines
28 KiB
JavaScript
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, crumbs, 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";
|
||
|
||
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");
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" },
|
||
crumbs(["Zones", lastList.zones || "#/zones"],
|
||
...(zone.view ? [[h("span", { class: view && view.color ? "vc-" + view.color : null }, zone.view), "#/zones?view=" + encodeURIComponent(zone.view)]] : [])),
|
||
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), " · "] : 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);
|
||
else if (tab === "servers") await serversTab(body, zone);
|
||
}
|
||
|
||
// ---- 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" });
|
||
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));
|
||
}
|