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:
1 parent
f9ef781d51
commit
1f84235e87
10 files changed
+536
-67
No files matched your search
+428
-45
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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)} – ${esc(state.received[state.received.length - 1].prefix)}</span>` : '';
|
||||
const html = !paged && !place.after && !place.before ? '' :
|
||||
link('« first', {}, page.prev_before || (!state.received.length && (place.after || place.before))) +
|
||||
link('‹ previous', { before: page.prev_before }, page.prev_before) +
|
||||
link('next ›', { 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();
|
||||
|
||||
Reference in new issue
Block a user