From ad8eb3fc3d1041b7abb024f4babea14117db2ec1 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 10 Oct 2026 11:55:41 +0000 Subject: [PATCH] Show and edit option values laid out as in named.conf Values with blocks (logging, controls, ...) are shown over several lines with indentation, and edited that way: the editor grows with its content, Tab indents, Enter keeps the indentation. With a draft open, option tables show the draft's values. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++ internal/api/types.go | 4 ++ internal/namedconf/printer.go | 18 +++++++++ internal/namedconf/printer_test.go | 18 +++++++++ internal/server/present.go | 4 ++ internal/web/static/assets/pages/options.js | 43 ++++++++++++++++++--- internal/web/static/assets/style.css | 2 + 7 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 internal/namedconf/printer_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d845ad..22e90a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ All notable changes to Jarl are documented here. The format follows many views there are. - A `blocking` permission; database schema 6. +### Changed + +- Web UI options (server, top-level, view and zone): values with blocks, + such as logging, are shown and edited laid out as in named.conf, over + several lines with indentation. The editor grows with its content, Tab + indents and Enter keeps the indentation (Esc, then Tab, moves on). With + a draft open, the tables show the draft's values, so a staged edit stays + visible after Save. + ### Fixed - docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0. diff --git a/internal/api/types.go b/internal/api/types.go index aac1dd2..4438cf5 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -77,6 +77,10 @@ type Option struct { Where string `json:"where"` // e.g. "view internal" Description string `json:"description,omitempty"` Status string `json:"status,omitempty"` + // Formatted is the value laid out over several lines, as in + // named.conf, when it has a block (for display and editing; Value is + // the same on one line). + Formatted string `json:"formatted,omitempty"` } // User is a user with formatted grants. diff --git a/internal/namedconf/printer.go b/internal/namedconf/printer.go index 6154803..6f14fdb 100644 --- a/internal/namedconf/printer.go +++ b/internal/namedconf/printer.go @@ -36,6 +36,24 @@ func Compact(items []Item) string { // CompactStmt returns the statement on a single line with its semicolon. func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" } +// FormatValue returns an option's value laid out as named.conf writes it: +// blocks over several lines, indented with tabs. Values without a block, +// and values that don't parse, are returned as they are. +func FormatValue(name, value string) string { + if !strings.Contains(value, "{") { + return value + } + st, err := ParseOptionValue(name, value) + if err != nil { + return value + } + out := strings.TrimSuffix(strings.TrimRight(FormatStmt(st, 0), "\n"), ";") + if rest, ok := strings.CutPrefix(out, st.Keyword()); ok { + return strings.TrimLeft(rest, " ") + } + return value +} + // QuoteString returns s as a named.conf quoted string. func QuoteString(s string) string { var sb strings.Builder diff --git a/internal/namedconf/printer_test.go b/internal/namedconf/printer_test.go new file mode 100644 index 0000000..744e72c --- /dev/null +++ b/internal/namedconf/printer_test.go @@ -0,0 +1,18 @@ +package namedconf + +import "testing" + +func TestFormatValue(t *testing.T) { + for _, c := range []struct{ name, value, want string }{ + {"logging", `{ channel c { file "/var/log/named/x.log" versions 3; print-time yes; }; category default { c; }; }`, + // A short inner block stays on one line, as in named.conf. + "{\n\tchannel c { file \"/var/log/named/x.log\" versions 3; print-time yes; };\n\tcategory default { c; };\n}"}, + {"allow-query", `{ allowed_clients; }`, `{ allowed_clients; }`}, // short: unchanged + {"directory", `"/var/cache/bind"`, `"/var/cache/bind"`}, + {"logging", `{ broken`, `{ broken`}, // unparsable: as it is + } { + if got := FormatValue(c.name, c.value); got != c.want { + t.Errorf("FormatValue(%s, %q) =\n%s\nwant\n%s", c.name, c.value, got, c.want) + } + } +} diff --git a/internal/server/present.go b/internal/server/present.go index 69dab3a..467434a 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -9,6 +9,7 @@ import ( "jarl/internal/dnsname" "jarl/internal/engine" "jarl/internal/model" + "jarl/internal/namedconf" "jarl/internal/render" "jarl/internal/rr" "jarl/internal/version" @@ -100,6 +101,9 @@ func presentOption(st *model.State, o *model.Option) api.Option { if c, ok := catalog.Default().Lookup(scope, o.Name); ok { out.Description, out.Status = c.Description, c.Status } + if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value { + out.Formatted = f + } return out } diff --git a/internal/web/static/assets/pages/options.js b/internal/web/static/assets/pages/options.js index 7fb47d7..e3c90c0 100644 --- a/internal/web/static/assets/pages/options.js +++ b/internal/web/static/assets/pages/options.js @@ -1,5 +1,5 @@ import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js"; -import { get, change, describeResult } from "../api.js"; +import { get, change, describeResult, draftQuery } from "../api.js"; import { rerender } from "../app.js"; import { emailOf } from "./zones.js"; import { amlContext, suggestions } from "../aml.js"; @@ -28,7 +28,7 @@ function valueWidget(stmt, value, ctx) { return { el: h("div", null, t, ctx ? suggestions(t, ctx) : null), get: () => "{ " + lines(t.value).map((l) => l.replace(/;$/, "") + ";").join(" ") + " }", hint: "One element per line: address or prefix, !negated, key NAME, ACL name, any, none, localhost, localnets. First match wins." }; } if (kind === "raw") { - const t = textarea(value || "", { class: "mono", rows: 3 }); + const t = codeArea(value || ""); // Statements such as allow-transfer and listen-on take an address // match list after optional parameters: suggest names at the cursor. const takesList = stmt && /address_match_element/.test(stmt.grammar || ""); @@ -39,9 +39,42 @@ function valueWidget(stmt, value, ctx) { return { el: i, get: () => i.value.trim() }; } +// codeArea is a textarea for named.conf text: as tall as its content +// (up to 30 lines), Tab indents, Enter keeps the line's indentation. After +// Esc, Tab moves on to the next field again. +function codeArea(value) { + const t = textarea(value, { class: "mono code", spellcheck: "false" }); + const fit = () => { t.rows = Math.min(30, Math.max(3, t.value.split("\n").length + 1)); }; + fit(); + let tabMoves = false; + const insert = (text) => { + const { selectionStart: a, selectionEnd: b } = t; + t.setRangeText(text, a, b, "end"); + fit(); + }; + t.addEventListener("input", fit); + t.addEventListener("focus", () => { tabMoves = false; }); + t.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { tabMoves = true; return; } + if (ev.key === "Tab" && !tabMoves && !ev.shiftKey) { + ev.preventDefault(); + insert("\t"); + } else if (ev.key === "Enter" && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { + const line = t.value.slice(0, t.selectionStart).split("\n").pop(); + let indent = line.match(/^\t*/)[0]; + if (line.trimEnd().endsWith("{")) indent += "\t"; + ev.preventDefault(); + insert("\n" + indent); + } + }); + return t; +} + // optionsEditor renders the options of one scope. export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) { - const [opts, cat, ctx] = await Promise.all([get("/options", { scope, scope_id }), catalog(catalogScope), amlContext()]); + // With a draft open, show the options as they will be: a staged edit + // stays visible after Save. + const [opts, cat, ctx] = await Promise.all([get("/options", draftQuery({ scope, scope_id })), catalog(catalogScope), amlContext()]); const byName = Object.fromEntries(cat.map((s) => [s.name, s])); const err = h("div"); const listEl = h("div"); @@ -49,7 +82,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title, const draw = () => { mount(listEl, table([ { label: "Option", render: (o) => h("span", null, h("strong", { class: "mono" }, o.name), o.status ? [" ", badge(o.status, "warn")] : null), tdClass: "nowrap" }, - { label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.value || "(no value)") }, + { label: "Value", render: (o) => editing === o.id ? editRow(o) : h("pre", { class: "code", style: { padding: "2px 6px", whiteSpace: "pre-wrap" } }, o.formatted || o.value || "(no value)") }, { label: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" }, { label: "", render: (o) => editing === o.id ? "" : h("span", null, h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ", @@ -57,7 +90,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title, ], opts, { empty: "No options set; BIND's defaults apply." })); }; const editRow = (o) => { - const w = valueWidget(byName[o.name], o.value, ctx); + const w = valueWidget(byName[o.name], o.formatted || o.value, ctx); const e2 = h("div"); return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null, h("div", { class: "actions", style: { marginTop: "6px" } }, diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index d553d0e..b94a42d 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -443,6 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; .query-log li.pending code { opacity: 0.85; } .err-text { color: var(--err); } ul.plain { list-style: none; margin: 8px 0; padding: 0; } +.code { tab-size: 4; } +textarea.code { width: 100%; white-space: pre; overflow-wrap: normal; overflow-x: auto; } details.view-picker { position: relative; } details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); min-width: 12em; max-width: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }