feature: restyle statusz after the classic Google status pages

Modelled on Vitess's servenv status page and golang.org/x/net/trace, both
written by Google engineers to mirror the internal pages: plain sans-serif
HTML, default links and native form controls, dense bordered tables, and a
full-width centred light blue (#eef) heading bar per section. Header shows
"Status for ferrix_bgp" with start time, uptime, version, router ID and AS
on the left and host, "View:" links and live-update state on the right.
The dark theme is dropped on purpose.

Behaviour is unchanged (live updates, owner-gated withdraw, per-session
UPDATE bytes, looped-route highlight, peers polling). Additions:
- a SwissIX large-community picker next to the field; presets with a peer
  AS ask for the number
- event log lines include prepend and MED
- /api/status gains hostname and started_at_unix (RouterInfo::new)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-19 17:06:00 +02:00
1 parent d23a578d72
commit cc022c3b7b
6 files changed
+242 -206

No files matched your search

+5 -7
View File
@@ -1,5 +1,4 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant;
use bgp_server::config::Config; use bgp_server::config::Config;
use bgp_server::proto::config::BgpConfig; use bgp_server::proto::config::BgpConfig;
@@ -34,12 +33,11 @@ async fn main() -> Result<()> {
if let Some(http_endpoint) = server.config().http_endpoint { if let Some(http_endpoint) = server.config().http_endpoint {
let state = AppState { let state = AppState {
info: Arc::new(RouterInfo { info: Arc::new(RouterInfo::new(
router_id: server.config().router_id, server.config().router_id,
local_asn: server.config().local_asn, server.config().local_asn,
version: env!("CARGO_PKG_VERSION").to_string(), env!("CARGO_PKG_VERSION"),
started: Instant::now(), )),
}),
registry: server.registry.clone(), registry: server.registry.clone(),
local_rib: server.local_rib.clone(), local_rib: server.local_rib.clone(),
adj_rib_in: server.adj_rib_in.clone(), adj_rib_in: server.adj_rib_in.clone(),
+1 -6
View File
@@ -87,12 +87,7 @@ impl TestServer {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
let base_url = format!("http://{}", listener.local_addr()?); let base_url = format!("http://{}", listener.local_addr()?);
let state = AppState { let state = AppState {
info: std::sync::Arc::new(RouterInfo { info: std::sync::Arc::new(RouterInfo::new(Ipv4Addr::new(127, 0, 0, 1), self.local_asn, "test")),
router_id: Ipv4Addr::new(127, 0, 0, 1),
local_asn: self.local_asn,
version: "test".into(),
started: std::time::Instant::now(),
}),
registry: self.registry.clone(), registry: self.registry.clone(),
local_rib: self.local_rib.clone(), local_rib: self.local_rib.clone(),
adj_rib_in: self.adj_rib_in.clone(), adj_rib_in: self.adj_rib_in.clone(),
+3 -1
View File
@@ -49,6 +49,8 @@ async fn test_status_and_peers() -> Result<()> {
assert_eq!(status["local_asn"], LOCAL_ASN); assert_eq!(status["local_asn"], LOCAL_ASN);
assert_eq!(status["router_id"], "127.0.0.1"); assert_eq!(status["router_id"], "127.0.0.1");
assert_eq!(status["sessions"], 0); assert_eq!(status["sessions"], 0);
assert!(status["started_at_unix"].as_u64().unwrap() > 1_700_000_000);
assert!(!status["hostname"].as_str().unwrap().is_empty());
let _peer = established_peer(&server).await?; let _peer = established_peer(&server).await?;
@@ -281,7 +283,7 @@ async fn test_statusz_page_is_served() -> Result<()> {
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
assert!(resp.headers()["content-type"].to_str()?.starts_with("text/html")); assert!(resp.headers()["content-type"].to_str()?.starts_with("text/html"));
let body = resp.text().await?; let body = resp.text().await?;
assert!(body.contains("<title>ferrix_bgp /statusz</title>")); assert!(body.contains("<title>Status for ferrix_bgp</title>"));
assert!(body.contains("/api/events")); assert!(body.contains("/api/events"));
// The root redirects to the page. // The root redirects to the page.
+4
View File
@@ -69,6 +69,8 @@ pub struct StatusView {
pub router_id: String, pub router_id: String,
pub local_asn: u32, pub local_asn: u32,
pub version: String, pub version: String,
pub hostname: String,
pub started_at_unix: u64,
pub uptime_secs: u64, pub uptime_secs: u64,
pub sessions: usize, pub sessions: usize,
pub local_routes: usize, pub local_routes: usize,
@@ -335,6 +337,8 @@ pub async fn status(State(state): State<AppState>) -> Json<StatusView> {
router_id: state.info.router_id.to_string(), router_id: state.info.router_id.to_string(),
local_asn: state.info.local_asn, local_asn: state.info.local_asn,
version: state.info.version.clone(), version: state.info.version.clone(),
hostname: state.info.hostname.clone(),
started_at_unix: unix_secs(state.info.started_at),
uptime_secs: state.info.started.elapsed().as_secs(), uptime_secs: state.info.started.elapsed().as_secs(),
sessions: state.registry.list().len(), sessions: state.registry.list().len(),
local_routes: state.local_rib.snapshot().await.len(), local_routes: state.local_rib.snapshot().await.len(),
+31 -1
View File
@@ -12,7 +12,7 @@ mod metrics;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::{Instant, SystemTime};
use axum::Router; use axum::Router;
use axum::http::header; use axum::http::header;
@@ -36,6 +36,36 @@ pub struct RouterInfo {
pub local_asn: u32, pub local_asn: u32,
pub version: String, pub version: String,
pub started: Instant, pub started: Instant,
/// Wall-clock start time, for display.
pub started_at: SystemTime,
/// Name of the machine we run on, for display.
pub hostname: String,
}
impl RouterInfo {
pub fn new(router_id: Ipv4Addr, local_asn: u32, version: impl Into<String>) -> Self {
Self {
router_id,
local_asn,
version: version.into(),
started: Instant::now(),
started_at: SystemTime::now(),
hostname: hostname(),
}
}
}
/// Best-effort host name without pulling in a dependency: ask the `hostname`
/// utility, which exists on every platform we run on.
fn hostname() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown host".into())
} }
#[derive(Clone)] #[derive(Clone)]
+198 -191
View File
@@ -1,155 +1,143 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ferrix_bgp /statusz</title> <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%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"> <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> <style>
:root { body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
--bg: #ffffff; --fg: #202124; --muted: #5f6368; --line: #dadce0; h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
--head: #f1f3f4; --accent: #1a73e8; --ok: #188038; --warn: #b06000; --err: #c5221f; h1.top { font-size: 150%; margin-top: 0; }
--hi: #fef7e0; --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; .lefthand { float: left; width: 70%; }
--hex-hdr:#e8f0fe; --hex-len:#fce8e6; --hex-attr:#e6f4ea; --hex-nlri:#fef7e0; .righthand { text-align: right; }
} a { color: #00c; } a:visited { color: #551a8b; }
* { box-sizing: border-box; } table.data { border-collapse: collapse; margin: 4px 0; }
body { margin: 0; font: 13px/1.45 var(--mono); color: var(--fg); background: var(--bg); } table.data th, table.data td { border: 1px solid #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } table.data th { background: #dde; font-weight: bold; }
header { padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--head); display: flex; flex-wrap: wrap; gap: 4px 24px; align-items: baseline; } .mono, table.data td.mono { font-family: monospace; }
header h1 { font-size: 15px; margin: 0 8px 0 0; font-weight: bold; } table.data tr.loop td { background: #ffc; }
header .kv { color: var(--muted); } header .kv b { color: var(--fg); font-weight: normal; } table.data tr.mine td.owner { font-weight: bold; }
nav { padding: 4px 16px; border-bottom: 1px solid var(--line); color: var(--muted); } .ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
nav a { margin-right: 14px; } .dim { color: #666; }
main { padding: 0 16px 40px; max-width: 1400px; } table.form td { padding: 2px 6px 2px 0; }
section { margin-top: 22px; } table.form td:first-child { width: 11em; }
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); } select { max-width: 14em; }
h2 .count { float: right; text-transform: none; letter-spacing: 0; } 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; }
table { border-collapse: collapse; width: 100%; } .hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
th, td { text-align: left; padding: 3px 8px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; } #event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
th { background: var(--head); font-weight: normal; color: var(--muted); } #event-log div { white-space: pre-wrap; }
td.wrap { white-space: normal; } address { font-size: smaller; margin-top: 2em; color: #444; }
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> </style>
</head> </head>
<body> <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"> <h1 class="top">Status for ferrix_bgp</h1>
<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"> <div>
<h2>Announce a prefix</h2> <div class="lefthand">
<form class="announce" id="announce-form" autocomplete="off"> Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
<label>owner (your name)<input name="owner" id="f-owner" required maxlength="64" placeholder="alice"></label> Version <span id="s-version">...</span><br>
<label>prefix<input name="prefix" id="f-prefix" required placeholder="10.100.1.0/24"></label> Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
<label>origin<select name="origin" id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></label> </div>
<label>AS-path prepends<input name="prepend" id="f-prepend" type="number" min="0" max="16" value="0"></label> <div class="righthand">
<label>MED (optional)<input name="med" id="f-med" type="number" min="0" max="4294967295" placeholder="none"></label> Running on <span id="s-hostname">...</span><br>
<label class="span2">communities <span class="muted">(comma separated, ASN:VALUE or name)</span> 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>
<div class="row"><input name="communities" id="f-communities" placeholder="65000:100, NO_EXPORT" style="flex:1"> Live updates: <span id="live-text" class="warn">connecting</span><br>
<select id="f-wk"><option value="">+ well-known…</option></select></div> </div>
</label> </div>
<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"> <h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
<h2>Local routes <span class="count" id="local-count"></span></h2> <table class="data">
<table> <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>
<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="peers-body"></tbody>
<tbody id="local-body"></tbody> </table>
</table>
</section>
<section id="received"> <h1 id="announce">Announce a prefix</h1>
<h2>Received routes (Adj-RIB-In) <span class="count" id="received-count"></span></h2> <form id="announce-form" autocomplete="off">
<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 class="form">
<table> <tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
<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> <td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
<tbody id="received-body"></tbody> <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>
</table> <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">
</section> &nbsp; <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">
&nbsp; <span id="announce-msg"></span></td></tr>
</table>
</form>
<section id="events"> <h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
<h2>Event log</h2> <table class="data">
<div id="event-log"></div> <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>
</section> <tbody id="local-body"></tbody>
</table>
</main> <h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
<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> <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 &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a> &middot; tables refresh on every event</address>
<script> <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 $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set() }; const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set() };
const DASH = '<span class="dim">-</span>';
// ---- helpers ----------------------------------------------------------- // ---- helpers -----------------------------------------------------------
const fmtDur = s => { const fmtDur = s => {
s = Math.max(0, Math.floor(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; 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`; 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 now = () => Math.floor(Date.now() / 1000);
const age = unix => fmtDur(now() - unix); const age = unix => fmtDur(now() - unix);
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString(); const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const tags = (list, wk) => list.length ? list.map(c => { const commList = (list, wk) => list.length ? list.map(c => {
const name = wk && state.wellKnown[c]; const name = wk && state.wellKnown[c];
return `<span class="tag${name ? ' wk' : ''}" title="${esc(c)}">${esc(name ? name : c)}</span>`; return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
}).join('') : '<span class="muted">—</span>'; }).join(' ') : DASH;
const asPath = p => p.length ? p.join(' ') : '<span class="muted">(empty)</span>'; const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
const myOwner = () => $('f-owner').value.trim(); const myOwner = () => $('f-owner').value.trim();
async function getJSON(url) { async function getJSON(url) {
@@ -162,52 +150,53 @@
function renderStatus() { function renderStatus() {
const s = state.status; if (!s) return; const s = state.status; if (!s) return;
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn; $('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-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
$('s-sessions').textContent = state.peers.filter(p => p.state === 'Established').length; $('s-local').textContent = state.local.length; $('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
$('s-received').textContent = state.received.length; $('s-uptime').textContent = fmtDur(now() - s.started_at_unix);
} }
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'err', Idle: 'muted' }; const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' };
function renderPeers() { function renderPeers() {
const b = $('peers-body'); $('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} up / ${state.peers.length}`; const b = $('peers-body');
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="10" class="empty">No peers configured and none connected.</td></tr>'; return; } $('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
if (!state.peers.length) { b.innerHTML = '<tr><td colspan="10" class="dim">No peers configured and none connected.</td></tr>'; return; }
b.innerHTML = state.peers.map(p => { b.innerHTML = state.peers.map(p => {
const up = p.state === 'Established'; const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : ''; const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : '';
return `<tr> 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>${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 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><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</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="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td class="wrap muted">${detail}</td></tr>`; }).join(''); <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() { function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length; 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; } if (!state.local.length) { b.innerHTML = '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'; return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?'; const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
b.innerHTML = state.local.map(r => { b.innerHTML = state.local.map(r => {
const mine = r.owner === me; const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix);
const path = Array(1 + r.prepend).fill(asn).join(' '); const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>`
const open = state.openWire.has(r.prefix); : '<span class="dim" title="only the owner can withdraw">withdraw</span>';
return `<tr class="${mine ? 'mine' : ''}" data-prefix="${esc(r.prefix)}"> return `<tr class="${mine ? 'mine' : ''}">
<td>${esc(r.prefix)}</td><td>${esc(r.owner)}</td><td>${esc(r.origin)}</td><td>${path}</td> <td class="mono">${p}</td><td class="owner">${esc(r.owner)}</td><td>${esc(r.origin)}</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 class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
<td>${age(r.announced_at_unix)}</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><button class="small" data-act="wire" data-prefix="${esc(r.prefix)}">${open ? 'hide bytes' : 'bytes'}</button> <td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
<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><td colspan="9" id="wire-${p}"><span class="dim">loading...</span></td></tr>` : '');
(open ? `<tr class="wire"><td colspan="9" id="wire-${esc(r.prefix)}"><span class="muted">loading…</span></td></tr>` : '');
}).join(''); }).join('');
state.openWire.forEach(loadWire); state.openWire.forEach(loadWire);
} }
function renderReceived() { function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length; 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; } if (!state.received.length) { b.innerHTML = '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'; return; }
const localPrefixes = new Set(state.local.map(r => r.prefix)); 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' : ''}"> b.innerHTML = state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
<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 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>${esc(s.next_hop ?? '—')}</td><td>${esc(s.origin ?? '—')}</td><td>${s.med ?? '<span class="muted">—</span>'}</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="wrap">${tags(s.communities, true)}</td><td class="wrap">${tags(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''); <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 ------------ // ---- wire view: split the UPDATE bytes into labelled fields ------------
@@ -215,60 +204,65 @@
function segmentUpdate(hex) { function segmentUpdate(hex) {
const bytes = hex.match(/../g) || [], segs = []; const bytes = hex.match(/../g) || [], segs = [];
const take = (n, cls, label) => { segs.push({ cls, label, hex: bytes.splice(0, n).join(' ') }); }; 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'); take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type = UPDATE');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'l', `withdrawn len=${wlen}`); if (wlen) take(wlen, 'n', 'withdrawn routes'); 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, 'l', `attr len=${alen}`); const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', `path attributes length = ${alen}`);
let rem = alen; let rem = alen, i = 0;
while (rem > 0) { while (rem > 0) {
const flags = parseInt(bytes[0], 16), type = parseInt(bytes[1], 16), ext = flags & 0x10; 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); 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; 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, 'n', 'NLRI'); if (bytes.length) take(bytes.length, 'hn', 'NLRI');
return segs; return segs;
} }
async function loadWire(prefix) { async function loadWire(prefix) {
const cell = $(`wire-${prefix}`); if (!cell) return; const cell = $(`wire-${prefix}`); if (!cell) return;
try { try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(prefix)}`); 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; } if (!views.length) { cell.innerHTML = '<span class="dim">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>` + cell.innerHTML = 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)} &nbsp;
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> <span class="hh">&nbsp;header&nbsp;</span> <span class="hl">&nbsp;lengths&nbsp;</span> <span class="ha">&nbsp;path attributes&nbsp;</span> <span class="hn">&nbsp;NLRI&nbsp;</span>
<div class="hex">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</div></div>`).join(''); <span class="dim">(hover a segment for its name)</span>
} catch (e) { cell.innerHTML = `<span class="err">${esc(e.message)}</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) { cell.innerHTML = `<span class="bad">${esc(e.message)}</span>`; }
} }
// ---- data loading ------------------------------------------------------ // ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); state.status._at = now(); renderStatus(); } async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderStatus(); } async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); renderStatus(); } async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); renderStatus(); } async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); } async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
// ---- events ------------------------------------------------------------ // ---- events ------------------------------------------------------------
function logEvent(text, cls) { function logEvent(html) {
const box = $('event-log'), d = document.createElement('div'); const box = $('event-log'), d = document.createElement('div');
d.innerHTML = `<span class="t">${new Date().toLocaleTimeString()}</span><span class="${cls || ''}">${text}</span>`; d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
box.prepend(d); while (box.children.length > 200) box.lastChild.remove(); 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) { function describe(ev) {
switch (ev.type) { 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_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 [`session DOWN with ${esc(ev.peer_addr)} AS${ev.peer_asn}`, 'err']; case 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
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_announced': { const r = ev.route; return `announced ${esc(r.prefix)} by ${esc(r.owner)}` +
case 'local_withdrawn': return [`withdrew ${esc(ev.prefix)}`, 'warn']; (r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
case 'received_announced': return [`received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`, '']; (r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
case 'received_withdrawn': return [`peer AS${ev.peer_asn} withdrew ${esc(ev.prefix)}`, 'warn']; case 'local_withdrawn': return `<span class="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'lagged': return [`missed ${ev.missed} events on ${ev.stream}; reloading`, 'err']; case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
default: return [esc(JSON.stringify(ev)), '']; 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() { function connect() {
const es = new EventSource('/api/events'); const es = new EventSource('/api/events');
es.onopen = () => { $('live-dot').classList.add('on'); $('live-text').textContent = 'live'; loadAll().catch(console.error); }; es.onopen = () => { setLive('connected', 'ok'); loadAll().catch(console.error); };
es.onerror = () => { $('live-dot').classList.remove('on'); $('live-text').textContent = 'reconnecting'; }; es.onerror = () => setLive('reconnecting', 'bad');
es.addEventListener('bgp', e => { es.addEventListener('bgp', e => {
const ev = JSON.parse(e.data); const [text, cls] = describe(ev); logEvent(text, cls); const ev = JSON.parse(e.data); logEvent(describe(ev));
const t = ev.type; const t = ev.type;
if (t.startsWith('session')) loadPeers().catch(console.error); if (t.startsWith('session')) loadPeers().catch(console.error);
else if (t.startsWith('local')) loadLocal().catch(console.error); else if (t.startsWith('local')) loadLocal().catch(console.error);
@@ -280,6 +274,10 @@
// ---- form -------------------------------------------------------------- // ---- form --------------------------------------------------------------
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean); 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 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 => { $('announce-form').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const body = { const body = {
@@ -289,40 +287,49 @@
}; };
if ($('f-med').value !== '') body.med = Number($('f-med').value); if ($('f-med').value !== '') body.med = Number($('f-med').value);
try { localStorage.setItem('owner', body.owner); } catch (_) {} try { localStorage.setItem('owner', body.owner); } catch (_) {}
setMsg('announcing…', 'muted'); setMsg('announcing...', 'dim');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); 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 j = await r.json().catch(() => ({}));
if (r.ok) { setMsg(`announced ${j.prefix} to ${state.peers.length} peer(s)`, 'ok'); loadLocal().catch(console.error); } const up = state.peers.filter(p => p.state === 'Established').length;
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'err'); 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-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 => { $('f-wk').addEventListener('change', e => { if (e.target.value) appendTo('f-communities', e.target.value); e.target.value = ''; });
if (!e.target.value) return; $('f-lc').addEventListener('change', e => {
const cur = splitList($('f-communities').value); if (!cur.includes(e.target.value)) cur.push(e.target.value); let v = e.target.value; e.target.value = '';
$('f-communities').value = cur.join(', '); 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); $('f-owner').addEventListener('input', renderLocal);
$('local-body').addEventListener('click', async e => { $('local-body').addEventListener('click', async e => {
const btn = e.target.closest('button[data-act]'); if (!btn) return; const link = e.target.closest('a[data-act]'); if (!link) return;
const prefix = btn.dataset.prefix; e.preventDefault();
if (btn.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; } 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; 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() }) }); 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'); } 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); loadLocal().catch(console.error); } else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); loadLocal().catch(console.error); }
}); });
// ---- boot -------------------------------------------------------------- // ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {} 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 => { 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); }); 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(); renderLocal(); renderReceived();
}).catch(console.error); }).catch(console.error);
loadAll().catch(e => logEvent(esc(e.message), 'err')); loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
connect(); connect();
// Dial-state changes (Connecting → Backoff → …) carry no event, so poll // Dial-state changes (Connecting, Backoff, ...) carry no event, so poll the
// the peers table; everything else is event-driven and only re-rendered // peers table; everything else is event-driven and only re-rendered here to
// here to keep ages ticking. // keep ages and uptime ticking.
setInterval(() => { loadPeers().catch(console.error); renderStatus(); renderLocal(); renderReceived(); }, 5000); setInterval(() => { loadPeers().catch(console.error); renderStatus(); renderLocal(); renderReceived(); }, 5000);
})(); })();
</script> </script>