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>
201 lines
6.5 KiB
JavaScript
201 lines
6.5 KiB
JavaScript
// API client and shared application state.
|
|
|
|
export const state = {
|
|
me: null, // whoami response
|
|
csrf: "",
|
|
draft: null, // the open changeset edits are staged into
|
|
immediate: false, // apply edits immediately instead of staging them
|
|
rrtypes: null,
|
|
listeners: new Set(),
|
|
};
|
|
|
|
export class APIError extends Error {
|
|
constructor(status, body) {
|
|
const e = (body && body.error) || {};
|
|
super(e.message || ("HTTP " + status));
|
|
this.status = status;
|
|
this.code = e.code || "";
|
|
this.details = e;
|
|
this.result = e.result || null;
|
|
}
|
|
}
|
|
|
|
let onUnauthorized = () => {};
|
|
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
|
|
|
// api calls the REST API. opts: {body, stage (bool), query}.
|
|
export async function api(method, path, opts = {}) {
|
|
const headers = { "Accept": "application/json" };
|
|
let body;
|
|
if (opts.body !== undefined) {
|
|
headers["Content-Type"] = "application/json";
|
|
body = JSON.stringify(opts.body);
|
|
}
|
|
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
|
|
let url = "/api/v1" + path;
|
|
if (opts.query) {
|
|
const q = new URLSearchParams();
|
|
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
|
|
const s = q.toString();
|
|
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
|
}
|
|
if (opts.stage) headers["X-Jarl-Changeset"] = String(opts.stage);
|
|
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
|
if (resp.status === 204) return null;
|
|
const ct = resp.headers.get("Content-Type") || "";
|
|
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
|
if (!resp.ok) {
|
|
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
|
|
throw new APIError(resp.status, data);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export const get = (path, query) => api("GET", path, { query });
|
|
|
|
// draftQuery adds the draft changeset to a query, so reads show the
|
|
// configuration as it will be once the pending changes are applied.
|
|
export function draftQuery(query = {}) {
|
|
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
|
|
return query;
|
|
}
|
|
|
|
// ---- drafts
|
|
|
|
const draftKey = () => "jarl.draft." + (state.me ? state.me.user.id : "");
|
|
const immediateKey = () => "jarl.immediate." + (state.me ? state.me.user.id : "");
|
|
|
|
export function onChange(fn) { state.listeners.add(fn); return () => state.listeners.delete(fn); }
|
|
export function notify() { for (const fn of state.listeners) fn(); }
|
|
|
|
// loadDraft restores the user's draft changeset if it is still open.
|
|
export async function loadDraft() {
|
|
state.immediate = localStorage.getItem(immediateKey()) === "1";
|
|
const id = localStorage.getItem(draftKey());
|
|
state.draft = null;
|
|
if (id) {
|
|
try {
|
|
const cs = await get("/changesets/" + id);
|
|
if (cs.status === "open") state.draft = cs;
|
|
} catch { /* gone */ }
|
|
}
|
|
if (!state.draft) {
|
|
// Pick up the newest open changeset made in the web UI.
|
|
try {
|
|
const list = await get("/changesets", { status: "open", mine: 1, limit: 20 });
|
|
const web = list.find((c) => c.via === "web");
|
|
if (web) state.draft = web;
|
|
} catch { /* ignore */ }
|
|
}
|
|
if (state.draft) localStorage.setItem(draftKey(), state.draft.id);
|
|
notify();
|
|
}
|
|
|
|
export function setImmediate(on) {
|
|
state.immediate = on;
|
|
localStorage.setItem(immediateKey(), on ? "1" : "0");
|
|
notify();
|
|
}
|
|
|
|
export async function refreshDraft() {
|
|
if (!state.draft) return;
|
|
try {
|
|
const cs = await get("/changesets/" + state.draft.id);
|
|
state.draft = cs.status === "open" ? cs : null;
|
|
} catch { state.draft = null; }
|
|
if (!state.draft) localStorage.removeItem(draftKey());
|
|
notify();
|
|
}
|
|
|
|
export function useDraft(cs) {
|
|
state.draft = cs;
|
|
if (cs) localStorage.setItem(draftKey(), cs.id); else localStorage.removeItem(draftKey());
|
|
notify();
|
|
}
|
|
|
|
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.
|
|
export async function change(method, path, body, opts = {}) {
|
|
if (state.immediate || opts.immediate) {
|
|
const r = await api(method, path, { body, query: opts.query });
|
|
notify();
|
|
return r;
|
|
}
|
|
const cs = await ensureDraft();
|
|
try {
|
|
const r = await api(method, path, { body, stage: cs.id, query: opts.query });
|
|
if (r && r.changeset) state.draft = r.changeset;
|
|
notify();
|
|
return r;
|
|
} catch (e) {
|
|
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
|
|
useDraft(null);
|
|
return change(method, path, body, opts);
|
|
}
|
|
await dropFreshDraft(cs);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// changeMany submits several requests ({op, id, data}) together.
|
|
export async function changeMany(ops, description, opts = {}) {
|
|
if (state.immediate || opts.immediate) {
|
|
const r = await api("POST", "/apply", { body: { ops, description } });
|
|
notify();
|
|
return r;
|
|
}
|
|
const cs = await ensureDraft();
|
|
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;
|
|
}
|
|
|
|
// describeResult turns a mutation response into a short message.
|
|
export function describeResult(r) {
|
|
if (!r) return "Done";
|
|
if (r.staged) {
|
|
const n = (r.added || []).length;
|
|
return "Staged " + (n === 1 ? "1 change" : n + " changes") + " — review and apply under Changes";
|
|
}
|
|
if (r.applied) return "Applied (changeset #" + r.changeset_id + ")";
|
|
if (r.dry_run) return "Dry run OK";
|
|
return "Done";
|
|
}
|
|
|
|
export function can(resource, write) {
|
|
const me = state.me;
|
|
if (!me) return false;
|
|
if (me.admin) return true;
|
|
return (me.user.grants || []).some((g) =>
|
|
(g.resource === "*" || g.resource === resource) && (!write || g.access === "write"));
|
|
}
|
|
|
|
export async function rrtypes() {
|
|
if (!state.rrtypes) state.rrtypes = await get("/catalog/rrtypes");
|
|
return state.rrtypes;
|
|
}
|