Status page: routes in order of selection, lookup by origin AS

Received routes are listed from the best down: the RIB gains ranked_routes,
which runs selection again without the winners, so the second route is the
one that would be installed were the best withdrawn. The API gives each
route its rank, and the page shows it as a numbered badge after the star.

The lookup box moves to the received-routes table, which is what it filters,
and takes an AS number: a new `origin` query parameter finds every route
whose path ends in that AS, with an empty path counting as the local AS.

Long cells are clamped to a few lines with a control to open them, and an
AS repeated along a path (prepending) is shown once with a ×N badge. Peer
names are bold and dark, not underlined links, so a row can be found by
its name among the addresses and numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-07 22:09:07 +02:00
1 parent 1ce7eef27e
commit 35ce2e2e57
9 files changed
+370 -66

No files matched your search

@@ -81,6 +81,19 @@ async fn test_the_best_route_is_chosen_and_replaced_when_it_goes() -> Result<()>
assert_eq!(routes.as_array().unwrap().len(), 2);
assert_eq!(best.len(), 1);
assert_eq!(best[0]["peer_asn"], 65002);
// Listed from the best down, each with its place in selection.
let ranks: Vec<(u64, u64)> = routes
.as_array()
.unwrap()
.iter()
.map(|route| {
(
route["rank"].as_u64().unwrap(),
route["peer_asn"].as_u64().unwrap(),
)
})
.collect();
assert_eq!(ranks, vec![(1, 65002), (2, 65001)]);
// The session with the best route ends: the other route takes over.
drop(near);
+17
View File
@@ -689,6 +689,23 @@ impl RibTable {
.unwrap_or_default()
}
/// Every route for `prefix`, from the best down: after the best, the
/// route that would be chosen were the ones before it gone, and so on.
pub fn ranked_routes(&self, prefix: &IpPrefix) -> Vec<Route> {
let hash = self.hasher.hash_one(prefix);
let shard = read(&self.shards[self.shard_of(hash)]);
shard
.rows
.find(hash, |row| row.prefix == *prefix)
.map(|row| {
select::ranked(row, &shard.peers)
.into_iter()
.map(|index| row.route(index).to_owned())
.collect()
})
.unwrap_or_default()
}
fn with_row<T>(&self, prefix: &IpPrefix, read_row: impl FnOnce(&Row) -> T) -> Option<T> {
let hash = self.hasher.hash_one(prefix);
let shard = read(&self.shards[self.shard_of(hash)]);
+35 -1
View File
@@ -27,7 +27,7 @@
//! of all its routes.
use super::attrs::SelectionKey;
use super::{DEFAULT_LOCAL_PREF, PeerInfo, Row};
use super::{DEFAULT_LOCAL_PREF, Path, PeerInfo, Row};
/// Steps 1 to 3 as one number of 48 bits: the larger, the better.
///
@@ -187,6 +187,40 @@ fn choose(row: &mut Row, peers: &[Option<PeerInfo>], scratch: &mut Scratch) -> b
row.best != before
}
/// The routes of `row` from the best down, as indices into its routes:
/// after the best, the one that would be chosen were the best gone, and
/// so on. What an operator asks when looking at a prefix: which route
/// would take over.
pub(super) fn ranked(row: &Row, peers: &[Option<PeerInfo>]) -> Vec<usize> {
// A copy to take the winners out of one by one, and where each of its
// routes is in the row itself.
let mut rest = Row {
prefix: row.prefix,
best: 0,
seqs: row.seqs,
paths: row
.paths
.iter()
.map(|path| Path {
word: path.word,
attrs: std::sync::Arc::clone(&path.attrs),
})
.collect(),
path_ids: row.path_ids.clone(),
};
let mut places: Vec<usize> = (0..row.paths.len()).collect();
let mut scratch = Scratch::default();
let mut order = Vec::with_capacity(row.paths.len());
while !rest.paths.is_empty() {
choose(&mut rest, peers, &mut scratch);
let at = rest.best();
order.push(places[at]);
rest.swap_remove(at);
places.swap_remove(at);
}
order
}
/// For a route just added at `added` that ties with the best in steps 1 to
/// 3: whether it is the new best, if that can be said from the two routes
/// alone. `None` if the whole row has to be looked at.
+56
View File
@@ -261,6 +261,62 @@ fn each_step_of_selection_decides_when_the_ones_before_tie() {
assert_eq!(fixture.best(&p), Some(1));
}
/// The ranking of a prefix's routes is selection run again without the
/// winners: each route in it is what would be chosen were the ones before
/// it gone.
#[test]
fn routes_are_ranked_as_they_would_be_chosen_one_after_another() {
let p = prefix("10.0.0.0/8");
// Peer 0 would win on identifier alone; peer 2 has the highest
// LOCAL_PREF, peer 1 the shortest path of the rest, peer 3 a MED
// that loses to peer 1's within their shared neighbouring AS.
let peers = [external(1), external(2), internal(3), external(4)];
let fixture = Fixture::new(&peers);
fixture.announce(0, &p, &Route::via(&[7, 1, 2]));
fixture.announce(1, &p, &Route::via(&[7, 3]));
fixture.announce(
2,
&p,
&Route {
local_pref: Some(200),
..Route::via(&[8, 1, 2, 3])
},
);
fixture.announce(
3,
&p,
&Route {
med: Some(5),
..Route::via(&[7, 4])
},
);
let ranked: Vec<usize> = fixture
.table
.ranked_routes(&p)
.iter()
.map(|route| {
fixture
.peers
.iter()
.position(|peer| *peer == route.peer)
.unwrap()
})
.collect();
assert_eq!(ranked, vec![2, 1, 3, 0]);
assert_eq!(fixture.best(&p), Some(2));
// Taking the best away makes the second the best, and so on down.
fixture.withdraw(2, &p);
assert_eq!(fixture.best(&p), Some(1));
fixture.withdraw(1, &p);
assert_eq!(fixture.best(&p), Some(3));
assert!(
fixture
.table
.ranked_routes(&prefix("10.1.0.0/16"))
.is_empty()
);
}
/// MEDs are compared only within a neighbouring AS. Taken pair by pair
/// that is not an order: A beats B, B beats C, C beats A. Whatever the
/// order of arrival, the answer must be the same.
+151 -32
View File
@@ -15,7 +15,7 @@ use bgp_packet::message::{
};
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::table::{PeerIndex, RibTable, Route, RouteRef};
use bgp_rib::table::{AttrSet, PeerIndex, RibTable, Route, RouteRef, SelectionKey};
use bgp_rib::{LocalRoute, RibStopped};
use bgp_rpki::{CacheState, Update};
use bgp_server::address::{PeerAddress, socket_text};
@@ -467,6 +467,9 @@ pub struct ReceivedRouteView {
pub path_id: u32,
/// Whether this is the route chosen for its prefix.
pub best: bool,
/// The route's place among the prefix's routes: 1 for the best, 2 for
/// the route that would be chosen were the best gone, and so on.
pub rank: u32,
/// Whether it is kept from a session of the peer's that ended, and not
/// sent again since (graceful restart).
pub stale: bool,
@@ -534,7 +537,7 @@ impl PeerNames {
/// A view of one route. `None` if the route's session has just ended:
/// the route is on its way out of the table.
pub(crate) fn view(&self, route: &Route) -> Option<ReceivedRouteView> {
pub(crate) fn view(&self, route: &Route, rank: u32) -> Option<ReceivedRouteView> {
let (peer, name, check) = self.peers.get(&route.peer)?;
let origin = route.attrs.key().origin();
// What a session stored was accepted by the decoder, and parses.
@@ -546,6 +549,7 @@ impl PeerNames {
prefix: route.prefix.to_string(),
path_id: route.path_id,
best: route.best,
rank,
stale: route.stale,
rpki: self
.rpki
@@ -648,7 +652,8 @@ const DEFAULT_ROUTE_LIMIT: usize = 1000;
const MAX_ROUTE_LIMIT: usize = 100_000;
/// Which routes to return. At most one of `prefix`, `address` and `within`
/// says where to look; `peer` and `best` narrow what is found there.
/// says where to look; `peer`, `origin` and `best` narrow what is found
/// there.
#[derive(Debug, Default, Deserialize)]
pub struct RouteQuery {
/// About this many routes to a page. A page holds whole prefixes: it
@@ -670,11 +675,68 @@ pub struct RouteQuery {
within: Option<String>,
/// Only routes from this peer: its configured name, or its address.
peer: Option<String>,
/// Only routes originated by this AS: the last of the AS path, as a
/// number, with or without `AS` in front. Routes with an empty path
/// are this router's own AS's.
origin: Option<String>,
/// Only the best route of each prefix.
#[serde(default)]
best: bool,
}
/// What narrows the routes found where a query looks.
struct Narrowing {
/// The peers whose routes are wanted, if not every peer's.
peers: Option<Vec<PeerIndex>>,
/// The AS whose routes are wanted, if not every AS's.
origin: Option<u32>,
/// This router's AS: what an empty path originates from.
local_asn: u32,
best: bool,
}
impl Narrowing {
fn of(query: &RouteQuery, names: &PeerNames, local_asn: u32) -> Result<Self, QueryError> {
let origin = match &query.origin {
None => None,
Some(text) => Some(parse_asn(text)?),
};
Ok(Self {
peers: query.peer.as_deref().map(|peer| names.matching(peer)),
origin,
local_asn,
best: query.best,
})
}
fn wants(&self, peer: PeerIndex, best: bool, key: &SelectionKey) -> bool {
(!self.best || best)
&& self
.peers
.as_ref()
.is_none_or(|peers| peers.contains(&peer))
&& self.origin.is_none_or(|origin| {
let originated_by = if key.as_path_len == 0 {
Some(self.local_asn)
} else {
key.origin()
};
originated_by == Some(origin)
})
}
}
/// An AS number given in a query: `64496` or `AS64496`.
fn parse_asn(text: &str) -> Result<u32, QueryError> {
let digits = text
.strip_prefix("AS")
.or_else(|| text.strip_prefix("as"))
.unwrap_or(text);
digits
.parse()
.map_err(|_| QueryError::Bad(format!("{text:?} is not an AS number")))
}
/// Why a query was not answered.
pub enum QueryError {
/// The query makes no sense; the text says how.
@@ -736,7 +798,7 @@ impl Scope {
pub struct RoutesPage {
/// The routes, prefix by prefix in address order (IPv4 first, a
/// shorter prefix before the longer ones at the same address), the
/// best route of each prefix first. A lookup by address has the most
/// routes of each prefix from the best down. A lookup by address has the most
/// specific prefix first instead.
pub routes: Vec<ReceivedRouteView>,
/// If there are routes after these: what to pass as `after` to get
@@ -881,37 +943,34 @@ fn find_routes(
names: &PeerNames,
scope: &Scope,
place: Place,
query: &RouteQuery,
narrowing: &Narrowing,
limit: usize,
) -> RoutesPage {
let peers = query.peer.as_deref().map(|peer| names.matching(peer));
let wanted = |peer: PeerIndex, best: bool| {
(!query.best || best) && peers.as_ref().is_none_or(|peers| peers.contains(&peer))
let wanted =
|peer: PeerIndex, best: bool, attrs: &AttrSet| narrowing.wants(peer, best, attrs.key());
// The routes of one prefix from the best down, each with its rank
// among all of them: that stays what it is whatever the query keeps.
let ranked = |prefix: &IpPrefix| -> Vec<(u32, Route)> {
(1..).zip(table.ranked_routes(prefix)).collect()
};
let views = |mut routes: Vec<Route>| -> Vec<ReceivedRouteView> {
routes.retain(|route| wanted(route.peer, route.best));
let views = |routes: Vec<(u32, Route)>| -> Vec<ReceivedRouteView> {
routes
.iter()
.filter_map(|route| names.view(route))
.filter(|(_, route)| wanted(route.peer, route.best, &route.attrs))
.filter_map(|(rank, route)| names.view(route, *rank))
.collect()
};
// The routes of each prefix come with the best one first.
let best_first = |routes: &mut Vec<Route>| routes.sort_by_key(|route| !route.best);
// A listing: a page of it.
let listed = |inside: Option<&IpPrefix>| {
let page = page_of_prefixes(table, place, limit, |route| {
inside.is_none_or(|outer| outer.contains(&route.prefix))
&& wanted(route.peer, route.best)
&& wanted(route.peer, route.best, route.attrs)
});
let routes = page
.prefixes
.iter()
.flat_map(|prefix| {
let mut routes = table.routes(prefix);
best_first(&mut routes);
views(routes)
})
.flat_map(|prefix| views(ranked(prefix)))
.collect();
let edge = |more: bool, prefix: Option<&IpPrefix>| {
prefix.filter(|_| more).map(IpPrefix::to_string)
@@ -923,29 +982,21 @@ fn find_routes(
}
};
// A lookup: all there is, which is little.
let looked_up = |routes: Vec<Route>| RoutesPage {
routes: views(routes),
let looked_up = |routes: Vec<ReceivedRouteView>| RoutesPage {
routes,
next_after: None,
prev_before: None,
};
match scope {
Scope::All => listed(None),
Scope::Within(outer) => listed(Some(outer)),
Scope::Exactly(prefix) => {
let mut routes = table.routes(prefix);
best_first(&mut routes);
looked_up(routes)
}
Scope::Exactly(prefix) => looked_up(views(ranked(prefix))),
// Place the longest prefix that could cover the address to the
// shortest: one lookup for each length.
Scope::Covering(host) => looked_up(
(0..=host.length())
.rev()
.flat_map(|length| {
let mut routes = table.routes(&host.shortened(length));
best_first(&mut routes);
routes
})
.flat_map(|length| views(ranked(&host.shortened(length))))
.collect(),
),
}
@@ -976,10 +1027,11 @@ pub async fn received_routes(
let scope = Scope::of(&query)?;
let place = Place::of(&query)?;
let names = PeerNames::of(&state);
let narrowing = Narrowing::of(&query, &names, state.info.local_asn)?;
// Looking through the table, copying, parsing and sorting many routes
// takes long enough to keep off the threads that sessions run on.
let page = tokio::task::spawn_blocking(move || {
find_routes(&state.rib, &names, &scope, place, &query, limit)
find_routes(&state.rib, &names, &scope, place, &narrowing, limit)
})
.await
// Only if making the views panicked.
@@ -1351,6 +1403,73 @@ mod tests {
/// The place a page begins is a prefix, not a count: what comes and
/// goes elsewhere in the table between two pages moves nothing.
#[test]
fn a_lookup_by_origin_finds_the_routes_whose_path_ends_in_that_as() {
let prefixes = some_prefixes();
let (table, _) = table(&prefixes);
let narrowed = |origin: &str| Narrowing {
peers: None,
origin: Some(parse_asn(origin).ok().unwrap()),
local_asn: 65000,
best: false,
};
let found = |narrowing: &Narrowing| {
walk(&table, 1000, false, |route| {
narrowing.wants(route.peer, route.best, route.attrs.key())
})
.concat()
.len()
};
// The first peer's paths end in 65010, the second's in 65002.
assert_eq!(found(&narrowed("AS65010")), prefixes.len());
assert_eq!(found(&narrowed("65002")), prefixes.len().div_ceil(3));
// 65001 is on every path, but originates nothing.
assert_eq!(found(&narrowed("65001")), 0);
assert!(parse_asn("AS-65001").is_err());
}
#[test]
fn a_route_with_an_empty_path_is_originated_by_this_router_s_as() {
let table = RibTable::new(1);
let peer = table
.add_peer(PeerInfo {
asn: 65000,
router_id: Ipv4Addr::new(10, 0, 0, 9),
address: Ipv4Addr::new(192, 0, 2, 9).into(),
interface: 0,
ibgp: true,
reflector_client: false,
})
.unwrap();
let attrs = table
.attrs()
.intern(
&[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(vec![]),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 9))),
],
None,
)
.unwrap();
table.announce(peer, &attrs, &[prefix("193.0.0.0/21")]);
let ours = |origin: u32| Narrowing {
peers: None,
origin: Some(origin),
local_asn: 65000,
best: false,
};
let found = |narrowing: &Narrowing| {
walk(&table, 10, false, |route| {
narrowing.wants(route.peer, route.best, route.attrs.key())
})
.concat()
.len()
};
assert_eq!(found(&ours(65000)), 1);
assert_eq!(found(&ours(65001)), 0);
}
#[test]
fn a_table_that_changes_between_pages_does_not_shift_them() {
let prefixes = some_prefixes();
+1 -1
View File
@@ -147,7 +147,7 @@ fn best_routes(state: &AppState) -> impl Stream<Item = WireEvent> + use<> {
let names = PeerNames::of(&state_for_views);
for change in changes {
let event = match change {
Change::Best(route) => match names.view(&route) {
Change::Best(route) => match names.view(&route, 1) {
Some(route) => WireEvent::BestChanged { route },
None => continue,
},
+80 -25
View File
@@ -63,7 +63,22 @@ table.data tr.dim td { color: #666; }
.comms { min-width: 26em; }
table.kv th { background: #eef; font-weight: normal; position: static; }
table.data td.path { min-width: 12em; }
/* An AS repeated along the path (prepended): once, with how many times. */
.g.x { font-family: monospace; background: #eef4ff; border-color: #88a; margin-left: 1px; cursor: help; }
/* A cell with more in it than a few lines: clamped until opened. */
.clamp { max-height: 4.4em; overflow: hidden; }
.clamp.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
.clamp.open { max-height: none; }
.clamp-more { cursor: pointer; display: none; margin-top: 2px; }
.tall + .clamp-more { display: inline-block; }
table.data td.name { white-space: nowrap; }
/* Peer names are what a row is found by: bold, dark, and not underlined
until pointed at, so that they stand out from the addresses and numbers
around them rather than joining the blue of every other link. */
table.data td.name a.peer-link { font-weight: bold; color: #112; text-decoration: none; font-size: 13.5px; }
table.data td.name a.peer-link:hover { text-decoration: underline; color: #00c; }
/* Where the route is in path selection: the best, or the Nth to take over. */
.g.rank { font-family: sans-serif; color: #446; background: #f3f3f8; min-width: 1em; text-align: center; }
/* The strip: what needs attention, if anything. */
#strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; }
#strip .item { display: inline-block; margin-right: 1.5em; white-space: nowrap; }
@@ -93,6 +108,7 @@ form.lookup input[type=text] { font-family: monospace; }
<div class="lefthand">
<b id="s-hostname">...</b>: router ID <b id="s-router-id">...</b>, AS <b id="s-asn">...</b>, Ferrix <span id="s-version">...</span><br>
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><br>
<span class="dim">On this page: <a href="#peers">peers</a> · <a href="#rpki">RPKI</a> · <a href="#local">originated</a> · <a href="#kernel">kernel</a> · <a href="#received">received, and looking routes up</a> · <a href="#rejected">rejected</a> · <a href="#events">events</a></span><br>
</div>
<div class="righthand">
<span id="refresh-ctl"></span><br>
@@ -102,19 +118,6 @@ JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href=
<div id="strip" style="clear: both">...</div>
<form class="lookup" id="lookup">
Look up <input type="text" id="lookup-q" size="30" placeholder="prefix, address, or peer" title="A prefix, an address to find every prefix that covers it, or a peer's name">
<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>
<details id="config-zippy" hidden>
<summary>Configuration <span class="dim" id="config-path"></span></summary>
<pre id="config-text"></pre>
@@ -143,6 +146,18 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<div id="kernel-body">...</div>
<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="prefix, address, AS number, or peer" title="A prefix; an address, to find every prefix that covers it; an AS number, to find every route it originates; or a peer's name">
<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>
<div class="pager" id="received-pager"></div>
<table class="data">
<thead><tr><th></th><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th class="num">MED</th><th class="num">local pref</th><th>communities</th><th>large communities</th><th>attrs</th><th title="Route origin validation (RPKI) and AS path verification (ASPA)">RPKI · ASPA</th></tr></thead>
@@ -297,7 +312,30 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
// An AS number, whose holder's name is shown when it is pointed at.
const asn = n => `<span class="asn" data-asn="${esc(n)}">${esc(n)}</span>`;
const asPath = p => p.length ? p.map(asn).join(' ') : '<span class="dim">(empty)</span>';
// A path, with an AS that repeats (prepending) once, and how many times.
function asPath(p) {
if (!p.length) return '<span class="dim">(empty)</span>';
const runs = [];
for (const n of p) {
const last = runs[runs.length - 1];
if (last && last.asn === n) last.times++; else runs.push({ asn: n, times: 1 });
}
return runs.map(r => r.times === 1 ? asn(r.asn)
: `<span class="nowrap">${asn(r.asn)}${glyph(`×${r.times}`, `AS${r.asn} ${r.times} times in a row: prepended ${r.times - 1} times`, 'x')}</span>`).join(' ');
}
// Wraps what can run to many lines: shown clamped, with a way to open
// it, if it does. See markTall.
const clamp = html => `<div class="clamp">${html}</div><span class="g dim clamp-more">▾ more</span>`;
// After a render: finds the clamped cells with more than fits.
function markTall(root) {
for (const c of root.querySelectorAll('.clamp')) c.classList.toggle('tall', c.scrollHeight > c.clientHeight + 1);
}
function toggleClamp(cell) {
const c = cell.querySelector('.clamp.tall'), more = cell.querySelector('.clamp-more');
if (!c) return;
const open = c.classList.toggle('open');
more.textContent = open ? '▴ less' : '▾ more';
}
async function getJSON(url) {
const r = await fetch(url);
@@ -448,7 +486,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
// ---- 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'];
const QUERY_KEYS = ['q', 'mode', 'peer', 'origin', 'best'];
// Which page of the listing is shown lives there too: the prefix the
// page comes after, or the one it comes before.
const PAGE_KEYS = ['after', 'before'];
@@ -456,7 +494,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const PAGE_ROUTES = 100;
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' };
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', origin: here.get('origin') || '', best: here.get('best') === '1' };
}
function pagePlace() {
const here = new URLSearchParams(location.search);
@@ -472,12 +510,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
renderRefreshControl();
loadReceived().catch(console.error);
}
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.best); }
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.origin || 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.origin) ask.set('origin', l.origin);
if (l.best) ask.set('best', 'true');
const place = pagePlace();
if (place.after) ask.set('after', place.after); else if (place.before) ask.set('before', place.before);
@@ -494,7 +533,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
}
function showLookup() {
const l = lookup();
$('lookup-q').value = l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
$('lookup-q').value = l.origin ? `AS${l.origin}` : l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
renderPeerChoices();
}
function setLookup(l) {
@@ -505,6 +544,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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.origin) url.searchParams.set('origin', l.origin);
if (l.best) url.searchParams.set('best', '1');
}
history.replaceState(null, '', url.pathname + url.search + '#received');
@@ -513,11 +553,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
renderRejected();
}
// What was typed is a peer's name if a peer has it: then it is the peer
// to look at, not a prefix.
// to look at, not a prefix. A number, with or without AS in front, is
// an AS: the routes it originates.
function submitLookup() {
const typed = $('lookup-q').value.trim();
const isPeer = state.peers.some(p => p.name === typed);
setLookup({ q: isPeer ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value, best: $('lookup-best').checked });
const asn = /^(?:AS)?(\d+)$/i.exec(typed);
setLookup({ q: isPeer || asn ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value,
origin: asn ? asn[1] : '', best: $('lookup-best').checked });
}
function renderLocal() {
@@ -539,6 +582,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const [mark, cls] = VALIDITY[value] || [value, ''];
return `<span class="${cls}" title="${esc(what)}: ${esc(value)}">${mark}</span>`;
}
// Where a route stands in path selection: the best, or the one that
// would take over were those ranked before it gone.
const ORDINAL = n => n + (n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th');
function rankGlyph(r) {
if (r.best) return glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green');
if (!r.rank) return '';
return glyph(String(r.rank), `${ORDINAL(r.rank)} in path selection: the route that would be chosen were the ${r.rank - 1} before it gone`, 'rank');
}
function renderReceived() {
const b = $('received-body'), st = state.status, looking = isLookup();
const page = state.receivedPage, paged = !!(page.next_after || page.prev_before);
@@ -553,14 +604,15 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
setHTML(b, `<tr><td colspan="12" 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 class="${r.best ? 'best' : ''}">
<td>${r.best ? glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green') : ''}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
<td class="nowrap">${rankGlyph(r)}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
<td class="mono">${esc(r.prefix)}</td>
<td class="mono">${r.peer_name ? `<a href="#received" class="peer-link" data-peer="${esc(r.peer_name)}">${esc(r.peer_name)}</a> ` : ''}<span class="dim">${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td>
<td class="mono path">${asPath(s.as_path)}</td>
<td class="mono path">${clamp(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 class="num">${s.med ?? DASH}</td><td class="num">${s.local_pref ?? DASH}</td>
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td>
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td>
<td class="nowrap">${validity('RPKI', r.rpki)} ${validity('ASPA', r.aspa)}</td></tr>`; }).join(''));
markTall(b);
}
const RPKI_STATE_CLASS = { synchronised: 'green', synchronising: 'amber', connecting: 'red' };
@@ -648,12 +700,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
return `<tr class="group"><td colspan="11"><a href="#received" class="peer-link" data-peer="${esc(p.peer)}">${esc(p.peer)}</a> <span class="dim" style="font-weight: normal">${esc(p.peer_addr)}</span>: <span style="font-weight: normal">${announcements(p.total)} rejected</span> ${reasonBar(p.by_reason, p.total)}${more}</td></tr>` +
lines.map(r => { const s = r.summary; return `<tr><td></td>
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
<td class="mono path">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td>
<td class="mono path">${clamp(asPath(r.as_path))}</td><td>${esc(r.reason)}</td>
<td class="num">${r.times > 1 ? `×${r.times}` : ''}</td>
<td class="nowrap"><span title="${esc(fmtWhen(r.at_unix))}${r.times > 1 ? `; the first of these ${esc(fmtWhen(r.first))}` : ''}">${age(r.at_unix)} ago</span></td>
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
}).join(''));
markTall(b);
}
// ---- data loading ------------------------------------------------------
@@ -790,6 +843,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
$('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); });
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
$('peers-problems').addEventListener('change', renderPeers);
document.addEventListener('click', e => { if (e.target.closest('.clamp-more')) toggleClamp(e.target.closest('td')); });
document.addEventListener('dblclick', e => { const td = e.target.closest('td'); if (td && td.querySelector('.clamp')) { toggleClamp(td); getSelection().removeAllRanges(); } });
// A peer's name anywhere on the page leads to its routes.
document.addEventListener('click', e => {
const link = e.target.closest('a.peer-link');