Fix the dashboard's pending-changes count; explain zones in the docs

The dashboard counted every open changeset, including empty ones and
other users', so "Pending changes (1 open)" led to an empty Changes page.
It now counts the user's own changesets that contain changes, the status
API's open_changesets skips empty ones, and in review mode a draft created
for an edit the server rejects is discarded instead of left open.

docs/concepts.md: zone types, a new zone's name servers and glue, reverse
zones for any network, and dynamic updates versus API tokens and TSIG keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 11:52:10 +00:00
1 parent 785b0006f9
commit 5cb8602a7f
5 files changed
+104 -6

No files matched your search

+8 -2
View File
@@ -1080,7 +1080,7 @@ type Status struct {
Drift []DriftEntry `json:"drift"`
Deployed bool `json:"deployed"`
LastApplied *model.Changeset `json:"last_applied,omitempty"`
OpenChangesets int `json:"open_changesets"`
OpenChangesets int `json:"open_changesets"` // with at least one operation
Problems []validate.Problem `json:"problems"`
Counts map[string]int `json:"counts"`
Paths render.Paths `json:"paths"`
@@ -1121,8 +1121,14 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
s.LastApplied = list[0]
s.LastApplied.Ops = RedactOps(s.LastApplied.Ops)
}
// Only changesets with something in them: an empty one (a web UI draft
// whose edit was rejected, say) has nothing to review.
if list, err := e.store.ListChangesets(ctx, store.ChangesetFilter{Status: model.ChangesetOpen, Limit: 1000}); err == nil {
s.OpenChangesets = len(list)
for _, cs := range list {
if len(cs.Ops) > 0 {
s.OpenChangesets++
}
}
}
s.Problems = nonNilProblems(e.validate(st))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
+17 -1
View File
@@ -116,10 +116,19 @@ export function useDraft(cs) {
async function ensureDraft() {
if (state.draft) return state.draft;
const cs = await api("POST", "/changesets", { body: { description: "" } });
cs.fresh = true;
useDraft(cs);
return cs;
}
// dropFreshDraft discards a draft that was created for an edit the server
// then rejected, so no empty changeset is left open.
async function dropFreshDraft(cs) {
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
useDraft(null);
}
// change sends a configuration change: staged into the draft changeset,
// or applied at once when immediate mode is on (or opts.immediate).
// It returns the API response.
@@ -140,6 +149,7 @@ export async function change(method, path, body, opts = {}) {
useDraft(null);
return change(method, path, body, opts);
}
await dropFreshDraft(cs);
throw e;
}
}
@@ -152,7 +162,13 @@ export async function changeMany(ops, description, opts = {}) {
return r;
}
const cs = await ensureDraft();
const r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } });
let r;
try {
r = await api("POST", "/changesets/" + cs.id + "/ops", { body: { ops } });
} catch (e) {
await dropFreshDraft(cs);
throw e;
}
if (r && r.changeset) state.draft = r.changeset;
notify();
return r;
@@ -2,8 +2,10 @@ import { h, mount, time, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js";
export async function render(el) {
const st = await get("/status");
const [st, mine] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
const s = st.engine || {};
// The user's own changesets with something in them: what the Changes page shows.
const pending = mine.filter((c) => (c.ops || []).length).length;
const n = s.named;
const cards = h("div", { class: "cards" },
card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""),
@@ -18,7 +20,7 @@ export async function render(el) {
h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))),
h("div", { class: "actions" },
h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"),
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (s.open_changesets ? " (" + s.open_changesets + " open)" : "")))),
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (pending ? " (" + pending + " open)" : "")))),
cards];
if (s.drift && s.drift.length) {
parts.push(h("div", { class: "msg warn" },