feat(fipstop): show every path to a peer in the Peers tab

`show_peers` now carries `paths[]` per peer — the `path_show` fields
minus the now-relative ages — from both the on-loop query and the
tick-published snapshot, projected once by `Node::project_peer_paths`.

fipstop's Peers tab draws a peer with more than one path as a tree: the
peer row keeps the peer-level columns (Dir, EffD, Goodput, packets) and
one child row per path carries transport, address, state with pinned /
backup markers, RTT and score. The active path is green, a live standby
dim, a probing path (never acked, as with a peer that predates
multi-path) yellow, suspect/dead red. A single-path peer stays one line.
The detail pane gains a Paths section. Cursor and selection still land
only on peer rows.
This commit is contained in:
Arjen
2026-09-23 11:48:33 -03:00
parent dca938c104
commit 502f7b6067
9 changed files with 485 additions and 3 deletions
+10
View File
@@ -51,6 +51,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
finite and at least 1.0, `switch_dwell_secs`, `min_samples`,
`active_heartbeat_ms`, `standby_heartbeat_ms`).
- Views, for multi-path: `show_peers` lists every path under its peer
(`paths[]`; `path_show` is the same rows plus the now-relative liveness
ages and `acked_once`), and `fipstop`'s Peers tab draws a peer with more
than one path as a tree — the peer row keeps the peer-level columns, one
child row per path carries transport, address,
`active`/`live`/`probing`/`suspect`/`dead` with `pinned` / `backup`
markers, RTT and score, the active path in green, an unproven one in
yellow — with a Paths section in the detail pane. A single-path peer
stays one line.
- An authentic frame arriving on a transport the peer has no path on no
longer re-pins the peer's send side to that transport, and a decrypt
failure on such a transport is not counted toward force-removal. Both
+15
View File
@@ -119,6 +119,21 @@ the keys the current context accepts.
| --- | ------ |
| `Del` | Disconnect the selected peer. Opens a `Y`/`N` confirmation modal first; this is the only state-mutating action in `fipstop`. |
A peer reachable over more than one transport is drawn as a tree: its own
row keeps the peer-level columns (name, npub, direction, effective depth,
goodput, packet counts) and one child row per path, under it, carries the
path's transport (instance name, or type), its state in the Npub column —
`active` for the path this node sends on, otherwise `live`, `probing`,
`suspect` or `dead`, with `,pinned`, `,remote` (the peer sends here) and
`,backup` markers — the transport type and address, the last probe round
trip under SRTT and the path score under LQI. The active path is green, a
live standby dim, a `probing` path (never acknowledged: an older peer, or
a medium it cannot hear us on) yellow, a `suspect` or `dead` one red. The
cursor lands on peer rows only. A peer with one path stays one line with
its transport inline. The detail view (`Enter`) adds a **Paths** section
with every path's transport, address, state, round trips, sample count,
ETX and score; `fipsctl path show` gives the same with the liveness ages.
### Transports tab (extra)
| Key | Action |
+1 -1
View File
@@ -116,7 +116,7 @@ table below lists every command currently registered.
| ------- | ------ | ----------------------------- |
| `show_status` | — | `version`, `npub`, `node_addr`, `ipv6_addr`, `state`, `is_leaf_only`, `is_root` (bool — this node is the spanning-tree root), `root` (hex node-addr of the current tree root), `persistent` (bool — identity is persisted, i.e. `persistent` set or an `nsec` configured), `peer_count`, `session_count`, `link_count`, `transport_count`, `connection_count`, `transport_peer_counts` (object mapping transport-type name to its connected-peer count; configured transports appear with `0`), `tun_state`, `tun_name`, `effective_ipv6_mtu`, `control_socket`, `pid`, `exe_path`, `uptime_secs`, `estimated_mesh_size`, `forwarding`, `sparklines`. |
| `show_acl` | — | `allow_file`, `deny_file`, `enforcement_active`, `effective_mode`, `default_decision`, `allow_all`, `deny_all`, `allow_file_entries`, `deny_file_entries`, `allow_entries`, `deny_entries`. |
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
| `show_peers` | — | `peers[]` — per-peer object: `node_addr`, `npub`, `display_name`, `ipv6_addr`, `connectivity`, `link_id`, `direction`, `transport_addr`, `transport_type`, `is_parent`, `is_child`, `tree_depth`, `effective_depth` (`tree_depth + link_cost` — the metric `evaluate_parent` ranks on; `null` when the peer has no coords, or is unmeasured while another peer has an SRTT sample, per the cold-start gate), `stats`, `noise`, `current_k_bit`, `mmp`, `paths[]` (every path to the peer: `transport_id`, `transport`, `transport_type`, `addr`, `state`, `active`, `remote_active`, `role`, `pinned`, `last_rtt_ms`, `min_rtt_ms`, `rtt_samples`, `etx`, `score` — the `path_show` fields minus the now-relative ages), plus optional `nostr_traversal`, `rekey_in_progress`, `rekey_draining`. |
| `show_links` | — | `links[]` — `link_id`, `transport_id`, `remote_addr`, `direction`, `state`, `created_at_ms`, `stats`. |
| `show_tree` | — | `my_node_addr`, `root`, `root_npub` (bech32 npub of the current tree root), `is_root`, `depth`, `my_coords[]`, `parent`, `parent_display_name`, `declaration_sequence`, `declaration_signed`, `peer_tree_count`, `peers[]`, `stats`. |
| `show_sessions` | — | `sessions[]` — `remote_addr`, `npub`, `display_name`, `state` (`established`, `initiating`, `awaiting_msg3`, `unknown`), `is_initiator`, `last_activity_ms`, `stats`, optional `mmp`, `current_k_bit`, `is_draining`. |
+161 -2
View File
@@ -92,8 +92,12 @@ fn draw_table(
// Build the grouped display: a styled label row before each non-empty
// group, the group's peer rows, and a blank separator before the next
// group. `peer_display_idx[p]` is the display-row index of sorted peer `p`,
// so the stored peer-index selection (used by detail + navigation) can be
// group. A peer with more than one path is a tree: its row keeps the
// peer-level columns (Dir, EffD, Goodput, packets) and one child row per
// path carries the path-specific ones (transport, RTT, score), the
// active path in green. A single-path peer stays one line, as before.
// `peer_display_idx[p]` is the display-row index of sorted peer `p`, so
// the stored peer-index selection (used by detail + navigation) can be
// translated to the display row to highlight, and the cursor only ever
// lands on peer rows.
let mut rows: Vec<Row> = Vec::new();
@@ -170,6 +174,15 @@ fn draw_table(
Style::default()
};
// Multi-path: the path-specific columns move to the child rows.
let paths = peer_paths(peer);
let multi = paths.len() > 1;
let (transport, srtt, loss, lqi) = if multi {
(String::new(), String::new(), String::new(), String::new())
} else {
(transport, srtt, loss, lqi)
};
peer_display_idx.push(rows.len());
rows.push(
Row::new(vec![
@@ -187,6 +200,21 @@ fn draw_table(
])
.style(row_style),
);
// One child row per path, tree-drawn under the peer:
// peer
// ├─ cable active udp/10.0.0.2:2121
// └─ wifi live udp/10.0.1.2:2121
if multi {
for (i, path) in paths.iter().enumerate() {
let branch = if i + 1 == paths.len() {
"└─"
} else {
"├─"
};
rows.push(path_row(path, branch));
}
}
}
let widths = [
@@ -243,6 +271,108 @@ fn draw_table(
}
}
/// The `paths` array of a `show_peers` row: every transport the peer is
/// reachable over. Empty on daemons that predate multi-path.
fn peer_paths(peer: &serde_json::Value) -> Vec<serde_json::Value> {
peer.get("paths")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
/// The transport a path runs over, by instance name when it has one and
/// type otherwise (`cable`, `udp`).
fn path_transport_label(path: &serde_json::Value) -> String {
let name = path.get("transport").and_then(|v| v.as_str());
let t_type = path.get("transport_type").and_then(|v| v.as_str());
match (name, t_type) {
(Some(n), _) if !n.is_empty() => n.to_string(),
(_, Some(t)) if !t.is_empty() => t.to_string(),
_ => format!("#{}", helpers::u64_field(path, "transport_id")),
}
}
/// Path state as shown in the table: `active` for the path we send on,
/// otherwise its lifecycle state, with `pinned` / `remote` markers.
fn path_state_label(path: &serde_json::Value) -> String {
let active = path
.get("active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut label = if active {
"active".to_string()
} else {
helpers::str_field(path, "state").to_string()
};
if path
.get("pinned")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
label.push_str(",pinned");
}
if path
.get("remote_active")
.and_then(|v| v.as_bool())
.unwrap_or(false)
&& !active
{
label.push_str(",remote");
}
if path.get("role").and_then(|v| v.as_str()) == Some("backup") {
label.push_str(",backup");
}
label
}
/// A path's own RTT / score fill the peer table's SRTT / LQI columns; the
/// rest (Dir, Loss, EffD, Goodput, packets) are link-level and stay on the
/// peer row. The active path is green, a live standby dim, an unproven one
/// (probing: never acked, as with a peer that predates multi-path) yellow,
/// a failing one red.
fn path_row(path: &serde_json::Value, branch: &str) -> Row<'static> {
let active = path
.get("active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let state = helpers::str_field(path, "state");
let style = if active {
Style::default().fg(Color::Green)
} else if state == "probing" {
Style::default().fg(Color::Yellow)
} else if state == "suspect" || state == "dead" {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::DarkGray)
};
let t_type = path
.get("transport_type")
.and_then(|v| v.as_str())
.unwrap_or("");
let addr = path.get("addr").and_then(|v| v.as_str()).unwrap_or("");
let transport = if t_type.is_empty() {
addr.to_string()
} else {
format!("{t_type}/{addr}")
};
let rtt = path
.get("last_rtt_ms")
.and_then(|v| v.as_u64())
.map(|ms| ms.to_string())
.unwrap_or_else(|| "-".to_string());
let score = helpers::opt_f64_field(path, "score", 2);
Row::new(vec![
Cell::from(format!(" {branch} {}", path_transport_label(path))),
Cell::from(path_state_label(path)),
Cell::from(transport),
Cell::from(""),
Cell::from(rtt),
Cell::from(""),
Cell::from(score),
])
.style(style)
}
fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Value]) {
let state = app.table_states.get(&Tab::Peers);
let selected = state.and_then(|s| s.selected()).unwrap_or(0);
@@ -363,6 +493,35 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va
lines.push(Line::from(""));
}
// Every path to the peer, active first.
let paths = peer_paths(peer);
if !paths.is_empty() {
lines.push(helpers::section_header("Paths"));
for path in &paths {
let addr = helpers::str_field(path, "addr");
let t_type = helpers::str_field(path, "transport_type");
lines.push(helpers::kv_line(
&path_transport_label(path),
&format!("{t_type}/{addr} {}", path_state_label(path)),
));
let rtt = |key: &str| {
path.get(key)
.and_then(|v| v.as_u64())
.map(|ms| format!("{ms}ms"))
.unwrap_or_else(|| "-".to_string())
};
lines.push(Line::from(format!(
" rtt {} min {} n={} etx {} score {}",
rtt("last_rtt_ms"),
rtt("min_rtt_ms"),
helpers::u64_field(path, "rtt_samples"),
helpers::opt_f64_field(path, "etx", 2),
helpers::opt_f64_field(path, "score", 2),
)));
}
lines.push(Line::from(""));
}
lines.extend([
// Tree & Bloom
helpers::section_header("Tree / Bloom"),
+155
View File
@@ -112,6 +112,161 @@ fn bloom_peer_filters_alignment() {
assert_eq!(cols[0], cols[1], "fill: columns align across rows");
}
/// Peers multi-path: a peer with two paths renders each as a tree-drawn
/// child row carrying the path-specific columns (the peer row's own
/// Transport / SRTT / LQI go blank), the active path green and a standby
/// dimmed; a single-path peer stays one line with its transport inline.
/// Selection stays on peer rows: the highlight of peer 1 lands on its own
/// row, not on a child of peer 0.
#[test]
fn peers_paths_render_as_child_rows() {
let data = json!({
"peers": [
{
"display_name": "alice", "npub": "npub1alice",
"is_parent": false, "is_child": false,
"transport_type": "udp", "transport_addr": "10.0.0.2:2121",
"mmp": { "srtt_ms": 12.0, "lqi": 1.10 },
"paths": [
{ "transport_id": 1, "transport": "cable", "transport_type": "udp",
"addr": "10.0.0.2:2121", "state": "live", "active": true,
"remote_active": true, "role": "normal", "pinned": false,
"last_rtt_ms": 12, "min_rtt_ms": 10, "rtt_samples": 4,
"etx": 1.0, "score": 1.10 },
{ "transport_id": 2, "transport": "wifi", "transport_type": "udp",
"addr": "10.0.1.2:2121", "state": "live", "active": false,
"remote_active": false, "role": "backup", "pinned": false,
"last_rtt_ms": 30, "min_rtt_ms": 28, "rtt_samples": 4,
"etx": 1.0, "score": 1.28 }
]
},
{
"display_name": "bob", "npub": "npub1bob",
"is_parent": false, "is_child": false,
"transport_type": "udp", "transport_addr": "10.0.2.2:2121",
"mmp": { "srtt_ms": 7.0, "lqi": 2.07 },
"paths": [
{ "transport_id": 1, "transport": "cable", "transport_type": "udp",
"addr": "10.0.2.2:2121", "state": "live", "active": true,
"remote_active": true, "role": "normal", "pinned": false,
"last_rtt_ms": 7, "min_rtt_ms": 7, "rtt_samples": 4,
"etx": 1.0, "score": 1.07 }
]
}
]
});
let mut app = app_with(Tab::Peers, data);
app.table_states
.entry(Tab::Peers)
.or_default()
.select(Some(1));
let buf = testkit::render(140, 20, |frame, area| {
super::peers::draw(frame, &mut app, area);
});
let y_alice = testkit::find(&buf, "alice").map(|(_, y)| y).unwrap();
let (_, y_cable) = testkit::find(&buf, "├─ cable").expect("first path row");
let (_, y_wifi) = testkit::find(&buf, "└─ wifi").expect("last path row");
let y_bob = testkit::find(&buf, "bob").map(|(_, y)| y).unwrap();
assert_eq!(y_cable, y_alice + 1, "active path directly under its peer");
assert_eq!(y_wifi, y_alice + 2, "standby path after the active one");
assert_eq!(y_bob, y_alice + 3, "next peer follows the last path row");
assert!(
testkit::contains_row(&buf, "active"),
"active path labelled"
);
assert!(
testkit::contains_row(&buf, "live,backup"),
"standby carries state and role"
);
assert!(
testkit::contains_row(&buf, "udp/10.0.1.2:2121"),
"path address shown"
);
assert_eq!(
testkit::fg_at(&buf, "├─ cable"),
Some(ratatui::style::Color::Green),
"active path green"
);
assert_eq!(
testkit::fg_at(&buf, "└─ wifi"),
Some(ratatui::style::Color::DarkGray),
"standby path dimmed"
);
// alice's own row carries no transport: that moved to the child rows.
let alice_row = &testkit::lines(&buf)[y_alice as usize];
assert!(
!alice_row.contains("udp/10.0.0.2:2121") && !alice_row.contains("12.0"),
"multi-path peer row leaves Transport and SRTT to its child rows: {alice_row}"
);
// bob has one path: a single line with the transport inline, no children.
let bob_row = &testkit::lines(&buf)[y_bob as usize];
assert!(
bob_row.contains("udp/10.0.2.2:2121") && bob_row.contains("7.0"),
"single-path peer keeps transport and SRTT on its own row: {bob_row}"
);
assert!(
!testkit::contains_row(&buf, "└─ cable"),
"single-path peer draws no child row"
);
// bob (peer index 1) is selected: the highlight symbol sits on bob's
// display row, not on alice's child rows.
let (_, y_sel) = testkit::find(&buf, "▶ ").expect("selection marker");
assert_eq!(
y_sel, y_bob,
"selection lands on the peer row, skipping path rows"
);
}
/// A peer that predates multi-path never acks a probe: its second path sits
/// in `probing` forever with no RTT or score. It still renders as a tree —
/// the operator sees the transport is there and unproven — in yellow.
#[test]
fn peers_probing_path_renders_unproven() {
let data = json!({
"peers": [
{
"display_name": "oldpeer", "npub": "npub1old",
"is_parent": false, "is_child": false,
"transport_type": "udp", "transport_addr": "10.0.0.2:2121",
"paths": [
{ "transport_id": 1, "transport": "cable", "transport_type": "udp",
"addr": "10.0.0.2:2121", "state": "live", "active": true,
"remote_active": false, "role": "normal", "pinned": false,
"last_rtt_ms": null, "min_rtt_ms": null, "rtt_samples": 0,
"etx": 1.0, "score": null },
{ "transport_id": 2, "transport": "wifi", "transport_type": "udp",
"addr": "10.0.1.2:2121", "state": "probing", "active": false,
"remote_active": false, "role": "normal", "pinned": false,
"last_rtt_ms": null, "min_rtt_ms": null, "rtt_samples": 0,
"etx": 1.0, "score": null }
]
}
]
});
let mut app = app_with(Tab::Peers, data);
let buf = testkit::render(140, 20, |frame, area| {
super::peers::draw(frame, &mut app, area);
});
let (_, y_wifi) = testkit::find(&buf, "└─ wifi").expect("probing path row");
let row = &testkit::lines(&buf)[y_wifi as usize];
assert!(row.contains("probing"), "unproven path labelled: {row}");
assert!(
row.contains("udp/10.0.1.2:2121"),
"its transport shown: {row}"
);
assert_eq!(
testkit::fg_at(&buf, "└─ wifi"),
Some(ratatui::style::Color::Yellow),
"probing path yellow"
);
}
/// Peers group-sort: the comparator orders parent before STP children
/// before other peers, regardless of LQI, while preserving within-group
/// LQI order.
+31
View File
@@ -294,6 +294,8 @@ pub fn show_peers(node: &Node) -> Value {
}
}
peer_json["paths"] = render_peer_paths(&node.project_peer_paths(peer));
// Add tree depth if available
if let Some(coords) = peer.coords() {
peer_json["tree_depth"] = json!(coords.depth());
@@ -411,6 +413,33 @@ pub fn show_peers(node: &Node) -> Value {
/// peers, `sqi` for sessions). Reproduces the on-loop key insertion order
/// exactly. `path_mtu` is emitted (inside the leading literal) only when
/// present (session-layer); for peers it is `None` and omitted.
/// Render a peer's path rows as the `paths` array of `show_peers`.
fn render_peer_paths(paths: &[super::snapshot::PeerPathRow]) -> Value {
Value::Array(
paths
.iter()
.map(|p| {
json!({
"transport_id": p.transport_id,
"transport": p.transport,
"transport_type": p.transport_type,
"addr": p.addr,
"state": p.state,
"active": p.active,
"remote_active": p.remote_active,
"role": p.role,
"pinned": p.pinned,
"last_rtt_ms": p.last_rtt_ms,
"min_rtt_ms": p.min_rtt_ms,
"rtt_samples": p.rtt_samples,
"etx": p.etx,
"score": p.score,
})
})
.collect(),
)
}
fn render_entity_mmp(mmp: &super::snapshot::EntityMmp, quality_key: &str) -> Value {
// The on-loop `show_sessions` block places loss_rate/etx/goodput_bps/
// delivery ratios/path_mtu in the leading json! literal, while `show_peers`
@@ -504,6 +533,8 @@ pub(crate) fn show_peers_from_handle(handle: &super::read_handle::ControlReadHan
}
}
peer_json["paths"] = render_peer_paths(&peer.paths);
if let Some(depth) = peer.tree_depth {
peer_json["tree_depth"] = json!(depth);
}
+31
View File
@@ -603,6 +603,34 @@ pub(crate) struct EntityMmp {
pub path_mtu: Option<u16>,
}
/// One path to a peer in `show_peers`: the transport binding it runs over
/// and the selection state that decides whether it carries traffic. The
/// per-peer `fipsctl path show` emits the same fields plus the
/// now-relative liveness ages, which a tick-published snapshot cannot carry.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerPathRow {
pub transport_id: u32,
/// Transport instance name (`cable`, `main`), when it has one.
pub transport: Option<String>,
/// Transport type name, present only when the transport handle is found.
pub transport_type: Option<String>,
pub addr: String,
/// `probing` / `live` / `suspect` / `dead`.
pub state: String,
/// Whether this is the path we currently send on.
pub active: bool,
/// Whether the peer last said it sends on this path.
pub remote_active: bool,
/// `normal` / `backup`.
pub role: String,
pub pinned: bool,
pub last_rtt_ms: Option<u64>,
pub min_rtt_ms: Option<u64>,
pub rtt_samples: u32,
pub etx: f64,
pub score: Option<f64>,
}
/// Link-layer stat counters for a peer in `show_peers`.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerLinkStats {
@@ -668,6 +696,9 @@ pub(crate) struct PeerRow {
/// the kernel chooses on both sides.
pub probe_bind: Option<IpAddr>,
pub link_info: Option<PeerLinkInfo>,
/// Every path to the peer, in the peer's own order (the active path
/// first is not guaranteed; `active` marks it).
pub paths: Vec<PeerPathRow>,
pub tree_depth: Option<usize>,
/// `effective_depth = tree_depth + link_cost` — the same quantity
/// `evaluate_parent` ranks parent candidates on. `None` when the peer is
+32
View File
@@ -2367,6 +2367,7 @@ impl Node {
.map(|sa| sa.ip())
.filter(|ip| !ip.is_unspecified()),
link_info,
paths: self.project_peer_paths(peer),
tree_depth: peer.coords().map(|c| c.depth()),
effective_depth,
stats: snap::PeerLinkStats {
@@ -2680,6 +2681,37 @@ impl Node {
id
}
/// Project every path to `peer` into the `show_peers` rows shared by the
/// on-loop query and the tick-published snapshot.
pub(crate) fn project_peer_paths(
&self,
peer: &ActivePeer,
) -> Vec<crate::control::snapshot::PeerPathRow> {
let active = peer.transport_id();
peer.paths()
.iter()
.map(|path| {
let handle = self.transports.get(&path.transport_id());
crate::control::snapshot::PeerPathRow {
transport_id: path.transport_id().as_u32(),
transport: handle.and_then(|t| t.name().map(str::to_string)),
transport_type: handle.map(|t| t.transport_type().name.to_string()),
addr: path.addr().to_string(),
state: format!("{:?}", path.state()).to_lowercase(),
active: Some(path.transport_id()) == active,
remote_active: path.remote_active(),
role: format!("{:?}", path.role()).to_lowercase(),
pinned: path.pinned(),
last_rtt_ms: path.last_rtt_ms(),
min_rtt_ms: path.min_rtt_ms(),
rtt_samples: path.rtt_samples(),
etx: path.etx(),
score: path.score(),
}
})
.collect()
}
/// Get a transport by ID.
pub fn get_transport(&self, id: &TransportId) -> Option<&TransportHandle> {
self.transports.get(id)
+49
View File
@@ -1438,6 +1438,55 @@ async fn fipsctl_path_show_pin_and_unpin_go_through_the_control_api() {
);
}
/// `show_peers` carries every path under its peer, from both the on-loop
/// query and the tick-published snapshot fipstop reads, so the Peers tab
/// can draw the transports a peer is reachable over without a per-peer
/// `path show` round trip.
#[tokio::test]
async fn show_peers_lists_every_path_on_and_off_loop() {
let (mut nodes, _wifi_0, _wifi_1) = pair_with_wifi_live().await;
let addr_0 = *nodes[0].node.node_addr();
let check = |peers: &serde_json::Value| {
let peer = peers["peers"]
.as_array()
.unwrap()
.iter()
.find(|p| p["node_addr"] == hex::encode(addr_0.as_bytes()))
.expect("peer 0 listed");
let paths = peer["paths"].as_array().expect("paths array");
assert_eq!(paths.len(), 2, "both transports listed");
assert_eq!(
paths.iter().filter(|p| p["active"] == true).count(),
1,
"exactly one active path"
);
assert!(paths.iter().all(|p| p["state"] == "live"));
assert!(paths.iter().all(|p| p["transport_type"] == "loopback"));
assert!(paths.iter().all(|p| p["addr"].is_string()));
let ids: std::collections::HashSet<u64> = paths
.iter()
.map(|p| p["transport_id"].as_u64().unwrap())
.collect();
assert!(
ids.contains(&u64::from(wifi().as_u32())),
"wifi path listed"
);
};
check(&crate::control::queries::show_peers(&nodes[1].node));
nodes[1].node.record_stats_history();
let handle = nodes[1].node.control_read_handle();
let off_loop = crate::control::queries::show_peers_from_handle(&handle);
check(&off_loop);
assert_eq!(
serde_json::to_string(&crate::control::queries::show_peers(&nodes[1].node)).unwrap(),
serde_json::to_string(&off_loop).unwrap(),
"off-loop show_peers matches on-loop, paths included"
);
}
#[test]
fn udp_interface_config_parses() {
let cfg: crate::config::UdpConfig = serde_yaml::from_str("interface: en0\n").unwrap();