Web UI: linked views link to their zones; pick views to link one per row, none ticked

The "linked with" badge links each view's zone (the API's zones carry links: id and view). The Link dialog lists the views one per row, with record count and any link they already have; nothing is ticked, Link waits for a choice and names every zone that will join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 16:27:00 +01:00
1 parent 057433c198
commit fd0cbb47a9
4 files changed
+51 -8

No files matched your search

+8
View File
@@ -32,6 +32,8 @@ type Zone struct {
// LinkedWith are the views of the zones this one is linked with // LinkedWith are the views of the zones this one is linked with
// (docs/views.md#linked-zones). // (docs/views.md#linked-zones).
LinkedWith []string `json:"linked_with,omitempty"` LinkedWith []string `json:"linked_with,omitempty"`
// Links are the zones this one is linked with, with their views.
Links []ZoneLink `json:"links,omitempty"`
// Pending is set when listing with a changeset: add, change or delete. // Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"` Pending string `json:"pending,omitempty"`
} }
@@ -567,3 +569,9 @@ type BlockRule struct {
AutoViews bool `json:"auto_views,omitempty"` AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"` Pending string `json:"pending,omitempty"`
} }
// ZoneLink is a zone a zone is linked with.
type ZoneLink struct {
ID int64 `json:"id"`
View string `json:"view"`
}
+1
View File
@@ -24,6 +24,7 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
out.Registrar = s.registrarFor(z) out.Registrar = s.registrarFor(z)
for _, o := range st.LinkedWith(z) { for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o)) out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
} }
return out return out
} }
+37 -8
View File
@@ -34,7 +34,8 @@ export async function render(el, params, query) {
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null, zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null, zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null, zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
zone.linked_with ? [badge("linked with " + zone.linked_with.join(", "), "accent"), " "] : null, zone.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" }, h("div", { class: "actions" },
zone.disabled zone.disabled
@@ -94,25 +95,53 @@ async function linkDialog(zone) {
await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked.")); await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked."));
return; return;
} }
const boxes = others.map((z) => { const c = checkbox("view " + z.view, true); c.view = z.view; return c; }); const picked = new Set();
const err = h("div"); const err = h("div");
await modal("Link " + zone.display_name, h("div", null, let linkBtn = null;
const rows = others.map((z) => {
const box = h("input", { type: "checkbox", "aria-label": "Link with view " + z.view });
box.addEventListener("change", () => {
if (box.checked) picked.add(z.view); else picked.delete(z.view);
tr.classList.toggle("selected", box.checked);
if (linkBtn) {
linkBtn.disabled = picked.size === 0;
// Zones already linked with a picked one join too: name them.
const all = new Set(picked);
for (const o of others) if (picked.has(o.view)) (o.linked_with || []).forEach((v) => all.add(v));
all.delete(zone.view);
linkBtn.textContent = picked.size ? "Link with " + [...all].join(", ") : "Link";
}
});
const tr = h("tr", { class: "pick", onclick: (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } } },
h("td", null, box),
h("td", null, h("strong", null, z.view)),
h("td", { class: "muted" }, z.record_count + " records"),
h("td", { class: "small" }, z.linked_with ? "already linked with " + z.linked_with.join(", ") + " (joins too)" : ""));
return tr;
});
const body = h("div", null,
h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."), h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."),
h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."), h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."),
field("Link view " + zone.view + " with", h("div", null, boxes)), err), [ h("h3", null, "Link view " + zone.view + " with"),
h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, rows))),
err);
const open = modal("Link " + zone.display_name, body, [
{ label: "Cancel", value: null }, { label: "Cancel", value: null },
{ label: "Link", kind: "primary", onclick: async () => { { label: "Link", kind: "primary", onclick: async () => {
const w = boxes.filter((b) => b.input.checked).map((b) => b.view); if (!picked.size) return false;
if (!w.length) { mount(err, h("div", { class: "msg err" }, "Pick at least one view.")); return false; }
try { try {
const r = await change("POST", "/zones/" + zone.id + "/link", { with: w }); const r = await change("POST", "/zones/" + zone.id + "/link", { with: [...picked] });
toast(describeResult(r)); toast(describeResult(r));
(r.warnings || []).forEach((m) => toast(m, "warn", 12000)); (r.warnings || []).forEach((m) => toast(m, "warn", 12000));
rerender(); rerender();
return true; return true;
} catch (e) { mount(err, errorBox(e)); return false; } } catch (e) { mount(err, errorBox(e)); return false; }
} }, } },
]); ], { wide: true });
// Nothing is ticked at first: Link waits for a choice.
linkBtn = [...document.querySelectorAll(".modal .buttons button.primary")].pop();
if (linkBtn) linkBtn.disabled = true;
await open;
} }
// ---- name servers: serials on the other servers, and the delegation // ---- name servers: serials on the other servers, and the delegation
+5
View File
@@ -513,3 +513,8 @@ td textarea.code { min-width: min(72ch, 50vw); }
/* Certificates: the "check the names" result under the ACME fields. */ /* Certificates: the "check the names" result under the ACME fields. */
.check-names { margin: 6px 0 14px; } .check-names { margin: 6px 0 14px; }
.check-names .table-wrap { margin-top: 8px; } .check-names .table-wrap { margin-top: 8px; }
/* A list to pick rows from (linking zones): the whole row toggles. */
table.pick-list tr.pick { cursor: pointer; }
table.pick-list tr.pick td:first-child { width: 2em; }
table.pick-list tr.pick.selected { background: var(--accent-soft); }