Every poll and event rebuilt each table from scratch, and an open bytes view collapsed to "loading..." and was refetched every five seconds. - Tables are rewritten only when their markup changes. Clock-dependent text (ages, retry countdowns, uptime) is rendered as placeholders and ticked in place once a second, so unchanged data means no DOM writes. - The bytes view is cached per route and set of established sessions, and the previous bytes stay visible while new ones load. Measured with a MutationObserver: zero table rebuilds across 13 s of polling with a bytes view open; one announcement redraws only the local and received tables once each, with no "loading..." flash. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
367 lines
23 KiB
HTML
367 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">
|
|
<!--
|
|
Styled after the classic Google-internal /statusz pages as mirrored by
|
|
Vitess (go/vt/servenv/status.go) and golang.org/x/net/trace: plain
|
|
sans-serif HTML, default links and form controls, dense bordered tables,
|
|
and one full-width light blue (#eef) heading bar per section. Deliberately
|
|
no dark theme, no framework, no external resources.
|
|
-->
|
|
<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; }
|
|
table.data tr.loop td { background: #ffc; }
|
|
table.data tr.mine td.owner { font-weight: bold; }
|
|
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
|
|
.dim { color: #666; }
|
|
table.form td { padding: 2px 6px 2px 0; }
|
|
table.form td:first-child { width: 11em; }
|
|
select { max-width: 14em; }
|
|
pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px 0; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
|
|
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
|
|
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
|
|
#event-log div { white-space: pre-wrap; }
|
|
address { font-size: smaller; margin-top: 2em; color: #444; }
|
|
</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">local routes json</a>, <a href="/api/routes/received">received routes json</a><br>
|
|
Live updates: <span id="live-text" class="warn">connecting</span><br>
|
|
</div>
|
|
</div>
|
|
|
|
<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>detail</th></tr></thead>
|
|
<tbody id="peers-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="announce">Announce a prefix</h1>
|
|
<form id="announce-form" autocomplete="off">
|
|
<table class="form">
|
|
<tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
|
|
<td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
|
|
<tr><td><label for="f-origin">Origin:</label></td><td><select id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></td>
|
|
<td><label for="f-prepend">AS-path prepends:</label></td><td><input id="f-prepend" type="number" min="0" max="16" value="0" style="width:4em">
|
|
<label for="f-med">MED:</label> <input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
|
|
</table>
|
|
<table class="form">
|
|
<tr><td><label for="f-communities">Communities:</label></td><td><input id="f-communities" size="50" placeholder="65000:100, NO_EXPORT">
|
|
<select id="f-wk" aria-label="Add a well-known community"><option value="">+ well-known...</option></select>
|
|
<span class="dim">comma separated, ASN:VALUE or a name</span></td></tr>
|
|
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="42476:101:0">
|
|
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ SwissIX...</option></select>
|
|
<span class="dim">GLOBAL:LOCAL1:LOCAL2</span></td></tr>
|
|
<tr><td></td><td><input type="submit" value="Announce"> <input type="button" id="f-clear" value="Clear">
|
|
<span id="announce-msg"></span></td></tr>
|
|
</table>
|
|
</form>
|
|
|
|
<h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
|
|
<table class="data">
|
|
<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>actions</th></tr></thead>
|
|
<tbody id="local-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
|
|
<div class="dim">A yellow row carries a prefix that is also announced locally: it has travelled through other routers and come back.</div>
|
|
<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>communities</th><th>large communities</th><th>age</th></tr></thead>
|
|
<tbody id="received-body"></tbody>
|
|
</table>
|
|
|
|
<h1 id="events">Event log</h1>
|
|
<div id="event-log"></div>
|
|
|
|
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a> · tables refresh on every event</address>
|
|
|
|
<script>
|
|
(() => {
|
|
// Large-community presets offered next to the field. "PEER" is replaced by
|
|
// an AS number the user is asked for. Source:
|
|
// https://www.swissix.ch/resources/action-bgp-communities/
|
|
const LARGE_PRESETS = [
|
|
['42476:0:PEER', 'do not send to peer AS'],
|
|
['42476:1:PEER', 'send to peer AS'],
|
|
['42476:0:0', 'send to nobody'],
|
|
['42476:1:0', 'send to all (default)'],
|
|
['42476:101:0', 'prepend 1x to all'],
|
|
['42476:102:0', 'prepend 2x to all'],
|
|
['42476:103:0', 'prepend 3x to all'],
|
|
['42476:101:PEER', 'prepend 1x to peer AS'],
|
|
['42476:102:PEER', 'prepend 2x to peer AS'],
|
|
['42476:103:PEER', 'prepend 3x to peer AS'],
|
|
['42476:901:PEER', 'add NO_EXPORT towards peer AS'],
|
|
['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'],
|
|
];
|
|
|
|
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(), wire: new Map() };
|
|
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 open
|
|
// rows, selections and hover state 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, wk) => list.length ? list.map(c => {
|
|
const name = wk && state.wellKnown[c];
|
|
return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
|
|
}).join(' ') : DASH;
|
|
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(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-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' };
|
|
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="10" 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}</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>${detail}</td></tr>`;
|
|
}).join(''));
|
|
}
|
|
|
|
function renderLocal() {
|
|
const b = $('local-body'); $('local-count').textContent = state.local.length;
|
|
if (!state.local.length) { setHTML(b, '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'); return; }
|
|
const me = myOwner(), asn = state.status ? state.status.local_asn : '?', stale = [];
|
|
setHTML(b, state.local.map(r => {
|
|
const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix);
|
|
const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>`
|
|
: '<span class="dim" title="only the owner can withdraw">withdraw</span>';
|
|
return `<tr class="${mine ? 'mine' : ''}">
|
|
<td class="mono">${p}</td><td class="owner">${esc(r.owner)}</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>
|
|
<td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
|
|
(open ? `<tr><td colspan="9">${wireHTML(r, stale)}</td></tr>` : '');
|
|
}).join(''));
|
|
stale.forEach(loadWire);
|
|
}
|
|
|
|
function renderReceived() {
|
|
const b = $('received-body'); $('received-count').textContent = state.received.length;
|
|
if (!state.received.length) { setHTML(b, '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'); return; }
|
|
const localPrefixes = new Set(state.local.map(r => r.prefix));
|
|
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
|
|
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</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 class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(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, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type = UPDATE');
|
|
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', `withdrawn routes length = ${wlen}`); if (wlen) take(wlen, 'hn', 'withdrawn routes');
|
|
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', `path attributes length = ${alen}`);
|
|
let rem = alen, i = 0;
|
|
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, i++ % 2 ? 'ha2' : 'ha', `${ATTR_NAMES[type] || 'attribute ' + type} (flags 0x${flags.toString(16)}, length ${len})`); rem -= hdr + len;
|
|
}
|
|
if (bytes.length) take(bytes.length, 'hn', 'NLRI');
|
|
return segs;
|
|
}
|
|
// The bytes for a route depend only on the route itself and on which
|
|
// sessions are up, so they are fetched once per such combination and kept.
|
|
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
|
|
function wireHTML(route, stale) {
|
|
const cached = state.wire.get(route.prefix), sig = wireSig(route);
|
|
if (cached && cached.sig === sig) return cached.html;
|
|
stale.push(route);
|
|
// Keep showing the previous bytes while the new ones load.
|
|
return cached ? cached.html : '<span class="dim">loading...</span>';
|
|
}
|
|
async function loadWire(route) {
|
|
const sig = wireSig(route);
|
|
const pending = state.wire.get(route.prefix);
|
|
if (pending && pending.loading === sig) return;
|
|
state.wire.set(route.prefix, { ...(pending || { html: '<span class="dim">loading...</span>' }), loading: sig });
|
|
let html;
|
|
try {
|
|
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
|
|
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
|
|
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)}
|
|
<span class="hh"> header </span> <span class="hl"> lengths </span> <span class="ha"> path attributes </span> <span class="hn"> NLRI </span>
|
|
<span class="dim">(hover a segment for its name)</span>
|
|
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</pre>`).join('');
|
|
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
|
|
state.wire.set(route.prefix, { sig, html });
|
|
renderLocal();
|
|
}
|
|
|
|
// ---- data loading ------------------------------------------------------
|
|
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
|
|
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); }
|
|
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
|
|
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
|
|
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
|
|
|
|
// ---- 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 => state.wellKnown[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 '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 `announced ${esc(r.prefix)} by ${esc(r.owner)}` +
|
|
(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">withdrew</span> ${esc(ev.prefix)}`;
|
|
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} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
|
|
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
|
|
default: return esc(JSON.stringify(ev));
|
|
}
|
|
}
|
|
function setLive(text, cls) { const e = $('live-text'); e.textContent = text; e.className = cls; }
|
|
function connect() {
|
|
const es = new EventSource('/api/events');
|
|
es.onopen = () => { setLive('connected', 'ok'); loadAll().catch(console.error); };
|
|
es.onerror = () => setLive('reconnecting', 'bad');
|
|
es.addEventListener('bgp', e => {
|
|
const ev = JSON.parse(e.data); logEvent(describe(ev));
|
|
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; }
|
|
function appendTo(id, value) {
|
|
const cur = splitList($(id).value); if (!cur.includes(value)) cur.push(value);
|
|
$(id).value = cur.join(', ');
|
|
}
|
|
$('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...', 'dim');
|
|
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
const j = await r.json().catch(() => ({}));
|
|
const up = state.peers.filter(p => p.state === 'Established').length;
|
|
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); loadLocal().catch(console.error); }
|
|
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
|
|
});
|
|
$('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) appendTo('f-communities', e.target.value); e.target.value = ''; });
|
|
$('f-lc').addEventListener('change', e => {
|
|
let v = e.target.value; e.target.value = '';
|
|
if (!v) return;
|
|
if (v.includes('PEER')) {
|
|
const peer = (prompt('Peer AS number?') || '').trim().replace(/^AS/i, '');
|
|
if (!/^\d+$/.test(peer)) { if (peer) setMsg(`"${peer}" is not an AS number`, 'bad'); return; }
|
|
v = v.replace('PEER', peer);
|
|
}
|
|
appendTo('f-large', v);
|
|
});
|
|
$('f-owner').addEventListener('input', renderLocal);
|
|
$('local-body').addEventListener('click', async e => {
|
|
const link = e.target.closest('a[data-act]'); if (!link) return;
|
|
e.preventDefault();
|
|
const prefix = link.dataset.prefix;
|
|
if (link.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'}`, 'bad'); }
|
|
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); loadLocal().catch(console.error); }
|
|
});
|
|
|
|
// ---- boot --------------------------------------------------------------
|
|
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
|
|
LARGE_PRESETS.forEach(([value, label]) => { const o = document.createElement('option'); o.value = value; o.textContent = `${value.replace('PEER', '<peer-as>')} ${label}`; $('f-lc').appendChild(o); });
|
|
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(`<span class="bad">${esc(e.message)}</span>`));
|
|
connect();
|
|
// Dial-state changes (Connecting, Backoff, ...) carry no event, so poll the
|
|
// peers table; it is only redrawn if something changed. Everything else is
|
|
// event-driven. Ages, countdowns and uptime tick as plain text.
|
|
setInterval(() => loadPeers().catch(console.error), 5000);
|
|
setInterval(tick, 1000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|