Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Fable 5.1 8086aa994f Verify AS paths with ASPAs, over RTR version 2
Origin validation says who may originate a prefix and nothing about the
rest of the path: it does not catch a route leak, nor a hijack that keeps
the right origin at the end of a made-up path. ASPAs, in which a network
lists its providers, do.

bgp-rpki:
- aspa: the ASPAs and the upstream and downstream verification procedures
  of draft-ietf-sidrops-aspa-verification-24, tested against the draft's
  wording as written.
- rtr: protocol version 2 (draft-ietf-sidrops-8210bis-23) with the ASPA
  PDU and the new error codes.
- client: asks for version 2 and falls back to whatever older version the
  cache speaks; keeps ASPAs with the VRPs, applied whole or not at all;
  publishes which customers' ASPAs changed; tells the cache why it is
  being left (Error Report); does not ask one version's serial of another.

Server:
- A peer has a role (provider, customer, peer, route server, route server
  client), which decides how its paths are verified. Without one they are
  not.
- A route whose path is invalid is refused ("ASPA invalid") and held, like
  one with an invalid origin; accept_aspa_invalid exempts a peer.
- When ASPAs change, routes in the table whose path has a changed customer
  on it are verified again, and held routes are released when they pass
  both checks.
- The revalidator finds a route's peer by index instead of searching a
  list for every route.

The status page and API show each peer's role, each route's path validity,
the number of ASPAs and the RTR version in use; bgp_rpki_aspas is on
/metricz.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 14:12:59 +02:00

392 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status for ferrix_bgp</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
<!--
A read-only status page in the manner of the classic /statusz pages: plain
sans-serif HTML, default links, dense bordered tables, and one full-width
light blue (#eef) heading bar per section. Deliberately no dark theme, no
framework, no external resources. Everything shown comes from /api/*.
-->
<style>
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
h1.top { font-size: 150%; margin-top: 0; }
.lefthand { float: left; width: 70%; }
.righthand { text-align: right; }
a { color: #00c; } a:visited { color: #551a8b; }
table.data { border-collapse: collapse; margin: 4px 0; }
table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
table.data th { background: #dde; font-weight: bold; }
.mono, table.data td.mono { font-family: monospace; }
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
.dim { color: #666; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: #00c; }
summary:hover { text-decoration: underline; }
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
address { font-size: smaller; margin-top: 2em; color: #444; }
form.lookup { margin: 4px 0; }
form.lookup input[type=text] { font-family: monospace; }
</style>
</head>
<body>
<h1 class="top">Status for ferrix_bgp</h1>
<div>
<div class="lefthand">
Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
Version <span id="s-version">...</span><br>
Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
</div>
<div class="righthand">
Running on <span id="s-hostname">...</span><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a>, <a href="/api/rpki">rpki json</a><br>
<span id="refresh-ctl"></span><br>
</div>
</div>
<details id="config-zippy" hidden>
<summary>Configuration <span class="dim" id="config-path"></span></summary>
<pre id="config-text"></pre>
</details>
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
<table class="data">
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>address families</th><th>routes</th><th>refused</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
<h1 id="rpki">Route origin and path validation (RPKI)</h1>
<div id="rpki-body">...</div>
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
<table class="data">
<thead><tr><th>prefix</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>since</th></tr></thead>
<tbody id="local-body"></tbody>
</table>
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
<form class="lookup" id="lookup">
Look up <input type="text" id="lookup-q" size="30" placeholder="193.0.0.0/21 or 193.0.0.1" title="A prefix, or an address to find every prefix that covers it">
<select id="lookup-mode" title="What to find for a prefix">
<option value="exact">routes for this prefix or address</option>
<option value="within">this prefix and everything inside it</option>
</select>
from <select id="lookup-peer"><option value="">any peer</option></select>
<label><input type="checkbox" id="lookup-best"> best only</label>
<button type="submit">look up</button>
<a href="#" id="lookup-clear">show all</a>
<span id="lookup-note"></span>
</form>
<table class="data">
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<h1 id="refused">Refused routes (<span id="refused-count">...</span>)</h1>
<div class="dim">Routes that peers announced and their import policies did not take. The counts are since the daemon started; the latest refusals of each peer are kept.</div>
<table class="data">
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th>when</th></tr></thead>
<tbody id="refused-body"></tbody>
</table>
<h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div>
<div id="event-log"></div>
<address>ferrix_bgp &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
<script>
(() => {
// Well-known standard communities (RFC 1997, RFC 7999, RFC 8326), shown by
// name with the number as a tooltip.
const WELL_KNOWN = {
'65535:0': 'GRACEFUL_SHUTDOWN', '65535:666': 'BLACKHOLE', '65535:65281': 'NO_EXPORT',
'65535:65282': 'NO_ADVERTISE', '65535:65283': 'NO_EXPORT_SUBCONFED', '65535:65284': 'NOPEER',
};
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], refused: [], rpki: null };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
const fmtDur = s => {
s = Math.max(0, Math.floor(s));
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`;
};
const now = () => Math.floor(Date.now() / 1000);
// Tables are only rewritten when their markup actually changes, so a
// refresh with unchanged data never touches the DOM (no flicker, and
// selections survive). Anything that changes with the clock is a
// placeholder that tick() fills in as plain text.
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
const age = unix => `<span data-since="${unix}"></span>`;
const until = unix => `<span data-until="${unix}"></span>`;
function tick() {
const t = now();
document.querySelectorAll('[data-since]').forEach(e => { const v = fmtDur(t - e.dataset.since); if (e.textContent !== v) e.textContent = v; });
document.querySelectorAll('[data-until]').forEach(e => { const v = fmtDur(e.dataset.until - t); if (e.textContent !== v) e.textContent = v; });
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
}
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const commList = (list, named) => list.length ? list.map(c =>
named && WELL_KNOWN[c] ? `<span title="${esc(c)}">${WELL_KNOWN[c]}</span>` : esc(c)).join(' ') : DASH;
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status} ${await r.text()}`);
return r.json();
}
// ---- renderers ---------------------------------------------------------
function renderStatus() {
const s = state.status; if (!s) return;
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
tick();
}
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' };
// Where several routes for a prefix are exchanged (ADD-PATH), and which way.
function addPath(p) {
const rx = p.add_path_receive || [], tx = p.add_path_send || [];
if (!rx.length && !tx.length) return '';
const parts = [];
if (rx.length) parts.push(`receive ${esc(rx.join(', '))}`);
if (tx.length) parts.push(`send ${esc(tx.join(', '))}`);
return ` <span class="dim">· add-path: ${parts.join('; ')}</span>`;
}
function renderPeers() {
const b = $('peers-body');
$('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
if (!state.peers.length) { setHTML(b, '<tr><td colspan="11" class="dim">No peers configured and none connected.</td></tr>'); return; }
setHTML(b, state.peers.map(p => {
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `<tr>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim">${esc(p.role)}</span>` : ''}</td>
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) : DASH}</td>
<td>${up ? p.routes : DASH}</td>
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
renderPeerChoices();
}
// ---- looking routes up -------------------------------------------------
// The query lives in the page's address, so that it survives a reload and
// auto-refresh, and can be passed on.
const QUERY_KEYS = ['q', 'mode', 'peer', 'best'];
function lookup() {
const here = new URLSearchParams(location.search);
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', best: here.get('best') === '1' };
}
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.best); }
// What to ask the API for: an address finds every prefix that covers it.
function receivedUrl() {
const l = lookup(), ask = new URLSearchParams();
if (l.q) ask.set(l.mode === 'within' ? 'within' : l.q.includes('/') ? 'prefix' : 'address', l.q);
if (l.peer) ask.set('peer', l.peer);
if (l.best) ask.set('best', 'true');
const query = ask.toString();
return '/api/routes/received' + (query ? `?${query}` : '');
}
function renderPeerChoices() {
const chosen = lookup().peer;
const names = [...new Set(state.peers.filter(p => p.state === 'Established')
.map(p => p.name || p.peer_addr.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')))];
if (chosen && !names.includes(chosen)) names.push(chosen);
setHTML($('lookup-peer'), '<option value="">any peer</option>' +
names.map(n => `<option value="${esc(n)}"${n === chosen ? ' selected' : ''}>${esc(n)}</option>`).join(''));
}
function showLookup() {
const l = lookup();
$('lookup-q').value = l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
renderPeerChoices();
}
function setLookup(l) {
const url = new URL(location.href);
QUERY_KEYS.forEach(k => url.searchParams.delete(k));
if (l) {
if (l.q) url.searchParams.set('q', l.q);
if (l.q && l.mode === 'within') url.searchParams.set('mode', 'within');
if (l.peer) url.searchParams.set('peer', l.peer);
if (l.best) url.searchParams.set('best', '1');
}
history.replaceState(null, '', url.pathname + url.search + '#received');
showLookup(); renderRefreshControl();
loadReceived().catch(console.error);
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { setHTML(b, '<tr><td colspan="7" class="dim">This router originates no routes.</td></tr>'); return; }
const asn = state.status ? state.status.local_asn : '?';
setHTML(b, state.local.map(r => `<tr>
<td class="mono">${esc(r.prefix)}</td><td>${esc(r.origin)}</td>
<td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td>
<td>${age(r.announced_at_unix)}</td></tr>`).join(''));
}
function renderReceived() {
const b = $('received-body'), st = state.status, looking = isLookup();
$('received-count').textContent = looking
? `${state.received.length} found${st ? ` among ${st.received_routes}` : ''}`
: st && st.received_routes > state.received.length
? `showing ${state.received.length} of ${st.received_routes}, for ${st.received_prefixes} prefixes`
: `${state.received.length}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
if (!state.received.length) {
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
}
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr>
<td class="mono">${esc(r.prefix)}</td><td class="mono">${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono">${asPath(s.as_path)}</td>
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td><td>${s.med ?? DASH}</td><td>${s.local_pref ?? DASH}</td>
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td>
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
<td>${r.aspa ? `<span class="${RPKI_CLASS[r.aspa] || ''}">${esc(r.aspa)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
}
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': 'dim', unknown: 'dim' };
const RPKI_STATE_CLASS = { synchronised: 'ok', synchronising: 'warn', connecting: 'bad' };
function renderRpki() {
const r = state.rpki, b = $('rpki-body');
if (!r) return;
if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; }
const data = r.updated_at_unix
? `${r.vrps_ipv4} IPv4 and ${r.vrps_ipv6} IPv6 VRPs and ${r.aspas} ASPAs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
(r.expires_at_unix ? `, dropped in ${until(r.expires_at_unix)} unless confirmed again` : '')
: '<span class="warn">no data: every route is "not found" and none is refused as invalid</span>';
setHTML(b, `Cache <span class="mono">${esc(r.cache || '?')}</span>: <span class="${RPKI_STATE_CLASS[r.state] || ''}">${esc(r.state)}</span>` +
` <span class="dim">(RTR version ${r.protocol_version})</span>. ${data}.` +
(r.updated_at_unix && r.protocol_version < 2 ? '<br><span class="warn">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
(r.last_error ? `<br><span class="bad">Last error:</span> ${esc(r.last_error)}` : ''));
}
// For each peer a line that says how many and why, then its latest few.
const REFUSED_SHOWN = 20;
function renderRefused() {
const b = $('refused-body');
const total = state.refused.reduce((n, p) => n + p.total, 0);
$('refused-count').textContent = total ? `${total} from ${state.refused.length} peer${state.refused.length === 1 ? '' : 's'}` : '0';
if (!state.refused.length) { setHTML(b, '<tr><td colspan="5" class="dim">Nothing a peer announced has been refused.</td></tr>'); return; }
setHTML(b, state.refused.map(p => {
const why = Object.entries(p.by_reason).sort((a, c) => c[1] - a[1]).map(([reason, n]) => `${n} ${esc(reason)}`).join(', ');
const more = p.recent.length > REFUSED_SHOWN
? ` <span class="dim">Latest ${REFUSED_SHOWN} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
return `<tr><td colspan="5"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${p.total} refused</span> (${why}).${more}</td></tr>` +
p.recent.slice(0, REFUSED_SHOWN).map(r => `<tr><td></td>
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
<td class="mono">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td>${age(r.at_unix)} ago</td></tr>`).join('');
}).join(''));
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
async function loadReceived() {
const note = $('lookup-note');
try {
state.received = await getJSON(receivedUrl());
note.textContent = '';
} catch (e) {
// A query the server could not make sense of: say what it said.
state.received = [];
note.innerHTML = `<span class="bad">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
}
renderReceived();
}
async function loadRefused() { state.refused = await getJSON('/api/routes/rejected'); renderRefused(); }
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRefused(), loadRpki()]); }
// ---- events ------------------------------------------------------------
function logEvent(html) {
const box = $('event-log'), d = document.createElement('div');
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
}
const names = list => list.map(c => WELL_KNOWN[c] || c).map(esc).join(' ');
function describe(ev) {
switch (ev.type) {
case 'session_up': return `<span class="ok">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent all originated routes`;
case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
case 'local_announced': { const r = ev.route; return `originating ${esc(r.prefix)}` +
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
case 'local_withdrawn': return `<span class="warn">no longer originating</span> ${esc(ev.prefix)}`;
case 'best_changed': return `best route for ${esc(ev.route.prefix)} is now from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
case 'best_withdrawn': return `${esc(ev.prefix)} has <span class="warn">no route</span> any more`;
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}`;
default: return esc(JSON.stringify(ev));
}
}
function connect() {
const st = $('log-state');
const es = new EventSource('/api/events');
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
// Events only feed the log. How often the tables reload is decided by
// ?refresh=, so the page never changes faster than the reader asked for.
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
}
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
function refreshLink(seconds, text) {
const url = new URL(location.href);
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
}
function renderRefreshControl() {
$('refresh-ctl').innerHTML = (REFRESH
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
` &middot; <a href="#" id="reload-now">reload now</a>`;
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
}
// ---- boot --------------------------------------------------------------
getJSON('/api/config').then(c => {
if (!c.text) return;
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
$('config-text').textContent = c.text;
$('config-zippy').hidden = false;
}).catch(console.error);
renderRefreshControl();
showLookup();
$('lookup').addEventListener('submit', e => {
e.preventDefault();
setLookup({ q: $('lookup-q').value.trim(), mode: $('lookup-mode').value, peer: $('lookup-peer').value, best: $('lookup-best').checked });
});
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
if (REFRESH) {
connect();
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
setInterval(tick, 1000);
} else {
// A snapshot: nothing moves until the reader reloads or turns refresh on.
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
}
})();
</script>
</body>
</html>