mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user