Compare views rebuilt: from the zone's view, in sections, one line per record

The Compare views tab and jarl zone diff compare a zone with the same zone in other views from the view you're on: a summary per view, what is answered differently (Use this everywhere), the names only some views have (Add to VIEW), and how many are the same. One name can be compared (jarl zone diff ZONE NAME [TYPE], a Compare button per record). The CLI prints one line per record, cut to the terminal (--wide shows it whole); the web UI cuts long values with the whole one on hover. GET /zones/{zone}/compare returns these sections, with ?with, ?name and ?type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 10:55:57 +01:00
1 parent ec16de875d
commit b1719ce362
12 files changed
+656 -146

No files matched your search

+99 -22
View File
@@ -1,5 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
import { api, get, stream, change, changeMany, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
@@ -70,7 +70,7 @@ export async function render(el, params, query) {
else if (tab === "servers") await serversTab(body, zone);
else if (tab === "dnssec") await dnssecTab(body, zone);
else if (tab === "registrar") await registrarTab(body, zone);
else if (tab === "compare") await compareTab(body, zone);
else if (tab === "compare") await compareTab(body, zone, query);
// A later click (another tab) has taken over the page.
if (seq !== renderSeq || !holder.isConnected) return;
holder.replaceWith(body);
@@ -82,34 +82,110 @@ export async function render(el, params, query) {
// compareTab shows the names and types that differ between the views that
// have the zone, with a button to share one view's version (linked zones).
async function compareTab(el, zone) {
let all = false;
// compareTab compares the zone with the same zone in other views, from
// this one (docs/views.md#comparing-views): a summary, what the views
// answer differently, the names only some views have, and how much is
// the same. ?name= (and &type=) narrows it to one name.
async function compareTab(el, zone, query) {
const others = zone.other_views || [];
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
let showSame = false;
const out = h("div");
const one = (d) => h("div", { class: "oneline mono small", title: d }, d);
const draw = async () => {
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
row.views.map((vs) => h("td", { class: "compare-cell" },
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
onclick: () => share(vs, row) }, "Use this version") : null))));
mount(out,
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined };
let r;
try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; }
const oneName = !!q.name;
const parts = [];
parts.push(h("p", { class: "muted" }, r.linked
? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose."
: "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step."));
if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null,
h("strong", null, s.view), ": ",
[[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]]
.map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)])))));
if (r.different.length) {
parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"),
h("div", { class: "table-wrap" }, h("table", { class: "compare" },
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))),
h("tbody", null, r.different.map((row) => h("tr", null,
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
row.views.map((vs) => h("td", { class: "compare-cell" },
vs.records.length ? vs.records.map(one) : h("span", { class: "muted" }, "–"),
vs.records.length ? h("div", { class: "compare-actions" },
vs.linked === "local" ? [badge("own records", "accent"), " "] : null,
h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null))))))));
}
for (const o of r.only) {
const missing = r.views.filter((v) => !o.views.includes(v));
parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 },
h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")",
h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")),
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => {
const src = row.views.find((vs) => vs.records.length);
return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
h("td", { class: "compare-cell" }, src.records.map(one)),
h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v))));
}))))));
}
if (r.same_rows && r.same_rows.length) {
parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"),
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" },
h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one))))))));
} else if (!oneName && r.same) {
parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them")));
}
if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views."));
else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs."));
mount(out, parts);
};
const share = async (vs, row) => {
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
// useEverywhere gives every view one view's records of a name and type:
// in a linked zone by sharing them, otherwise by setting each zone's.
const useEverywhere = async (r, row, vs) => {
const rest = row.views.filter((x) => x !== vs);
const linkedAll = r.linked && zone.linked_with;
const msg = linkedAll
? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced."
: rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + ".";
if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return;
try {
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
const res = linkedAll
? await change("PATCH", "/records/" + vs.ids[0], { local: false })
: await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type);
toast(describeResult(res));
await draw();
} catch (e) { toast(e.message, "err", 8000); }
};
const copyTo = async (row, src, dst) => {
try {
toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view)));
await draw();
} catch (e) { toast(e.message, "err", 8000); }
};
const go = () => draw();
withSel.addEventListener("change", go);
nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
mount(el, h("div", { class: "panel" },
h("div", { class: "compare-controls" },
h("label", null, "Compare " + zone.view + " with "), withSel, " ",
h("label", null, " Name "), nameIn, typeIn, " ",
h("button", { class: "small", onclick: go }, "Compare"),
nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null),
out));
await draw();
mount(el, out);
}
// rrsetFrom is the request giving zone dst a name and type's records from
// src: its own (local) records, in a linked zone.
function rrsetFrom(row, src, dst) {
const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true };
if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl;
return d;
}
// ---- linked zones (docs/views.md#linked-zones)
@@ -610,6 +686,7 @@ async function recordsTab(el, zone, query) {
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null,
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
r.disabled
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
+13 -1
View File
@@ -564,8 +564,20 @@ ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
ul.probes > li .msg { margin: 6px 0 0; }
/* Comparing a zone across views. */
table.compare td.compare-cell { vertical-align: top; }
table.compare td { vertical-align: top; }
/* The same name and type columns in every section, so they line up. */
table.compare { table-layout: auto; width: 100%; }
table.compare tr > :nth-child(1) { width: 18ch; }
table.compare tr > :nth-child(2) { width: 8ch; }
table.compare td.compare-cell button { margin-top: 4px; }
/* One line per record: a long TXT record is cut, with the whole of it on hover. */
table.compare .oneline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46ch; }
.compare-actions { margin-top: 4px; }
.compare-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
.compare-controls label { color: var(--muted); }
ul.compare-summary { margin: 0 0 12px; padding-left: 18px; }
details.compare-only { margin: 12px 0; }
details.compare-only summary { cursor: pointer; padding: 4px 0; }
table.compare tr.muted td { color: var(--muted); }
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */