import { h, mount, table, field, input, select, textarea, toast, errorBox, confirmDialog, modal, lines, checkbox } from "../ui.js"; import { amlContext, suggestions } from "../aml.js"; import { get, change, describeResult } from "../api.js"; import { rerender } from "../app.js"; import { optionsEditor } from "./options.js"; export async function render(el, params, query) { const [views, zones] = await Promise.all([get("/views"), get("/zones")]); const viewless = zones.filter((z) => !z.view_id); const optsEl = h("div"); const selected = query.get("view") ? views.find((v) => String(v.id) === query.get("view")) : null; const move = async (i, d) => { const ids = views.map((v) => v.id); [ids[i], ids[i + d]] = [ids[i + d], ids[i]]; try { const r = await change("POST", "/views/order", { ids }); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Views"), h("div", { class: "muted" }, "A client sees the first view it matches (by source address, destination address or TSIG key). Order matters.")), h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => viewDialog(null, viewless) }, "New view"))), views.length === 0 ? h("div", { class: "msg info" }, "No views: every client sees the same zones. Views let you serve different answers to different clients, for example an internal view with private records and recursion for your LAN, and an external view with only public records.") : null, h("div", { class: "panel" }, table([ { label: "#", render: (v, i) => String(i + 1) }, { label: "View", render: (v) => h("a", { href: "#/views?view=" + v.id }, h("strong", null, v.name)) }, { label: "Match clients", render: (v) => (v.match_clients || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" }, { label: "Match destinations", render: (v) => (v.match_destinations || []).join("; ") || h("span", { class: "muted" }, "any"), tdClass: "data" }, { label: "Zones", render: (v) => h("a", { href: "#/zones?view=" + encodeURIComponent(v.name) }, String(v.zone_count)) }, { label: "Comment", render: (v) => h("span", { class: "muted" }, v.comment || "") }, { label: "", render: (v, i) => h("span", null, h("button", { class: "small", disabled: i === 0, title: "Move up", onclick: () => move(i, -1) }, "↑"), " ", h("button", { class: "small", disabled: i === views.length - 1, title: "Move down", onclick: () => move(i, 1) }, "↓"), " ", h("button", { class: "small", onclick: () => viewDialog(v, viewless) }, "Edit"), " ", h("button", { class: "small danger", onclick: () => deleteView(v) }, "Delete")), tdClass: "actions-cell" }, ], views, { empty: "No views." })), selected ? optsEl : (views.length ? h("p", { class: "muted" }, "Select a view to see its options.") : null)); if (selected) await optionsEditor(optsEl, { scope: "view", scope_id: selected.id, catalogScope: "view", title: "Options of view " + selected.name, intro: "View options override the server options for clients in this view (for example recursion and allow-recursion)." }); } async function viewDialog(v, viewless) { const err = h("div"); const ctx = await amlContext(); const name = input({ value: v ? v.name : "", placeholder: "internal" }); const mc = textarea(v ? (v.match_clients || []).join("\n") : "", { class: "mono", rows: 4, placeholder: "192.168.1.0/24\nlocalhost\n(empty = any)" }); const md = textarea(v ? (v.match_destinations || []).join("\n") : "", { class: "mono", rows: 2, placeholder: "(empty = any)" }); const mro = select([["", "(default: no)"], ["true", "yes"], ["false", "no"]], v && v.match_recursive_only !== undefined && v.match_recursive_only !== null ? String(v.match_recursive_only) : ""); const comment = input({ value: v ? v.comment : "" }); const adopt = checkbox("Move the " + viewless.length + " zone(s) that are not in a view into this view", true); const body = h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("Match clients", h("div", null, mc, suggestions(mc, ctx)), "One element per line: addresses, prefixes, ACL names, key NAME, any/none/localhost/localnets; !negates. First match wins."), field("Match destinations", h("div", null, md, suggestions(md, ctx)), "Server addresses this view answers on (optional)."), field("Recursive only", mro, "If yes, only recursive queries match this view."), field("Comment", comment)), !v && viewless.length ? h("div", { class: "msg warn" }, "Once views exist, named requires every zone to be inside a view. ", adopt) : null, err); await modal(v ? "Edit view " + v.name : "New view", body, [ { label: "Cancel", value: null }, { label: v ? "Save" : "Create view", kind: "primary", onclick: async () => { const data = { name: name.value.trim(), match_clients: lines(mc.value), match_destinations: lines(md.value), comment: comment.value }; data.match_recursive_only = mro.value === "" ? null : mro.value === "true"; if (!v && viewless.length && adopt.input.checked) data.adopt_zones = true; try { const r = v ? await change("PATCH", "/views/" + v.id, data) : await change("POST", "/views", data); toast(describeResult(r)); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; } } }, ], { wide: true, sticky: true }); } async function deleteView(v) { let cascade = false; if (v.zone_count > 0) { const c = checkbox("Also delete its " + v.zone_count + " zone(s) and their records", false); const ok = await modal("Delete view " + v.name, h("div", null, h("p", null, "The view contains zones."), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]); if (ok !== true) return; cascade = c.input.checked; } else if (!(await confirmDialog("Delete view", "Delete view " + v.name + "?", "Delete", "danger"))) return; try { const r = await change("DELETE", "/views/" + v.id, undefined, { query: cascade ? { cascade: 1 } : undefined }); toast(describeResult(r)); rerender(); } catch (e) { toast(e.message, "err", 8000); } }