Phase 4 of the teaching demo: the daemon now brings up its own sessions. - New dialer task per configured peer: connect with a timeout, run the same FSM as the passive side, back off exponentially (from connect_retry_seconds, capped at two minutes) and retry forever. Stays idle while a session with the same remote IP and ASN already exists. - The peer's OPEN must carry the configured ASN, otherwise the session is refused with a Bad Peer AS notification. Inbound connections from a configured peer's address get the same check plus the configured name and hold time. - SessionRegistry tracks configured peers and their dial state (Idle / Connecting / Backoff with last error and retry time). - /api/peers merges configured peers with live sessions so the page shows peers that are down, why, and when the next attempt is; unconfigured inbound sessions are listed after them. - fake_peer gains --listen so it can stand in for a router the daemon dials. - Integration tests: dial and establish, reconnect after the peer closes, wrong ASN refused, inbound from a configured address checked and named. Not implemented: RFC 4271 §6.8 collision detection; a real router on the other end resolves the rare race by closing one connection. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
328 lines
21 KiB
HTML
328 lines
21 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>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 => ({'&':'&','<':'<','>':'>','"':'"'}[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="9" 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 ? 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();
|
|
setInterval(() => { renderStatus(); renderPeers(); renderLocal(); renderReceived(); }, 10000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|