Page through the received routes, on the status page and in the API

/api/routes/received now returns one page: {routes, next_after,
prev_before}. Pages are in prefix order and begin at a prefix (after= or
before=), not at a count, so a table that changes between two pages does
not make them overlap or leave a gap. A page holds whole prefixes, about
`limit` routes' worth.

The table keeps prefixes in no order, so a page is found by looking at
every prefix once and keeping the nearest few; the routes of the others
are not touched (RibTable::for_each_prefix).

The status page shows about 100 routes with first / previous / next
links; the place is kept in the page's address with the lookup.

The response of /api/routes/received was an array before: this changes
its shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-10-02 14:33:38 +02:00
1 parent f9ef781d51
commit 1f84235e87
10 files changed
+536 -67

No files matched your search

+1 -1
View File
@@ -185,7 +185,7 @@ async fn test_several_routes_for_a_prefix_from_one_peer() -> Result<()> {
.await?
.json()
.await?;
let mut ids: Vec<u64> = routes
let mut ids: Vec<u64> = routes["routes"]
.as_array()
.unwrap()
.iter()
+2 -2
View File
@@ -172,7 +172,7 @@ async fn test_a_leaked_route_is_refused_and_the_rest_are_taken() -> Result<()> {
let request = client.get(format!("{base}{path}"));
async move { Ok::<Value, eyre::Report>(request.send().await?.error_for_status()?.json().await?) }
};
let routes = get("/api/routes/received").await?;
let routes = get("/api/routes/received").await?["routes"].take();
let validity: Vec<(&str, &str)> = routes
.as_array()
.unwrap()
@@ -323,7 +323,7 @@ async fn test_a_peer_can_be_exempt_from_path_verification() -> Result<()> {
.await?
.json()
.await?;
assert_eq!(routes[0]["aspa"], "invalid");
assert_eq!(routes["routes"][0]["aspa"], "invalid");
assert!(server.refusals.peers().is_empty());
Ok(())
}
+2 -2
View File
@@ -113,7 +113,7 @@ async fn test_the_best_route_is_chosen_and_replaced_when_it_goes() -> Result<()>
let status = get_json(&client, &format!("{base}/api/status")).await?;
assert_eq!(status["received_routes"], 2);
assert_eq!(status["received_prefixes"], 1);
let routes = get_json(&client, &format!("{base}/api/routes/received")).await?;
let routes = get_json(&client, &format!("{base}/api/routes/received")).await?["routes"].take();
let best: Vec<&Value> = routes
.as_array()
.unwrap()
@@ -226,7 +226,7 @@ async fn test_received_routes_are_returned_up_to_a_limit() -> Result<()> {
peer.send(&announcement(&prefixes, &[65001])).await?;
wait_for_routes(&server, 40).await?;
let count = |routes: Value| routes.as_array().unwrap().len();
let count = |page: Value| page["routes"].as_array().unwrap().len();
let url = format!("{base}/api/routes/received");
assert_eq!(count(get_json(&client, &url).await?), 40);
assert_eq!(
+5 -3
View File
@@ -42,8 +42,8 @@ async fn routes(client: &reqwest::Client, base: &str, query: &str) -> Result<Vec
.send()
.await?
.error_for_status()?;
let routes: Value = reply.json().await?;
Ok(routes
let page: Value = reply.json().await?;
Ok(page["routes"]
.as_array()
.unwrap()
.iter()
@@ -131,7 +131,9 @@ async fn test_routes_can_be_looked_up() -> Result<()> {
]
);
assert_eq!(routes(&client, &base, "").await?.len(), 5);
assert_eq!(routes(&client, &base, "limit=2").await?.len(), 2);
// A page holds whole prefixes: at most the limit, unless one prefix
// alone has more.
assert!((1..=2).contains(&routes(&client, &base, "limit=2").await?.len()));
// One peer's routes, by its address; a peer that is not there has none.
assert_eq!(routes(&client, &base, "peer=127.0.0.1").await?.len(), 5);
+2 -1
View File
@@ -169,7 +169,8 @@ async fn test_received_routes_summary() -> Result<()> {
let received = tokio::time::timeout(TIMEOUT, async {
loop {
let v = get_json(&client, &format!("{base}/api/routes/received")).await?;
let v =
get_json(&client, &format!("{base}/api/routes/received")).await?["routes"].take();
if !v.as_array().unwrap().is_empty() {
return Ok::<_, eyre::Error>(v);
}
+2 -2
View File
@@ -184,7 +184,7 @@ async fn test_an_invalid_route_is_refused_and_the_rest_are_taken() -> Result<()>
async move { Ok::<Value, eyre::Report>(request.send().await?.error_for_status()?.json().await?) }
};
// Each route says how it stands.
let routes = get("/api/routes/received").await?;
let routes = get("/api/routes/received").await?["routes"].take();
let validity: Vec<(&str, &str)> = routes
.as_array()
.unwrap()
@@ -314,7 +314,7 @@ async fn test_a_peer_can_be_exempt_from_validation() -> Result<()> {
.await?
.json()
.await?;
assert_eq!(routes[0]["rpki"], "invalid");
assert_eq!(routes["routes"][0]["rpki"], "invalid");
assert!(server.refusals.peers().is_empty());
Ok(())
}
+32
View File
@@ -215,6 +215,23 @@ impl Row {
}
}
/// A prefix in the table with its routes, looked at in place.
#[derive(Clone, Copy)]
pub struct PrefixRef<'a> {
row: &'a Row,
}
impl<'a> PrefixRef<'a> {
pub fn prefix(&self) -> &'a IpPrefix {
&self.row.prefix
}
/// The routes there are for the prefix: at least one.
pub fn routes(&self) -> impl Iterator<Item = RouteRef<'a>> + use<'a> {
self.row.routes()
}
}
/// A route in the table, looked at in place.
#[derive(Debug, Clone, Copy)]
pub struct RouteRef<'a> {
@@ -556,6 +573,21 @@ impl RibTable {
routes
}
/// Calls `visit` with every prefix that has routes, in no particular
/// order. As [`RibTable::for_each_best`]: the table is not stopped,
/// and `visit` must not call into it.
///
/// The prefix's routes are there to be looked at, and cost nothing if
/// they are not: this is how to pick a few prefixes out of a table
/// with a great many routes for each.
pub fn for_each_prefix(&self, mut visit: impl FnMut(PrefixRef<'_>)) {
for shard in &self.shards {
for row in &read(shard).rows {
visit(PrefixRef { row });
}
}
}
/// Calls `visit` with every route in the table. As
/// [`RibTable::for_each_best`]: the table is not stopped, and `visit`
/// must not call into it.
+428 -45
View File
@@ -1,7 +1,7 @@
//! JSON handlers and their view types. Everything here is read-only: the
//! daemon is configured through its configuration file.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BinaryHeap, HashMap};
use std::net::IpAddr;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -14,7 +14,7 @@ use bgp_packet::community;
use bgp_packet::message::{NlriNextHop, OriginPathAttribute, PathAttribute};
use bgp_primitives::constants::IpVersion;
use bgp_primitives::ip_prefix::IpPrefix;
use bgp_rib::table::{PeerIndex, RibTable, Route};
use bgp_rib::table::{PeerIndex, RibTable, Route, RouteRef};
use bgp_rib::{LocalRoute, RibStopped};
use bgp_rpki::{CacheState, Update};
use bgp_server::policy::PeerRole;
@@ -441,20 +441,28 @@ pub async fn local_routes(
Ok(Json(routes))
}
/// How many routes [`received_routes`] returns unless asked for another
/// number. The table can hold hundreds of millions.
/// How many routes a page of [`received_routes`] holds unless another
/// number is asked for. The table can hold hundreds of millions.
const DEFAULT_ROUTE_LIMIT: usize = 1000;
/// The most routes [`received_routes`] returns, whatever is asked for:
/// each is copied out of the table and written as JSON.
/// The most routes a page holds, whatever is asked for: each is copied
/// out of the table and written as JSON.
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.
#[derive(Debug, Default, Deserialize)]
pub struct RouteQuery {
/// At most this many routes.
/// About this many routes to a page. A page holds whole prefixes: it
/// ends before the prefix whose routes would take it over the limit,
/// but always holds one prefix, however many routes that has.
limit: Option<usize>,
/// The page that begins after this prefix, in the order of the
/// listing: [`RoutesPage::next_after`] of the page before.
after: Option<String>,
/// The page that ends before this prefix:
/// [`RoutesPage::prev_before`] of the page after.
before: Option<String>,
/// The routes for exactly this prefix, e.g. `193.0.0.0/21`.
prefix: Option<String>,
/// The routes for every prefix that covers this address, the most
@@ -524,77 +532,264 @@ impl Scope {
}
}
/// One page of a listing of routes.
#[derive(Debug, Clone, Serialize)]
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
/// specific prefix first instead.
pub routes: Vec<ReceivedRouteView>,
/// If there are routes after these: what to pass as `after` to get
/// them.
pub next_after: Option<String>,
/// If there are routes before these: what to pass as `before` to get
/// them.
pub prev_before: Option<String>,
}
/// Which page of a listing is wanted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Place {
Start,
After(IpPrefix),
Before(IpPrefix),
}
impl Place {
fn of(query: &RouteQuery) -> Result<Self, QueryError> {
let prefix = |text: &str| {
IpPrefix::try_from(text)
.map_err(|e| QueryError::Bad(format!("{text:?} is not a prefix: {e}")))
};
match (&query.after, &query.before) {
(None, None) => Ok(Self::Start),
(Some(after), None) => Ok(Self::After(prefix(after)?)),
(None, Some(before)) => Ok(Self::Before(prefix(before)?)),
(Some(_), Some(_)) => Err(QueryError::Bad(
"give at most one of after and before".into(),
)),
}
}
}
/// The prefixes of one page, in order, and whether there are more on
/// either side.
#[derive(Debug, Default, PartialEq, Eq)]
struct PrefixPage {
prefixes: Vec<IpPrefix>,
more_before: bool,
more_after: bool,
}
/// Finds the page of prefixes that `place` asks for, among the prefixes of
/// `table` with at least one route that `wanted` picks. The page holds as
/// many prefixes as have `limit` such routes between them, and at least
/// one.
///
/// The table keeps prefixes in no order, so this looks at every one of
/// them. It keeps only the page's worth that are nearest the place asked
/// for, and looks at the routes of few others: with a million prefixes
/// that is some milliseconds, whatever the page. The place is a prefix,
/// not a count of prefixes passed, so that a table that changes between
/// one page and the next does not make the pages overlap or leave a gap.
fn page_of_prefixes(
table: &RibTable,
place: Place,
limit: usize,
wanted: impl Fn(&RouteRef<'_>) -> bool,
) -> PrefixPage {
// Towards the end of the listing, or back towards its start.
let backwards = matches!(place, Place::Before(_));
let edge = match place {
Place::Start => None,
Place::After(prefix) | Place::Before(prefix) => Some(sort_key(&prefix)),
};
// How far a prefix is from the place asked for, in the direction of
// the page: the nearest come first.
let rank = |key: SortKey| match backwards {
false => Rank::Forwards(key),
true => Rank::Backwards(std::cmp::Reverse(key)),
};
// One prefix more than could be on the page, to know whether there
// are any beyond it. Each has at least one route.
let room = limit.max(1) + 1;
// The nearest prefixes found so far, the furthest of them on top,
// each with how many of its routes are wanted.
let mut nearest: BinaryHeap<(Rank, IpPrefix, usize)> = BinaryHeap::new();
// Whether anything lies on the other side of the place asked for.
let mut behind = false;
table.for_each_prefix(|row| {
let key = sort_key(row.prefix());
let on_page_side = match (edge, backwards) {
(None, _) => true,
(Some(edge), false) => key > edge,
(Some(edge), true) => key < edge,
};
if !on_page_side {
// The routes are looked at only until one such prefix is
// known of.
behind = behind || row.routes().any(|route| wanted(&route));
return;
}
let rank = rank(key);
if nearest.len() == room
&& nearest
.peek()
.is_some_and(|(furthest, ..)| rank >= *furthest)
{
return;
}
let routes = row.routes().filter(|route| wanted(route)).count();
if routes == 0 {
return;
}
if nearest.len() == room {
nearest.pop();
}
nearest.push((rank, *row.prefix(), routes));
});
// Place the nearest: as many as fit.
let found = nearest.into_sorted_vec();
let mut prefixes = Vec::new();
let mut total = 0;
for (_, prefix, routes) in &found {
if !prefixes.is_empty() && total + routes > limit {
break;
}
total += routes;
prefixes.push(*prefix);
}
let beyond = found.len() > prefixes.len();
if backwards {
prefixes.reverse();
}
PrefixPage {
prefixes,
more_before: if backwards { beyond } else { behind },
more_after: if backwards { behind } else { beyond },
}
}
/// See [`page_of_prefixes`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Rank {
Forwards(SortKey),
Backwards(std::cmp::Reverse<SortKey>),
}
/// The routes a query asks for, in the order to show them.
fn find_routes(
table: &RibTable,
names: &PeerNames,
scope: &Scope,
place: Place,
query: &RouteQuery,
limit: usize,
) -> Vec<ReceivedRouteView> {
) -> 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 mut routes = match scope {
Scope::Exactly(prefix) => table.routes(prefix),
// From the longest prefix that could cover the address to the
// shortest: one lookup for each length.
Scope::Covering(host) => (0..=host.length())
.rev()
.flat_map(|length| table.routes(&host.shortened(length)))
.collect(),
Scope::Within(outer) => table.routes_where(limit, |route| {
outer.contains(&route.prefix) && wanted(route.peer, route.best)
}),
Scope::All => table.routes_where(limit, |route| wanted(route.peer, route.best)),
let views = |mut routes: Vec<Route>| -> Vec<ReceivedRouteView> {
routes.retain(|route| wanted(route.peer, route.best));
routes
.iter()
.filter_map(|route| names.view(route))
.collect()
};
routes.retain(|route| wanted(route.peer, route.best));
routes.truncate(limit);
// 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);
// For each prefix the best route first. The prefixes in address
// order; for a lookup by address, which finds one prefix of each
// length, the most specific first.
if matches!(scope, Scope::Covering(_)) {
routes.sort_by_key(|route| (std::cmp::Reverse(route.prefix.length()), !route.best));
} else {
routes.sort_by_key(|route| (sort_key(&route.prefix), !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)
});
let routes = page
.prefixes
.iter()
.flat_map(|prefix| {
let mut routes = table.routes(prefix);
best_first(&mut routes);
views(routes)
})
.collect();
let edge = |more: bool, prefix: Option<&IpPrefix>| {
prefix.filter(|_| more).map(IpPrefix::to_string)
};
RoutesPage {
routes,
next_after: edge(page.more_after, page.prefixes.last()),
prev_before: edge(page.more_before, page.prefixes.first()),
}
};
// A lookup: all there is, which is little.
let looked_up = |routes: Vec<Route>| RoutesPage {
routes: views(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)
}
// 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
})
.collect(),
),
}
routes
.iter()
.filter_map(|route| names.view(route))
.collect()
}
/// What prefixes are ordered by.
type SortKey = (u16, [u8; 16], u8);
/// Orders prefixes by IP version, then address, then shorter before
/// longer.
fn sort_key(prefix: &IpPrefix) -> (u16, [u8; 16], u8) {
fn sort_key(prefix: &IpPrefix) -> SortKey {
let mut address = [0u8; 16];
address[..prefix.bytes().len()].copy_from_slice(prefix.bytes());
(prefix.address_family() as u16, address, prefix.length())
}
/// Routes learned from peers. Without a query, all the routes of as many
/// prefixes as fit in the limit; see [`RouteQuery`] for looking up
/// particular ones. `/api/status` has the totals.
/// Routes learned from peers, a page at a time. Without a query, the
/// table from its start; see [`RouteQuery`] for looking up particular
/// routes and for the pages after the first. `/api/status` has the totals.
pub async fn received_routes(
State(state): State<AppState>,
Query(query): Query<RouteQuery>,
) -> Result<Json<Vec<ReceivedRouteView>>, QueryError> {
) -> Result<Json<RoutesPage>, QueryError> {
let limit = query
.limit
.unwrap_or(DEFAULT_ROUTE_LIMIT)
.min(MAX_ROUTE_LIMIT);
.clamp(1, MAX_ROUTE_LIMIT);
let scope = Scope::of(&query)?;
let place = Place::of(&query)?;
let names = PeerNames::of(&state);
// Looking through the table, copying, parsing and sorting many routes
// takes long enough to keep off the threads that sessions run on.
let routes =
tokio::task::spawn_blocking(move || find_routes(&state.rib, &names, &scope, &query, limit))
.await
// Only if making the views panicked.
.map_err(|_| QueryError::Failed)?;
Ok(Json(routes))
let page = tokio::task::spawn_blocking(move || {
find_routes(&state.rib, &names, &scope, place, &query, limit)
})
.await
// Only if making the views panicked.
.map_err(|_| QueryError::Failed)?;
Ok(Json(page))
}
/// Validation with the RPKI: where the data comes from and how fresh it
@@ -728,3 +923,191 @@ pub async fn rejected_routes(
peers.sort_by(|a, b| a.peer.cmp(&b.peer));
Json(peers)
}
#[cfg(test)]
mod tests {
use std::net::Ipv4Addr;
use bgp_packet::message::{AsPathAttribute, NextHopPathAttribute};
use bgp_rib::table::PeerInfo;
use super::*;
fn prefix(s: &str) -> IpPrefix {
IpPrefix::try_from(s).unwrap()
}
/// A table in which the first peer has a route for every prefix and
/// the second, whose routes are the better ones, for every third.
fn table(prefixes: &[IpPrefix]) -> (RibTable, [PeerIndex; 2]) {
let table = RibTable::new(4);
let peers = [1u8, 2].map(|n| {
table
.add_peer(PeerInfo {
asn: 65000 + u32::from(n),
router_id: Ipv4Addr::new(10, 0, 0, n),
address: Ipv4Addr::new(192, 0, 2, n).into(),
ibgp: false,
})
.unwrap()
});
let attrs = |path: Vec<u32>| {
table
.attrs()
.intern(
&[
PathAttribute::Origin(OriginPathAttribute::IGP),
AsPathAttribute::from_asns(path),
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
],
None,
)
.unwrap()
};
table.announce(peers[0], &attrs(vec![65001, 65010]), prefixes);
let thirds: Vec<IpPrefix> = prefixes.iter().copied().step_by(3).collect();
table.announce(peers[1], &attrs(vec![65002]), &thirds);
(table, peers)
}
fn some_prefixes() -> Vec<IpPrefix> {
let mut prefixes: Vec<IpPrefix> = (0..40u8)
.map(|n| IpPrefix::new(IpVersion::Ipv4, [193, n.wrapping_mul(37), 0, 0], 16).unwrap())
.collect();
// The same address at several lengths, and the other family.
prefixes.extend(["193.0.0.0/8", "193.0.0.0/21", "2001:67c::/32", "2a00::/16"].map(prefix));
prefixes
}
/// Walks the listing from one end to the other and returns its pages.
fn walk(
table: &RibTable,
limit: usize,
backwards: bool,
wanted: impl Fn(&RouteRef<'_>) -> bool,
) -> Vec<Vec<IpPrefix>> {
let mut pages = Vec::new();
let mut place = Place::Start;
if backwards {
// From past the end.
place = Place::Before(IpPrefix::new(IpVersion::Ipv6, [0xff; 16], 128).unwrap());
}
loop {
let page = page_of_prefixes(table, place, limit, &wanted);
let (more, next) = match backwards {
false => (
page.more_after,
page.prefixes.last().map(|p| Place::After(*p)),
),
true => (
page.more_before,
page.prefixes.first().map(|p| Place::Before(*p)),
),
};
if page.prefixes.is_empty() {
assert!(
pages.is_empty(),
"only a listing with nothing in it has an empty page"
);
return pages;
}
// Whether there is a way back says whether this is the first.
let is_first = pages.is_empty();
assert_eq!(
if backwards {
page.more_after
} else {
page.more_before
},
!is_first
);
pages.push(page.prefixes);
if !more {
return pages;
}
place = next.unwrap();
assert!(pages.len() < 1000, "the pages go round in circles");
}
}
#[test]
fn the_pages_of_a_listing_are_the_whole_of_it_in_order_whichever_way_it_is_walked() {
let prefixes = some_prefixes();
let (table, peers) = table(&prefixes);
let mut in_order = prefixes.clone();
in_order.sort_by_key(sort_key);
// IPv4 before IPv6, by address, shorter first.
assert_eq!(in_order[0], prefix("193.0.0.0/8"));
assert_eq!(in_order[1], prefix("193.0.0.0/16"));
assert_eq!(in_order[2], prefix("193.0.0.0/21"));
assert_eq!(in_order.last(), Some(&prefix("2a00::/16")));
for limit in [1, 2, 5, 7, 44, 1000] {
let pages = walk(&table, limit, false, |_| true);
assert_eq!(pages.concat(), in_order, "limit {limit}");
let mut backwards = walk(&table, limit, true, |_| true);
backwards.reverse();
assert_eq!(backwards.concat(), in_order, "limit {limit}, backwards");
}
// A page holds whole prefixes, about as many as have `limit`
// routes: here every third prefix has two.
let pages = walk(&table, 4, false, |_| true);
for page in &pages[..pages.len() - 1] {
let routes: usize = page.iter().map(|prefix| table.routes(prefix).len()).sum();
assert!((3..=4).contains(&routes), "{page:?} has {routes} routes");
}
// And at least one, though it has more routes than the limit.
let two_routes = in_order
.iter()
.position(|prefix| table.routes(prefix).len() == 2)
.filter(|at| *at > 0)
.expect("a prefix with two routes that is not the first");
let page = page_of_prefixes(&table, Place::After(in_order[two_routes - 1]), 1, |_| true);
assert_eq!(page.prefixes, [in_order[two_routes]]);
// Only the prefixes with a route that is wanted are listed.
let of_second: Vec<IpPrefix> = in_order
.iter()
.copied()
.filter(|prefix| {
table
.routes(prefix)
.iter()
.any(|route| route.peer == peers[1])
})
.collect();
assert_eq!(of_second.len(), 15);
for limit in [1, 4, 100] {
let pages = walk(&table, limit, false, |route| route.peer == peers[1]);
assert_eq!(pages.concat(), of_second, "limit {limit}");
}
assert!(walk(&table, 10, false, |_| false).is_empty());
}
/// 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_table_that_changes_between_pages_does_not_shift_them() {
let prefixes = some_prefixes();
let (table, peers) = table(&prefixes);
let mut in_order = prefixes.clone();
in_order.sort_by_key(sort_key);
let first = page_of_prefixes(&table, Place::Start, 10, |_| true);
let last_seen = *first.prefixes.last().unwrap();
// Routes on the first page go, the cursor's own among them, and
// one arrives there.
table.withdraw(peers[0], &first.prefixes[..3]);
table.withdraw(peers[1], &first.prefixes[..3]);
table.withdraw(peers[0], &[last_seen]);
table.withdraw(peers[1], &[last_seen]);
let attrs = table.routes(&in_order[20])[0].attrs.clone();
table.announce(peers[0], &attrs, &[prefix("1.0.0.0/8")]);
let second = page_of_prefixes(&table, Place::After(last_seen), 10, |_| true);
let at = in_order.iter().position(|p| *p == last_seen).unwrap();
assert_eq!(second.prefixes[0], in_order[at + 1]);
assert!(second.more_before);
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ use tracing::info;
pub use api::{
AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, RefusalsView,
RefusedRouteView, RpkiView, StatusView,
RefusedRouteView, RoutesPage, RpkiView, StatusView,
};
/// Static facts about the router shown in status output.
+61 -10
View File
@@ -32,6 +32,8 @@ 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; }
.pager { margin: 4px 0; }
.pager a, .pager span { margin-right: 1em; }
form.lookup input[type=text] { font-family: monospace; }
</style>
</head>
@@ -85,10 +87,12 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<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>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>ASPA</th><th>best</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<div class="pager" id="received-pager-below"></div>
<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>
@@ -114,7 +118,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], refused: [], rpki: null };
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
@@ -190,10 +194,29 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
// 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'];
// 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'];
// About this many routes to a page. A page holds whole prefixes.
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' };
}
function pagePlace() {
const here = new URLSearchParams(location.search);
return { after: here.get('after') || '', before: here.get('before') || '' };
}
// Goes to another page: {after: prefix}, {before: prefix}, or nothing
// for the first.
function setPage(place) {
const url = new URL(location.href);
PAGE_KEYS.forEach(k => url.searchParams.delete(k));
PAGE_KEYS.forEach(k => { if (place && place[k]) url.searchParams.set(k, place[k]); });
history.replaceState(null, '', url.pathname + url.search + '#received');
renderRefreshControl();
loadReceived().catch(console.error);
}
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() {
@@ -201,8 +224,10 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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}` : '');
const place = pagePlace();
if (place.after) ask.set('after', place.after); else if (place.before) ask.set('before', place.before);
ask.set('limit', PAGE_ROUTES);
return `/api/routes/received?${ask}`;
}
function renderPeerChoices() {
const chosen = lookup().peer;
@@ -219,7 +244,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
}
function setLookup(l) {
const url = new URL(location.href);
QUERY_KEYS.forEach(k => url.searchParams.delete(k));
// Another question starts at its first page.
QUERY_KEYS.concat(PAGE_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');
@@ -244,11 +270,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
function renderReceived() {
const b = $('received-body'), st = state.status, looking = isLookup();
const page = state.receivedPage, paged = !!(page.next_after || page.prev_before);
const shown = paged ? `${state.received.length} on this page` : `${state.received.length}`;
$('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` : ''}`;
? `${shown}${paged ? '' : ' found'}${st ? ` among ${st.received_routes}` : ''}`
: paged && st
? `${st.received_routes}, for ${st.received_prefixes} prefixes; ${shown}`
: `${shown}${st ? `, for ${st.received_prefixes} prefixes` : ''}`;
renderPager();
if (!state.received.length) {
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
}
@@ -297,15 +326,30 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); }
// The way to the pages before and after this one, where there are any.
function renderPager() {
const page = state.receivedPage, place = pagePlace();
const paged = page.next_after || page.prev_before;
const link = (label, to, there) => there ? `<a href="#" data-page='${esc(JSON.stringify(to))}'>${label}</a>` : `<span class="dim">${label}</span>`;
const range = state.received.length
? `<span class="mono">${esc(state.received[0].prefix)} &ndash; ${esc(state.received[state.received.length - 1].prefix)}</span>` : '';
const html = !paged && !place.after && !place.before ? '' :
link('&laquo; first', {}, page.prev_before || (!state.received.length && (place.after || place.before))) +
link('&lsaquo; previous', { before: page.prev_before }, page.prev_before) +
link('next &rsaquo;', { after: page.next_after }, page.next_after) + range;
for (const id of ['received-pager', 'received-pager-below']) setHTML($(id), html);
}
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
async function loadReceived() {
const note = $('lookup-note');
try {
state.received = await getJSON(receivedUrl());
const page = await getJSON(receivedUrl());
state.received = page.routes; state.receivedPage = page;
note.textContent = '';
} catch (e) {
// A query the server could not make sense of: say what it said.
state.received = [];
state.received = []; state.receivedPage = {};
note.innerHTML = `<span class="bad">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
}
renderReceived();
@@ -376,6 +420,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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); });
for (const id of ['received-pager', 'received-pager-below']) {
$(id).addEventListener('click', e => {
const to = e.target.closest('a[data-page]');
if (!to) return;
e.preventDefault(); setPage(JSON.parse(to.dataset.page));
});
}
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
if (REFRESH) {
connect();