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:
adamandClaude Opus 5.5 committed 2026-10-09 15:49:11 +00:00
1 parent d961cb8792
commit 020223d1a5
11 files changed
+362 -21

No files matched your search

+9
View File
@@ -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.
+10
View File
@@ -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`
+6
View File
@@ -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 == "" {
+9
View File
@@ -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
+64 -1
View File
@@ -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)
}
+46 -1
View File
@@ -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 {
+58
View File
@@ -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)
}
}
+4
View File
@@ -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
+28
View File
@@ -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 = {}) {
+115 -19
View File
@@ -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." });
}
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");
// 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];
mount(serials,
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)),
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
} catch (err) {
mount(serials, errorBox(err));
}
};
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 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) {
+13
View File
@@ -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; }