diff --git a/crates/integration-tests/tests/add_path.rs b/crates/integration-tests/tests/add_path.rs index 8188c6d..9033566 100644 --- a/crates/integration-tests/tests/add_path.rs +++ b/crates/integration-tests/tests/add_path.rs @@ -185,7 +185,7 @@ async fn test_several_routes_for_a_prefix_from_one_peer() -> Result<()> { .await? .json() .await?; - let mut ids: Vec = routes + let mut ids: Vec = routes["routes"] .as_array() .unwrap() .iter() diff --git a/crates/integration-tests/tests/aspa.rs b/crates/integration-tests/tests/aspa.rs index 8325b94..03007bf 100644 --- a/crates/integration-tests/tests/aspa.rs +++ b/crates/integration-tests/tests/aspa.rs @@ -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::(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(()) } diff --git a/crates/integration-tests/tests/best_route.rs b/crates/integration-tests/tests/best_route.rs index b334fa3..1813990 100644 --- a/crates/integration-tests/tests/best_route.rs +++ b/crates/integration-tests/tests/best_route.rs @@ -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!( diff --git a/crates/integration-tests/tests/debugging.rs b/crates/integration-tests/tests/debugging.rs index b6dafba..333890a 100644 --- a/crates/integration-tests/tests/debugging.rs +++ b/crates/integration-tests/tests/debugging.rs @@ -42,8 +42,8 @@ async fn routes(client: &reqwest::Client, base: &str, query: &str) -> Result 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); diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs index 31fc7c2..322db21 100644 --- a/crates/integration-tests/tests/http_api.rs +++ b/crates/integration-tests/tests/http_api.rs @@ -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); } diff --git a/crates/integration-tests/tests/rpki.rs b/crates/integration-tests/tests/rpki.rs index 58b6528..53d0484 100644 --- a/crates/integration-tests/tests/rpki.rs +++ b/crates/integration-tests/tests/rpki.rs @@ -184,7 +184,7 @@ async fn test_an_invalid_route_is_refused_and_the_rest_are_taken() -> Result<()> async move { Ok::(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(()) } diff --git a/crates/rib/src/table/mod.rs b/crates/rib/src/table/mod.rs index 443570e..86c5f4f 100644 --- a/crates/rib/src/table/mod.rs +++ b/crates/rib/src/table/mod.rs @@ -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> + 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. diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index f2fd0b1..e186e15 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -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, + /// The page that begins after this prefix, in the order of the + /// listing: [`RoutesPage::next_after`] of the page before. + after: Option, + /// The page that ends before this prefix: + /// [`RoutesPage::prev_before`] of the page after. + before: Option, /// The routes for exactly this prefix, e.g. `193.0.0.0/21`. prefix: Option, /// 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, + /// If there are routes after these: what to pass as `after` to get + /// them. + pub next_after: Option, + /// If there are routes before these: what to pass as `before` to get + /// them. + pub prev_before: Option, +} + +/// 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 { + 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, + 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), +} + /// 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 { +) -> 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| -> Vec { + 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| 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| 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, Query(query): Query, -) -> Result>, QueryError> { +) -> Result, 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| { + 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 = prefixes.iter().copied().step_by(3).collect(); + table.announce(peers[1], &attrs(vec![65002]), &thirds); + (table, peers) + } + + fn some_prefixes() -> Vec { + let mut prefixes: Vec = (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> { + 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 = 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); + } +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 2ad1d67..26c97e2 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -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. diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html index dad9633..9bf6a65 100644 --- a/crates/web/static/statusz.html +++ b/crates/web/static/statusz.html @@ -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; } @@ -85,10 +87,12 @@ from show all +
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesRPKIASPAbest
+

Refused routes (...)

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.
@@ -114,7 +118,7 @@ from 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 = '-'; // ---- helpers ----------------------------------------------------------- @@ -190,10 +194,29 @@ from // 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 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 } 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 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, `${looking ? 'No route matches.' : 'No routes received from peers.'}`); return; } @@ -297,15 +326,30 @@ from // ---- 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 ? `${label}` : `${label}`; + const range = state.received.length + ? `${esc(state.received[0].prefix)} – ${esc(state.received[state.received.length - 1].prefix)}` : ''; + 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 = `${esc(e.message.replace(/^[^:]*: \d+ /, ''))}`; } renderReceived(); @@ -376,6 +420,13 @@ from 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(`${esc(e.message)}`)); if (REFRESH) { connect();