Files
jarl/internal/web/static/assets/pages/views.js
T
abrown38andClaude Opus 5.5 bf7f99874b Web UI: zone naming and reverse zones, ACL suggestions, record form
- 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>
2026-10-07 20:49:43 +00:00

93 lines
6.2 KiB
JavaScript

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); }
}