From 020223d1a564b62e75cc0bd6319fa7f842f558a7 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 9 Oct 2026 15:49:11 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++ docs/api.md | 10 ++ internal/bind/bind.go | 6 + internal/engine/engine.go | 9 ++ internal/nscheck/nscheck.go | 65 ++++++++++- internal/server/h_nscheck.go | 47 +++++++- internal/server/nscheck_test.go | 58 ++++++++++ internal/server/server.go | 4 + internal/web/static/assets/api.js | 28 +++++ internal/web/static/assets/pages/zone.js | 138 +++++++++++++++++++---- internal/web/static/assets/style.css | 13 +++ 11 files changed, 364 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b4c47..76d93ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,15 @@ All notable changes to Jarl are documented here. The format follows - The view in the zone list, and on the zone page, links to that view, and the views page highlights it. It used to be plain text, and the zone page linked to the views page as a whole. +- The zone's Name servers tab shows at once and follows the check as it + happens. Each query is listed as a dig command line (`dig @192.0.2.1 + example.org. SOA +norec`) with a "waiting 1.8 s" counter until it is + answered. The finished result then replaces the progress, and the queries + stay under "Queries sent". Results, the delegation check's included, are + kept for 10 minutes, so returning to the tab shows them at once; "Check + again" asks anew. A check keeps running if you leave the tab. Behind it, + `GET /zones/{zone}/serials?stream=1` streams the queries as + newline-delimited JSON (docs/api.md). - Switching a zone's tabs (Records, Settings, …) no longer blanks the page while the tab loads: the page changes once the tab is ready, and "Loading…" appears only if a page takes a while. diff --git a/docs/api.md b/docs/api.md index ab54d0a..8a508de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -220,6 +220,16 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat `error` (with `error`). `source` is `NS record`, `also-notify` or `primaries`; `port` is set for a non-default port. +`/serials?stream=1` answers with newline-delimited JSON +(`application/x-ndjson`) while it works, which is how the web UI shows the +check's progress. Each query is an event when it is sent and another when +it is answered: `{"kind": "start", "id": 2, "query": "dig @192.0.2.1 +example.org. SOA +norec"}`, then `{"kind": "end", "id": 2, "result": +{...}}`. The result is a `servers` entry, or `addresses` or `error` for a +name server's address lookup. ID 0 is `rndc zonestatus`, which asks this +server's named. The last line is `{"kind": "done", "response": {...}}`, +the response above. + `/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`, `jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`, `not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue` diff --git a/internal/bind/bind.go b/internal/bind/bind.go index 0dd661d..a3e83f6 100644 --- a/internal/bind/bind.go +++ b/internal/bind/bind.go @@ -235,6 +235,12 @@ func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg return t.RNDCRun(ctx, args...) } +// ZoneCommand is the rndc command line for a zone command, as shown to +// users ("rndc zonestatus example.org IN internal"). +func ZoneCommand(cmd, zone, view string) string { + return "rndc " + strings.Join(zoneArgs(cmd, zone, view), " ") +} + func zoneArgs(cmd, zone, view string) []string { z := strings.TrimSuffix(zone, ".") if z == "" { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c6ee894..c4c6b1a 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -1222,6 +1222,15 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal // secondaries and the rest of the world see), not that of the zone file // Jarl wrote. ok is false when named isn't asked (no_reload) or doesn't // report a serial (the zone isn't loaded yet). +// LoadedSerialCommand is the command LoadedSerial runs, or "" when it +// doesn't ask named. +func (e *Engine) LoadedSerialCommand(st *model.State, z *model.Zone) string { + if e.cfg.NoReload || e.tools == nil { + return "" + } + return bind.ZoneCommand("zonestatus", z.Name, e.namedView(st, z)) +} + func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) { if e.cfg.NoReload || e.tools == nil { return 0, false, nil diff --git a/internal/nscheck/nscheck.go b/internal/nscheck/nscheck.go index 6127d39..748f108 100644 --- a/internal/nscheck/nscheck.go +++ b/internal/nscheck/nscheck.go @@ -93,6 +93,40 @@ type Checker struct { // IsLocal reports whether an address belongs to this host (default: // its interface addresses and loopback). IsLocal func(netip.Addr) bool + // Watch, if set, is told about each query Serials sends and its + // outcome as it happens (the web UI shows them while it waits). It is + // called from several goroutines at once. + Watch func(Event) +} + +// Event is a query Serials starts or finishes (Checker.Watch). +type Event struct { + // Kind is "start" or "end" (of query ID). + Kind string `json:"kind"` + ID int `json:"id"` + // Query describes what is asked, as a dig command line. + Query string `json:"query,omitempty"` + // End: the server's result (SOA queries), or the addresses found or + // the error (address lookups). + Result *Result `json:"result,omitempty"` + Addresses []string `json:"addresses,omitempty"` + Error string `json:"error,omitempty"` +} + +// digLine is a dig command line for a query (no TSIG secret). +func digLine(server, port, name, types string, norec bool, key *Key) string { + s := "dig @" + server + if port != "" && port != "53" { + s += " -p " + port + } + s += " " + name + " " + types + if norec { + s += " +norec" + } + if key != nil { + s += " -y " + key.Algorithm + ":" + strings.TrimSuffix(key.Name, ".") + ":…" + } + return s } func (c *Checker) timeout() time.Duration { @@ -288,19 +322,42 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets out = append(out, r) mu.Unlock() } + var ids int + start := func(query string) int { + if c.Watch == nil { + return 0 + } + mu.Lock() + ids++ + id := ids + mu.Unlock() + c.Watch(Event{Kind: "start", ID: id, Query: query}) + return id + } + end := func(e Event) { + if c.Watch != nil { + e.Kind = "end" + c.Watch(e) + } + } seen := map[string]bool{} for _, t := range targets { addrs := t.Addresses if addrs == nil && t.Name != "" { + res, _ := c.resolver() + id := start(digLine(res, "", t.Name, "A, AAAA", false, nil)) found, err := c.lookup(ctx, t.Name) if err != nil || len(found) == 0 { msg := "no A or AAAA record" if err != nil { msg = "looking up its address: " + err.Error() } - add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg}) + r := Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg} + end(Event{ID: id, Error: msg, Result: &r}) + add(r) continue } + end(Event{ID: id, Addresses: found}) addrs = found } for _, a := range addrs { @@ -320,9 +377,15 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets continue } wg.Add(1) + port := r.Port + if port == "" { + port = c.port() + } + id := start(digLine(r.Address, port, zone, "SOA", true, t.Key)) go func(r Result, key *Key) { defer wg.Done() c.soa(ctx, zone, key, ref, &r) + end(Event{ID: id, Result: &r}) add(r) }(r, t.Key) } diff --git a/internal/server/h_nscheck.go b/internal/server/h_nscheck.go index f4f0e11..2079a97 100644 --- a/internal/server/h_nscheck.go +++ b/internal/server/h_nscheck.go @@ -7,11 +7,13 @@ package server import ( "context" + "encoding/json" "fmt" "net" "net/http" "net/netip" "strings" + "sync" "time" "jarl/internal/api" @@ -58,10 +60,30 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() + // ?stream=1: newline-delimited JSON events while the servers are + // asked (nscheck.Event, kind start/end), then {"kind":"done", + // "response":...}. The web UI shows the queries as they go. + ck := s.checker() + var emit func(any) + if queryBool(r, "stream") { + emit = s.streamJSON(w) + ck.Watch = func(e nscheck.Event) { emit(e) } + } + cmd := s.eng.LoadedSerialCommand(st, z) + if emit != nil && cmd != "" { + emit(nscheck.Event{Kind: "start", ID: 0, Query: cmd}) + } resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z) if err != nil { resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error()) } + if emit != nil && cmd != "" { + e := nscheck.Event{Kind: "end", ID: 0} + if err != nil { + e.Error = err.Error() + } + emit(e) + } if z.Signed() && resp.SerialSource == "jarl" { // named gives the signed zone its own serial, ahead of Jarl's. resp.Notes = append(resp.Notes, "The zone is signed and named wasn't asked: other servers serve the signed zone's serial, which named sets and which is usually ahead of Jarl's.") @@ -77,15 +99,38 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri if resp.View != "" { resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View)) } - resp.Servers = s.checker().Serials(ctx, z.Name, resp.Serial, targets) + resp.Servers = ck.Serials(ctx, z.Name, resp.Serial, targets) if len(resp.Servers) > 0 && !hasOther(resp.Servers) { resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.") } resp.CheckedAt = time.Now() + if emit != nil { + emit(map[string]any{"kind": "done", "response": resp}) + return nil + } writeJSON(w, http.StatusOK, resp) return nil } +// streamJSON starts a newline-delimited JSON response and returns a +// function that writes one value and flushes it; it is safe for +// concurrent use. +func (s *Server) streamJSON(w http.ResponseWriter) func(any) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") // nginx: don't buffer + w.WriteHeader(http.StatusOK) + rc := http.NewResponseController(w) + enc := json.NewEncoder(w) + var mu sync.Mutex + return func(v any) { + mu.Lock() + defer mu.Unlock() + enc.Encode(v) + rc.Flush() + } +} + func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { diff --git a/internal/server/nscheck_test.go b/internal/server/nscheck_test.go index 104c076..1c5a74a 100644 --- a/internal/server/nscheck_test.go +++ b/internal/server/nscheck_test.go @@ -1,7 +1,9 @@ package server import ( + "encoding/json" "net/netip" + "strings" "testing" "time" @@ -69,3 +71,59 @@ func TestZoneSerialsTargets(t *testing.T) { e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400) e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400) } + +func TestZoneSerialsStream(t *testing.T) { + e := newAPIEnv(t) + e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }} + e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201) + e.do("POST", "/zones", e.token, map[string]any{ + "name": "example.com", + "nameservers": []string{"ns1.example.com.", "ns2.example.com."}, + "records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}}, + "options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.24 port 5300 key xfer; }"}}, + }, 201) + body, status := e.req("GET", "/zones/example.com/serials?stream=1", e.token, nil) + if status != 200 { + t.Fatalf("stream = %d: %s", status, body) + } + var starts, ends []map[string]any + var done map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") { + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("line %q: %v", line, err) + } + switch ev["kind"] { + case "start": + starts = append(starts, ev) + case "end": + ends = append(ends, ev) + case "done": + done = ev + } + } + // .21 is this server (not asked); .22 and .24 are. + queries := map[string]bool{} + for _, s := range starts { + queries[s["query"].(string)] = true + } + for _, q := range []string{ + "dig @127.0.0.22 -p 1 example.com. SOA +norec", + "dig @127.0.0.24 -p 5300 example.com. SOA +norec -y hmac-sha256:xfer:…", + } { + if !queries[q] { + t.Errorf("no query %q in %v", q, queries) + } + } + if len(starts) != 2 || len(ends) != 2 { + t.Errorf("starts %d, ends %d", len(starts), len(ends)) + } + for _, e := range ends { + if r, _ := e["result"].(map[string]any); r == nil || r["status"] != "unreachable" { + t.Errorf("end event %v", e) + } + } + if done == nil || len(done["response"].(map[string]any)["servers"].([]any)) != 3 { + t.Errorf("done = %v", done) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 12766bb..3f66c77 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -384,6 +384,10 @@ func (w *statusWriter) WriteHeader(code int) { w.ResponseWriter.WriteHeader(code) } +// Unwrap lets http.ResponseController reach the connection (to flush a +// streamed response). +func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + func (w *statusWriter) Write(b []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK diff --git a/internal/web/static/assets/api.js b/internal/web/static/assets/api.js index bfdb8fc..a1da54b 100644 --- a/internal/web/static/assets/api.js +++ b/internal/web/static/assets/api.js @@ -73,6 +73,34 @@ async function call(method, path, opts) { 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 = {}) { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index a1cc5fa..d7789f2 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -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) { diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 00f48b3..5f630cf 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -426,3 +426,16 @@ tr.selected td { background: var(--accent-soft); } footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); } footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; } footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; } + +/* a request in progress */ +.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } } + +/* the queries a name server check sends */ +.query-log { list-style: none; margin: 6px 0; padding: 0; font-size: 13px; } +.query-log li { padding: 3px 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; } +.query-log code { background: var(--code-bg); padding: 1px 6px; border-radius: 4px; overflow-wrap: anywhere; } +.query-log li.pending code { opacity: 0.85; } +.err-text { color: var(--err); } +.query-details { margin: 8px 0; }