Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Fable 5.1 87f96f9389 feature: RFC 4271 §6.8 connection collision detection
When both ends dial each other, exactly one TCP connection must survive
and both ends must pick the same one. Previously ferrix relied on the
other router to sort this out and could briefly hold two sessions.

- collision: a shared map keyed by the peer's BGP Identifier holds the one
  connection currently preferred for that peer plus a kill signal. Each
  connection calls admit() on receiving the peer's OPEN. The connection
  initiated by the higher BGP Identifier wins; the loser gets Cease /
  Connection Collision Resolution, whether it is the new connection or an
  existing one in OpenConfirm running in another task.
- A collision with an Established session closes the new connection (RFC
  default). The optional CollisionDetectEstablishedState is available as
  collision_detect_established_state: a restarted peer's new connection
  then replaces the stale session at once instead of being refused until
  the hold timer expires. Enabled in the lab config.
- Two connections opened by the same side: the newer wins.
- OPEN validation rejects a peer using our own BGP Identifier or 0.0.0.0
  with Bad BGP Identifier; collision resolution needs distinct identifiers.
- OpenConfirm no longer times out instantly when the negotiated hold time
  is zero.
- Sessions record which side opened them; shown as "opened by" on the
  status page and as `direction` in /api/peers. New counter
  bgp_connection_collisions_total{closed}.
- Tests: unit tests for the rule (including that both ends agree), and
  integration tests for simultaneous open in both identifier orders and
  both arrival orders, Established default, restart replacement with route
  flush, distinct identifiers, slot release, and Bad BGP Identifier.
  Existing fan-out test now gives its two peers distinct identifiers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:05:13 +02:00

331 lines
22 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ferrix_bgp /statusz</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7' fill='%231a73e8'/%3E%3Ctext x='8' y='12' font-size='10' font-family='monospace' fill='white' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
<style>
:root {
--bg: #ffffff; --fg: #202124; --muted: #5f6368; --line: #dadce0;
--head: #f1f3f4; --accent: #1a73e8; --ok: #188038; --warn: #b06000; --err: #c5221f;
--hi: #fef7e0; --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--hex-hdr:#e8f0fe; --hex-len:#fce8e6; --hex-attr:#e6f4ea; --hex-nlri:#fef7e0;
}
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.45 var(--mono); color: var(--fg); background: var(--bg); }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
header { padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--head); display: flex; flex-wrap: wrap; gap: 4px 24px; align-items: baseline; }
header h1 { font-size: 15px; margin: 0 8px 0 0; font-weight: bold; }
header .kv { color: var(--muted); } header .kv b { color: var(--fg); font-weight: normal; }
nav { padding: 4px 16px; border-bottom: 1px solid var(--line); color: var(--muted); }
nav a { margin-right: 14px; }
main { padding: 0 16px 40px; max-width: 1400px; }
section { margin-top: 22px; }
h2 { font-size: 13px; margin: 0 0 6px; padding-bottom: 3px; border-bottom: 1px solid var(--line); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
h2 .count { float: right; text-transform: none; letter-spacing: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
th { background: var(--head); font-weight: normal; color: var(--muted); }
td.wrap { white-space: normal; }
tr.mine { background: var(--hi); }
tr.wire td { background: #fafafa; white-space: normal; }
.empty { color: var(--muted); padding: 6px 8px; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--err); margin-right: 4px; vertical-align: middle; }
.dot.on { background: var(--ok); }
.tag { display: inline-block; padding: 0 5px; border: 1px solid var(--line); border-radius: 3px; margin: 1px 2px 1px 0; background: #fff; }
.tag.wk { border-color: var(--warn); color: var(--warn); }
.muted { color: var(--muted); } .ok { color: var(--ok); } .err { color: var(--err); } .warn { color: var(--warn); }
form.announce { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px 12px; align-items: end; padding: 10px; border: 1px solid var(--line); background: #fafafa; }
form.announce label { display: flex; flex-direction: column; gap: 2px; color: var(--muted); }
form.announce .span2 { grid-column: span 2; }
input, select, button { font: inherit; padding: 3px 6px; border: 1px solid var(--line); border-radius: 3px; background: #fff; color: var(--fg); }
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
button { cursor: pointer; } button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.small { padding: 0 6px; line-height: 18px; } button:disabled { opacity: .5; cursor: default; }
.row { display: flex; gap: 6px; align-items: center; }
#announce-msg { grid-column: 1 / -1; min-height: 1.4em; }
.hex { font-family: var(--mono); word-break: break-all; line-height: 1.7; }
.hex span { padding: 1px 0; } .hex .h { background: var(--hex-hdr); } .hex .l { background: var(--hex-len); } .hex .a { background: var(--hex-attr); } .hex .n { background: var(--hex-nlri); }
.hex .a:nth-of-type(odd) { filter: brightness(.96); }
.legend span { padding: 0 6px; margin-right: 8px; }
.wirebox { margin: 4px 0 8px; padding: 6px 8px; border-left: 3px solid var(--line); }
#event-log { max-height: 260px; overflow-y: auto; border: 1px solid var(--line); padding: 4px 8px; background: #fafafa; }
#event-log div { white-space: nowrap; } #event-log .t { color: var(--muted); margin-right: 8px; }
footer { padding: 10px 16px; color: var(--muted); border-top: 1px solid var(--line); margin-top: 30px; }
@media (prefers-color-scheme: dark) {
:root { --bg:#202124; --fg:#e8eaed; --muted:#9aa0a6; --line:#3c4043; --head:#292a2d; --accent:#8ab4f8; --ok:#81c995; --warn:#fdd663; --err:#f28b82; --hi:#3b3419;
--hex-hdr:#28344b; --hex-len:#4a2a28; --hex-attr:#26382b; --hex-nlri:#3b3419; }
input, select, button, .tag { background: #303134; color: var(--fg); }
tr.wire td, form.announce, #event-log { background: #292a2d; }
}
</style>
</head>
<body>
<header>
<h1>ferrix_bgp</h1>
<span class="kv">router-id <b id="s-router-id">…</b></span>
<span class="kv">AS <b id="s-asn">…</b></span>
<span class="kv">version <b id="s-version">…</b></span>
<span class="kv">uptime <b id="s-uptime">…</b></span>
<span class="kv">sessions <b id="s-sessions">…</b></span>
<span class="kv">local <b id="s-local">…</b></span>
<span class="kv">received <b id="s-received">…</b></span>
<span class="kv"><span class="dot" id="live-dot"></span><span id="live-text">connecting</span></span>
</header>
<nav>
<a href="#peers">peers</a><a href="#announce">announce</a><a href="#local">local routes</a><a href="#received">received routes</a><a href="#events">events</a>
<a href="/metricz">/metricz</a><a href="/api/status">/api/status</a>
</nav>
<main>
<section id="peers">
<h2>Peers <span class="count" id="peers-count"></span></h2>
<table>
<thead><tr><th>name</th><th>peer</th><th>ASN</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte ASN</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
</section>
<section id="announce">
<h2>Announce a prefix</h2>
<form class="announce" id="announce-form" autocomplete="off">
<label>owner (your name)<input name="owner" id="f-owner" required maxlength="64" placeholder="alice"></label>
<label>prefix<input name="prefix" id="f-prefix" required placeholder="10.100.1.0/24"></label>
<label>origin<select name="origin" id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></label>
<label>AS-path prepends<input name="prepend" id="f-prepend" type="number" min="0" max="16" value="0"></label>
<label>MED (optional)<input name="med" id="f-med" type="number" min="0" max="4294967295" placeholder="none"></label>
<label class="span2">communities <span class="muted">(comma separated, ASN:VALUE or name)</span>
<div class="row"><input name="communities" id="f-communities" placeholder="65000:100, NO_EXPORT" style="flex:1">
<select id="f-wk"><option value="">+ well-known…</option></select></div>
</label>
<label class="span2">large communities <span class="muted">(GLOBAL:LOCAL1:LOCAL2)</span><input name="large_communities" id="f-large" placeholder="65000:1:2"></label>
<div class="row"><button type="submit" class="primary">Announce</button><button type="button" id="f-clear">Clear</button></div>
<div id="announce-msg"></div>
</form>
</section>
<section id="local">
<h2>Local routes <span class="count" id="local-count"></span></h2>
<table>
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th></th></tr></thead>
<tbody id="local-body"></tbody>
</table>
</section>
<section id="received">
<h2>Received routes (Adj-RIB-In) <span class="count" id="received-count"></span></h2>
<p class="muted" style="margin:0 0 6px">Rows highlighted in yellow carry a prefix that is also announced locally: a route that has travelled through the lab and come back.</p>
<table>
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
</section>
<section id="events">
<h2>Event log</h2>
<div id="event-log"></div>
</section>
</main>
<footer>ferrix_bgp · Adj-RIB-In and local routes are live via <a href="/api/events">/api/events</a> · tables refresh on every event.</footer>
<script>
(() => {
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: [], wellKnown: {}, openWire: new Set() };
// ---- 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);
const age = unix => fmtDur(now() - unix);
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const tags = (list, wk) => list.length ? list.map(c => {
const name = wk && state.wellKnown[c];
return `<span class="tag${name ? ' wk' : ''}" title="${esc(c)}">${esc(name ? name : c)}</span>`;
}).join('') : '<span class="muted">—</span>';
const asPath = p => p.length ? p.join(' ') : '<span class="muted">(empty)</span>';
const myOwner = () => $('f-owner').value.trim();
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
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-uptime').textContent = fmtDur(s.uptime_secs + (now() - s._at));
$('s-sessions').textContent = state.peers.filter(p => p.state === 'Established').length; $('s-local').textContent = state.local.length;
$('s-received').textContent = state.received.length;
}
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'err', Idle: 'muted' };
function renderPeers() {
const b = $('peers-body'); $('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} up / ${state.peers.length}`;
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="10" class="empty">No peers configured and none connected.</td></tr>'; return; }
b.innerHTML = state.peers.map(p => {
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : '';
return `<tr>
<td>${esc(p.name ?? '')}${p.name ? '' : '<span class="muted">(inbound)</span>'}</td><td>${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
<td class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : '—'}</td><td>${up ? esc(p.local_addr) : '—'}</td>
<td>${up ? fmtTime(p.established_at_unix) : '—'}</td><td>${up ? age(p.established_at_unix) : '—'}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : '—'}</td>
<td class="wrap muted">${detail}</td></tr>`; }).join('');
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { b.innerHTML = '<tr><td colspan="9" class="empty">Nothing announced yet.</td></tr>'; return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
b.innerHTML = state.local.map(r => {
const mine = r.owner === me;
const path = Array(1 + r.prepend).fill(asn).join(' ');
const open = state.openWire.has(r.prefix);
return `<tr class="${mine ? 'mine' : ''}" data-prefix="${esc(r.prefix)}">
<td>${esc(r.prefix)}</td><td>${esc(r.owner)}</td><td>${esc(r.origin)}</td><td>${path}</td>
<td>${r.med ?? '<span class="muted">—</span>'}</td><td class="wrap">${tags(r.communities, true)}</td><td class="wrap">${tags(r.large_communities)}</td>
<td>${age(r.announced_at_unix)}</td>
<td><button class="small" data-act="wire" data-prefix="${esc(r.prefix)}">${open ? 'hide bytes' : 'bytes'}</button>
<button class="small" data-act="withdraw" data-prefix="${esc(r.prefix)}" ${mine ? '' : 'disabled title="only the owner can withdraw"'}>withdraw</button></td></tr>` +
(open ? `<tr class="wire"><td colspan="9" id="wire-${esc(r.prefix)}"><span class="muted">loading…</span></td></tr>` : '');
}).join('');
state.openWire.forEach(loadWire);
}
function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length;
if (!state.received.length) { b.innerHTML = '<tr><td colspan="9" class="empty">No routes received from peers.</td></tr>'; return; }
const localPrefixes = new Set(state.local.map(r => r.prefix));
b.innerHTML = state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'mine' : ''}">
<td>${esc(r.prefix)}</td><td>${esc(r.peer_addr)} <span class="muted">AS${r.peer_asn}</span></td><td>${asPath(s.as_path)}</td>
<td>${esc(s.next_hop ?? '—')}</td><td>${esc(s.origin ?? '—')}</td><td>${s.med ?? '<span class="muted">—</span>'}</td>
<td class="wrap">${tags(s.communities, true)}</td><td class="wrap">${tags(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join('');
}
// ---- wire view: split the UPDATE bytes into labelled fields ------------
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
function segmentUpdate(hex) {
const bytes = hex.match(/../g) || [], segs = [];
const take = (n, cls, label) => { segs.push({ cls, label, hex: bytes.splice(0, n).join(' ') }); };
take(16, 'h', 'marker'); take(2, 'h', 'length'); take(1, 'h', 'type=UPDATE');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'l', `withdrawn len=${wlen}`); if (wlen) take(wlen, 'n', 'withdrawn routes');
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'l', `attr len=${alen}`);
let rem = alen;
while (rem > 0) {
const flags = parseInt(bytes[0], 16), type = parseInt(bytes[1], 16), ext = flags & 0x10;
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
take(hdr + len, 'a', `${ATTR_NAMES[type] || 'attr ' + type} (flags 0x${flags.toString(16)}, len ${len})`); rem -= hdr + len;
}
if (bytes.length) take(bytes.length, 'n', 'NLRI');
return segs;
}
async function loadWire(prefix) {
const cell = $(`wire-${prefix}`); if (!cell) return;
try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(prefix)}`);
if (!views.length) { cell.innerHTML = '<span class="muted">No established session to send this on.</span>'; return; }
cell.innerHTML = `<div class="legend muted">UPDATE as sent: <span class="h">header</span><span class="l">lengths</span><span class="a">path attributes</span><span class="n">NLRI</span></div>` +
views.map(v => `<div class="wirebox"><div>to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn} · ${v.length} bytes · next hop ${esc(v.next_hop)} · AS path ${asPath(v.summary.as_path)}</div>
<div class="hex">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</div></div>`).join('');
} catch (e) { cell.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); state.status._at = now(); renderStatus(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderStatus(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); renderStatus(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); renderStatus(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
// ---- events ------------------------------------------------------------
function logEvent(text, cls) {
const box = $('event-log'), d = document.createElement('div');
d.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span><span class="${cls || ''}">${text}</span>`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
}
function describe(ev) {
switch (ev.type) {
case 'session_up': return [`session UP with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn}`, 'ok'];
case 'session_down': return [`session DOWN with ${esc(ev.peer_addr)} AS${ev.peer_asn}`, 'err'];
case 'local_announced': return [`announced ${esc(ev.route.prefix)} by ${esc(ev.route.owner)} [${ev.route.communities.map(c => state.wellKnown[c] || c).map(esc).join(' ')}]`, ''];
case 'local_withdrawn': return [`withdrew ${esc(ev.prefix)}`, 'warn'];
case 'received_announced': return [`received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`, ''];
case 'received_withdrawn': return [`peer AS${ev.peer_asn} withdrew ${esc(ev.prefix)}`, 'warn'];
case 'lagged': return [`missed ${ev.missed} events on ${ev.stream}; reloading`, 'err'];
default: return [esc(JSON.stringify(ev)), ''];
}
}
function connect() {
const es = new EventSource('/api/events');
es.onopen = () => { $('live-dot').classList.add('on'); $('live-text').textContent = 'live'; loadAll().catch(console.error); };
es.onerror = () => { $('live-dot').classList.remove('on'); $('live-text').textContent = 'reconnecting'; };
es.addEventListener('bgp', e => {
const ev = JSON.parse(e.data); const [text, cls] = describe(ev); logEvent(text, cls);
const t = ev.type;
if (t.startsWith('session')) loadPeers().catch(console.error);
else if (t.startsWith('local')) loadLocal().catch(console.error);
else if (t.startsWith('received')) loadReceived().catch(console.error);
else loadAll().catch(console.error);
});
}
// ---- form --------------------------------------------------------------
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
$('announce-form').addEventListener('submit', async e => {
e.preventDefault();
const body = {
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
prepend: Number($('f-prepend').value || 0), communities: splitList($('f-communities').value),
large_communities: splitList($('f-large').value),
};
if ($('f-med').value !== '') body.med = Number($('f-med').value);
try { localStorage.setItem('owner', body.owner); } catch (_) {}
setMsg('announcing…', 'muted');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (r.ok) { setMsg(`announced ${j.prefix} to ${state.peers.length} peer(s)`, 'ok'); loadLocal().catch(console.error); }
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'err');
});
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-prepend').value = 0; $('f-origin').value = 'IGP'; setMsg(''); });
$('f-wk').addEventListener('change', e => {
if (!e.target.value) return;
const cur = splitList($('f-communities').value); if (!cur.includes(e.target.value)) cur.push(e.target.value);
$('f-communities').value = cur.join(', '); e.target.value = '';
});
$('f-owner').addEventListener('input', renderLocal);
$('local-body').addEventListener('click', async e => {
const btn = e.target.closest('button[data-act]'); if (!btn) return;
const prefix = btn.dataset.prefix;
if (btn.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
if (!confirm(`Withdraw ${prefix}?`)) return;
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'err'); }
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); loadLocal().catch(console.error); }
});
// ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
getJSON('/api/communities/well-known').then(list => {
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
renderLocal(); renderReceived();
}).catch(console.error);
loadAll().catch(e => logEvent(esc(e.message), 'err'));
connect();
// Dial-state changes (Connecting → Backoff → …) carry no event, so poll
// the peers table; everything else is event-driven and only re-rendered
// here to keep ages ticking.
setInterval(() => { loadPeers().catch(console.error); renderStatus(); renderLocal(); renderReceived(); }, 5000);
})();
</script>
</body>
</html>