With rpki { caches { ... } } in the configuration the server keeps the
validated ROA payloads of a validator and validates every route from a
peer outside its own AS (RFC 6811). An invalid route is refused, unless
its peer has accept_rpki_invalid. Valid and not-found routes are taken;
validity does not otherwise enter route selection.
Validity is not settled when a route arrives: the data changes every few
minutes. The routes follow, without the peer sending anything again.
- A route refused as invalid is held by its session, out of the table.
When the data changes the session looks at what it holds, and what is
no longer invalid goes into the table. A held route is the peer's to
withdraw or replace like any other.
- A route in the table that becomes invalid is found by a task that
looks through the table when the data changes, at the prefixes the
changed VRPs cover, and is taken out and held by its session.
- If the data expires because no cache confirms it, nothing is invalid
any more and everything held is taken.
- A session holds at most 200,000 routes; past that it asks the peer for
a route refresh when the data changes.
So that the table can be looked through without parsing attributes, an
attribute set's key now has the route's origin AS. Memory per route is
unchanged at 52 bytes on the real table.
Shown: /api/rpki and a section of the status page with the cache, its
state and the age of the data; each route's validity; each peer's count
of held routes. Refusals are recorded under "RPKI invalid". Metrics:
bgp_rpki_vrps and bgp_rpki_updates_total.
Also: Negotiated says whether the peer supports route refresh, and
docs/RFCs.md is brought up to date.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
390 lines
22 KiB
HTML
390 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>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 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>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 · <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 => ({'&':'&','<':'<','>':'>','"':'"'}[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}</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 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="11" 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.best ? '<span class="ok">best</span>' : ''}</td></tr>`; }).join(''));
|
|
}
|
|
|
|
const RPKI_CLASS = { valid: 'ok', invalid: 'bad', 'not found': '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 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.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(', ')})`) +
|
|
` · <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>
|