Files
adamandClaude Opus 5.5 020223d1a5 Show the Name servers check as it happens, and keep its results
The zone's Name servers tab no longer waits for the other servers before
showing: it lists each query as a dig command line with a waiting
counter, fills in the answers as they arrive, then shows the result with
the queries under "Queries sent". Results (serials and delegation) are
kept for 10 minutes so revisiting the tab is instant; "Check again" asks
anew, and a check keeps running when the tab is left.

GET /zones/{zone}/serials?stream=1 streams the queries as
newline-delimited JSON (nscheck.Checker.Watch); without it the endpoint
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:49:11 +00:00

249 lines
8.4 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; }
// With two-factor login, some actions (API tokens, users) need a code
// from the last few minutes. The server then answers totp_step_up (or
// totp_invalid for a wrong code); askCode asks for one, and the request is
// sent again with it. It returns null when the user cancels.
let askCode = null;
export function setCodePrompt(fn) { askCode = fn; }
const stepUpCodes = new Set(["totp_step_up", "totp_invalid"]);
// api calls the REST API. opts: {body, stage (bool), query, code}.
export async function api(method, path, opts = {}) {
try {
return await call(method, path, opts);
} catch (e) {
if (!askCode || e.status !== 403 || !stepUpCodes.has(e.code) || path.startsWith("/me/totp/disable")) throw e;
const code = await askCode(e.code === "totp_invalid" ? e.message : "");
if (!code) throw e;
return api(method, path, { ...opts, code });
}
}
async function call(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);
if (opts.code) headers["X-Jarl-TOTP"] = opts.code;
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 });
// stream GETs a newline-delimited JSON response (?stream=1 endpoints) and
// calls onEvent with each value as it arrives. It resolves at the end.
export async function stream(path, query, onEvent) {
const q = new URLSearchParams(query || {});
const resp = await fetch("/api/v1" + path + (q.toString() ? "?" + q : ""), { headers: { "Accept": "application/x-ndjson" }, credentials: "same-origin" });
if (!resp.ok) {
const ct = resp.headers.get("Content-Type") || "";
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
if (resp.status === 401) onUnauthorized();
throw new APIError(resp.status, data);
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (value) buf += dec.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) onEvent(JSON.parse(line));
}
if (done) break;
}
if (buf.trim()) onEvent(JSON.parse(buf));
}
// 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;
}