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>
This commit is contained in:
1 parent
d961cb8792
commit
020223d1a5
11 files changed
+364
-23
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
|
||||
import { api, get, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
@@ -89,43 +89,138 @@ function nsTable(servers, withSource) {
|
||||
].filter(Boolean), servers, { empty: "No other name servers to ask." });
|
||||
}
|
||||
|
||||
// Name server checks ask other servers over the network, which can take
|
||||
// seconds. Results are kept for a while (per zone, for this page load),
|
||||
// so coming back to the tab shows them at once; "Check again" asks anew.
|
||||
const nsCache = new Map(); // zone id -> {serials: {at, resp, log} | {job}, delegation: {at, resolver, resp}}
|
||||
const nsFresh = 10 * 60 * 1000;
|
||||
|
||||
function nsEntry(zone) {
|
||||
if (!nsCache.has(zone.id)) nsCache.set(zone.id, {});
|
||||
return nsCache.get(zone.id);
|
||||
}
|
||||
|
||||
// runSerials starts the serials check, streamed: the log fills with the
|
||||
// queries as they are sent and answered. Views subscribe to it.
|
||||
function runSerials(zone) {
|
||||
const job = { log: [], subs: new Set(), done: null, error: null, started: Date.now() };
|
||||
const entry = nsEntry(zone);
|
||||
entry.serials = { job };
|
||||
const notify = () => job.subs.forEach((f) => f());
|
||||
stream("/zones/" + zone.id + "/serials", { stream: 1 }, (ev) => {
|
||||
if (ev.kind === "start") job.log.push({ id: ev.id, query: ev.query, started: Date.now() });
|
||||
else if (ev.kind === "end") {
|
||||
const q = job.log.find((q) => q.id === ev.id && !q.ended);
|
||||
if (q) Object.assign(q, { ended: Date.now(), result: ev.result, addresses: ev.addresses, error: ev.error });
|
||||
} else if (ev.kind === "done") job.done = ev.response;
|
||||
notify();
|
||||
}).then(() => {
|
||||
if (job.done) entry.serials = { at: Date.now(), resp: job.done, log: job.log };
|
||||
else job.error = new Error("the check ended without a result");
|
||||
notify();
|
||||
}).catch((e) => {
|
||||
job.error = e;
|
||||
delete entry.serials;
|
||||
notify();
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
// queryLog lists the queries of a check: what was sent, and the answer or
|
||||
// that it's still waiting.
|
||||
function queryLog(log) {
|
||||
const secs = (ms) => (ms / 1000).toFixed(1) + " s";
|
||||
return h("ul", { class: "query-log" }, log.map((q) => {
|
||||
let outcome;
|
||||
if (!q.ended) outcome = [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted elapsed", dataset: { started: q.started } }, "waiting " + secs(Date.now() - q.started))];
|
||||
else if (q.result) outcome = [nsBadge(q.result), q.result.rtt_ms ? h("span", { class: "muted" }, " " + q.result.rtt_ms + " ms") : h("span", { class: "muted" }, " " + secs(q.ended - q.started))];
|
||||
else if (q.addresses) outcome = h("span", { class: "muted mono" }, "→ " + q.addresses.join(", "));
|
||||
else if (q.error) outcome = h("span", { class: "err-text" }, q.error);
|
||||
else outcome = h("span", { class: "muted" }, "done, " + secs(q.ended - q.started));
|
||||
return h("li", { class: q.ended ? "" : "pending" }, h("code", null, q.query), " ", outcome);
|
||||
}));
|
||||
}
|
||||
|
||||
// serialsView fills el with the serials check: the cached result, or the
|
||||
// running check's progress until its result is in.
|
||||
function serialsView(el, zone, again) {
|
||||
const entry = nsEntry(zone);
|
||||
const c = entry.serials;
|
||||
if (!again && c && c.resp && Date.now() - c.at < nsFresh) {
|
||||
mount(el, serialsResult(c.resp, c.log, true));
|
||||
return;
|
||||
}
|
||||
const job = !again && c && c.job ? c.job : runSerials(zone);
|
||||
let seen = false, timer = null;
|
||||
const draw = () => {
|
||||
// Stop following the check once the tab is gone (the check goes on
|
||||
// and its result is kept).
|
||||
if (el.isConnected) seen = true;
|
||||
else if (seen) { job.subs.delete(draw); clearInterval(timer); return; }
|
||||
if (job.error) mount(el, errorBox(job.error));
|
||||
else if (job.done) mount(el, serialsResult(job.done, job.log, false));
|
||||
else {
|
||||
const local = job.log.find((q) => q.id === 0);
|
||||
mount(el,
|
||||
h("p", null, "This server: serial ", local && !local.ended ? [h("span", { class: "spinner", "aria-hidden": "true" }), h("span", { class: "muted" }, "asking named")] : h("span", { class: "muted" }, "…")),
|
||||
h("p", { class: "muted small" }, job.log.length ? "Waiting for the servers to answer:" : "Finding the servers to ask…"),
|
||||
queryLog(job.log));
|
||||
}
|
||||
if ((job.done || job.error) && timer) { clearInterval(timer); timer = null; }
|
||||
};
|
||||
job.subs.add(draw);
|
||||
draw();
|
||||
// Tick the "waiting n s" counters.
|
||||
if (!job.done && !job.error) {
|
||||
timer = setInterval(() => {
|
||||
if (seen && !el.isConnected) { clearInterval(timer); job.subs.delete(draw); return; }
|
||||
for (const s of el.querySelectorAll(".elapsed")) s.textContent = "waiting " + ((Date.now() - Number(s.dataset.started)) / 1000).toFixed(1) + " s";
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function serialsResult(r, log, cached) {
|
||||
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
|
||||
return [
|
||||
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
|
||||
nsTable(r.servers, true),
|
||||
r.notes.map((n) => h("p", { class: "muted small" }, n)),
|
||||
log && log.length ? h("details", { class: "query-details" }, h("summary", { class: "small muted" }, "Queries sent (" + log.length + ")"), queryLog(log)) : null,
|
||||
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), cached ? " (kept from earlier; Check again to ask anew)." : "."),
|
||||
];
|
||||
}
|
||||
|
||||
async function serversTab(el, zone) {
|
||||
const serials = h("div");
|
||||
const loadSerials = async () => {
|
||||
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
|
||||
try {
|
||||
const r = await get("/zones/" + zone.id + "/serials");
|
||||
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
|
||||
mount(serials,
|
||||
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
|
||||
nsTable(r.servers, true),
|
||||
r.notes.map((n) => h("p", { class: "muted small" }, n)),
|
||||
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
|
||||
} catch (err) {
|
||||
mount(serials, errorBox(err));
|
||||
}
|
||||
};
|
||||
const again = h("button", { class: "small", onclick: () => serialsView(serials, zone, true) }, "Check again");
|
||||
const parts = [h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" },
|
||||
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
|
||||
h("button", { class: "small", onclick: loadSerials }, "Check again")),
|
||||
again),
|
||||
h("p", { class: "muted small" }, zone.type === "primary"
|
||||
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
|
||||
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
|
||||
serials)];
|
||||
if (zone.type === "primary") {
|
||||
const out = h("div");
|
||||
const resolver = input({ placeholder: "the server's own" });
|
||||
const entry = nsEntry(zone);
|
||||
const resolver = input({ placeholder: "the server's own", value: entry.delegation ? entry.delegation.resolver : "" });
|
||||
const show = (d, cached) => mount(out, delegationReport(d.resp),
|
||||
h("p", { class: "muted small" }, "Checked ", time(new Date(d.at).toISOString()), cached ? " (kept from earlier; Check delegation to ask anew)." : "."));
|
||||
const run = async (e) => {
|
||||
e.preventDefault();
|
||||
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
|
||||
const res = resolver.value.trim();
|
||||
mount(out, h("p", { class: "muted" }, h("span", { class: "spinner", "aria-hidden": "true" }),
|
||||
"Finding the parent zone through " + (res || "the server's resolver") + ", then asking its servers for the delegation and each delegated server for the zone…"));
|
||||
try {
|
||||
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
|
||||
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
|
||||
const d = { at: Date.now(), resolver: res, resp: await get("/zones/" + zone.id + "/delegation", res ? { resolver: res } : {}) };
|
||||
entry.delegation = d;
|
||||
if (out.isConnected) show(d, false);
|
||||
} catch (err) {
|
||||
mount(out, errorBox(err));
|
||||
}
|
||||
};
|
||||
if (entry.delegation && Date.now() - entry.delegation.at < nsFresh) show(entry.delegation, true);
|
||||
parts.push(h("div", { class: "panel" },
|
||||
h("h2", null, "Delegation from the parent zone"),
|
||||
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
|
||||
@@ -135,7 +230,8 @@ async function serversTab(el, zone) {
|
||||
out));
|
||||
}
|
||||
mount(el, parts);
|
||||
await loadSerials();
|
||||
// Don't wait for the answers: the tab shows the check's progress.
|
||||
serialsView(serials, zone, false);
|
||||
}
|
||||
|
||||
function delegationReport(r) {
|
||||
|
||||
Reference in new issue
Block a user