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 <noreply@anthropic.com>
This commit is contained in:
1 parent
27d1f9e538
commit
ad8eb3fc3d
7 files changed
+93
-5
No files matched your search
@@ -38,6 +38,15 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
many views there are.
|
many views there are.
|
||||||
- A `blocking` permission; database schema 6.
|
- 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
|
### Fixed
|
||||||
|
|
||||||
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
|
- docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0.
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ type Option struct {
|
|||||||
Where string `json:"where"` // e.g. "view internal"
|
Where string `json:"where"` // e.g. "view internal"
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Status string `json:"status,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.
|
// User is a user with formatted grants.
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ func Compact(items []Item) string {
|
|||||||
// CompactStmt returns the statement on a single line with its semicolon.
|
// CompactStmt returns the statement on a single line with its semicolon.
|
||||||
func CompactStmt(s *Stmt) string { return Compact(s.Items) + ";" }
|
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.
|
// QuoteString returns s as a named.conf quoted string.
|
||||||
func QuoteString(s string) string {
|
func QuoteString(s string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"jarl/internal/dnsname"
|
"jarl/internal/dnsname"
|
||||||
"jarl/internal/engine"
|
"jarl/internal/engine"
|
||||||
"jarl/internal/model"
|
"jarl/internal/model"
|
||||||
|
"jarl/internal/namedconf"
|
||||||
"jarl/internal/render"
|
"jarl/internal/render"
|
||||||
"jarl/internal/rr"
|
"jarl/internal/rr"
|
||||||
"jarl/internal/version"
|
"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 {
|
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||||
out.Description, out.Status = c.Description, c.Status
|
out.Description, out.Status = c.Description, c.Status
|
||||||
}
|
}
|
||||||
|
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
|
||||||
|
out.Formatted = f
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, badge, confirmDialog, lines } from "../ui.js";
|
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 { rerender } from "../app.js";
|
||||||
import { emailOf } from "./zones.js";
|
import { emailOf } from "./zones.js";
|
||||||
import { amlContext, suggestions } from "../aml.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." };
|
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") {
|
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
|
// Statements such as allow-transfer and listen-on take an address
|
||||||
// match list after optional parameters: suggest names at the cursor.
|
// match list after optional parameters: suggest names at the cursor.
|
||||||
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
|
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() };
|
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.
|
// optionsEditor renders the options of one scope.
|
||||||
export async function optionsEditor(el, { scope, scope_id, catalogScope, title, intro }) {
|
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 byName = Object.fromEntries(cat.map((s) => [s.name, s]));
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const listEl = h("div");
|
const listEl = h("div");
|
||||||
@@ -49,7 +82,7 @@ export async function optionsEditor(el, { scope, scope_id, catalogScope, title,
|
|||||||
const draw = () => {
|
const draw = () => {
|
||||||
mount(listEl, table([
|
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: "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: "Description", render: (o) => h("span", { class: "muted small" }, o.description || ""), tdClass: "small" },
|
||||||
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
{ label: "", render: (o) => editing === o.id ? "" : h("span", null,
|
||||||
h("button", { class: "small", onclick: () => { editing = o.id; draw(); } }, "Edit"), " ",
|
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." }));
|
], opts, { empty: "No options set; BIND's defaults apply." }));
|
||||||
};
|
};
|
||||||
const editRow = (o) => {
|
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");
|
const e2 = h("div");
|
||||||
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
return h("div", null, w.el, w.hint ? h("div", { class: "hint" }, w.hint) : null,
|
||||||
h("div", { class: "actions", style: { marginTop: "6px" } },
|
h("div", { class: "actions", style: { marginTop: "6px" } },
|
||||||
|
|||||||
@@ -443,6 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor;
|
|||||||
.query-log li.pending code { opacity: 0.85; }
|
.query-log li.pending code { opacity: 0.85; }
|
||||||
.err-text { color: var(--err); }
|
.err-text { color: var(--err); }
|
||||||
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
|
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 { 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 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); }
|
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); }
|
||||||
|
|||||||
Reference in new issue
Block a user