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
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user