diff --git a/src/bin/fipstop/app.rs b/src/bin/fipstop/app.rs index b69eeea..939356b 100644 --- a/src/bin/fipstop/app.rs +++ b/src/bin/fipstop/app.rs @@ -2,7 +2,7 @@ use ratatui::widgets::TableState; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] pub enum Tab { Node, Peers, @@ -113,6 +113,19 @@ impl Tab { Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway ) } + + /// Number of focusable, independently-scrollable panes on this tab, for the + /// multi-pane focus/scroll model. Returns 0 for tabs that don't participate + /// (they use table selection or their own scroll instead). The Tree, Bloom + /// (Filters), and Routing tabs each lay out three stacked panes; the + /// Performance (Mmp) tab lays out two (Link MMP, Session MMP). + pub fn scroll_pane_count(&self) -> usize { + match self { + Tab::Tree | Tab::Bloom | Tab::Routing => 3, + Tab::Mmp => 2, + _ => 0, + } + } } #[derive(Clone)] @@ -125,6 +138,17 @@ pub struct DetailView { pub scroll: u16, } +/// A pending Del-disconnect confirmation against a selected peer. Holds the +/// peer's npub (for the control command) and a human-readable label plus a +/// reconnect note tailored to the peer kind (or a generic line when the +/// connect-policy is not surfaced). +#[derive(Clone)] +pub struct ConfirmDisconnect { + pub npub: String, + pub display_name: String, + pub reconnect_note: String, +} + #[derive(Clone, Copy)] pub enum SelectedTreeItem { None, @@ -132,6 +156,45 @@ pub enum SelectedTreeItem { Link, } +/// Per-view column-sort state: the active sort column index and direction. +/// `s` cycles the column; `S` toggles direction. Default is column 0 ascending, +/// which for the name-first column layouts is an alphabetical-by-name order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SortState { + pub col: usize, + pub descending: bool, +} + +impl SortState { + /// Cycle to the next sort column (wrapping over `n` columns), resetting to + /// ascending on a column change so a fresh column starts predictably. + pub fn cycle_col(&mut self, n: usize) { + if n == 0 { + return; + } + self.col = (self.col + 1) % n; + self.descending = false; + } + + /// Toggle the sort direction on the current column. + pub fn toggle_dir(&mut self) { + self.descending = !self.descending; + } +} + +/// Sortable column labels for the Link MMP table (sort key order matches the +/// rendered column order). Column 0 is the peer name. +pub const MMP_LINK_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "lqi", "gp"]; +pub const MMP_LINK_SORT_COLS: usize = MMP_LINK_SORT_LABELS.len(); + +/// Sortable column labels for the Session MMP table. +pub const MMP_SESSION_SORT_LABELS: &[&str] = &["name", "srtt", "loss", "etx", "sqi", "mtu"]; +pub const MMP_SESSION_SORT_COLS: usize = MMP_SESSION_SORT_LABELS.len(); + +/// Sortable column labels for the Graphs by-peer summary list. +pub const GRAPHS_PEER_SORT_LABELS: &[&str] = &["name", "min", "max", "last", "n"]; +pub const GRAPHS_PEER_SORT_COLS: usize = GRAPHS_PEER_SORT_LABELS.len(); + /// Options for the Graphs tab window selector. pub const GRAPHS_WINDOWS: &[(&str, &str)] = &[("1m", "1s"), ("10m", "1s"), ("1h", "1s"), ("24h", "1m")]; @@ -201,6 +264,18 @@ pub struct App { pub data: HashMap, pub table_states: HashMap, pub detail_view: Option, + /// Whether the `?` help overlay is currently shown. + pub show_help: bool, + /// A pending Del-disconnect confirmation, if the modal is open. + pub confirm_disconnect: Option, + /// Per-tab focused pane index for multi-pane tabs, generalizing the + /// one-off peers `TableState`. Absent entry means pane 0. The accessors + /// below are the general focus/scroll model the interaction consumers + /// (multi-pane focus, Graphs by-peer) build on. + pub focused_pane: HashMap, + /// Per-(tab, pane) scroll offset (rows), generalizing the one-off detail + /// and graphs scroll state. + pub scroll_offsets: HashMap<(Tab, usize), u16>, pub last_fetch: Instant, pub last_error: Option<(Instant, String)>, pub expanded_transports: HashSet, @@ -227,6 +302,12 @@ pub struct App { /// Cached peer list from `show_stats_peers`, populated when the /// Graphs tab is active in a non-Node mode. pub graphs_peers: Vec, + /// Column-sort state for the Link MMP table. + pub mmp_link_sort: SortState, + /// Column-sort state for the Session MMP table. + pub mmp_session_sort: SortState, + /// Column-sort state for the Graphs by-peer summary list. + pub graphs_peer_sort: SortState, } impl App { @@ -239,6 +320,10 @@ impl App { data: HashMap::new(), table_states: HashMap::new(), detail_view: None, + show_help: false, + confirm_disconnect: None, + focused_pane: HashMap::new(), + scroll_offsets: HashMap::new(), last_fetch: Instant::now(), last_error: None, expanded_transports: HashSet::new(), @@ -253,13 +338,52 @@ impl App { graphs_peer_metric_idx: 0, graphs_peer_idx: 0, graphs_peers: Vec::new(), + mmp_link_sort: SortState::default(), + mmp_session_sort: SortState::default(), + graphs_peer_sort: SortState::default(), } } - /// Cycle the Graphs-tab view mode. + /// Cycle the sort column for the active view (Link/Session MMP or Graphs + /// by-peer), passing the view's column count. On the Performance tab the + /// sort acts on the focused pane only (pane 0 Link MMP, pane 1 Session MMP), + /// so each pane keeps its own sort state. + pub fn cycle_sort_col(&mut self) { + match self.active_tab { + Tab::Mmp => { + if self.focused_pane() == 1 { + self.mmp_session_sort.cycle_col(MMP_SESSION_SORT_COLS); + } else { + self.mmp_link_sort.cycle_col(MMP_LINK_SORT_COLS); + } + } + Tab::Graphs => self.graphs_peer_sort.cycle_col(GRAPHS_PEER_SORT_COLS), + _ => {} + } + } + + /// Toggle the sort direction for the active view (the focused pane on the + /// Performance tab). + pub fn toggle_sort_dir(&mut self) { + match self.active_tab { + Tab::Mmp => { + if self.focused_pane() == 1 { + self.mmp_session_sort.toggle_dir(); + } else { + self.mmp_link_sort.toggle_dir(); + } + } + Tab::Graphs => self.graphs_peer_sort.toggle_dir(), + _ => {} + } + } + + /// Cycle the Graphs-tab view mode. Closes any open by-peer detail, which + /// only applies to the MetricByPeer mode. pub fn graphs_next_mode(&mut self) { self.graphs_mode = self.graphs_mode.next(); self.graphs_scroll = 0; + self.detail_view = None; } /// Advance the mode-specific selector (metric or peer). @@ -310,6 +434,48 @@ impl App { Some(&self.graphs_peers[idx]) } + /// Number of peers in the current Graphs by-peer (MetricByPeer) payload. + /// The MetricByPeer view lists one summary line per peer carried in the + /// `peers` array of the fetched `show_stats_history_all_peers` response. + pub fn graphs_metric_peer_count(&self) -> usize { + self.data + .get(&Tab::Graphs) + .and_then(|d| d.get("peers")) + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0) + } + + /// Move the by-peer list / detail cursor to the next peer (wrapping). + /// Shared by the MetricByPeer summary list (Up/Down select) and the + /// open by-peer detail (Up/Down follow the selection, re-rendering the + /// plot for the newly selected peer). + pub fn graphs_peer_select_next(&mut self) { + let n = self.graphs_metric_peer_count(); + if n > 0 { + self.graphs_peer_idx = (self.graphs_peer_idx + 1) % n; + } + } + + /// Move the by-peer list / detail cursor to the previous peer (wrapping). + pub fn graphs_peer_select_prev(&mut self) { + let n = self.graphs_metric_peer_count(); + if n > 0 { + self.graphs_peer_idx = (self.graphs_peer_idx + n - 1) % n; + } + } + + /// Open the Graphs by-peer detail (full-pane btop plot) for the currently + /// selected peer. No-op unless the by-peer list has at least one peer. + pub fn graphs_open_peer_detail(&mut self) { + if self.graphs_metric_peer_count() > 0 { + if self.graphs_peer_idx >= self.graphs_metric_peer_count() { + self.graphs_peer_idx = 0; + } + self.detail_view = Some(DetailView { scroll: 0 }); + } + } + /// Current Graphs-tab (window, granularity) pair. pub fn graphs_window(&self) -> (&'static str, &'static str) { GRAPHS_WINDOWS[self.graphs_window_idx % GRAPHS_WINDOWS.len()] @@ -393,6 +559,161 @@ impl App { self.detail_view = None; } + /// Toggle the `?` help overlay. + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } + + /// Open a disconnect confirmation for the currently selected Peers row. + /// No-op unless the Peers tab is active with a selected row that carries an + /// npub. The reconnect note states that the peer stays disconnected until + /// it is manually reconnected; a manual disconnect suppresses + /// auto-reconnect for all peer kinds, so there is no per-direction + /// tailoring. + pub fn request_disconnect_confirm(&mut self) { + if self.active_tab != Tab::Peers { + return; + } + let Some(selected) = self + .table_states + .get(&Tab::Peers) + .and_then(|s| s.selected()) + else { + return; + }; + // The displayed order is the role-grouped sort (peers.rs); mirror it so + // the confirm names the same peer the cursor is on. + let mut peers = self + .data + .get(&Tab::Peers) + .and_then(|v| v.get("peers")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + peers.sort_by(|a, b| { + let rank = |p: &serde_json::Value| -> u8 { + let parent = p + .get("is_parent") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false); + if parent { + 0 + } else if child { + 1 + } else { + 2 + } + }; + rank(a).cmp(&rank(b)).then_with(|| { + let lqi = |p: &serde_json::Value| { + p.get("mmp") + .and_then(|m| m.get("lqi")) + .and_then(|v| v.as_f64()) + }; + match (lqi(a), lqi(b)) { + (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(std::cmp::Ordering::Equal), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }) + }); + let Some(peer) = peers.get(selected) else { + return; + }; + let npub = peer.get("npub").and_then(|v| v.as_str()).unwrap_or(""); + if npub.is_empty() { + return; + } + let display_name = peer + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(npub) + .to_string(); + let reconnect_note = "It stays disconnected until you manually reconnect it.".to_string(); + self.confirm_disconnect = Some(ConfirmDisconnect { + npub: npub.to_string(), + display_name, + reconnect_note, + }); + } + + /// Cancel a pending disconnect confirmation. + pub fn cancel_disconnect(&mut self) { + self.confirm_disconnect = None; + } + + /// Take the pending disconnect target, clearing the confirmation. Returns + /// the npub to disconnect when one was confirmed. + pub fn take_disconnect_target(&mut self) -> Option { + self.confirm_disconnect.take().map(|c| c.npub) + } + + /// Deselect the active tab's table row (return to the overview state). + /// No-op when the active tab has no selection. + pub fn deselect_row(&mut self) { + if let Some(state) = self.table_states.get_mut(&self.active_tab) { + state.select(None); + } + } + + // The focus/scroll model below is the shared substrate the interaction + // consumers (multi-pane focus, Graphs by-peer detail) build on; some + // accessors land ahead of their first consumer, mirroring the test-kit's + // not-yet-used-helper allowance. + /// Currently focused pane index on the active tab (0 if unset). The general + /// focus model the multi-pane and Graphs-by-peer consumers read. + #[allow(dead_code)] + pub fn focused_pane(&self) -> usize { + self.focused_pane + .get(&self.active_tab) + .copied() + .unwrap_or(0) + } + + /// Cycle pane focus forward across `pane_count` panes on the active tab. + #[allow(dead_code)] + pub fn focus_next_pane(&mut self, pane_count: usize) { + if pane_count == 0 { + return; + } + let cur = self.focused_pane(); + self.focused_pane + .insert(self.active_tab, (cur + 1) % pane_count); + } + + /// Scroll offset for a given pane on the active tab. + pub fn pane_scroll(&self, pane: usize) -> u16 { + self.scroll_offsets + .get(&(self.active_tab, pane)) + .copied() + .unwrap_or(0) + } + + /// Scroll the focused pane on the active tab by `delta` rows (saturating), + /// generalizing the one-off detail/graphs scroll counters. + #[allow(dead_code)] + pub fn scroll_focused_pane(&mut self, delta: i16) { + let pane = self.focused_pane(); + let entry = self + .scroll_offsets + .entry((self.active_tab, pane)) + .or_insert(0); + *entry = if delta >= 0 { + entry.saturating_add(delta as u16) + } else { + entry.saturating_sub((-delta) as u16) + }; + } + + /// Set the focused pane's scroll offset directly (used by Home/End). End + /// passes a large value the renderer clamps to the pane's content height. + pub fn set_focused_pane_scroll(&mut self, offset: u16) { + let pane = self.focused_pane(); + self.scroll_offsets.insert((self.active_tab, pane), offset); + } + /// Scroll detail view down. pub fn scroll_detail_down(&mut self) { if let Some(ref mut dv) = self.detail_view { diff --git a/src/bin/fipstop/main.rs b/src/bin/fipstop/main.rs index 77b47c2..4661886 100644 --- a/src/bin/fipstop/main.rs +++ b/src/bin/fipstop/main.rs @@ -223,10 +223,39 @@ fn fetch_data( { app.data.insert(Tab::Cache, data); } + // The Tree and Filters views carry no parent/child role flags in their own + // daemon responses, so cross-fetch the peers view and join by node address + // to group their peer lists the same way the Peers tab does. Non-fatal: on + // error the grouping falls back to placing every peer under Other. + if (app.active_tab == Tab::Tree || app.active_tab == Tab::Bloom) + && let Ok(data) = rt.block_on(client.query("show_peers")) + { + app.data.insert(Tab::Peers, data); + } app.last_fetch = std::time::Instant::now(); } +/// Down-arrow behaviour on the Graphs tab. The by-peer detail follows the +/// selection (next peer); the by-peer list moves its cursor; the stacked +/// node/peer modes scroll the content. +fn graphs_down(app: &mut App) { + match app.graphs_mode { + crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_next(), + _ if app.detail_view.is_some() => app.scroll_detail_down(), + _ => app.graphs_scroll_down(), + } +} + +/// Up-arrow behaviour on the Graphs tab (mirror of `graphs_down`). +fn graphs_up(app: &mut App) { + match app.graphs_mode { + crate::app::GraphsMode::MetricByPeer => app.graphs_peer_select_prev(), + _ if app.detail_view.is_some() => app.scroll_detail_up(), + _ => app.graphs_scroll_up(), + } +} + fn main() { let cli = Cli::parse(); @@ -272,10 +301,65 @@ fn main() { if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } + // The disconnect confirmation is modal: while open, only Y + // (confirm), N/Esc (cancel), and quit are honored. + if app.confirm_disconnect.is_some() { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + app.should_quit = true; + } + (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => { + if let Some(npub) = app.take_disconnect_target() { + let params = serde_json::json!({ "npub": npub }); + if let Err(e) = + rt.block_on(client.query_with_params("disconnect", params)) + { + app.last_error = Some((std::time::Instant::now(), e)); + } + fetch_data(&rt, &client, &gateway_client, &mut app); + } + } + (KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => { + app.cancel_disconnect(); + } + _ => {} + } + if app.should_quit { + break; + } + continue; + } + // The `?` overlay is modal: while open, only `?`/Esc (close) + // and quit are honored, so navigation keys don't act behind it. + if app.show_help { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + app.should_quit = true; + } + (KeyCode::Char('?'), _) | (KeyCode::Esc, _) => { + app.show_help = false; + } + _ => {} + } + if app.should_quit { + break; + } + continue; + } match (key.code, key.modifiers) { (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { app.should_quit = true; } + (KeyCode::Char('?'), _) => { + app.toggle_help(); + } + (KeyCode::Delete, _) => { + // Del on a selected Peers row opens the disconnect + // confirmation (the only state-mutating action). + if app.active_tab == Tab::Peers && app.detail_view.is_none() { + app.request_disconnect_confirm(); + } + } (KeyCode::Tab, KeyModifiers::NONE) => { app.close_detail(); app.active_tab = app.active_tab.next(); @@ -287,25 +371,64 @@ fn main() { fetch_data(&rt, &client, &gateway_client, &mut app); } (KeyCode::Down, _) => { - if app.detail_view.is_some() { + if app.active_tab == Tab::Graphs { + graphs_down(&mut app); + } else if app.detail_view.is_some() { app.scroll_detail_down(); - } else if app.active_tab == Tab::Graphs { - app.graphs_scroll_down(); } else if app.active_tab.has_table() { app.select_next(); + } else if app.active_tab.scroll_pane_count() > 0 { + app.scroll_focused_pane(1); } } (KeyCode::Up, _) => { - if app.detail_view.is_some() { + if app.active_tab == Tab::Graphs { + graphs_up(&mut app); + } else if app.detail_view.is_some() { app.scroll_detail_up(); - } else if app.active_tab == Tab::Graphs { - app.graphs_scroll_up(); } else if app.active_tab.has_table() { app.select_prev(); + } else if app.active_tab.scroll_pane_count() > 0 { + app.scroll_focused_pane(-1); + } + } + (KeyCode::PageDown, _) => { + if app.active_tab.scroll_pane_count() > 0 { + app.scroll_focused_pane(10); + } + } + (KeyCode::PageUp, _) => { + if app.active_tab.scroll_pane_count() > 0 { + app.scroll_focused_pane(-10); + } + } + (KeyCode::Home, _) => { + if app.active_tab.scroll_pane_count() > 0 { + app.set_focused_pane_scroll(0); + } + } + (KeyCode::End, _) => { + if app.active_tab.scroll_pane_count() > 0 { + // A large offset the renderer clamps to content. + app.set_focused_pane_scroll(u16::MAX); + } + } + (KeyCode::Char('f'), KeyModifiers::NONE) => { + // Cycle pane focus on the multi-pane scrollable tabs. + let panes = app.active_tab.scroll_pane_count(); + if panes > 0 { + app.focus_next_pane(panes); } } (KeyCode::Enter, _) => { - if app.active_tab.has_table() && app.detail_view.is_none() { + if app.active_tab == Tab::Graphs + && app.detail_view.is_none() + && app.graphs_mode == crate::app::GraphsMode::MetricByPeer + { + // Expand the selected by-peer summary line into a + // full-pane btop plot. + app.graphs_open_peer_detail(); + } else if app.active_tab.has_table() && app.detail_view.is_none() { app.open_detail(); } } @@ -324,21 +447,20 @@ fn main() { } } } - (KeyCode::Char('m'), KeyModifiers::NONE) - if app.active_tab == Tab::Graphs && app.detail_view.is_none() => - { + (KeyCode::Char('m'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => { + // `m` cycles the broader Graphs mode, even from inside + // the by-peer detail (which then closes, since the + // detail only applies to the by-peer mode). app.graphs_next_mode(); fetch_data(&rt, &client, &gateway_client, &mut app); } - (KeyCode::Char('n'), KeyModifiers::NONE) - if app.active_tab == Tab::Graphs && app.detail_view.is_none() => - { + (KeyCode::Char('n'), KeyModifiers::NONE) if app.active_tab == Tab::Graphs => { + // `n` switches the statistic, for both the by-peer list + // and the open by-peer detail (which re-renders). app.graphs_next_selector(); fetch_data(&rt, &client, &gateway_client, &mut app); } - (KeyCode::Char('N'), KeyModifiers::SHIFT) - if app.active_tab == Tab::Graphs && app.detail_view.is_none() => - { + (KeyCode::Char('N'), KeyModifiers::SHIFT) if app.active_tab == Tab::Graphs => { app.graphs_prev_selector(); fetch_data(&rt, &client, &gateway_client, &mut app); } @@ -354,8 +476,12 @@ fn main() { } } (KeyCode::Esc, _) => { + // Priority: close an open detail first, otherwise + // deselect the active table row (return to overview). if app.detail_view.is_some() { app.close_detail(); + } else if app.active_tab.has_table() { + app.deselect_row(); } } (KeyCode::Char('e'), KeyModifiers::NONE) => { @@ -381,6 +507,15 @@ fn main() { app.graphs_scroll = 0; fetch_data(&rt, &client, &gateway_client, &mut app); } + (KeyCode::Char('s'), KeyModifiers::NONE) => { + // `s` cycles the active sort column on the MMP and + // Graphs by-peer tables (no-op on other tabs). + app.cycle_sort_col(); + } + (KeyCode::Char('S'), _) => { + // `S` toggles the sort direction on those same tables. + app.toggle_sort_dir(); + } _ => {} } } diff --git a/src/bin/fipstop/ui/bloom.rs b/src/bin/fipstop/ui/bloom.rs index ba1aa98..ea06dd8 100644 --- a/src/bin/fipstop/ui/bloom.rs +++ b/src/bin/fipstop/ui/bloom.rs @@ -2,7 +2,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; use crate::app::{App, Tab}; @@ -20,47 +20,99 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { }; let chunks = Layout::vertical([ - Constraint::Length(7), // Bloom Filter State + Constraint::Length(8), // Bloom Filter State Constraint::Length(15), // Bloom Announce Stats Constraint::Min(3), // Peer Filters ]) .split(area); - draw_state(frame, data, chunks[0]); - draw_stats(frame, data, chunks[1]); - draw_peer_filters(frame, data, chunks[2]); + let focused = app.focused_pane(); + draw_state( + frame, + app, + data, + app.pane_scroll(0), + focused == 0, + chunks[0], + ); + draw_stats(frame, data, app.pane_scroll(1), focused == 1, chunks[1]); + draw_peer_filters( + frame, + app, + data, + app.pane_scroll(2), + focused == 2, + chunks[2], + ); } -fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let lines = vec![ - helpers::kv_line( +fn draw_state( + frame: &mut Frame, + app: &App, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { + // is_root determines whether the uptree filter renders as "n/a (root)"; + // read it from the dashboard (State) surface, which carries it. + let is_root = app + .data + .get(&Tab::Node) + .and_then(|d| d.get("is_root")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Uptree filter (what we last sent to the tree parent): "n/a (root)" for a + // root node, an em-dash before the first announce, else the value. + let uptree_fill = if is_root { + "n/a (root)".to_string() + } else { + match data.get("uptree_fill_ratio").and_then(|v| v.as_f64()) { + Some(r) => format!("{:.1}%", r * 100.0), + None => "\u{2014}".into(), + } + }; + let subtree_est = if is_root { + "n/a (root)".to_string() + } else { + match data.get("uptree_estimated_count").and_then(|v| v.as_f64()) { + Some(n) => format!("{:.0}", n), + None => "\u{2014}".into(), + } + }; + + let lines = helpers::kv_lines(&[ + ( "Node Addr", - &helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16), + helpers::truncate_hex(helpers::str_field(data, "own_node_addr"), 16), ), - helpers::kv_line("Leaf Only", helpers::bool_field(data, "is_leaf_only")), - helpers::kv_line("Sequence", &helpers::u64_field(data, "sequence")), - helpers::kv_line( + ( + "Leaf Only", + helpers::bool_field(data, "is_leaf_only").into(), + ), + ("Sequence", helpers::u64_field(data, "sequence")), + ( "Leaf Deps", - &helpers::u64_field(data, "leaf_dependent_count"), + helpers::u64_field(data, "leaf_dependent_count"), ), - ]; + ("Fill (sent uptree)", uptree_fill), + ("Subtree est", subtree_est), + ]); - let block = Block::default() - .borders(Borders::ALL) - .title(" Bloom Filter State "); + let block = helpers::pane_block(" Bloom Filter State ", focused); let inner = block.inner(area); frame.render_widget(block, area); - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } -fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Bloom Announce Stats "); +fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused: bool, area: Rect) { + let block = helpers::pane_block(" Bloom Announce Stats ", focused); let inner = block.inner(area); frame.render_widget(block, area); - let mut lines = vec![ + let lines = vec![ helpers::section_header("Inbound"), helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")), helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")), @@ -107,13 +159,18 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { ), ]; - let max_lines = inner.height as usize; - lines.truncate(max_lines); - - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } -fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) { +fn draw_peer_filters( + frame: &mut Frame, + app: &App, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { let filters = data .get("peer_filters") .and_then(|v| v.as_array()) @@ -121,9 +178,7 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) { .unwrap_or_default(); let count = filters.len(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" Peer Filters ({count}) ")); + let block = helpers::pane_block(&format!(" Peer Filters ({count}) "), focused); let inner = block.inner(area); frame.render_widget(block, area); @@ -133,48 +188,65 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) { return; } - let lines: Vec = filters - .iter() - .map(|f| { - let name = helpers::str_field(f, "display_name"); - let seq = helpers::u64_field(f, "filter_sequence"); - let has = f - .get("has_filter") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let mut spans = vec![ - Span::styled( - format!(" {name:<16}"), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled("seq: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{seq:<6}")), - ]; - - if has { - let fill = f - .get("fill_ratio") - .and_then(|v| v.as_f64()) - .map(|r| format!("{:.1}%", r * 100.0)) - .unwrap_or_else(|| "-".into()); - let est = f - .get("estimated_count") - .and_then(|v| v.as_f64()) - .map(|n| format!("{:.0}", n)) - .unwrap_or_else(|| "-".into()); - spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray))); - spans.push(Span::raw(format!("{fill:<8}"))); - spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray))); - spans.push(Span::raw(format!("{est:<6}"))); - spans.push(Span::styled("ok", Style::default().fg(Color::Green))); - } else { - spans.push(Span::styled("none", Style::default().fg(Color::Red))); - } - - Line::from(spans) - }) + // The bloom response carries no role flags; recover them from the peers view + // (cross-fetched on this tab) by joining each filter's `peer` hex address, + // then group by tree role (parent -> STP children -> other) to match the + // Peers and Tree tabs so the same peer sits under the same heading. + let role_map = helpers::peer_role_map(app.data.get(&Tab::Peers)); + let mut filters: Vec = filters + .into_iter() + .map(|f| helpers::enrich_role(f, &role_map, "peer")) .collect(); + helpers::sort_by_group(&mut filters); - frame.render_widget(Paragraph::new(lines), inner); + let lines = helpers::grouped_peer_lines(&filters, peer_filter_line); + + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); +} + +/// Render one Bloom-tab peer-filter line: name, filter sequence, and either the +/// fill/estimate columns (when the peer has a filter) or a "none" marker. +fn peer_filter_line(f: &serde_json::Value) -> Line<'static> { + let name = helpers::str_field(f, "display_name"); + let seq = helpers::u64_field(f, "filter_sequence"); + let has = f + .get("has_filter") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Right-justify each numeric into a fixed field wide enough for + // realistic data, with a guaranteed trailing separator so a + // wider-than-expected value can never touch the next label, and + // the digit columns line up across rows. + let mut spans = vec![ + Span::styled( + format!(" {} ", helpers::truncate_name(name, 16)), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("seq: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{seq:>9} ")), + ]; + + if has { + let fill = f + .get("fill_ratio") + .and_then(|v| v.as_f64()) + .map(|r| format!("{:.1}%", r * 100.0)) + .unwrap_or_else(|| "-".into()); + let est = f + .get("estimated_count") + .and_then(|v| v.as_f64()) + .map(|n| format!("{:.0}", n)) + .unwrap_or_else(|| "-".into()); + spans.push(Span::styled("fill: ", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(format!("{fill:>6} "))); + spans.push(Span::styled("est: ", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(format!("{est:>6} "))); + spans.push(Span::styled("ok", Style::default().fg(Color::Green))); + } else { + spans.push(Span::styled("none", Style::default().fg(Color::Red))); + } + + Line::from(spans) } diff --git a/src/bin/fipstop/ui/dashboard.rs b/src/bin/fipstop/ui/dashboard.rs index f4082c5..33c3611 100644 --- a/src/bin/fipstop/ui/dashboard.rs +++ b/src/bin/fipstop/ui/dashboard.rs @@ -23,7 +23,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::vertical([ Constraint::Length(7), // Runtime Constraint::Length(7), // Identity - Constraint::Length(6), // State (sparkline row adds one line) + Constraint::Length(8), // State (root egg + transports + sparkline rows) Constraint::Length(9), // Traffic + Listening on fips0 (side-by-side) Constraint::Min(0), // remaining ]) @@ -95,6 +95,12 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) { let npub = helpers::str_field(data, "npub"); let node_addr = helpers::str_field(data, "node_addr"); let ipv6_addr = helpers::str_field(data, "ipv6_addr"); + // Effective persistence: whether this identity survives a restart. + let mode = match data.get("persistent").and_then(|v| v.as_bool()) { + Some(true) => "persistent", + Some(false) => "ephemeral", + None => "-", + }; let label = Style::default().fg(Color::DarkGray); @@ -111,6 +117,10 @@ fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) { Span::styled(" ipv6: ", label), Span::raw(ipv6_addr.to_string()), ]), + Line::from(vec![ + Span::styled(" identity: ", label), + Span::raw(mode.to_string()), + ]), ]; frame.render_widget(Paragraph::new(lines), inner); @@ -148,6 +158,24 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "peer_count")); let spark_style = Style::default().fg(Color::DarkGray); + // Root: an Easter-egg marker when this node IS the root, otherwise the + // truncated root hex. The full root address + npub live on the Tree tab. + let is_root = data + .get("is_root") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let root_display = if is_root { + "I am the one who roots".to_string() + } else { + let root_hex = helpers::str_field(data, "root"); + let head: String = root_hex.chars().take(16).collect(); + format!("{head}\u{2026}") + }; + + // Configured transport types each with their peer count, e.g. + // "udp (5), tcp (2), tor (0)". Idle-but-configured types stay visible at 0. + let transports_by_type = format_transport_peer_counts(data); + let lines = vec![ Line::from(vec![ Span::styled(" state: ", label), @@ -170,9 +198,22 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { Span::styled(transports, count), Span::styled(" connections: ", label), Span::styled(connections, count), - Span::styled(" mesh: ", label), + ]), + // The mesh size is a bloom-cardinality estimate, not an exact count; + // it gets its own line so the longer "approx. mesh estimate:" label + // does not overflow the counts line at narrow widths. + Line::from(vec![ + Span::styled(" approx. mesh estimate: ", label), Span::styled(mesh_size, count), ]), + Line::from(vec![ + Span::styled(" root: ", label), + Span::raw(root_display), + ]), + Line::from(vec![ + Span::styled(" transports: ", label), + Span::raw(transports_by_type), + ]), Line::from(vec![ Span::styled(" peers: ", label), Span::styled(peer_spark, spark_style), @@ -184,6 +225,25 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { frame.render_widget(Paragraph::new(lines), inner); } +/// Format the `transport_peer_counts` map as `type (count)` joined with +/// commas, e.g. `udp (5), tcp (2), tor (0)`. Keys are rendered in sorted +/// order (the daemon emits a sorted map). Returns `-` when absent or empty. +fn format_transport_peer_counts(data: &serde_json::Value) -> String { + let Some(map) = data + .get("transport_peer_counts") + .and_then(|v| v.as_object()) + else { + return "-".into(); + }; + if map.is_empty() { + return "-".into(); + } + map.iter() + .map(|(ty, count)| format!("{ty} ({})", count.as_u64().unwrap_or(0))) + .collect::>() + .join(", ") +} + fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { let block = Block::default().borders(Borders::ALL).title(" Traffic "); let inner = block.inner(area); diff --git a/src/bin/fipstop/ui/graphs.rs b/src/bin/fipstop/ui/graphs.rs index f7b90b1..1359ac0 100644 --- a/src/bin/fipstop/ui/graphs.rs +++ b/src/bin/fipstop/ui/graphs.rs @@ -18,7 +18,9 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; -use crate::app::{App, GRAPHS_METRICS, GraphsMode, PEER_GRAPHS_METRICS, Tab}; +use crate::app::{ + App, GRAPHS_METRICS, GRAPHS_PEER_SORT_LABELS, GraphsMode, PEER_GRAPHS_METRICS, SortState, Tab, +}; /// 5×5 braille lookup table indexed by (left fill 0..=4, right fill /// 0..=4). Direct transcription of btop's `braille_up` glyph set. @@ -78,10 +80,16 @@ fn draw_selector(frame: &mut Frame, app: &App, area: Rect) { Span::styled(" scroll: ", label), Span::styled(format!("{}", app.graphs_scroll), dim), ]); - let line2 = Line::from(Span::styled( - " [↑/↓] scroll [←/→] window [m] mode [n/N] cycle [g] graphs [q] quit", - label, - )); + // The full keybinding reference lives in the status-bar footer (registry) + // and the `?` overlay; this in-pane line is a brief mode-specific reminder. + let line2_text = match app.graphs_mode { + GraphsMode::MetricByPeer if app.detail_view.is_some() => { + " [↑/↓] peer [n/N] stat [m] mode [Esc] back" + } + GraphsMode::MetricByPeer => " [↑/↓] select [Enter] expand [n/N] stat [m] mode", + _ => " [↑/↓] scroll [←/→] window [m] mode [n/N] cycle", + }; + let line2 = Line::from(Span::styled(line2_text, label)); frame.render_widget(Paragraph::new(vec![line1, line2]), area); } @@ -187,21 +195,11 @@ fn draw_stacked(frame: &mut Frame, app: &mut App, inner: Rect) { frame.render_widget(paragraph, inner); } -fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) { - let data = match app.data.get(&Tab::Graphs) { - Some(d) => d, - None => { - frame.render_widget( - Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)), - inner, - ); - return; - } - }; - - let metric_name = app.graphs_selected_peer_metric(); - let peers = data.get("peers").and_then(|v| v.as_array()); - let peer_series: Vec<(String, Vec)> = peers +/// Parse the by-peer payload (`peers` array of `{display_name, values}`) into +/// `[(name, values)]`, in payload order. +fn peer_series_from_data(data: &serde_json::Value) -> Vec<(String, Vec)> { + data.get("peers") + .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .map(|p| { @@ -219,7 +217,28 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) { }) .collect() }) - .unwrap_or_default(); + .unwrap_or_default() +} + +/// By-peer mode: a scrollable summary list (one line per peer, with +/// min/max/last/n for the selected metric). Selecting a peer (Enter) swaps to a +/// full-pane btop plot via `draw_metric_by_peer_detail`. The cursor follows +/// `graphs_peer_idx`; Up/Down move it and the focus/scroll model keeps the +/// selection visible. +fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) { + let data = match app.data.get(&Tab::Graphs) { + Some(d) => d, + None => { + frame.render_widget( + Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)), + inner, + ); + return; + } + }; + + let metric_name = app.graphs_selected_peer_metric(); + let peer_series = peer_series_from_data(data); if peer_series.is_empty() { frame.render_widget( @@ -230,94 +249,133 @@ fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) { return; } - // Pick a column count that keeps each cell wide enough for a - // readable braille plot. Each cell needs ~30 columns minimum. - let cols = if inner.width < 40 { - 1 - } else if inner.width < 100 { - 2 - } else { - 3 - }; - let rows = peer_series.len().div_ceil(cols); - - // Stack of cell-rows; each row is a horizontal split of cell cells. - let row_constraints: Vec = (0..rows) - .map(|_| Constraint::Length(METRIC_BLOCK_ROWS)) - .collect(); - let row_areas = Layout::vertical(row_constraints).split(inner); - - for row_idx in 0..rows { - let col_constraints: Vec = (0..cols) - .map(|_| Constraint::Ratio(1, cols as u32)) - .collect(); - let col_areas = Layout::horizontal(col_constraints).split(row_areas[row_idx]); - - for col_idx in 0..cols { - let peer_idx = row_idx * cols + col_idx; - if peer_idx >= peer_series.len() { - break; - } - let (peer_name, values) = &peer_series[peer_idx]; - let cell_lines = render_metric_block_labeled( - metric_name, - peer_name, - values, - col_areas[col_idx].width, - ); - frame.render_widget(Paragraph::new(cell_lines), col_areas[col_idx]); - } + // An open detail view swaps the whole pane for a full-width btop plot of + // the selected peer; Up/Down then flip peers rather than scroll the list. + if app.detail_view.is_some() { + draw_metric_by_peer_detail(frame, app, inner, metric_name, &peer_series); + return; } + + // The cursor tracks a payload-order peer index; build a display-order + // permutation per the active sort so re-sorting reorders the list while the + // cursor stays on the same logical peer (mapped to its new display row). + let order = sorted_order(&peer_series, app.graphs_peer_sort); + let sel_payload = app.graphs_peer_idx.min(peer_series.len() - 1); + let display_selected = order.iter().position(|&i| i == sel_payload).unwrap_or(0); + + let unit = metric_unit(metric_name); + let cursor = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let label = Style::default().fg(Color::DarkGray); + let name_style = Style::default().fg(Color::White); + + let mut lines: Vec> = vec![sort_header(app.graphs_peer_sort)]; + for (display_row, &payload_idx) in order.iter().enumerate() { + let (peer_name, values) = &peer_series[payload_idx]; + let (min, max, last, n) = summarize(values); + let is_sel = display_row == display_selected; + let marker = if is_sel { "\u{25b6} " } else { " " }; + let nm = name_style; + // Right-justify the numeric columns into fixed-width fields so the + // min/max/last values and the sample count line up down the list + // regardless of magnitude. + let row = Line::from(vec![ + Span::styled( + format!("{marker}{peer_name:<18}"), + if is_sel { cursor } else { nm }, + ), + Span::styled(format!(" [{unit}]"), label), + Span::styled(" min ", label), + Span::raw(format!("{:>8}", format_value(min))), + Span::styled(" max ", label), + Span::raw(format!("{:>8}", format_value(max))), + Span::styled(" last ", label), + Span::raw(format!("{:>8}", format_value(last))), + Span::styled(" n=", label), + Span::raw(format!("{n:>5}")), + ]); + lines.push(row); + } + + // Keep the selected row visible using the shared per-pane scroll model + // (Graphs by-peer list is pane 0). The +1 accounts for the sort header row. + let visible = inner.height as usize; + let selected = display_selected + 1; + let mut offset = app.pane_scroll(0) as usize; + if selected < offset { + offset = selected; + } else if visible > 0 && selected >= offset + visible { + offset = selected + 1 - visible; + } + let max_offset = lines.len().saturating_sub(visible); + offset = offset.min(max_offset); + app.scroll_offsets.insert((Tab::Graphs, 0), offset as u16); + + let paragraph = Paragraph::new(lines).scroll((offset as u16, 0)); + frame.render_widget(paragraph, inner); } -/// Variant of `render_metric_block` that labels the block with the -/// peer name in addition to the metric. Used by the metric-by-peer grid. -fn render_metric_block_labeled( +/// Full-pane btop plot for the selected by-peer peer. The detail follows the +/// selection (Up/Down flip peers, n/N switch the statistic), re-rendering this +/// plot for the current `(peer, metric)`. +fn draw_metric_by_peer_detail( + frame: &mut Frame, + app: &App, + inner: Rect, metric: &str, - peer_name: &str, - values: &[f64], - width: u16, -) -> Vec> { + peer_series: &[(String, Vec)], +) { + let idx = app.graphs_peer_idx.min(peer_series.len() - 1); + let (peer_name, values) = &peer_series[idx]; let unit = metric_unit(metric); - let mut out: Vec> = Vec::with_capacity(METRIC_BLOCK_ROWS as usize); - let (min, max, last, n) = summarize(values); let title_style = Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD); let label = Style::default().fg(Color::DarkGray); - let title = Line::from(vec![ + + let (min, max, last, n) = summarize(values); + let header = Line::from(vec![ Span::styled(format!(" {peer_name}"), title_style), - Span::styled(format!(" [{unit}]"), label), - Span::styled(" max ", label), + Span::styled(format!(" {metric} [{unit}]"), label), + Span::styled(" min ", label), + Span::raw(format_value(min)), + Span::styled(" max ", label), Span::raw(format_value(max)), Span::styled(" last ", label), Span::raw(format_value(last)), - Span::styled(" n=", label), + Span::styled(" samples ", label), Span::raw(format!("{n}")), ]); - out.push(title); + + let mut lines: Vec> = vec![header, Line::from("")]; let gutter = 2u16; - let plot_cols = width.saturating_sub(gutter) as usize; + let plot_cols = inner.width.saturating_sub(gutter) as usize; + // Reserve the header (1) + blank (1) + a footer hint line; the rest is plot. + let plot_rows = (inner.height as usize).saturating_sub(3).max(1); if plot_cols == 0 || values.is_empty() { - for _ in 0..METRIC_PLOT_ROWS { - out.push(Line::from(Span::styled( + for _ in 0..plot_rows { + lines.push(Line::from(Span::styled( " (no samples)", Style::default().fg(Color::DarkGray), ))); } - out.push(Line::from("")); - return out; + } else { + let sampled = resample(values, plot_cols * 2); + lines.extend(render_btop_graph( + &sampled, + plot_rows, + min, + max, + gutter as usize, + )); } - let sampled = resample(values, plot_cols * 2); - let rows = METRIC_PLOT_ROWS as usize; - let plot_lines = render_btop_graph(&sampled, rows, min, max, gutter as usize); - out.extend(plot_lines); - out.push(Line::from("")); - out + frame.render_widget(Paragraph::new(lines), inner); } /// Render a single metric's mini block: one title row, four plot rows, @@ -337,7 +395,9 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec Vec)], sort: SortState) -> Vec { + let mut order: Vec = (0..peer_series.len()).collect(); + order.sort_by(|&a, &b| { + let (na, va) = &peer_series[a]; + let (nb, vb) = &peer_series[b]; + let ord = if sort.col == 0 { + na.cmp(nb) + } else { + let key = |v: &[f64]| -> f64 { + let (min, max, last, n) = summarize(v); + match sort.col { + 1 => min, + 2 => max, + 3 => last, + _ => n as f64, + } + }; + let ka = key(va); + let kb = key(vb); + // NaN keys (e.g. an empty series' last) sort last under ascending. + match (ka.is_nan(), kb.is_nan()) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal), + } + }; + if sort.descending { ord.reverse() } else { ord } + }); + order +} + +/// Render the Graphs by-peer sort-column header: each column label with the +/// active sort column highlighted and carrying a direction arrow. +fn sort_header(sort: SortState) -> Line<'static> { + let active = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let idle = Style::default().fg(Color::DarkGray); + // Solid triangles mark the sort direction, distinct from the cursor and + // any plot glyphs. + let arrow = if sort.descending { + "\u{25bc}" + } else { + "\u{25b2}" + }; + let mut spans: Vec> = vec![Span::styled(" sort: ", idle)]; + for (i, lbl) in GRAPHS_PEER_SORT_LABELS.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", idle)); + } + if i == sort.col { + spans.push(Span::styled(format!("{lbl}{arrow}"), active)); + } else { + spans.push(Span::styled(lbl.to_string(), idle)); + } + } + Line::from(spans) +} + fn summarize(values: &[f64]) -> (f64, f64, f64, usize) { if values.is_empty() { return (0.0, 0.0, 0.0, 0); @@ -444,15 +567,23 @@ fn render_btop_graph( } let range = max - min; - // NaN samples pass through normalize as NaN so the cell loop below - // can blank them. Non-NaN samples are clamped into 0..=100. + // Flat series (range <= 0) carry no scale, so map them by their level: + // a genuine zero reading renders as an empty plot (NaN blanks every + // cell), while a steady non-zero reading rests on the baseline (0.0) + // rather than floating at mid-height. NaN samples always blank. + let flat = !range.is_finite() || range <= 0.0; + let flat_zero = flat && max == 0.0; let normalized: Vec = values .iter() .map(|&v| { - if v.is_nan() { + if v.is_nan() || flat_zero { + // Blank cells: a NaN sample, or a genuine flat-zero series + // (rendered as an empty plot). f64::NAN - } else if !range.is_finite() || range <= 0.0 { - 50.0 + } else if flat { + // A small positive level so the steady value rests as a + // row of dots on the baseline rather than an empty plot. + 8.0 } else { ((v - min) / range * 100.0).clamp(0.0, 100.0) } @@ -618,6 +749,66 @@ mod tests { assert_eq!(lines.len(), METRIC_BLOCK_ROWS as usize); } + /// Collect the rendered braille plot rows (excluding the gutter) of a + /// metric block into one concatenated string for inspection. + fn plot_text(lines: &[Line<'static>]) -> String { + // The block is: title, METRIC_PLOT_ROWS plot rows, blank separator. + lines + .iter() + .skip(1) + .take(METRIC_PLOT_ROWS as usize) + .flat_map(|l| l.spans.iter()) + .map(|s| s.content.to_string()) + .collect::() + } + + #[test] + fn flat_zero_renders_empty_plot() { + // All-zero flat series: empty plot (no braille dots). + let lines = render_metric_block("loss_rate", &[0.0, 0.0, 0.0, 0.0], 40); + let plot = plot_text(&lines); + assert!( + plot.trim().is_empty(), + "flat-zero plot should have no dots, got {plot:?}" + ); + } + + #[test] + fn flat_nonzero_renders_baseline_dots() { + // Steady non-zero flat series: a baseline row of dots, not an empty + // plot and not floating at mid-height. + let lines = render_metric_block("mesh_size", &[7.0, 7.0, 7.0, 7.0], 40); + let plot = plot_text(&lines); + assert!( + !plot.trim().is_empty(), + "flat non-zero plot should rest on the baseline as dots" + ); + } + + #[test] + fn no_data_has_distinct_placeholder() { + // Empty input must be visibly distinct from a flat-zero empty plot. + let lines = render_metric_block("mesh_size", &[], 40); + let joined: String = lines + .iter() + .flat_map(|l| l.spans.iter()) + .map(|s| s.content.to_string()) + .collect(); + assert!(joined.contains("no samples")); + } + + #[test] + fn title_row_includes_min() { + let lines = render_metric_block("mesh_size", &[2.0, 5.0, 9.0], 60); + let title: String = lines[0] + .spans + .iter() + .map(|s| s.content.to_string()) + .collect(); + assert!(title.contains("min "), "title should label a min field"); + assert!(title.contains("max "), "title should still show max"); + } + #[test] fn gradient_spans_stops() { if let Color::Rgb(r, g, _) = gradient_rgb(0.0) { diff --git a/src/bin/fipstop/ui/help.rs b/src/bin/fipstop/ui/help.rs new file mode 100644 index 0000000..e8e641d --- /dev/null +++ b/src/bin/fipstop/ui/help.rs @@ -0,0 +1,367 @@ +//! Declarative keybinding registry and the `?` help overlay. +//! +//! A single static table keyed by `(Tab, UiMode)` is the one source of truth +//! both the always-visible context footer (`draw_status_bar`) and the full `?` +//! overlay render from, so the two can never drift. Every key the dispatch +//! handles in a given context is registered here as a `(key, label)` pair; the +//! footer renders the contextual subset (with a width-aware truncation rule), +//! and the overlay renders the whole reference. +//! +//! A test (`registry_keys_exist_in_dispatch`) asserts every key string the +//! table mentions is one the `main.rs` dispatch actually recognizes, so a +//! stale or invented hint can't slip in. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +use crate::app::{App, Tab}; + +/// The UI interaction mode the active tab is in, derived from existing `App` +/// fields. Selects which hint set the footer and overlay show. Order: +/// overview (nothing selected/open) is the base; a selected table row, an open +/// detail view, and (for multi-pane tabs) pane focus refine it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum UiMode { + /// No row selected, no detail open — the tab's resting state. + Overview, + /// A table row is selected (Peers/Sessions/Transports/Gateway). + RowSelected, + /// A detail view is open over the active tab. + DetailOpen, +} + +impl UiMode { + /// Derive the current mode from `App` state for the active tab. + pub fn of(app: &App) -> UiMode { + if app.detail_view.is_some() { + return UiMode::DetailOpen; + } + if app.active_tab.has_table() + && app + .table_states + .get(&app.active_tab) + .and_then(|s| s.selected()) + .is_some() + { + return UiMode::RowSelected; + } + UiMode::Overview + } +} + +/// One keybinding hint: the key glyph shown in brackets and its action label. +#[derive(Clone, Copy)] +pub struct Hint { + pub key: &'static str, + pub label: &'static str, +} + +const fn hint(key: &'static str, label: &'static str) -> Hint { + Hint { key, label } +} + +/// Global hints available on (almost) every tab regardless of mode. These are +/// the lowest-priority footer candidates: when the bar overflows they drop +/// first, leaving the contextual hints and the always-present `[?] Help`. +pub const GLOBAL_HINTS: &[Hint] = &[hint("Tab", "next"), hint("g", "graphs"), hint("q", "quit")]; + +const DETAIL_HINTS: &[Hint] = &[hint("Esc", "close"), hint("\u{2191}\u{2193}", "scroll")]; +const PEERS_SELECTED_HINTS: &[Hint] = &[ + hint("Enter", "detail"), + hint("Del", "disconnect"), + hint("Esc", "deselect"), +]; +const ROW_SELECTED_HINTS: &[Hint] = &[hint("Enter", "detail"), hint("Esc", "deselect")]; +const TABLE_OVERVIEW_HINTS: &[Hint] = &[hint("\u{2191}\u{2193}", "select")]; +const GRAPHS_OVERVIEW_HINTS: &[Hint] = &[ + hint("Enter", "expand"), + hint("m", "mode"), + hint("n/N", "stat"), + hint("\u{2190}\u{2192}", "window"), + hint("s/S", "sort"), +]; +/// The MMP (Performance) tab: `f` moves focus between the Link and Session MMP +/// panes, the arrows scroll the focused pane, and `s`/`S` sort the focused pane. +const MMP_HINTS: &[Hint] = &[ + hint("f", "focus pane"), + hint("\u{2191}\u{2193}", "scroll"), + hint("s/S", "sort"), +]; +/// The multi-pane scrollable tabs (Tree, Filters, Routing): `f` moves pane +/// focus and the arrow keys scroll the focused pane. +const PANE_SCROLL_HINTS: &[Hint] = &[hint("f", "focus pane"), hint("\u{2191}\u{2193}", "scroll")]; +/// By-peer detail (full-pane plot) on the Graphs tab: Up/Down flip the peer the +/// plot follows, n/N switch the statistic, m cycles the mode, Esc returns to the +/// scrollable peer list. +const GRAPHS_DETAIL_HINTS: &[Hint] = &[ + hint("\u{2191}\u{2193}", "peer"), + hint("n/N", "stat"), + hint("m", "mode"), + hint("Esc", "back"), +]; +const NO_HINTS: &[Hint] = &[]; + +/// The contextual hints for a `(Tab, UiMode)`. Highest footer priority — these +/// describe what the current state's keys do and are kept when the bar is +/// narrow. The overlay shows these plus the globals plus `[?] Help`. +pub fn contextual_hints(tab: Tab, mode: UiMode) -> &'static [Hint] { + match (tab, mode) { + (Tab::Graphs, UiMode::DetailOpen) => GRAPHS_DETAIL_HINTS, + (_, UiMode::DetailOpen) => DETAIL_HINTS, + (Tab::Peers, UiMode::RowSelected) => PEERS_SELECTED_HINTS, + (_, UiMode::RowSelected) => ROW_SELECTED_HINTS, + (Tab::Peers | Tab::Sessions | Tab::Transports | Tab::Gateway, UiMode::Overview) => { + TABLE_OVERVIEW_HINTS + } + (Tab::Graphs, UiMode::Overview) => GRAPHS_OVERVIEW_HINTS, + (Tab::Mmp, UiMode::Overview) => MMP_HINTS, + (Tab::Tree | Tab::Bloom | Tab::Routing, UiMode::Overview) => PANE_SCROLL_HINTS, + _ => NO_HINTS, + } +} + +/// Render a key hint as `[key] label` spans (key dim-bracketed, label plain). +fn hint_spans(h: &Hint) -> Vec> { + vec![ + Span::styled(format!("[{}] ", h.key), Style::default().fg(Color::Yellow)), + Span::styled( + format!("{} ", h.label), + Style::default().fg(Color::DarkGray), + ), + ] +} + +/// Build the footer hint line for the active context, fitting `budget` columns. +/// +/// Contextual hints come first and are kept; global hints fill remaining width +/// and drop when they don't fit; `[?] Help` is always appended last as the +/// overflow affordance. Returns the spans to append after the connection and +/// timing spans in the status bar. +pub fn footer_hint_spans(tab: Tab, mode: UiMode, budget: usize) -> Vec> { + let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray)); + let help_w = "[?] Help ".len(); + + let mut spans: Vec> = Vec::new(); + let mut used = 0usize; + + // Reserve room for the always-present help affordance. + let avail = budget.saturating_sub(help_w); + + let push_if_fits = |spans: &mut Vec>, used: &mut usize, h: &Hint| -> bool { + let w = h.key.chars().count() + h.label.chars().count() + 4; // "[] " + " " + if *used + w <= avail { + spans.extend(hint_spans(h)); + *used += w; + true + } else { + false + } + }; + + // Contextual first (highest priority). + for h in contextual_hints(tab, mode) { + push_if_fits(&mut spans, &mut used, h); + } + // Globals fill remaining space, dropping when they don't fit. + for h in GLOBAL_HINTS { + push_if_fits(&mut spans, &mut used, h); + } + + spans.push(help); + spans +} + +/// Render the full `?` help overlay: a centered modal listing every binding +/// for the active `(Tab, UiMode)` (contextual + global), drawn from the same +/// registry the footer reads. +pub fn draw_overlay(frame: &mut Frame, app: &App, area: Rect) { + let tab = app.active_tab; + let mode = UiMode::of(app); + + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(Span::styled( + format!(" {} — {:?}", tab.label(), mode), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + lines.push(Line::from(Span::styled( + " Context", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + let ctx = contextual_hints(tab, mode); + if ctx.is_empty() { + lines.push(Line::from(Span::styled( + " (no context-specific keys)", + Style::default().fg(Color::DarkGray), + ))); + } else { + for h in ctx { + lines.push(overlay_row(h)); + } + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Global", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ))); + for h in GLOBAL_HINTS { + lines.push(overlay_row(h)); + } + lines.push(overlay_row(&hint("BackTab", "previous tab"))); + lines.push(overlay_row(&hint("?", "toggle this help"))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Press ? or Esc to close", + Style::default().fg(Color::DarkGray), + ))); + + let popup = centered_rect(60, 70, area); + frame.render_widget(Clear, popup); + let block = Block::default() + .borders(Borders::ALL) + .title(" Help ") + .style(Style::default().bg(Color::Black)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + frame.render_widget(Paragraph::new(lines), inner); +} + +/// Render the Del-disconnect confirmation modal: a centered Y/N prompt naming +/// the peer and showing a reconnect note tailored to its kind. +pub fn draw_disconnect_modal(frame: &mut Frame, app: &App, area: Rect) { + let Some(confirm) = &app.confirm_disconnect else { + return; + }; + + let lines = vec![ + Line::from(Span::styled( + " Disconnect peer?", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" Peer: ", Style::default().fg(Color::DarkGray)), + Span::styled( + confirm.display_name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + format!(" {}", confirm.reconnect_note), + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(vec![ + Span::styled(" [Y] ", Style::default().fg(Color::Yellow)), + Span::raw("disconnect "), + Span::styled("[N/Esc] ", Style::default().fg(Color::Yellow)), + Span::raw("cancel"), + ]), + ]; + + let popup = centered_rect_lines(64, 8, area); + frame.render_widget(Clear, popup); + let block = Block::default() + .borders(Borders::ALL) + .title(" Confirm ") + .style(Style::default().bg(Color::Black)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + frame.render_widget(Paragraph::new(lines), inner); +} + +/// A centered rectangle of fixed `w`×`h` cells (clamped to `area`). +fn centered_rect_lines(w: u16, h: u16, area: Rect) -> Rect { + let w = w.min(area.width); + let h = h.min(area.height); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + Rect { + x, + y, + width: w, + height: h, + } +} + +fn overlay_row(h: &Hint) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!(" {:<10}", format!("[{}]", h.key)), + Style::default().fg(Color::Yellow), + ), + Span::raw(h.label.to_string()), + ]) +} + +/// Compute a centered rectangle `pct_x`%×`pct_y`% of `area`. +fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect { + let w = area.width * pct_x / 100; + let h = area.height * pct_y / 100; + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + Rect { + x, + y, + width: w, + height: h, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every key glyph the registry mentions must be one the `main.rs` + /// dispatch actually handles, so a stale or invented hint can't ship. The + /// dispatch key set is mirrored here; adding a binding to the registry + /// without wiring it (or vice versa) trips this. + #[test] + fn registry_keys_exist_in_dispatch() { + // The authoritative set of key glyphs the dispatch recognizes. Mirror + // of the match arms in `main.rs` (plus the arrow/Enter/Esc/Tab keys). + const DISPATCH_KEYS: &[&str] = &[ + "q", + "Tab", + "BackTab", + "g", + "m", + "n/N", + "s/S", + "f", + "?", + "Del", + "Enter", + "Esc", + "\u{2191}\u{2193}", // up/down + "\u{2190}\u{2192}", // left/right + ]; + + let mut all: Vec = GLOBAL_HINTS.to_vec(); + all.push(hint("BackTab", "previous tab")); + all.push(hint("?", "toggle this help")); + for &tab in &Tab::ALL { + for mode in [UiMode::Overview, UiMode::RowSelected, UiMode::DetailOpen] { + all.extend_from_slice(contextual_hints(tab, mode)); + } + } + for h in all { + assert!( + DISPATCH_KEYS.contains(&h.key), + "registry key [{}] ({}) has no dispatch handler", + h.key, + h.label + ); + } + } +} diff --git a/src/bin/fipstop/ui/helpers.rs b/src/bin/fipstop/ui/helpers.rs index 4dbff67..b790f2d 100644 --- a/src/bin/fipstop/ui/helpers.rs +++ b/src/bin/fipstop/ui/helpers.rs @@ -1,7 +1,37 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders}; use serde_json::Value; +/// A bordered pane block with a title that highlights its border (cyan, bold +/// title) when `focused`, so the multi-pane focus model has a clear visual +/// indicator of which pane the scroll keys act on. +pub fn pane_block(title: &str, focused: bool) -> Block<'static> { + let block = Block::default() + .borders(Borders::ALL) + .title(title.to_string()); + if focused { + block + .border_style(Style::default().fg(Color::Cyan)) + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + block + } +} + +/// Clamp a desired scroll offset to a pane's content so an over-scroll (e.g. +/// from End, which passes `u16::MAX`) rests at the last full screen rather than +/// scrolling past the content. `content_rows` is the total rendered line count +/// and `visible_rows` the pane's inner height. +pub fn clamp_scroll(offset: u16, content_rows: usize, visible_rows: usize) -> u16 { + let max = content_rows.saturating_sub(visible_rows) as u16; + offset.min(max) +} + /// Extract a string field from JSON, returning "-" if missing. pub fn str_field<'a>(data: &'a Value, key: &str) -> &'a str { data.get(key).and_then(|v| v.as_str()).unwrap_or("-") @@ -24,6 +54,23 @@ pub fn truncate_hex(s: &str, max_len: usize) -> String { } } +/// Truncate a display name to a fixed visible width, appending an ellipsis when +/// it overflows, then pad to exactly `width` columns. Unlike a bare `{: String { + let len = s.chars().count(); + if len <= width { + format!("{s: String { if bytes_per_sec < 0.0 { @@ -133,6 +180,17 @@ pub fn nested_f64_prefer( .unwrap_or_else(|| "-".into()) } +/// Format an optional numeric field as a fixed-precision number, or an em-dash +/// placeholder when the value is JSON `null` or the key is absent. Used for +/// daemon-emitted `Option` fields (e.g. `effective_depth`) so an +/// unmeasured value renders distinctly from a real zero. +pub fn opt_f64_field(data: &Value, key: &str, decimals: usize) -> String { + match data.get(key).and_then(|v| v.as_f64()) { + Some(n) => format!("{:.prec$}", n, prec = decimals), + None => "\u{2014}".into(), + } +} + /// Extract a bool field from JSON, returning "yes"/"no" or "-" if missing. pub fn bool_field(data: &Value, key: &str) -> &'static str { data.get(key) @@ -172,6 +230,148 @@ pub fn kv_line(key: &str, value: &str) -> Line<'static> { ]) } +/// Render a group of key-value pairs with the keys padded to a common +/// width so the values share a single left edge. Alignment is computed +/// once over the whole group rather than padded per call site, keeping +/// the convention (one aligned value column per stack) in one place. +pub fn kv_lines(pairs: &[(&str, String)]) -> Vec> { + let key_width = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + pairs + .iter() + .map(|(key, value)| { + Line::from(vec![ + Span::styled( + format!(" {key: (is_parent, is_child) map from the peers view's +/// `peers` array. Only the peers view carries the tree-role flags, so the Tree +/// and Bloom surfaces join their own peer lists against this map by node address +/// to recover each peer's role. A missing or malformed payload yields an empty +/// map (every peer then falls back to the Other group). +pub fn peer_role_map( + peers_data: Option<&Value>, +) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + if let Some(arr) = peers_data + .and_then(|d| d.get("peers")) + .and_then(|v| v.as_array()) + { + for p in arr { + if let Some(addr) = p.get("node_addr").and_then(|v| v.as_str()) { + let is_parent = p + .get("is_parent") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_child = p.get("is_child").and_then(|v| v.as_bool()).unwrap_or(false); + map.insert(addr.to_string(), (is_parent, is_child)); + } + } + } + map +} + +/// Enrich a tree/bloom peer Value with `is_parent`/`is_child` looked up in the +/// peers role map by `addr_key` (the peer's node-address field, which differs +/// per surface: `node_addr` on Tree, `peer` on Bloom). A peer not found in the +/// map is left without role flags, so `group_rank` places it under Other. +pub fn enrich_role( + mut peer: Value, + role_map: &std::collections::HashMap, + addr_key: &str, +) -> Value { + let addr = peer + .get(addr_key) + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(addr) = addr + && let Some(&(is_parent, is_child)) = role_map.get(&addr) + && let Some(obj) = peer.as_object_mut() + { + obj.insert("is_parent".into(), Value::Bool(is_parent)); + obj.insert("is_child".into(), Value::Bool(is_child)); + } + peer +} + +/// Tree-role group rank for a peer JSON object: parent first (0), then STP +/// children (1), then everything else (2). A node with no parent simply has +/// an empty group 0; a leaf with no children an empty group 1. Shared by the +/// Peers, Tree, and Bloom surfaces so they group peers identically. +pub fn group_rank(peer: &Value) -> u8 { + let is_parent = peer + .get("is_parent") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_child = peer + .get("is_child") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if is_parent { + 0 + } else if is_child { + 1 + } else { + 2 + } +} + +/// The section label for a tree-role group rank, matching the Peers tab's +/// box-drawing labels so all three surfaces read consistently. +pub fn group_label(rank: u8) -> &'static str { + match rank { + 0 => "\u{2500}\u{2500} Parent \u{2500}\u{2500}", + 1 => "\u{2500}\u{2500} STP Children \u{2500}\u{2500}", + _ => "\u{2500}\u{2500} Other \u{2500}\u{2500}", + } +} + +/// Stable-sort `peers` in place by tree-role group rank, preserving the input +/// order within each group. Callers that want a finer secondary key (e.g. LQI) +/// should sort by that key first, then call this for the group partition, or +/// supply their own comparator keyed off `group_rank`. +pub fn sort_by_group(peers: &mut [Value]) { + peers.sort_by_key(group_rank); +} + +/// Render a group of peers as `Paragraph` lines: a styled section label before +/// each non-empty group (in parent -> children -> other order), a blank +/// separator between groups, and each peer rendered by `render_peer`. Empty +/// groups are omitted (no label). `peers` is expected to already be grouped by +/// `group_rank` (callers sort first). This is the Paragraph-of-Lines analogue +/// of the Peers tab's grouped table, shared by the Tree and Bloom peer lists. +pub fn grouped_peer_lines(peers: &[Value], render_peer: F) -> Vec> +where + F: Fn(&Value) -> Line<'static>, +{ + let label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let mut lines: Vec> = Vec::new(); + let mut last_group: Option = None; + for peer in peers { + let g = group_rank(peer); + if last_group != Some(g) { + if last_group.is_some() { + lines.push(Line::from("")); + } + lines.push(Line::from(Span::styled( + format!(" {}", group_label(g)), + label_style, + ))); + last_group = Some(g); + } + lines.push(render_peer(peer)); + } + lines +} + /// Render a sequence of values as Unicode block characters. /// /// Returns an empty string for empty input. Constant series render as a diff --git a/src/bin/fipstop/ui/mmp.rs b/src/bin/fipstop/ui/mmp.rs index 42330b2..bb7bd99 100644 --- a/src/bin/fipstop/ui/mmp.rs +++ b/src/bin/fipstop/ui/mmp.rs @@ -2,9 +2,9 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; -use crate::app::{App, Tab}; +use crate::app::{App, MMP_LINK_SORT_LABELS, MMP_SESSION_SORT_LABELS, SortState, Tab}; use super::helpers; @@ -22,21 +22,129 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { let chunks = Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area); - draw_link_mmp(frame, data, chunks[0]); - draw_session_mmp(frame, data, chunks[1]); + let focused = app.focused_pane(); + draw_link_mmp( + frame, + data, + app.mmp_link_sort, + app.pane_scroll(0), + focused == 0, + chunks[0], + ); + draw_session_mmp( + frame, + data, + app.mmp_session_sort, + app.pane_scroll(1), + focused == 1, + chunks[1], + ); } -fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let peers = data +/// A numeric sort key for a metric value from a layer object, with absent +/// values sorting last under an ascending sort by mapping them to +infinity. +fn metric_key(layer: Option<&serde_json::Value>, prefer: &str, fallback: Option<&str>) -> f64 { + layer + .and_then(|l| l.get(prefer).or_else(|| fallback.and_then(|f| l.get(f)))) + .and_then(|v| v.as_f64()) + .unwrap_or(f64::INFINITY) +} + +/// Render the sortable-column header line: each column label, with the active +/// sort column highlighted and carrying a direction arrow. +fn sort_header(labels: &[&str], sort: SortState) -> Line<'static> { + let active = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let idle = Style::default().fg(Color::DarkGray); + // Solid triangles for the sort direction, distinct from the line-arrow + // glyphs the MMP trend columns use, so the two never collide visually or + // in tests. + let arrow = if sort.descending { + "\u{25bc}" + } else { + "\u{25b2}" + }; + let mut spans: Vec> = vec![Span::styled(" sort: ", idle)]; + for (i, label) in labels.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", idle)); + } + if i == sort.col { + spans.push(Span::styled(format!("{label}{arrow}"), active)); + } else { + spans.push(Span::styled(label.to_string(), idle)); + } + } + Line::from(spans) +} + +/// Apply the sort state to `peers` in place. Column 0 sorts by display name; +/// the remaining columns sort by the corresponding metric from `layer_key` +/// (the `link_layer` / `session_layer` object). Descending reverses the order. +fn sort_peers(peers: &mut [serde_json::Value], sort: SortState, layer_key: &str) { + peers.sort_by(|a, b| { + let ord = if sort.col == 0 { + let na = a.get("display_name").and_then(|v| v.as_str()).unwrap_or(""); + let nb = b.get("display_name").and_then(|v| v.as_str()).unwrap_or(""); + na.cmp(nb) + } else { + let la = a.get(layer_key); + let lb = b.get(layer_key); + let (ka, kb) = metric_pair(la, lb, layer_key, sort.col); + ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal) + }; + if sort.descending { ord.reverse() } else { ord } + }); +} + +/// Compute the numeric sort keys for two peers on the given column, dispatching +/// to the correct metric for the Link vs Session layer. +fn metric_pair( + la: Option<&serde_json::Value>, + lb: Option<&serde_json::Value>, + layer_key: &str, + col: usize, +) -> (f64, f64) { + let (prefer, fallback): (&str, Option<&str>) = if layer_key == "link_layer" { + match col { + 1 => ("srtt_ms", None), + 2 => ("smoothed_loss", Some("loss_rate")), + 3 => ("smoothed_etx", Some("etx")), + 4 => ("lqi", None), + _ => ("goodput_bps", None), + } + } else { + match col { + 1 => ("srtt_ms", None), + 2 => ("smoothed_loss", Some("loss_rate")), + 3 => ("smoothed_etx", Some("etx")), + 4 => ("sqi", None), + _ => ("path_mtu", None), + } + }; + ( + metric_key(la, prefer, fallback), + metric_key(lb, prefer, fallback), + ) +} + +fn draw_link_mmp( + frame: &mut Frame, + data: &serde_json::Value, + sort: SortState, + scroll: u16, + focused: bool, + area: Rect, +) { + let mut peers = data .get("peers") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let count = peers.len(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" Link MMP ({count} peers) ")); + let block = helpers::pane_block(&format!(" Link MMP ({count} peers) "), focused); let inner = block.inner(area); frame.render_widget(block, area); @@ -46,7 +154,9 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { return; } - let mut lines: Vec = Vec::new(); + sort_peers(&mut peers, sort, "link_layer"); + + let mut lines: Vec = vec![sort_header(MMP_LINK_SORT_LABELS, sort)]; for peer in &peers { let name = helpers::str_field(peer, "display_name"); let ll = peer.get("link_layer"); @@ -77,73 +187,59 @@ fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { .map(helpers::format_throughput) .unwrap_or_else(|| "-".into()); - // Line 1: primary metrics + // Trend arrows sit inline, immediately after the value they + // describe: rtt -> srtt, loss -> loss, goodput -> gp. etx and lqi + // carry no trend; jitter has no numeric column and is dropped. Each + // tracked value reserves a fixed 1-char arrow slot (a space when + // stable) so the columns stay aligned regardless of trend state. + let label = Style::default().fg(Color::DarkGray); + let srtt_arrow = trend_arrow(ll, "rtt_trend", true); + let loss_arrow = trend_arrow(ll, "loss_trend", true); + let gp_arrow = trend_arrow(ll, "goodput_trend", false); + lines.push(Line::from(vec![ Span::styled( - format!(" {name:<16}"), + format!(" {} ", helpers::truncate_name(name, 16)), Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), - Span::styled("srtt: ", Style::default().fg(Color::DarkGray)), + Span::styled("srtt: ", label), Span::raw(format!("{srtt:<10}")), - Span::styled("loss: ", Style::default().fg(Color::DarkGray)), + srtt_arrow, + Span::styled(" loss: ", label), Span::raw(format!("{loss:<8}")), - Span::styled("etx: ", Style::default().fg(Color::DarkGray)), + loss_arrow, + Span::styled(" etx: ", label), Span::raw(format!("{etx:<6}")), - Span::styled("lqi: ", Style::default().fg(Color::DarkGray)), + Span::styled("lqi: ", label), Span::raw(format!("{lqi:<8}")), - Span::styled("gp: ", Style::default().fg(Color::DarkGray)), + Span::styled("gp: ", label), Span::raw(goodput), + gp_arrow, ])); - - // Line 2: trends - if let Some(ll_val) = ll { - let mut trend_spans: Vec = vec![Span::raw(" ")]; - let mut has_trends = false; - - for (label, key, bad_rising) in [ - ("rtt", "rtt_trend", true), - ("loss", "loss_trend", true), - ("goodput", "goodput_trend", false), - ("jitter", "jitter_trend", true), - ] { - if let Some(trend) = ll_val.get(key).and_then(|v| v.as_str()) { - if has_trends { - trend_spans.push(Span::raw(" ")); - } - trend_spans.push(Span::styled( - format!("{label}: "), - Style::default().fg(Color::DarkGray), - )); - trend_spans.push(Span::styled( - trend.to_string(), - Style::default().fg(trend_color(trend, bad_rising)), - )); - has_trends = true; - } - } - - if has_trends { - lines.push(Line::from(trend_spans)); - } - } } - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } -fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let sessions = data +fn draw_session_mmp( + frame: &mut Frame, + data: &serde_json::Value, + sort: SortState, + scroll: u16, + focused: bool, + area: Rect, +) { + let mut sessions = data .get("sessions") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let count = sessions.len(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" Session MMP ({count} sessions) ")); + let block = helpers::pane_block(&format!(" Session MMP ({count} sessions) "), focused); let inner = block.inner(area); frame.render_widget(block, area); @@ -153,60 +249,92 @@ fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) { return; } - let lines: Vec = sessions - .iter() - .map(|s| { - let name = helpers::str_field(s, "display_name"); - let sl = s.get("session_layer"); + sort_peers(&mut sessions, sort, "session_layer"); - let srtt = sl - .and_then(|l| l.get("srtt_ms")) - .and_then(|v| v.as_f64()) - .map(|v| format!("{:.1}ms", v)) - .unwrap_or_else(|| "-".into()); - let loss = sl - .and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate"))) - .and_then(|v| v.as_f64()) - .map(|v| format!("{:.4}", v)) - .unwrap_or_else(|| "-".into()); - let etx = sl - .and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx"))) - .and_then(|v| v.as_f64()) - .map(|v| format!("{:.2}", v)) - .unwrap_or_else(|| "-".into()); - let sqi = sl - .and_then(|l| l.get("sqi")) - .and_then(|v| v.as_f64()) - .map(|v| format!("{:.2}", v)) - .unwrap_or_else(|| "-".into()); - let mtu = sl - .and_then(|l| l.get("path_mtu")) - .and_then(|v| v.as_u64()) - .map(|v| v.to_string()) - .unwrap_or_else(|| "-".into()); + let mut lines: Vec = vec![sort_header(MMP_SESSION_SORT_LABELS, sort)]; + lines.extend(sessions.iter().map(|s| { + let name = helpers::str_field(s, "display_name"); + let sl = s.get("session_layer"); - Line::from(vec![ - Span::styled( - format!(" {name:<16}"), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - Span::styled("srtt: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{srtt:<10}")), - Span::styled("loss: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{loss:<8}")), - Span::styled("etx: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{etx:<6}")), - Span::styled("sqi: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{sqi:<8}")), - Span::styled("mtu: ", Style::default().fg(Color::DarkGray)), - Span::raw(mtu), - ]) - }) - .collect(); + let srtt = sl + .and_then(|l| l.get("srtt_ms")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.1}ms", v)) + .unwrap_or_else(|| "-".into()); + let loss = sl + .and_then(|l| l.get("smoothed_loss").or_else(|| l.get("loss_rate"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.4}", v)) + .unwrap_or_else(|| "-".into()); + let etx = sl + .and_then(|l| l.get("smoothed_etx").or_else(|| l.get("etx"))) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let sqi = sl + .and_then(|l| l.get("sqi")) + .and_then(|v| v.as_f64()) + .map(|v| format!("{:.2}", v)) + .unwrap_or_else(|| "-".into()); + let mtu = sl + .and_then(|l| l.get("path_mtu")) + .and_then(|v| v.as_u64()) + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".into()); - frame.render_widget(Paragraph::new(lines), inner); + // Inline trend arrows mirror the Link MMP pane: srtt -> rtt_trend, + // loss -> loss_trend, etx -> etx_trend, each with a fixed 1-char + // slot (blank when stable) so the value columns stay aligned. + let label = Style::default().fg(Color::DarkGray); + let srtt_arrow = trend_arrow(sl, "rtt_trend", true); + let loss_arrow = trend_arrow(sl, "loss_trend", true); + let etx_arrow = trend_arrow(sl, "etx_trend", true); + + Line::from(vec![ + Span::styled( + format!(" {} ", helpers::truncate_name(name, 16)), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled("srtt: ", label), + Span::raw(format!("{srtt:<10}")), + srtt_arrow, + Span::styled(" loss: ", label), + Span::raw(format!("{loss:<8}")), + loss_arrow, + Span::styled(" etx: ", label), + Span::raw(format!("{etx:<6}")), + etx_arrow, + Span::styled(" sqi: ", label), + Span::raw(format!("{sqi:<8}")), + Span::styled("mtu: ", label), + Span::raw(mtu), + ]) + })); + + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); +} + +/// Build the inline trend arrow span for a metric: a colored `↑`/`↓` for a +/// rising/falling trend, or a single blank space when stable or absent. +/// The slot is always one cell wide so value columns stay aligned. `layer` +/// is the `link_layer` / `session_layer` object carrying the `*_trend` key. +fn trend_arrow(layer: Option<&serde_json::Value>, key: &str, bad_rising: bool) -> Span<'static> { + let trend = layer.and_then(|l| l.get(key)).and_then(|v| v.as_str()); + match trend { + Some("rising") => Span::styled( + "\u{2191}", + Style::default().fg(trend_color("rising", bad_rising)), + ), + Some("falling") => Span::styled( + "\u{2193}", + Style::default().fg(trend_color("falling", bad_rising)), + ), + // Stable or no trend: a blank reserved slot. + _ => Span::raw(" "), + } } /// Color a trend value based on whether "rising" is bad or good for this metric. diff --git a/src/bin/fipstop/ui/mod.rs b/src/bin/fipstop/ui/mod.rs index 31c6763..1582a42 100644 --- a/src/bin/fipstop/ui/mod.rs +++ b/src/bin/fipstop/ui/mod.rs @@ -2,12 +2,17 @@ mod bloom; mod dashboard; mod gateway; mod graphs; +pub(crate) mod help; mod helpers; pub(crate) mod listening; mod mmp; mod peers; mod routing; mod sessions; +#[cfg(test)] +mod snapshots; +#[cfg(test)] +mod testkit; mod transports; mod tree; @@ -30,6 +35,15 @@ pub fn draw(frame: &mut Frame, app: &mut App) { draw_tab_bar(frame, app, chunks[0]); draw_content(frame, app, chunks[1]); draw_status_bar(frame, app, chunks[2]); + + // The `?` help overlay draws over everything when toggled on. + if app.show_help { + help::draw_overlay(frame, app, chunks[1]); + } + // The Del-disconnect confirmation modal draws over the content. + if app.confirm_disconnect.is_some() { + help::draw_disconnect_modal(frame, app, chunks[1]); + } } fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) { @@ -101,9 +115,17 @@ fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { elapsed.as_secs_f64() )); - let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray)); + // Context-aware hints fill the remaining width after the connection and + // timing spans, sourced from the shared keybinding registry so they can't + // drift from the `?` overlay. + let fixed_w = conn.width() + timing.width(); + let budget = (area.width as usize).saturating_sub(fixed_w); + let mode = help::UiMode::of(app); + let hint_spans = help::footer_hint_spans(app.active_tab, mode, budget); - let line = Line::from(vec![conn, timing, help]); + let mut spans = vec![conn, timing]; + spans.extend(hint_spans); + let line = Line::from(spans); let bar = Paragraph::new(line); frame.render_widget(bar, area); } diff --git a/src/bin/fipstop/ui/peers.rs b/src/bin/fipstop/ui/peers.rs index 74bad72..c6b2886 100644 --- a/src/bin/fipstop/ui/peers.rs +++ b/src/bin/fipstop/ui/peers.rs @@ -4,6 +4,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{ Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + TableState, }; use crate::app::{App, Tab}; @@ -26,7 +27,9 @@ pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) { } } -/// Get peers sorted by LQI ascending (best first). Peers without LQI sort last. +/// Get peers grouped by role (parent -> STP children -> other), and within +/// each group sorted by LQI ascending (best first). Peers without LQI sort +/// last within their group. fn get_peers_sorted(app: &App) -> Vec { let mut peers = app .data @@ -37,20 +40,25 @@ fn get_peers_sorted(app: &App) -> Vec { .unwrap_or_default(); peers.sort_by(|a, b| { - let lqi_a = a - .get("mmp") - .and_then(|m| m.get("lqi")) - .and_then(|v| v.as_f64()); - let lqi_b = b - .get("mmp") - .and_then(|m| m.get("lqi")) - .and_then(|v| v.as_f64()); - match (lqi_a, lqi_b) { - (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } + // Primary key: role group. Secondary key: LQI ascending. + helpers::group_rank(a) + .cmp(&helpers::group_rank(b)) + .then_with(|| { + let lqi_a = a + .get("mmp") + .and_then(|m| m.get("lqi")) + .and_then(|v| v.as_f64()); + let lqi_b = b + .get("mmp") + .and_then(|m| m.get("lqi")) + .and_then(|v| v.as_f64()); + match (lqi_a, lqi_b) { + (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }) }); peers @@ -71,6 +79,7 @@ fn draw_table( Cell::from("SRTT"), Cell::from("Loss"), Cell::from("LQI"), + Cell::from("EffD"), Cell::from("Goodput"), Cell::from("Pkts Tx"), Cell::from("Pkts Rx"), @@ -81,90 +90,114 @@ fn draw_table( .add_modifier(Modifier::BOLD), ); - let rows: Vec = peers - .iter() - .map(|peer| { - let name = helpers::str_field(peer, "display_name"); - let npub = helpers::str_field(peer, "npub"); - let is_parent = peer - .get("is_parent") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let is_child = peer - .get("is_child") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + // 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 + // translated to the display row to highlight, and the cursor only ever + // lands on peer rows. + let mut rows: Vec = Vec::new(); + let mut peer_display_idx: Vec = Vec::with_capacity(peers.len()); + let mut last_group: Option = None; + let group_label_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + for peer in peers.iter() { + let g = helpers::group_rank(peer); + if last_group != Some(g) { + if last_group.is_some() { + rows.push(Row::new(vec![Cell::from("")])); + } + rows.push(Row::new(vec![Cell::from(helpers::group_label(g))]).style(group_label_style)); + last_group = Some(g); + } - // Transport: "type addr" (e.g., "udp 1.2.3.4:2121") - let transport = { - let t_type = peer - .get("transport_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let t_addr = peer - .get("transport_addr") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if t_type.is_empty() && t_addr.is_empty() { - "-".to_string() - } else if t_type.is_empty() { - t_addr.to_string() - } else if t_addr.is_empty() { - t_type.to_string() - } else { - format!("{t_type}/{t_addr}") - } - }; + let name = helpers::str_field(peer, "display_name"); + let npub = helpers::str_field(peer, "npub"); + let is_parent = peer + .get("is_parent") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let is_child = peer + .get("is_child") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - let dir = peer - .get("direction") + // Transport: "type addr" (e.g., "udp 1.2.3.4:2121") + let transport = { + let t_type = peer + .get("transport_type") .and_then(|v| v.as_str()) - .map(|d| match d { - "inbound" => "in", - "outbound" => "out", - other => other, - }) - .unwrap_or("-"); - let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1); - let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3); - let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2); - let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps"); - let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent"); - let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv"); - - // Tree role colorization - let row_style = if is_parent { - Style::default().fg(Color::Magenta) - } else if is_child { - Style::default().fg(Color::Cyan) + .unwrap_or(""); + let t_addr = peer + .get("transport_addr") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if t_type.is_empty() && t_addr.is_empty() { + "-".to_string() + } else if t_type.is_empty() { + t_addr.to_string() + } else if t_addr.is_empty() { + t_type.to_string() } else { - Style::default() - }; + format!("{t_type}/{t_addr}") + } + }; + let dir = peer + .get("direction") + .and_then(|v| v.as_str()) + .map(|d| match d { + "inbound" => "in", + "outbound" => "out", + other => other, + }) + .unwrap_or("-"); + let srtt = helpers::nested_f64(peer, "mmp", "srtt_ms", 1); + let loss = helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 3); + let lqi = helpers::nested_f64(peer, "mmp", "lqi", 2); + let eff_depth = helpers::opt_f64_field(peer, "effective_depth", 2); + let goodput = helpers::nested_throughput(peer, "mmp", "goodput_bps"); + let pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent"); + let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv"); + + // Tree role colorization + let row_style = if is_parent { + Style::default().fg(Color::Magenta) + } else if is_child { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + + peer_display_idx.push(rows.len()); + rows.push( Row::new(vec![ Cell::from(name.to_string()), - Cell::from(npub.to_string()), + Cell::from(helpers::truncate_hex(npub, 18)), Cell::from(transport), Cell::from(dir.to_string()), Cell::from(srtt), Cell::from(loss), Cell::from(lqi), + Cell::from(eff_depth), Cell::from(goodput), Cell::from(pkts_tx), Cell::from(pkts_rx), ]) - .style(row_style) - }) - .collect(); + .style(row_style), + ); + } let widths = [ - Constraint::Min(12), // Name - Constraint::Length(67), // Npub (full bech32) + Constraint::Min(20), // Name (wide enough for the group labels) + Constraint::Length(20), // Npub (truncated; full form in the detail view) Constraint::Min(20), // Transport Constraint::Length(4), // Dir Constraint::Length(8), // SRTT Constraint::Length(7), // Loss Constraint::Length(6), // LQI + Constraint::Length(6), // EffD Constraint::Length(10), // Goodput Constraint::Length(9), // Pkts Tx Constraint::Length(9), // Pkts Rx @@ -184,12 +217,21 @@ fn draw_table( ) .highlight_symbol("▶ "); - let state = app.table_states.entry(Tab::Peers).or_default(); - frame.render_stateful_widget(table, area, state); + // The stored selection is the *peer* index (used by detail + navigation); + // translate it to the display row so the highlight lands on the peer's row + // and the cursor never sits on a label or blank separator. + let peer_sel = app.table_states.get(&Tab::Peers).and_then(|s| s.selected()); + let mut display_state = TableState::default(); + if let Some(p) = peer_sel + && let Some(&disp) = peer_display_idx.get(p) + { + display_state.select(Some(disp)); + } + frame.render_stateful_widget(table, area, &mut display_state); - // Scrollbar + // Scrollbar tracks the peer position within the peer count. if row_count > 0 { - let selected = state.selected().unwrap_or(0); + let selected = peer_sel.unwrap_or(0); let mut scrollbar_state = ScrollbarState::new(row_count).position(selected); frame.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::VerticalRight) @@ -329,6 +371,10 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Va if let Some(depth) = peer.get("tree_depth").and_then(|v| v.as_u64()) { lines.push(helpers::kv_line("Tree Depth", &depth.to_string())); } + lines.push(helpers::kv_line( + "Effective Depth", + &helpers::opt_f64_field(peer, "effective_depth", 2), + )); lines.extend([ helpers::kv_line("Bloom Filter", if has_bloom { "yes" } else { "no" }), helpers::kv_line("Filter Seq", &helpers::u64_field(peer, "filter_sequence")), diff --git a/src/bin/fipstop/ui/routing.rs b/src/bin/fipstop/ui/routing.rs index 9547203..2ab57de 100644 --- a/src/bin/fipstop/ui/routing.rs +++ b/src/bin/fipstop/ui/routing.rs @@ -2,7 +2,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; use crate::app::{App, Tab}; @@ -26,45 +26,50 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { ]) .split(area); - draw_routing_state(frame, data, chunks[0]); - draw_coord_cache(frame, app, chunks[1]); - draw_routing_stats(frame, data, chunks[2]); + let focused = app.focused_pane(); + draw_routing_state(frame, data, app.pane_scroll(0), focused == 0, chunks[0]); + draw_coord_cache(frame, app, app.pane_scroll(1), focused == 1, chunks[1]); + draw_routing_stats(frame, data, app.pane_scroll(2), focused == 2, chunks[2]); } -fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let lines = vec![ - helpers::kv_line( +fn draw_routing_state( + frame: &mut Frame, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { + let lines = helpers::kv_lines(&[ + ( "Coord Cache", - &helpers::u64_field(data, "coord_cache_entries"), + helpers::u64_field(data, "coord_cache_entries"), ), - helpers::kv_line( + ( "Identity Cache", - &helpers::u64_field(data, "identity_cache_entries"), + helpers::u64_field(data, "identity_cache_entries"), ), - helpers::kv_line( + ( "Pending Lookups", - &data - .get("pending_lookups") + data.get("pending_lookups") .and_then(|v| v.as_array()) .map(|a| a.len().to_string()) .unwrap_or_else(|| "0".into()), ), - helpers::kv_line( + ( "Recent Requests", - &helpers::u64_field(data, "recent_requests"), + helpers::u64_field(data, "recent_requests"), ), - ]; + ]); - let block = Block::default() - .borders(Borders::ALL) - .title(" Routing State "); + let block = helpers::pane_block(" Routing State ", focused); let inner = block.inner(area); frame.render_widget(block, area); - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } /// Format a forwarding counter as "N pkts (formatted_bytes)". -fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str) -> Line<'static> { +fn fwd_value(data: &serde_json::Value, pkt_key: &str, byte_key: &str) -> String { let pkts = data .get("forwarding") .and_then(|f| f.get(pkt_key)) @@ -75,187 +80,145 @@ fn fwd_line(data: &serde_json::Value, label: &str, pkt_key: &str, byte_key: &str .and_then(|f| f.get(byte_key)) .and_then(|v| v.as_u64()) .unwrap_or(0); - helpers::kv_line( - label, - &format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)), - ) + format!("{} pkts ({})", pkts, helpers::format_bytes(bytes)) } -fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Routing Statistics "); +/// Build a section: a styled header line followed by the kv pairs rendered +/// through the group helper so the section's values share a left edge. +fn section(title: &str, pairs: &[(&str, String)]) -> Vec> { + let mut out = vec![helpers::section_header(title)]; + out.extend(helpers::kv_lines(pairs)); + out +} + +fn draw_routing_stats( + frame: &mut Frame, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { + let block = helpers::pane_block(" Routing Statistics ", focused); let inner = block.inner(area); frame.render_widget(block, area); let cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(inner); - // Left column: Forwarding + Discovery - let mut left = vec![ - helpers::section_header("Forwarding"), - fwd_line(data, "Received", "received_packets", "received_bytes"), - fwd_line(data, "Delivered", "delivered_packets", "delivered_bytes"), - fwd_line(data, "Forwarded", "forwarded_packets", "forwarded_bytes"), - fwd_line(data, "Originated", "originated_packets", "originated_bytes"), - fwd_line( - data, - "Decode Error", - "decode_error_packets", - "decode_error_bytes", - ), - fwd_line( - data, - "TTL Exhausted", - "ttl_exhausted_packets", - "ttl_exhausted_bytes", - ), - fwd_line( - data, - "No Route", - "drop_no_route_packets", - "drop_no_route_bytes", - ), - fwd_line( - data, - "MTU Exceeded", - "drop_mtu_exceeded_packets", - "drop_mtu_exceeded_bytes", - ), - fwd_line( - data, - "Send Error", - "drop_send_error_packets", - "drop_send_error_bytes", - ), - Line::from(""), - helpers::section_header("Discovery Requests"), - helpers::kv_line( - "Received", - &helpers::nested_u64(data, "discovery", "req_received"), - ), - helpers::kv_line( - "Forwarded", - &helpers::nested_u64(data, "discovery", "req_forwarded"), - ), - helpers::kv_line( - "Initiated", - &helpers::nested_u64(data, "discovery", "req_initiated"), - ), - helpers::kv_line( - "Deduplicated", - &helpers::nested_u64(data, "discovery", "req_deduplicated"), - ), - helpers::kv_line( - "Target Is Us", - &helpers::nested_u64(data, "discovery", "req_target_is_us"), - ), - helpers::kv_line( - "Duplicate", - &helpers::nested_u64(data, "discovery", "req_duplicate"), - ), - helpers::kv_line( - "Bloom Miss", - &helpers::nested_u64(data, "discovery", "req_bloom_miss"), - ), - helpers::kv_line( - "Backoff Suppressed", - &helpers::nested_u64(data, "discovery", "req_backoff_suppressed"), - ), - helpers::kv_line( - "Fwd Rate Limited", - &helpers::nested_u64(data, "discovery", "req_forward_rate_limited"), - ), - helpers::kv_line( - "TTL Exhausted", - &helpers::nested_u64(data, "discovery", "req_ttl_exhausted"), - ), - helpers::kv_line( - "Decode Error", - &helpers::nested_u64(data, "discovery", "req_decode_error"), - ), - Line::from(""), - helpers::section_header("Discovery Responses"), - helpers::kv_line( - "Received", - &helpers::nested_u64(data, "discovery", "resp_received"), - ), - helpers::kv_line( - "Accepted", - &helpers::nested_u64(data, "discovery", "resp_accepted"), - ), - helpers::kv_line( - "Forwarded", - &helpers::nested_u64(data, "discovery", "resp_forwarded"), - ), - helpers::kv_line( - "Timed Out", - &helpers::nested_u64(data, "discovery", "resp_timed_out"), - ), - helpers::kv_line( - "Identity Miss", - &helpers::nested_u64(data, "discovery", "resp_identity_miss"), - ), - helpers::kv_line( - "Proof Failed", - &helpers::nested_u64(data, "discovery", "resp_proof_failed"), - ), - helpers::kv_line( - "Decode Error", - &helpers::nested_u64(data, "discovery", "resp_decode_error"), - ), - ]; + // Shorthand for a nested counter value (e.g. discovery.req_received). + let disc = |key: &str| helpers::nested_u64(data, "discovery", key); + let err = |key: &str| helpers::nested_u64(data, "error_signals", key); + let cong = |key: &str| helpers::nested_u64(data, "congestion", key); + + // Left column: Forwarding + Discovery. Each section's values share a left + // edge via the kv_lines group helper. + let mut left = section( + "Forwarding", + &[ + ( + "Received", + fwd_value(data, "received_packets", "received_bytes"), + ), + ( + "Delivered", + fwd_value(data, "delivered_packets", "delivered_bytes"), + ), + ( + "Forwarded", + fwd_value(data, "forwarded_packets", "forwarded_bytes"), + ), + ( + "Originated", + fwd_value(data, "originated_packets", "originated_bytes"), + ), + ( + "Decode Error", + fwd_value(data, "decode_error_packets", "decode_error_bytes"), + ), + ( + "TTL Exhausted", + fwd_value(data, "ttl_exhausted_packets", "ttl_exhausted_bytes"), + ), + ( + "No Route", + fwd_value(data, "drop_no_route_packets", "drop_no_route_bytes"), + ), + ( + "MTU Exceeded", + fwd_value(data, "drop_mtu_exceeded_packets", "drop_mtu_exceeded_bytes"), + ), + ( + "Send Error", + fwd_value(data, "drop_send_error_packets", "drop_send_error_bytes"), + ), + ], + ); + left.push(Line::from("")); + left.extend(section( + "Discovery Requests", + &[ + ("Received", disc("req_received")), + ("Forwarded", disc("req_forwarded")), + ("Initiated", disc("req_initiated")), + ("Deduplicated", disc("req_deduplicated")), + ("Target Is Us", disc("req_target_is_us")), + ("Duplicate", disc("req_duplicate")), + ("Bloom Miss", disc("req_bloom_miss")), + ("Backoff Suppressed", disc("req_backoff_suppressed")), + ("Fwd Rate Limited", disc("req_forward_rate_limited")), + ("TTL Exhausted", disc("req_ttl_exhausted")), + ("Decode Error", disc("req_decode_error")), + ], + )); + left.push(Line::from("")); + left.extend(section( + "Discovery Responses", + &[ + ("Received", disc("resp_received")), + ("Accepted", disc("resp_accepted")), + ("Forwarded", disc("resp_forwarded")), + ("Timed Out", disc("resp_timed_out")), + ("Identity Miss", disc("resp_identity_miss")), + ("Proof Failed", disc("resp_proof_failed")), + ("Decode Error", disc("resp_decode_error")), + ], + )); // Right column: Error Signals + Congestion - let mut right = vec![ - helpers::section_header("Error Signals"), - helpers::kv_line( - "Coords Required", - &helpers::nested_u64(data, "error_signals", "coords_required"), - ), - helpers::kv_line( - "Path Broken", - &helpers::nested_u64(data, "error_signals", "path_broken"), - ), - helpers::kv_line( - "MTU Exceeded", - &helpers::nested_u64(data, "error_signals", "mtu_exceeded"), - ), - Line::from(""), - helpers::section_header("Congestion"), - helpers::kv_line( - "CE Forwarded", - &helpers::nested_u64(data, "congestion", "ce_forwarded"), - ), - helpers::kv_line( - "CE Received", - &helpers::nested_u64(data, "congestion", "ce_received"), - ), - helpers::kv_line( - "Congestion Detected", - &helpers::nested_u64(data, "congestion", "congestion_detected"), - ), - helpers::kv_line( - "Kernel Drops", - &helpers::nested_u64(data, "congestion", "kernel_drop_events"), - ), - ]; + let mut right = section( + "Error Signals", + &[ + ("Coords Required", err("coords_required")), + ("Path Broken", err("path_broken")), + ("MTU Exceeded", err("mtu_exceeded")), + ], + ); + right.push(Line::from("")); + right.extend(section( + "Congestion", + &[ + ("CE Forwarded", cong("ce_forwarded")), + ("CE Received", cong("ce_received")), + ("Congestion Detected", cong("congestion_detected")), + ("Kernel Drops", cong("kernel_drop_events")), + ], + )); - let max_lines = cols[0].height as usize; - left.truncate(max_lines); - right.truncate(max_lines); + // Both columns scroll together under the focused-pane offset, clamped to + // the taller column so neither over-scrolls past its content. + let visible = cols[0].height as usize; + let content = left.len().max(right.len()); + let scroll = helpers::clamp_scroll(scroll, content, visible); - frame.render_widget(Paragraph::new(left), cols[0]); - frame.render_widget(Paragraph::new(right), cols[1]); + frame.render_widget(Paragraph::new(left).scroll((scroll, 0)), cols[0]); + frame.render_widget(Paragraph::new(right).scroll((scroll, 0)), cols[1]); } -fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) { +fn draw_coord_cache(frame: &mut Frame, app: &App, scroll: u16, focused: bool, area: Rect) { let data = match app.data.get(&Tab::Cache) { Some(d) => d, None => { - let block = Block::default() - .borders(Borders::ALL) - .title(" Coordinate Cache "); + let block = helpers::pane_block(" Coordinate Cache ", focused); let inner = block.inner(area); frame.render_widget(block, area); let msg = @@ -284,18 +247,17 @@ fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) { .map(helpers::format_duration_ms) .unwrap_or_else(|| "-".into()); - let lines = vec![ - helpers::kv_line("Entries", &format!("{entries} / {max_entries}")), - helpers::kv_line("Fill Ratio", &fill_pct), - helpers::kv_line("Default TTL", &ttl), - helpers::kv_line("Expired", &expired), - helpers::kv_line("Avg Age", &avg_age), - ]; + let lines = helpers::kv_lines(&[ + ("Entries", format!("{entries} / {max_entries}")), + ("Fill Ratio", fill_pct), + ("Default TTL", ttl), + ("Expired", expired), + ("Avg Age", avg_age), + ]); - let block = Block::default() - .borders(Borders::ALL) - .title(" Coordinate Cache "); + let block = helpers::pane_block(" Coordinate Cache ", focused); let inner = block.inner(area); frame.render_widget(block, area); - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } diff --git a/src/bin/fipstop/ui/snapshots.rs b/src/bin/fipstop/ui/snapshots.rs new file mode 100644 index 0000000..7515311 --- /dev/null +++ b/src/bin/fipstop/ui/snapshots.rs @@ -0,0 +1,1289 @@ +//! Render snapshots for the `ui::draw_*` functions the TUI overhaul touches. +//! +//! Each test renders a draw function into a fixed-size `TestBackend` from a +//! canned `show_*` payload (the inner `data` object the control client hands +//! `App`) and asserts on the resulting text grid plus, for colour-bearing +//! items, per-cell style. These double as regression tests; when a render +//! item lands, its snapshot asserts the new structure here. + +#![cfg(test)] + +use serde_json::json; + +use super::testkit::{self, app_with}; +use crate::app::Tab; + +/// Baseline harness check: the Bloom tab renders its three pane titles and +/// the peer-filter rows from canned data. +#[test] +fn bloom_panes_render() { + let data = json!({ + "is_leaf_only": false, + "leaf_dependent_count": 3, + "own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", + "sequence": 42, + "peer_filters": [ + { + "display_name": "alice", + "filter_sequence": 50677047u64, + "has_filter": true, + "fill_ratio": 0.139, + "estimated_count": 1182.0 + }, + { + "display_name": "bob", + "filter_sequence": 7u64, + "has_filter": false + } + ], + "stats": { + "received": 1, "accepted": 1, "decode_error": 0, "invalid": 0, + "non_v1": 0, "unknown_peer": 0, "stale": 0, + "sent": 5, "debounce_suppressed": 0, "send_failed": 0 + } + }); + let app = app_with(Tab::Bloom, data); + let buf = testkit::render(80, 30, |frame, area| { + super::bloom::draw(frame, &app, area); + }); + + assert!(testkit::contains_row(&buf, "Bloom Filter State")); + assert!(testkit::contains_row(&buf, "Bloom Announce Stats")); + assert!(testkit::contains_row(&buf, "Peer Filters (2)")); + assert!(testkit::contains_row(&buf, "alice")); + assert!(testkit::contains_row(&buf, "bob")); +} + +/// Peer Filters: the right-justified seq field keeps a separator before +/// `fill:` even for a long sequence, and the `fill:`/`est:` labels start at +/// the same column across rows so the numeric columns align. +#[test] +fn bloom_peer_filters_alignment() { + let data = json!({ + "is_leaf_only": false, + "leaf_dependent_count": 0, + "own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", + "sequence": 0, + "peer_filters": [ + { + "display_name": "alice", + "filter_sequence": 50677047u64, + "has_filter": true, + "fill_ratio": 0.139, + "estimated_count": 1182.0 + }, + { + "display_name": "bob", + "filter_sequence": 7u64, + "has_filter": true, + "fill_ratio": 0.5, + "estimated_count": 12.0 + } + ], + "stats": { + "received": 0, "accepted": 0, "decode_error": 0, "invalid": 0, + "non_v1": 0, "unknown_peer": 0, "stale": 0, + "sent": 0, "debounce_suppressed": 0, "send_failed": 0 + } + }); + let app = app_with(Tab::Bloom, data); + let buf = testkit::render(90, 30, |frame, area| { + super::bloom::draw(frame, &app, area); + }); + + // Long seq never butts against the fill label. + assert!(!testkit::contains_row(&buf, "50677047fill")); + // The fill: label starts at the same column on both peer rows (the + // rows are ASCII so byte offset equals cell column here). + let alice_row = testkit::find(&buf, "alice").map(|(_, y)| y).unwrap(); + let bob_row = testkit::find(&buf, "bob").map(|(_, y)| y).unwrap(); + let cols: Vec = testkit::lines(&buf) + .iter() + .enumerate() + .filter_map(|(y, r)| { + if y as u16 == alice_row || y as u16 == bob_row { + r.find("fill:") + } else { + None + } + }) + .collect(); + assert_eq!(cols.len(), 2, "both peer rows show a fill: label"); + assert_eq!(cols[0], cols[1], "fill: columns align across rows"); +} + +/// Peers group-sort: the comparator orders parent before STP children +/// before other peers, regardless of LQI, while preserving within-group +/// LQI order. +#[test] +fn peers_group_sort_order() { + // Deliberately list out-of-order: an "other" peer with the best LQI + // first, then the parent, then a child. The group sort must reorder. + let data = json!({ + "peers": [ + { "display_name": "zeta_other", "npub": "npub1other", "is_parent": false, "is_child": false, "mmp": { "lqi": 1.0 } }, + { "display_name": "papa_parent", "npub": "npub1parent", "is_parent": true, "is_child": false, "mmp": { "lqi": 9.0 } }, + { "display_name": "kidd_child", "npub": "npub1child", "is_parent": false, "is_child": true, "mmp": { "lqi": 5.0 } } + ] + }); + let mut app = app_with(Tab::Peers, data); + let buf = testkit::render(120, 20, |frame, area| { + super::peers::draw(frame, &mut app, area); + }); + + let y_parent = testkit::find(&buf, "papa_parent").map(|(_, y)| y).unwrap(); + let y_child = testkit::find(&buf, "kidd_child").map(|(_, y)| y).unwrap(); + let y_other = testkit::find(&buf, "zeta_other").map(|(_, y)| y).unwrap(); + assert!(y_parent < y_child, "parent renders above child"); + assert!(y_child < y_other, "child renders above other"); +} + +/// Peers grouped view: styled group labels precede each non-empty group, the +/// groups are ordered parent -> children -> other, and a selected peer's row +/// is highlighted (the cursor never sits on a label or blank row). +#[test] +fn peers_grouped_view_labels_and_cursor() { + use ratatui::widgets::TableState; + let data = json!({ + "peers": [ + { "display_name": "zeta_other", "npub": "npub1o", "is_parent": false, "is_child": false, "mmp": { "lqi": 1.0 } }, + { "display_name": "papa_parent", "npub": "npub1p", "is_parent": true, "is_child": false, "mmp": { "lqi": 9.0 } }, + { "display_name": "kidd_child", "npub": "npub1c", "is_parent": false, "is_child": true, "mmp": { "lqi": 5.0 } } + ] + }); + let mut app = app_with(Tab::Peers, data); + // Select peer index 0 (papa, the parent — first in grouped order). + let mut st = TableState::default(); + st.select(Some(0)); + app.table_states.insert(Tab::Peers, st); + let buf = testkit::render(140, 24, |frame, area| { + super::peers::draw(frame, &mut app, area); + }); + + // Group labels render and are ordered. + let y_parent_lbl = testkit::find(&buf, "Parent").map(|(_, y)| y).unwrap(); + let y_children_lbl = testkit::find(&buf, "STP Children").map(|(_, y)| y).unwrap(); + let y_other_lbl = testkit::find(&buf, "Other").map(|(_, y)| y).unwrap(); + assert!(y_parent_lbl < y_children_lbl, "Parent label above Children"); + assert!(y_children_lbl < y_other_lbl, "Children label above Other"); + + // The selected peer (papa) is highlighted with the cursor symbol on its + // row, not on a label. + let cursor_row = testkit::find(&buf, "\u{25b6}").map(|(_, y)| y); + let papa_row = testkit::find(&buf, "papa_parent").map(|(_, y)| y); + assert_eq!(cursor_row, papa_row, "cursor sits on the selected peer row"); +} + +/// Link MMP: trend arrows render inline after the value (rising srtt is a +/// red up-arrow), there is no separate trend line, and a stable metric +/// leaves a blank slot rather than a glyph. +#[test] +fn mmp_link_trend_arrows() { + use ratatui::style::Color; + let data = json!({ + "peers": [ + { + "display_name": "alice", + "link_layer": { + "srtt_ms": 42.0, + "smoothed_loss": 0.01, + "smoothed_etx": 1.2, + "lqi": 3.4, + "goodput_bps": 1000.0, + "rtt_trend": "rising", + "loss_trend": "stable", + "goodput_trend": "falling", + "jitter_trend": "rising" + } + } + ], + "sessions": [] + }); + let app = app_with(Tab::Mmp, data); + let buf = testkit::render(120, 20, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + + // No separate "rtt:"/"jitter:" trend line survives. + assert!(!testkit::contains_row(&buf, "jitter:")); + // A rising srtt (bad) shows a red up-arrow somewhere on the row. + assert_eq!(testkit::fg_at(&buf, "\u{2191}"), Some(Color::Red)); + // A falling goodput (bad) shows a red down-arrow. + assert_eq!(testkit::fg_at(&buf, "\u{2193}"), Some(Color::Red)); +} + +/// MMP peer names: a full-length npub used as the display name (no friendly +/// name) is truncated to its fixed column with an ellipsis, so it never butts +/// against the following `srtt:` label, in both the Link and Session panes. +#[test] +fn mmp_long_peer_name_truncated() { + let npub = "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r"; + let data = json!({ + "peers": [ + { + "display_name": npub, + "link_layer": { "srtt_ms": 42.0, "lqi": 3.4 } + } + ], + "sessions": [ + { + "display_name": npub, + "session_layer": { "srtt_ms": 42.0, "sqi": 3.4, "path_mtu": 1280 } + } + ] + }); + let app = app_with(Tab::Mmp, data); + let buf = testkit::render(120, 24, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + + // The full npub must not appear (it is truncated with an ellipsis). + assert!(!testkit::contains_row(&buf, npub)); + // A truncated name carries the ellipsis glyph. + assert!( + testkit::find(&buf, "\u{2026}").is_some(), + "name is truncated" + ); + // The truncated name never runs directly into the srtt label: the npub + // prefix is not immediately followed by "srtt:". + assert!( + !testkit::contains_row(&buf, "\u{2026}srtt:"), + "truncated name keeps a separator before srtt:" + ); + // Both panes still render the srtt label for the peer. + assert!(testkit::contains_row(&buf, "srtt:")); +} + +/// Dashboard Identity panel surfaces effective persistence as +/// persistent/ephemeral. +#[test] +fn dashboard_identity_persistence() { + let base = json!({ + "version": "v", "npub": "npub1abc", "node_addr": "00112233", + "ipv6_addr": "fd00::1", "state": "Running", "is_leaf_only": false, + "peer_count": 0, "session_count": 0, "link_count": 0, + "transport_count": 0, "connection_count": 0, "tun_state": "Up", + "tun_name": "fips0", "effective_ipv6_mtu": 1280, "control_socket": "/x", + "pid": 1, "exe_path": "/x", "uptime_secs": 1, "estimated_mesh_size": 1, + "forwarding": {}, "sparklines": {}, + "persistent": true + }); + let app = app_with(Tab::Node, base); + let buf = testkit::render(100, 40, |frame, area| { + super::dashboard::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "identity:")); + assert!(testkit::contains_row(&buf, "persistent")); +} + +/// Build a Dashboard `show_status` payload with the given root fields. +fn dashboard_status(extra: serde_json::Value) -> serde_json::Value { + let mut base = json!({ + "version": "v", "npub": "npub1abc", "node_addr": "00112233", + "ipv6_addr": "fd00::1", "state": "Running", "is_leaf_only": false, + "peer_count": 0, "session_count": 0, "link_count": 0, + "transport_count": 0, "connection_count": 0, "tun_state": "Up", + "tun_name": "fips0", "effective_ipv6_mtu": 1280, "control_socket": "/x", + "pid": 1, "exe_path": "/x", "uptime_secs": 1, "estimated_mesh_size": 1, + "forwarding": {}, "sparklines": {}, "persistent": true + }); + let obj = base.as_object_mut().unwrap(); + for (k, v) in extra.as_object().unwrap() { + obj.insert(k.clone(), v.clone()); + } + base +} + +/// Dashboard State panel: when this node is root, the root line shows the +/// Easter-egg marker rather than an address. +#[test] +fn dashboard_root_egg_when_root() { + let data = dashboard_status(json!({ + "is_root": true, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "transport_peer_counts": { "udp": 5, "tcp": 2, "tor": 0 } + })); + let app = app_with(Tab::Node, data); + let buf = testkit::render(100, 40, |frame, area| { + super::dashboard::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "I am the one who roots")); + // Transports enumerated by type with per-type peer counts, sorted. + assert!(testkit::contains_row(&buf, "tcp (2)")); + assert!(testkit::contains_row(&buf, "udp (5)")); + assert!(testkit::contains_row(&buf, "tor (0)")); +} + +/// Dashboard State panel: the mesh size renders under the +/// "approx. mesh estimate:" label, making clear it is a bloom-cardinality +/// estimate rather than an exact count. +#[test] +fn dashboard_mesh_estimate_label() { + let data = dashboard_status(json!({ + "is_root": false, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "estimated_mesh_size": 17, + "transport_peer_counts": { "udp": 1 } + })); + let app = app_with(Tab::Node, data); + // Render at 80 columns to confirm the label fits on its own line. + let buf = testkit::render(80, 40, |frame, area| { + super::dashboard::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "approx. mesh estimate:")); + // The estimate value renders alongside the label. + let row = testkit::lines(&buf) + .into_iter() + .find(|r| r.contains("approx. mesh estimate:")) + .unwrap(); + assert!( + row.contains("~17"), + "mesh estimate value on its line: {row}" + ); +} + +/// Dashboard State panel: when not root, the root line shows a truncated hex +/// (16 chars + ellipsis), not the egg. +#[test] +fn dashboard_root_truncated_when_not_root() { + let data = dashboard_status(json!({ + "is_root": false, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "transport_peer_counts": { "udp": 1 } + })); + let app = app_with(Tab::Node, data); + let buf = testkit::render(100, 40, |frame, area| { + super::dashboard::draw(frame, &app, area); + }); + assert!(!testkit::contains_row(&buf, "I am the one who roots")); + assert!(testkit::contains_row(&buf, "1b4788b7ab7a436a\u{2026}")); +} + +/// Tree Position: full (un-truncated) root hex plus an `Npub:` line; when the +/// daemon can't resolve the root npub the slot reads ``. +#[test] +fn tree_full_root_and_npub() { + let data = json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r", + "is_root": true, + "depth": 0, + "parent_display_name": "self", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [], + "stats": {} + }); + let app = app_with(Tab::Tree, data); + let buf = testkit::render(100, 40, |frame, area| { + super::tree::draw(frame, &app, area); + }); + // Full 32-char hex appears (with the self marker), not the 16-char form. + assert!(testkit::contains_row( + &buf, + "1b4788b7ab7a436a611fc59fb1e34c6e (self)" + )); + assert!(testkit::contains_row(&buf, "npub1sx42mj99aql52")); +} + +/// Tree Position: a null root_npub renders the `` placeholder. +#[test] +fn tree_root_npub_unknown() { + let data = json!({ + "root": "aabbccddeeff00112233445566778899", + "root_npub": serde_json::Value::Null, + "is_root": false, + "depth": 2, + "parent_display_name": "alice", + "declaration_sequence": 7, + "declaration_signed": true, + "my_coords": [], + "peers": [], + "stats": {} + }); + let app = app_with(Tab::Tree, data); + let buf = testkit::render(100, 40, |frame, area| { + super::tree::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "")); +} + +/// Tree peers line: the daemon-computed effective_depth (read back from the +/// Peers tab by node_addr) appears after `dist:`, and an unmeasured peer shows +/// an em-dash rather than a misleading number. +#[test] +fn tree_peer_effective_depth() { + let tree = json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": serde_json::Value::Null, + "is_root": false, + "depth": 1, + "parent_display_name": "alice", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [ + { + "display_name": "alice", + "node_addr": "aa00", + "depth": 0, + "distance_to_us": 1, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e" + }, + { + "display_name": "bob", + "node_addr": "bb00", + "depth": 2, + "distance_to_us": 3, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e" + } + ], + "stats": {} + }); + let peers = json!({ + "peers": [ + { "node_addr": "aa00", "display_name": "alice", "effective_depth": 1.25 }, + { "node_addr": "bb00", "display_name": "bob", "effective_depth": serde_json::Value::Null } + ] + }); + let mut app = app_with(Tab::Tree, tree); + app.data.insert(Tab::Peers, peers); + let buf = testkit::render(120, 40, |frame, area| { + super::tree::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "eff: 1.25")); + // bob's effective_depth is null -> em-dash. + let bob_row = testkit::lines(&buf) + .into_iter() + .find(|r| r.contains("bob")) + .unwrap(); + assert!( + bob_row.contains("eff: \u{2014}"), + "bob shows eff em-dash: {bob_row}" + ); +} + +/// Tree Peers list groups by tree role with the same section labels as the +/// Peers tab (parent -> STP children -> other), ordered, and omits a label for +/// an empty group (here there is no parent among the tree peers). +#[test] +fn tree_peers_grouped_by_role() { + let tree = json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": serde_json::Value::Null, + "is_root": false, + "depth": 1, + "parent_display_name": "alice", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [ + { "display_name": "other_peer", "node_addr": "cc00", "depth": 3, "distance_to_us": 4, "root": "1b4788b7ab7a436a611fc59fb1e34c6e" }, + { "display_name": "parent_peer", "node_addr": "aa00", "depth": 0, "distance_to_us": 1, "root": "1b4788b7ab7a436a611fc59fb1e34c6e" }, + { "display_name": "child_peer", "node_addr": "bb00", "depth": 2, "distance_to_us": 3, "root": "1b4788b7ab7a436a611fc59fb1e34c6e" } + ], + "stats": {} + }); + // The role flags live only in the peers view; the tree tab joins them in by + // node address. + let peers = json!({ + "peers": [ + { "node_addr": "aa00", "is_parent": true, "is_child": false }, + { "node_addr": "bb00", "is_parent": false, "is_child": true }, + { "node_addr": "cc00", "is_parent": false, "is_child": false } + ] + }); + let mut app = app_with(Tab::Tree, tree); + app.data.insert(Tab::Peers, peers); + // Tall enough that the Tree Peers pane (below the 10+22-row Position and + // Stats panes) has room for all three groups and their separators. + let buf = testkit::render(120, 50, |frame, area| { + super::tree::draw(frame, &app, area); + }); + + // Match the box-drawing group labels specifically so the Tree Position + // pane's "Parent:" kv label is not mistaken for the group heading. + let y_parent = testkit::find(&buf, "\u{2500}\u{2500} Parent") + .map(|(_, y)| y) + .unwrap(); + let y_children = testkit::find(&buf, "STP Children").map(|(_, y)| y).unwrap(); + let y_other = testkit::find(&buf, "\u{2500}\u{2500} Other") + .map(|(_, y)| y) + .unwrap(); + assert!(y_parent < y_children, "Parent label above Children"); + assert!(y_children < y_other, "Children label above Other"); + // The grouped order also places the peers under their headings. + let y_parent_peer = testkit::find(&buf, "parent_peer").map(|(_, y)| y).unwrap(); + let y_child_peer = testkit::find(&buf, "child_peer").map(|(_, y)| y).unwrap(); + let y_other_peer = testkit::find(&buf, "other_peer").map(|(_, y)| y).unwrap(); + assert!(y_parent_peer < y_child_peer && y_child_peer < y_other_peer); +} + +/// Tree Peers list with no parent among the peers omits the Parent label +/// entirely (empty groups render no heading). +#[test] +fn tree_peers_omit_empty_group() { + let tree = json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": serde_json::Value::Null, + "is_root": true, + "depth": 0, + "parent_display_name": "self", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [ + { "display_name": "child_peer", "node_addr": "bb00", "depth": 1, "distance_to_us": 1, "root": "1b4788b7ab7a436a611fc59fb1e34c6e" } + ], + "stats": {} + }); + let peers = json!({ + "peers": [ + { "node_addr": "bb00", "is_parent": false, "is_child": true } + ] + }); + let mut app = app_with(Tab::Tree, tree); + app.data.insert(Tab::Peers, peers); + let buf = testkit::render(120, 40, |frame, area| { + super::tree::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "STP Children")); + assert!( + !testkit::contains_row(&buf, "\u{2500}\u{2500} Parent"), + "no Parent heading when no parent peer is present" + ); +} + +/// Bloom Peer Filters list groups by tree role with the same labels as the +/// Peers and Tree tabs. +#[test] +fn bloom_peer_filters_grouped_by_role() { + let data = json!({ + "is_leaf_only": false, + "leaf_dependent_count": 0, + "own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", + "sequence": 0, + "peer_filters": [ + { "display_name": "other_peer", "peer": "cc00", "filter_sequence": 1u64, "has_filter": true, "fill_ratio": 0.1, "estimated_count": 5.0 }, + { "display_name": "parent_peer", "peer": "aa00", "filter_sequence": 2u64, "has_filter": true, "fill_ratio": 0.2, "estimated_count": 6.0 }, + { "display_name": "child_peer", "peer": "bb00", "filter_sequence": 3u64, "has_filter": false } + ], + "stats": { + "received": 0, "accepted": 0, "decode_error": 0, "invalid": 0, + "non_v1": 0, "unknown_peer": 0, "stale": 0, + "sent": 0, "debounce_suppressed": 0, "send_failed": 0 + } + }); + // Role flags come from the peers view, joined by the filter's `peer` address. + let peers = json!({ + "peers": [ + { "node_addr": "aa00", "is_parent": true, "is_child": false }, + { "node_addr": "bb00", "is_parent": false, "is_child": true }, + { "node_addr": "cc00", "is_parent": false, "is_child": false } + ] + }); + let mut app = app_with(Tab::Bloom, data); + app.data.insert(Tab::Peers, peers); + let buf = testkit::render(100, 40, |frame, area| { + super::bloom::draw(frame, &app, area); + }); + + let y_parent = testkit::find(&buf, "Parent").map(|(_, y)| y).unwrap(); + let y_children = testkit::find(&buf, "STP Children").map(|(_, y)| y).unwrap(); + let y_other = testkit::find(&buf, "Other").map(|(_, y)| y).unwrap(); + assert!(y_parent < y_children, "Parent label above Children"); + assert!(y_children < y_other, "Children label above Other"); + let y_parent_peer = testkit::find(&buf, "parent_peer").map(|(_, y)| y).unwrap(); + let y_child_peer = testkit::find(&buf, "child_peer").map(|(_, y)| y).unwrap(); + let y_other_peer = testkit::find(&buf, "other_peer").map(|(_, y)| y).unwrap(); + assert!(y_parent_peer < y_child_peer && y_child_peer < y_other_peer); +} + +/// Peers table + detail: effective_depth renders as a column (em-dash when +/// null) and as a detail kv_line. +#[test] +fn peers_effective_depth_column_and_detail() { + let data = json!({ + "peers": [ + { + "display_name": "alice", "npub": "npub1a", "node_addr": "aa00", + "ipv6_addr": "fd00::a", "connectivity": "direct", "link_id": 1, + "is_parent": false, "is_child": false, + "has_tree_position": true, "tree_depth": 1, + "effective_depth": 2.5, + "mmp": { "lqi": 3.0 }, + "stats": {} + } + ] + }); + let mut app = app_with(Tab::Peers, data); + let buf = testkit::render(140, 20, |frame, area| { + super::peers::draw(frame, &mut app, area); + }); + assert!(testkit::contains_row(&buf, "EffD")); + assert!(testkit::contains_row(&buf, "2.50")); +} + +/// Bloom Filter State: the uptree fill/subtree-est lines render the values for +/// a non-root node, and `n/a (root)` when this node is root. +#[test] +fn bloom_uptree_render() { + let bloom = json!({ + "is_leaf_only": false, "leaf_dependent_count": 0, + "own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", "sequence": 4, + "peer_filters": [], + "uptree_fill_ratio": 0.514, "uptree_estimated_count": 1182.0, + "stats": {} + }); + // Non-root node (State surface says is_root false). + let mut app = app_with(Tab::Bloom, bloom); + app.data.insert(Tab::Node, json!({ "is_root": false })); + let buf = testkit::render(90, 30, |frame, area| { + super::bloom::draw(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "Fill (sent uptree)")); + assert!(testkit::contains_row(&buf, "51.4%")); + assert!(testkit::contains_row(&buf, "Subtree est")); + assert!(testkit::contains_row(&buf, "1182")); + + // Root node -> n/a (root). + let bloom_root = json!({ + "is_leaf_only": false, "leaf_dependent_count": 0, + "own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", "sequence": 0, + "peer_filters": [], + "uptree_fill_ratio": serde_json::Value::Null, + "uptree_estimated_count": serde_json::Value::Null, + "stats": {} + }); + let mut app2 = app_with(Tab::Bloom, bloom_root); + app2.data.insert(Tab::Node, json!({ "is_root": true })); + let buf2 = testkit::render(90, 30, |frame, area| { + super::bloom::draw(frame, &app2, area); + }); + assert!(testkit::contains_row(&buf2, "n/a (root)")); +} + +/// Session MMP: trend arrows render inline on the session values (a rising +/// srtt is a red up-arrow), mirroring the Link MMP pane. +#[test] +fn mmp_session_trend_arrows() { + use ratatui::style::Color; + let data = json!({ + "peers": [], + "sessions": [ + { + "display_name": "alice", + "session_layer": { + "srtt_ms": 42.0, + "smoothed_loss": 0.01, + "smoothed_etx": 1.2, + "sqi": 3.4, + "path_mtu": 1280, + "rtt_trend": "rising", + "loss_trend": "stable", + "etx_trend": "falling" + } + } + ] + }); + let app = app_with(Tab::Mmp, data); + let buf = testkit::render(120, 20, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + // Rising srtt (bad) -> red up-arrow; falling etx (good) -> green down-arrow. + assert_eq!(testkit::fg_at(&buf, "\u{2191}"), Some(Color::Red)); + assert_eq!(testkit::fg_at(&buf, "\u{2193}"), Some(Color::Green)); +} + +/// Help registry: the contextual hint set differs per `(Tab, UiMode)`. A +/// selected Peers row surfaces disconnect/deselect; a detail-open state +/// surfaces close/scroll. +#[test] +fn footer_hints_per_mode() { + use super::help::{UiMode, contextual_hints}; + + let peers_sel = contextual_hints(Tab::Peers, UiMode::RowSelected); + assert!(peers_sel.iter().any(|h| h.label == "disconnect")); + assert!(peers_sel.iter().any(|h| h.label == "deselect")); + + let detail = contextual_hints(Tab::Peers, UiMode::DetailOpen); + assert!(detail.iter().any(|h| h.label == "close")); + + let graphs = contextual_hints(Tab::Graphs, UiMode::Overview); + assert!(graphs.iter().any(|h| h.label == "mode")); + assert!(graphs.iter().any(|h| h.label == "expand")); + + // The Graphs by-peer detail has its own hint set: peer nav, stat switch, + // mode cycle, and back (distinct from the generic detail scroll hints). + let graphs_detail = contextual_hints(Tab::Graphs, UiMode::DetailOpen); + assert!(graphs_detail.iter().any(|h| h.label == "peer")); + assert!(graphs_detail.iter().any(|h| h.label == "stat")); + assert!(graphs_detail.iter().any(|h| h.label == "back")); +} + +/// Footer truncation: contextual hints are kept and `[?] Help` is always +/// present even when the budget is too small for the globals. +#[test] +fn footer_truncation_keeps_context_and_help() { + use super::help::{UiMode, footer_hint_spans}; + + // A wide budget shows contextual hints plus the globals. + let wide = footer_hint_spans(Tab::Peers, UiMode::RowSelected, 120); + let wide_text: String = wide.iter().map(|s| s.content.as_ref()).collect(); + assert!(wide_text.contains("disconnect")); + assert!(wide_text.contains("quit")); + assert!(wide_text.contains("[?] Help")); + + // A narrow budget drops globals but keeps `[?] Help`. + let narrow = footer_hint_spans(Tab::Peers, UiMode::RowSelected, 14); + let narrow_text: String = narrow.iter().map(|s| s.content.as_ref()).collect(); + assert!(narrow_text.contains("[?] Help")); + assert!( + !narrow_text.contains("quit"), + "globals drop first: {narrow_text}" + ); +} + +/// Del-disconnect modal: the confirmation names the peer, shows a reconnect +/// note, and offers Y/N. +#[test] +fn disconnect_modal_render() { + let mut app = app_with(Tab::Peers, json!({ "peers": [] })); + app.confirm_disconnect = Some(crate::app::ConfirmDisconnect { + npub: "npub1alice".to_string(), + display_name: "alice".to_string(), + reconnect_note: "It stays disconnected until you manually reconnect it.".to_string(), + }); + let buf = testkit::render(100, 30, |frame, area| { + super::help::draw_disconnect_modal(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "Disconnect peer?")); + assert!(testkit::contains_row(&buf, "alice")); + assert!(testkit::contains_row( + &buf, + "stays disconnected until you manually reconnect" + )); + assert!(testkit::contains_row(&buf, "[Y]")); + assert!(testkit::contains_row(&buf, "[N/Esc]")); +} + +/// Del-disconnect selection: request_disconnect_confirm picks the peer under +/// the cursor in the grouped display order and sets the fixed reconnect note +/// for every peer kind. +#[test] +fn disconnect_confirm_picks_selected_peer() { + use ratatui::widgets::TableState; + let data = json!({ + "peers": [ + { "display_name": "zeta", "npub": "npub1z", "is_parent": false, "is_child": false, "direction": "inbound", "mmp": { "lqi": 1.0 } }, + { "display_name": "papa", "npub": "npub1p", "is_parent": true, "is_child": false, "direction": "outbound", "mmp": { "lqi": 9.0 } } + ] + }); + let mut app = app_with(Tab::Peers, data); + // Select row 0 in the *displayed* order — parent (papa) sorts first. + let mut st = TableState::default(); + st.select(Some(0)); + app.table_states.insert(Tab::Peers, st); + app.request_disconnect_confirm(); + let c = app.confirm_disconnect.as_ref().unwrap(); + assert_eq!(c.display_name, "papa"); + assert_eq!(c.npub, "npub1p"); + assert_eq!( + c.reconnect_note, + "It stays disconnected until you manually reconnect it." + ); +} + +/// Build a Graphs by-peer (`show_stats_history_all_peers`) payload: a `peers` +/// array of `{display_name, values}`. +fn graphs_by_peer_data() -> serde_json::Value { + json!({ + "metric": "srtt_ms", + "peers": [ + { "display_name": "alice", "values": [10.0, 20.0, 30.0, 25.0, 40.0] }, + { "display_name": "bob", "values": [5.0, 5.0, 6.0, 7.0, 8.0] }, + { "display_name": "carol", "values": [100.0, 90.0, 80.0, 70.0, 60.0] } + ] + }) +} + +/// Graphs by-peer list: the resting MetricByPeer state renders one scrollable +/// summary line per peer (name + min/max/last/n), with a cursor marker on the +/// selected peer (never the grid that was deleted). +#[test] +fn graphs_by_peer_list_state() { + let mut app = app_with(Tab::Graphs, graphs_by_peer_data()); + app.graphs_mode = crate::app::GraphsMode::MetricByPeer; + app.graphs_peer_idx = 1; // select bob + let buf = testkit::render(120, 24, |frame, area| { + super::graphs::draw(frame, &mut app, area); + }); + + // Every peer appears as a summary line. + assert!(testkit::contains_row(&buf, "alice")); + assert!(testkit::contains_row(&buf, "bob")); + assert!(testkit::contains_row(&buf, "carol")); + // Summary scalars are present (min/max/last/n labels). + assert!(testkit::contains_row(&buf, "min")); + assert!(testkit::contains_row(&buf, "last")); + // The cursor marker sits on the selected peer's row (bob). + let cursor_row = testkit::find(&buf, "\u{25b6}").map(|(_, y)| y); + let bob_row = testkit::find(&buf, "bob").map(|(_, y)| y); + assert_eq!(cursor_row, bob_row, "cursor on the selected peer row"); +} + +/// Graphs by-peer detail: selecting a peer (detail_view open) swaps to a +/// full-pane btop plot headed by that peer's name and the metric, with summary +/// scalars; the grid is gone. +#[test] +fn graphs_by_peer_detail_state() { + let mut app = app_with(Tab::Graphs, graphs_by_peer_data()); + app.graphs_mode = crate::app::GraphsMode::MetricByPeer; + app.graphs_peer_idx = 2; // carol + app.detail_view = Some(crate::app::DetailView { scroll: 0 }); + let buf = testkit::render(120, 24, |frame, area| { + super::graphs::draw(frame, &mut app, area); + }); + + // The detail header names the selected peer and metric. + assert!(testkit::contains_row(&buf, "carol")); + assert!(testkit::contains_row(&buf, "srtt_ms")); + // Summary scalars in the header. + assert!(testkit::contains_row(&buf, "min")); + assert!(testkit::contains_row(&buf, "samples")); + // The other peers' summary lines are NOT shown in the full-pane detail. + assert!(!testkit::contains_row(&buf, "alice")); +} + +/// Tree peer line with a long npub-style name: the name field is truncated to +/// a fixed width with a guaranteed trailing space, so it never butts against +/// the `depth:` label. +#[test] +fn tree_long_peer_name_truncated() { + let tree = json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": serde_json::Value::Null, + "is_root": false, + "depth": 1, + "parent_display_name": "alice", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [ + { + "display_name": "npub1verylongnamethatoverflows", + "node_addr": "aa00", + "depth": 0, + "distance_to_us": 1, + "root": "1b4788b7ab7a436a611fc59fb1e34c6e" + } + ], + "stats": {} + }); + let app = app_with(Tab::Tree, tree); + let buf = testkit::render(120, 40, |frame, area| { + super::tree::draw(frame, &app, area); + }); + // The full name must not appear (it is truncated with an ellipsis), and the + // name must never run directly into the depth label. + assert!(!testkit::contains_row( + &buf, + "npub1verylongnamethatoverflows" + )); + assert!( + !testkit::contains_row(&buf, "overflowsdepth:"), + "truncated name keeps a separator before depth:" + ); + assert!(testkit::contains_row(&buf, "depth:")); +} + +/// Routing State pane: with the values rendered through the kv_lines group +/// helper, the key column is padded to a common width so all values begin at +/// the same column. +#[test] +fn routing_state_values_aligned() { + let data = json!({ + "coord_cache_entries": 3, + "identity_cache_entries": 5, + "pending_lookups": [], + "recent_requests": 7, + "forwarding": {}, + "discovery": {}, + "error_signals": {}, + "congestion": {} + }); + let mut app = app_with(Tab::Routing, data); + app.data.insert(Tab::Cache, json!({})); + let buf = testkit::render(100, 30, |frame, area| { + super::routing::draw(frame, &app, area); + }); + + // Locate the value column for two state keys; they must match. The keys are + // padded to a common width, so the value's leading char shares a column. + let lines = testkit::lines(&buf); + let coord = lines.iter().find(|r| r.contains("Coord Cache")).unwrap(); + let ident = lines.iter().find(|r| r.contains("Identity Cache")).unwrap(); + // After the padded key the value follows ": "; both rows have the value at + // the same column because the key field is a fixed width. + let coord_val = coord.rfind(": ").map(|i| i + 2).unwrap(); + let ident_val = ident.rfind(": ").map(|i| i + 2).unwrap(); + assert_eq!( + coord_val, ident_val, + "routing state values share a column: {coord:?} vs {ident:?}" + ); +} + +/// Graphs by-peer summary list: the min/max/last numeric columns are +/// right-justified into fixed-width fields so they align across rows of +/// differing magnitude. +#[test] +fn graphs_by_peer_columns_right_justified() { + // Peers whose values differ in width (single vs triple digit). + let data = json!({ + "metric": "srtt_ms", + "peers": [ + { "display_name": "alice", "values": [1.0, 2.0, 3.0] }, + { "display_name": "bob", "values": [100.0, 200.0, 300.0] } + ] + }); + let mut app = app_with(Tab::Graphs, data); + app.graphs_mode = crate::app::GraphsMode::MetricByPeer; + let buf = testkit::render(120, 24, |frame, area| { + super::graphs::draw(frame, &mut app, area); + }); + + let lines = testkit::lines(&buf); + let alice = lines.iter().find(|r| r.contains("alice")).unwrap(); + let bob = lines.iter().find(|r| r.contains("bob")).unwrap(); + // The selected row carries a multibyte cursor glyph that shifts byte + // offsets, so compare the byte distance between the "min " and "max " + // labels on each row (both labels and the field between them are ASCII). + // Because the min value is right-justified into a fixed-width field, this + // distance is identical regardless of the value's own width. + let alice_span = alice.find("max ").unwrap() - alice.find("min ").unwrap(); + let bob_span = bob.find("max ").unwrap() - bob.find("min ").unwrap(); + assert_eq!( + alice_span, bob_span, + "min->max spacing is constant despite differing value widths: {alice:?} vs {bob:?}" + ); +} + +/// Link MMP column sort: with the sort column set to srtt descending, the +/// higher-srtt peer renders above the lower-srtt peer, and the sortable-column +/// header marks srtt as active. +#[test] +fn mmp_link_sort_reorders() { + let data = json!({ + "peers": [ + { "display_name": "low", "link_layer": { "srtt_ms": 10.0, "lqi": 1.0 } }, + { "display_name": "high", "link_layer": { "srtt_ms": 99.0, "lqi": 2.0 } } + ], + "sessions": [] + }); + let mut app = app_with(Tab::Mmp, data); + // Sort by srtt (column index 1) descending. + app.mmp_link_sort = crate::app::SortState { + col: 1, + descending: true, + }; + let buf = testkit::render(120, 24, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + + let y_high = testkit::find(&buf, "high").map(|(_, y)| y).unwrap(); + let y_low = testkit::find(&buf, "low").map(|(_, y)| y).unwrap(); + assert!( + y_high < y_low, + "higher srtt sorts above lower under descending sort" + ); + // The sortable-column header is present and names the columns. + assert!(testkit::contains_row(&buf, "sort:")); + assert!(testkit::contains_row(&buf, "srtt")); +} + +/// Graphs by-peer column sort: sorting by max descending reorders the summary +/// list so the peer with the larger maximum renders first, while the cursor +/// stays on the originally selected peer. +#[test] +fn graphs_by_peer_sort_reorders() { + let data = json!({ + "metric": "srtt_ms", + "peers": [ + { "display_name": "small", "values": [1.0, 2.0, 3.0] }, + { "display_name": "large", "values": [50.0, 60.0, 70.0] } + ] + }); + let mut app = app_with(Tab::Graphs, data); + app.graphs_mode = crate::app::GraphsMode::MetricByPeer; + // Cursor on "small" (payload index 0). + app.graphs_peer_idx = 0; + // Sort by max (column index 2) descending. + app.graphs_peer_sort = crate::app::SortState { + col: 2, + descending: true, + }; + let buf = testkit::render(120, 24, |frame, area| { + super::graphs::draw(frame, &mut app, area); + }); + + let y_large = testkit::find(&buf, "large").map(|(_, y)| y).unwrap(); + let y_small = testkit::find(&buf, "small").map(|(_, y)| y).unwrap(); + assert!( + y_large < y_small, + "larger max sorts above smaller under descending sort" + ); + // The cursor stays on the originally selected peer (small), now lower. + let cursor_row = testkit::find(&buf, "\u{25b6}").map(|(_, y)| y); + assert_eq!( + cursor_row, + Some(y_small), + "cursor follows the selected peer" + ); + // The sort header is present. + assert!(testkit::contains_row(&buf, "sort:")); +} + +/// Sort hint registration: the MMP tab and the Graphs by-peer overview both +/// advertise the column-sort key in the contextual hint set. +#[test] +fn sort_hint_registered() { + use super::help::{UiMode, contextual_hints}; + + let mmp = contextual_hints(Tab::Mmp, UiMode::Overview); + assert!( + mmp.iter().any(|h| h.label == "sort" && h.key == "s/S"), + "MMP tab advertises the sort key" + ); + + let graphs = contextual_hints(Tab::Graphs, UiMode::Overview); + assert!( + graphs.iter().any(|h| h.label == "sort" && h.key == "s/S"), + "Graphs overview advertises the sort key" + ); +} + +/// Build a minimal Tree payload for the focus/scroll tests. +fn tree_scroll_data() -> serde_json::Value { + json!({ + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": serde_json::Value::Null, + "is_root": false, + "depth": 1, + "parent_display_name": "alice", + "declaration_sequence": 1, + "declaration_signed": true, + "my_coords": [], + "peers": [], + "stats": {} + }) +} + +/// Tree tab focus/scroll: with the Tree Announce Stats pane focused and a short +/// terminal that clips it, a late stat row ("Flap Dampened") is not visible at +/// offset 0 but is revealed after scrolling the focused pane. +#[test] +fn tree_focused_pane_scrolls() { + // Height 16: Position pane takes 10, leaving ~6 for the clipped Stats pane. + // The Stats pane (index 1) is focused. + let mut app0 = app_with(Tab::Tree, tree_scroll_data()); + app0.focused_pane.insert(Tab::Tree, 1); + let buf0 = testkit::render(100, 16, |frame, area| { + super::tree::draw(frame, &app0, area); + }); + assert!( + !testkit::contains_row(&buf0, "Flap Dampened"), + "late stat row is clipped at offset 0" + ); + + // Same layout, but the focused stats pane is scrolled down. + let mut app1 = app_with(Tab::Tree, tree_scroll_data()); + app1.focused_pane.insert(Tab::Tree, 1); + app1.scroll_offsets.insert((Tab::Tree, 1), 17); + let buf1 = testkit::render(100, 16, |frame, area| { + super::tree::draw(frame, &app1, area); + }); + assert!( + testkit::contains_row(&buf1, "Flap Dampened"), + "scrolling the focused pane reveals the previously-clipped row" + ); +} + +/// Routing tab focus/scroll: with the Routing Statistics pane focused and a +/// short terminal, a late stat row ("Congestion") is revealed only after +/// scrolling. +#[test] +fn routing_focused_pane_scrolls() { + let data = json!({ + "coord_cache_entries": 0, "identity_cache_entries": 0, + "pending_lookups": [], "recent_requests": 0, + "forwarding": {}, "discovery": {}, "error_signals": {}, "congestion": {} + }); + // Routing State (7) + Coord Cache (8) leave the Stats pane (index 2) short. + let mut app0 = app_with(Tab::Routing, data.clone()); + app0.data.insert(Tab::Cache, json!({})); + app0.focused_pane.insert(Tab::Routing, 2); + let buf0 = testkit::render(100, 20, |frame, area| { + super::routing::draw(frame, &app0, area); + }); + assert!( + !testkit::contains_row(&buf0, "Congestion"), + "Congestion section is clipped at offset 0" + ); + + let mut app1 = app_with(Tab::Routing, data); + app1.data.insert(Tab::Cache, json!({})); + app1.focused_pane.insert(Tab::Routing, 2); + app1.scroll_offsets.insert((Tab::Routing, 2), 6); + let buf1 = testkit::render(100, 20, |frame, area| { + super::routing::draw(frame, &app1, area); + }); + assert!( + testkit::contains_row(&buf1, "Congestion"), + "scrolling the focused Routing Statistics pane reveals the section" + ); +} + +/// Focus/scroll hint registration: the Tree, Filters, and Routing tabs all +/// advertise the pane-focus key and the scroll keys in their hint set. +#[test] +fn pane_scroll_hints_registered() { + use super::help::{UiMode, contextual_hints}; + for tab in [Tab::Tree, Tab::Bloom, Tab::Routing] { + let hints = contextual_hints(tab, UiMode::Overview); + assert!( + hints + .iter() + .any(|h| h.label == "focus pane" && h.key == "f"), + "{tab:?} advertises the pane-focus key" + ); + assert!( + hints.iter().any(|h| h.label == "scroll"), + "{tab:?} advertises the scroll keys" + ); + } +} + +/// MMP pane focus: focusing the second pane (Session MMP) highlights its title +/// cyan, while the unfocused Link MMP title stays plain. +#[test] +fn mmp_focused_pane_indicator() { + use ratatui::style::Color; + let data = json!({ + "peers": [ + { "display_name": "alice", "link_layer": { "srtt_ms": 10.0, "lqi": 1.0 } } + ], + "sessions": [ + { "display_name": "alice", "session_layer": { "srtt_ms": 10.0, "sqi": 1.0, "path_mtu": 1280 } } + ] + }); + let mut app = app_with(Tab::Mmp, data); + app.focused_pane.insert(Tab::Mmp, 1); + let buf = testkit::render(120, 24, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + // The focused Session MMP title is cyan; the unfocused Link MMP title is not. + assert_eq!(testkit::fg_at(&buf, "Session MMP"), Some(Color::Cyan)); + assert_ne!(testkit::fg_at(&buf, "Link MMP"), Some(Color::Cyan)); +} + +/// MMP per-pane sort: sorting the focused Session MMP pane by srtt descending +/// reorders only that pane, leaving the unfocused Link MMP pane in its default +/// (name-ascending) order. +#[test] +fn mmp_focused_pane_sort_targets_one_pane() { + let data = json!({ + "peers": [ + { "display_name": "aaa", "link_layer": { "srtt_ms": 10.0, "lqi": 1.0 } }, + { "display_name": "zzz", "link_layer": { "srtt_ms": 99.0, "lqi": 2.0 } } + ], + "sessions": [ + { "display_name": "aaa", "session_layer": { "srtt_ms": 10.0, "sqi": 1.0, "path_mtu": 1280 } }, + { "display_name": "zzz", "session_layer": { "srtt_ms": 99.0, "sqi": 2.0, "path_mtu": 1280 } } + ] + }); + let mut app = app_with(Tab::Mmp, data); + // Focus the Session pane and sort it by srtt descending. + app.focused_pane.insert(Tab::Mmp, 1); + app.mmp_session_sort = crate::app::SortState { + col: 1, + descending: true, + }; + let buf = testkit::render(120, 24, |frame, area| { + super::mmp::draw(frame, &app, area); + }); + + // In the Session MMP pane the high-srtt peer (zzz) sorts above the low one. + // In the Link MMP pane the default name-ascending order keeps aaa above zzz. + // The two panes are stacked, Link on top; find the pane boundary by the + // Session MMP title row, then compare the peer rows within each pane. + let session_title = testkit::find(&buf, "Session MMP").map(|(_, y)| y).unwrap(); + let lines = testkit::lines(&buf); + let row_of = |name: &str, above: bool| -> u16 { + lines + .iter() + .enumerate() + .filter_map(|(y, r)| { + let y = y as u16; + let in_pane = if above { + y < session_title + } else { + y > session_title + }; + if in_pane && r.contains(name) { + Some(y) + } else { + None + } + }) + .next() + .unwrap() + }; + // Link pane (above the Session title): default order, aaa before zzz. + assert!( + row_of("aaa", true) < row_of("zzz", true), + "Link pane keeps default name order" + ); + // Session pane (below the Session title): srtt-descending, zzz before aaa. + assert!( + row_of("zzz", false) < row_of("aaa", false), + "Session pane sorted by srtt descending" + ); +} + +/// MMP focus/scroll hint registration: the Performance tab advertises the +/// pane-focus key, the scroll keys, and the sort key. +#[test] +fn mmp_focus_hints_registered() { + use super::help::{UiMode, contextual_hints}; + let hints = contextual_hints(Tab::Mmp, UiMode::Overview); + assert!( + hints + .iter() + .any(|h| h.label == "focus pane" && h.key == "f"), + "MMP tab advertises the pane-focus key" + ); + assert!( + hints.iter().any(|h| h.label == "scroll"), + "MMP tab advertises the scroll keys" + ); + assert!( + hints.iter().any(|h| h.label == "sort" && h.key == "s/S"), + "MMP tab advertises the sort key" + ); +} + +/// Help overlay: the `?` modal lists the active context and global keys. +#[test] +fn help_overlay_lists_keys() { + let mut app = app_with(Tab::Peers, json!({ "peers": [] })); + app.show_help = true; + let buf = testkit::render(100, 40, |frame, area| { + super::help::draw_overlay(frame, &app, area); + }); + assert!(testkit::contains_row(&buf, "Help")); + assert!(testkit::contains_row(&buf, "Global")); + assert!(testkit::contains_row(&buf, "Context")); + assert!(testkit::contains_row(&buf, "quit")); + assert!(testkit::contains_row(&buf, "Press ? or Esc to close")); +} diff --git a/src/bin/fipstop/ui/testkit.rs b/src/bin/fipstop/ui/testkit.rs new file mode 100644 index 0000000..fa0f396 --- /dev/null +++ b/src/bin/fipstop/ui/testkit.rs @@ -0,0 +1,110 @@ +//! Render-snapshot test harness for fipstop's `ui::draw_*` functions. +//! +//! Renders a draw function into an in-memory `ratatui` `TestBackend` +//! `Buffer` from a fixed area and canned JSON, then exposes the result as +//! a text grid plus per-cell style lookups. This makes layout, columns, +//! alignment, labels, grouping order, and per-cell colour machine-checkable +//! under `cargo test`, with no operator eyes. + +#![cfg(test)] +// This is a test toolkit: some accessors (grid/find/fg_at) are consumed by +// snapshot cases added as render items land, so allow not-yet-used helpers. +#![allow(dead_code)] + +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; + +use crate::app::{App, Tab}; + +/// Build an `App` with `data` registered under `tab` and that tab active. +pub fn app_with(tab: Tab, data: serde_json::Value) -> App { + let mut app = App::new(std::time::Duration::from_secs(2)); + app.active_tab = tab; + app.connection_state = crate::app::ConnectionState::Connected; + app.data.insert(tab, data); + app +} + +/// Render a draw closure into a `w`x`h` `TestBackend` and return the buffer. +pub fn render(w: u16, h: u16, draw: F) -> Buffer +where + F: FnOnce(&mut ratatui::Frame, Rect), +{ + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + draw(frame, area); + }) + .expect("draw"); + terminal.backend().buffer().clone() +} + +/// Dump a buffer as a vector of trimmed-right text rows. +pub fn lines(buf: &Buffer) -> Vec { + let w = buf.area.width as usize; + let h = buf.area.height as usize; + let mut out = Vec::with_capacity(h); + for y in 0..h { + let mut row = String::new(); + for x in 0..w { + if let Some(cell) = buf.cell((x as u16, y as u16)) { + row.push_str(cell.symbol()); + } + } + out.push(row.trim_end().to_string()); + } + out +} + +/// The full buffer as a single newline-joined string (for eyeball diffs). +pub fn grid(buf: &Buffer) -> String { + lines(buf).join("\n") +} + +/// Return true if any row, after trimming, contains `needle`. +pub fn contains_row(buf: &Buffer, needle: &str) -> bool { + lines(buf).iter().any(|r| r.contains(needle)) +} + +/// Find the first (x, y) of `needle` in the rendered grid, if present. +/// +/// The returned `x` is the cell column (not a byte offset), so it can be +/// used directly with `buf.cell`. `needle` is matched against the running +/// concatenation of cell symbols; the column reported is the cell at which +/// the match begins. +pub fn find(buf: &Buffer, needle: &str) -> Option<(u16, u16)> { + let w = buf.area.width as usize; + let h = buf.area.height as usize; + for y in 0..h { + // Per-cell symbols paired with their column, so a byte match maps + // back to the originating cell column even with multibyte glyphs. + let mut row = String::new(); + let mut starts: Vec = Vec::new(); + for x in 0..w { + if let Some(cell) = buf.cell((x as u16, y as u16)) { + starts.push(row.len()); + row.push_str(cell.symbol()); + } + } + if let Some(byte_off) = row.find(needle) { + // Map the byte offset back to the cell column. + let col = starts + .iter() + .position(|&s| s == byte_off) + .unwrap_or(byte_off); + return Some((col as u16, y as u16)); + } + } + None +} + +/// Foreground colour of the cell at the first column where `needle` starts. +pub fn fg_at(buf: &Buffer, needle: &str) -> Option { + let (x, y) = find(buf, needle)?; + buf.cell((x, y)).map(|c| c.fg) +} diff --git a/src/bin/fipstop/ui/transports.rs b/src/bin/fipstop/ui/transports.rs index e86484e..4c1ca97 100644 --- a/src/bin/fipstop/ui/transports.rs +++ b/src/bin/fipstop/ui/transports.rs @@ -211,7 +211,10 @@ fn draw_table( "Inbound" => "In", other => other, }; - let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 16); + // Wide enough to render a full MAC (~17) or `hci0/MAC` + // (~22) without chopping mid-octet; the link detail view + // shows the untruncated address. + let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 24); let label = format!(" {tree_char} {dir_short} {addr}"); let state = helpers::str_field(link, "state"); diff --git a/src/bin/fipstop/ui/tree.rs b/src/bin/fipstop/ui/tree.rs index f662909..2d221f2 100644 --- a/src/bin/fipstop/ui/tree.rs +++ b/src/bin/fipstop/ui/tree.rs @@ -2,7 +2,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; use crate::app::{App, Tab}; @@ -26,12 +26,44 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) { ]) .split(area); - draw_position(frame, data, chunks[0]); - draw_stats(frame, data, chunks[1]); - draw_peers(frame, data, chunks[2]); + let focused = app.focused_pane(); + draw_position(frame, data, app.pane_scroll(0), focused == 0, chunks[0]); + draw_stats(frame, data, app.pane_scroll(1), focused == 1, chunks[1]); + draw_peers( + frame, + app, + data, + app.pane_scroll(2), + focused == 2, + chunks[2], + ); } -fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) { +/// Look up a peer's daemon-computed `effective_depth` from the Peers tab data +/// by node_addr, formatted, or an em-dash when unavailable/unmeasured. The +/// value is a single daemon derivation (`show_peers`); the Tree tab reads it +/// back rather than recomputing, so the surfaces cannot drift. +fn peer_effective_depth(app: &App, node_addr: &str) -> String { + app.data + .get(&Tab::Peers) + .and_then(|v| v.get("peers")) + .and_then(|v| v.as_array()) + .and_then(|peers| { + peers + .iter() + .find(|p| p.get("node_addr").and_then(|v| v.as_str()) == Some(node_addr)) + }) + .map(|p| helpers::opt_f64_field(p, "effective_depth", 2)) + .unwrap_or_else(|| "\u{2014}".into()) +} + +fn draw_position( + frame: &mut Frame, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { let root_hex = helpers::str_field(data, "root"); let is_root = data .get("is_root") @@ -42,10 +74,18 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) { let decl_seq = helpers::u64_field(data, "declaration_sequence"); let decl_signed = helpers::bool_field(data, "declaration_signed"); + // Full root hex (no truncation) so it can be correlated against logs and + // configs; the npub line below resolves the root's identity when known. let root_display = if is_root { - format!("{} (self)", helpers::truncate_hex(root_hex, 16)) + format!("{root_hex} (self)") } else { - helpers::truncate_hex(root_hex, 16) + root_hex.to_string() + }; + let root_npub = helpers::str_field(data, "root_npub"); + let npub_display = if root_npub == "-" { + "".to_string() + } else { + root_npub.to_string() }; let parent_display = if is_root { @@ -56,6 +96,7 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) { let mut lines = vec![ helpers::kv_line("Root", &root_display), + helpers::kv_line("Npub", &npub_display), helpers::kv_line("Depth", &depth), helpers::kv_line("Parent", &parent_display), helpers::kv_line("Declaration", &format!("seq {decl_seq}, {decl_signed}")), @@ -100,22 +141,19 @@ fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) { lines.push(Line::from(path_parts)); } - let block = Block::default() - .borders(Borders::ALL) - .title(" Tree Position "); + let block = helpers::pane_block(" Tree Position ", focused); let inner = block.inner(area); frame.render_widget(block, area); - frame.render_widget(Paragraph::new(lines), inner); + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } -fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Tree Announce Stats "); +fn draw_stats(frame: &mut Frame, data: &serde_json::Value, scroll: u16, focused: bool, area: Rect) { + let block = helpers::pane_block(" Tree Announce Stats ", focused); let inner = block.inner(area); frame.render_widget(block, area); - let mut lines = vec![ + let lines = vec![ helpers::section_header("Inbound"), helpers::kv_line("Received", &helpers::nested_u64(data, "stats", "received")), helpers::kv_line("Accepted", &helpers::nested_u64(data, "stats", "accepted")), @@ -175,25 +213,29 @@ fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) { ), ]; - // Trim to fit available height - let max_lines = inner.height as usize; - lines.truncate(max_lines); - - frame.render_widget(Paragraph::new(lines), inner); + // Apply the focused-pane scroll instead of truncating, so over-full stats + // can be revealed by scrolling. + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); } -fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) { +fn draw_peers( + frame: &mut Frame, + app: &App, + data: &serde_json::Value, + scroll: u16, + focused: bool, + area: Rect, +) { let peers = data .get("peers") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); - let my_root = helpers::str_field(data, "root"); + let my_root = helpers::str_field(data, "root").to_string(); let count = peers.len(); - let block = Block::default() - .borders(Borders::ALL) - .title(format!(" Tree Peers ({count}) ")); + let block = helpers::pane_block(&format!(" Tree Peers ({count}) "), focused); let inner = block.inner(area); frame.render_widget(block, area); @@ -203,44 +245,62 @@ fn draw_peers(frame: &mut Frame, data: &serde_json::Value, area: Rect) { return; } - let lines: Vec = peers - .iter() - .map(|p| { - let name = helpers::str_field(p, "display_name"); - let has_depth = p.get("depth").is_some(); - - if !has_depth { - return Line::from(vec![ - Span::styled( - format!(" {name:<16}"), - Style::default().fg(Color::DarkGray), - ), - Span::styled("(no position)", Style::default().fg(Color::DarkGray)), - ]); - } - - let depth = helpers::u64_field(p, "depth"); - let dist = helpers::u64_field(p, "distance_to_us"); - let peer_root = helpers::str_field(p, "root"); - let (root_ind, root_color) = if peer_root == my_root { - ("same root", Color::Green) - } else { - ("diff root", Color::Red) - }; - - Line::from(vec![ - Span::styled( - format!(" {name:<16}"), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled("depth: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{depth:<4}")), - Span::styled("dist: ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{dist:<4}")), - Span::styled(root_ind, Style::default().fg(root_color)), - ]) - }) + // The tree response carries no role flags; recover them from the peers view + // (cross-fetched on this tab) by joining on the hex node address, then group + // by tree role (parent -> STP children -> other) like the Peers tab so the + // same peer sits under the same heading on every surface. + let role_map = helpers::peer_role_map(app.data.get(&Tab::Peers)); + let mut peers: Vec = peers + .into_iter() + .map(|p| helpers::enrich_role(p, &role_map, "node_addr")) .collect(); + helpers::sort_by_group(&mut peers); - frame.render_widget(Paragraph::new(lines), inner); + let lines = helpers::grouped_peer_lines(&peers, |p| tree_peer_line(app, &my_root, p)); + + let scroll = helpers::clamp_scroll(scroll, lines.len(), inner.height as usize); + frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner); +} + +/// Render one Tree-tab peer line: name plus depth/dist/eff columns and a +/// same-root/diff-root indicator, or a "(no position)" note for a peer with no +/// tree depth yet. +fn tree_peer_line(app: &App, my_root: &str, p: &serde_json::Value) -> Line<'static> { + let name = helpers::str_field(p, "display_name"); + let has_depth = p.get("depth").is_some(); + + if !has_depth { + return Line::from(vec![ + Span::styled( + format!(" {} ", helpers::truncate_name(name, 16)), + Style::default().fg(Color::DarkGray), + ), + Span::styled("(no position)", Style::default().fg(Color::DarkGray)), + ]); + } + + let depth = helpers::u64_field(p, "depth"); + let dist = helpers::u64_field(p, "distance_to_us"); + let peer_root = helpers::str_field(p, "root"); + let node_addr = helpers::str_field(p, "node_addr"); + let eff = peer_effective_depth(app, node_addr); + let (root_ind, root_color) = if peer_root == my_root { + ("same root", Color::Green) + } else { + ("diff root", Color::Red) + }; + + Line::from(vec![ + Span::styled( + format!(" {} ", helpers::truncate_name(name, 16)), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled("depth: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{depth:<4}")), + Span::styled("dist: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{dist:<4}")), + Span::styled("eff: ", Style::default().fg(Color::DarkGray)), + Span::raw(format!("{eff:<7}")), + Span::styled(root_ind, Style::default().fg(root_color)), + ]) } diff --git a/src/control/commands.rs b/src/control/commands.rs index a0c08ff..69b519d 100644 --- a/src/control/commands.rs +++ b/src/control/commands.rs @@ -12,7 +12,7 @@ use tracing::debug; pub async fn dispatch(node: &mut Node, command: &str, params: Option<&Value>) -> Response { match command { "connect" => connect(node, params).await, - "disconnect" => disconnect(node, params), + "disconnect" => disconnect(node, params).await, _ => Response::error(format!("unknown command: {command}")), } } @@ -49,7 +49,7 @@ async fn connect(node: &mut Node, params: Option<&Value>) -> Response { /// Disconnect a peer. /// /// Params: `{"npub": "npub1..."}` -fn disconnect(node: &mut Node, params: Option<&Value>) -> Response { +async fn disconnect(node: &mut Node, params: Option<&Value>) -> Response { let Some(params) = params else { return Response::error("missing params for disconnect"); }; @@ -61,7 +61,7 @@ fn disconnect(node: &mut Node, params: Option<&Value>) -> Response { debug!(npub = %npub, "API disconnect requested"); - match node.api_disconnect(npub) { + match node.api_disconnect(npub).await { Ok(data) => Response::ok(data), Err(msg) => Response::error(msg), } diff --git a/src/control/queries.rs b/src/control/queries.rs index 6e15181..059bd77 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -63,6 +63,9 @@ pub fn show_status(node: &Node) -> Value { "loss_rate": hist.recent(Metric::LossRate, SPARK_N), }); + let tree = node.tree_state(); + let transport_peer_counts = status_transport_peer_counts(node); + json!({ "version": crate::version::short_version(), "npub": node.npub(), @@ -83,11 +86,48 @@ pub fn show_status(node: &Node) -> Value { "exe_path": exe_path, "uptime_secs": uptime_secs, "estimated_mesh_size": node.estimated_mesh_size(), + "persistent": effective_persistent(&node.config().node.identity), + "root": hex::encode(tree.root().as_bytes()), + "is_root": tree.is_root(), + "transport_peer_counts": transport_peer_counts, "forwarding": serde_json::to_value(&fwd).unwrap_or_default(), "sparklines": sparklines, }) } +/// Effective identity persistence: an explicit `nsec` behaves as persistent +/// regardless of the `persistent` flag, so the honest "survives a restart" +/// signal is `persistent || nsec.is_some()`. +fn effective_persistent(identity: &crate::config::IdentityConfig) -> bool { + identity.persistent || identity.nsec.is_some() +} + +/// Per-configured-transport-type peer counts for `show_status`. Seeds every +/// configured transport type at 0 (so an idle-but-configured type stays +/// visible), then tallies peers by their active link's transport type. Returns +/// a sorted-key JSON object (via `BTreeMap`) so the on-loop and off-loop +/// outputs match. +fn status_transport_peer_counts(node: &Node) -> Value { + let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for id in node.transport_ids() { + if let Some(handle) = node.get_transport(id) { + counts + .entry(handle.transport_type().name.to_string()) + .or_insert(0); + } + } + for peer in node.peers() { + if let Some(link) = node.get_link(&peer.link_id()) + && let Some(handle) = node.get_transport(&link.transport_id()) + { + *counts + .entry(handle.transport_type().name.to_string()) + .or_insert(0) += 1; + } + } + serde_json::to_value(&counts).unwrap_or_default() +} + /// Off-loop variant of [`show_status`]: renders from the /// [`ControlReadHandle`](super::read_handle::ControlReadHandle) in the control /// task. Reads the effectively-immutable `NodeContext`, the `MetricsRegistry` @@ -135,6 +175,10 @@ pub(crate) fn show_status_from_handle(handle: &super::read_handle::ControlReadHa "exe_path": exe_path, "uptime_secs": uptime_secs, "estimated_mesh_size": stats.estimated_mesh_size, + "persistent": effective_persistent(&ctx.config.node.identity), + "root": hex::encode(stats.root.as_bytes()), + "is_root": stats.is_root, + "transport_peer_counts": serde_json::to_value(&stats.transport_peer_counts).unwrap_or_default(), "forwarding": serde_json::to_value(&fwd).unwrap_or_default(), "sparklines": sparklines, }) @@ -201,6 +245,11 @@ pub fn show_peers(node: &Node) -> Value { }) .unwrap_or_default(); + // Cold-start gate for effective_depth, mirroring `evaluate_parent`: when any + // peer has an SRTT measurement, unmeasured peers are excluded; during cold + // start (no peer has SRTT) every peer uses the default link cost of 1.0. + let any_peer_has_srtt = node.peers().any(|p| p.has_srtt()); + let peers: Vec = node .peers() .map(|peer| { @@ -249,6 +298,21 @@ pub fn show_peers(node: &Node) -> Value { peer_json["tree_depth"] = json!(coords.depth()); } + // effective_depth = tree_depth + link_cost, the value + // `evaluate_parent` ranks on. `null` when the peer has no coords or + // is unmeasured while other peers have SRTT (cold-start gate). + let effective_depth: Option = peer.coords().and_then(|coords| { + if any_peer_has_srtt && !peer.has_srtt() { + None + } else { + Some(coords.depth() as f64 + peer.link_cost()) + } + }); + peer_json["effective_depth"] = match effective_depth { + Some(v) => json!(v), + None => Value::Null, + }; + // Add link stats let stats = peer.link_stats(); peer_json["stats"] = json!({ @@ -445,6 +509,11 @@ pub(crate) fn show_peers_from_handle(handle: &super::read_handle::ControlReadHan peer_json["tree_depth"] = json!(depth); } + peer_json["effective_depth"] = match peer.effective_depth { + Some(v) => json!(v), + None => Value::Null, + }; + peer_json["stats"] = json!({ "packets_sent": peer.stats.packets_sent, "packets_recv": peer.stats.packets_recv, @@ -595,10 +664,12 @@ pub fn show_tree(node: &Node) -> Value { let parent_display = node.peer_display_name(parent_addr); let tree_stats = node.metrics().tree.snapshot(); + let root_npub = node.resolve_root_npub(tree); json!({ "my_node_addr": hex::encode(tree.my_node_addr().as_bytes()), "root": hex::encode(tree.root().as_bytes()), + "root_npub": root_npub, "is_root": tree.is_root(), "depth": my_coords.depth(), "my_coords": coords, @@ -654,6 +725,7 @@ pub(crate) fn show_tree_from_handle(handle: &super::read_handle::ControlReadHand json!({ "my_node_addr": hex::encode(tree.my_node_addr.as_bytes()), "root": hex::encode(tree.root.as_bytes()), + "root_npub": tree.root_npub, "is_root": tree.is_root, "depth": tree.depth, "my_coords": coords, @@ -833,6 +905,25 @@ pub fn show_bloom(node: &Node) -> Value { let bloom_stats = node.metrics().bloom.snapshot(); + // Uptree filter metrics: the last filter actually sent to the tree parent + // (`record_sent_filter`). `null` for a root node or before the first + // announce. The estimate is this node's whole subtree (split-horizon), not + // the mesh. + let tree = node.tree_state(); + let (uptree_fill_ratio, uptree_estimated_count): (Option, Option) = if tree.is_root() + { + (None, None) + } else { + let parent = tree.my_coords().parent_id(); + match bloom.last_sent_filter(parent) { + Some(filter) => { + let max_fpr = node.config().node.bloom.max_inbound_fpr; + (Some(filter.fill_ratio()), filter.estimated_count(max_fpr)) + } + None => (None, None), + } + }; + json!({ "own_node_addr": hex::encode(node.node_addr().as_bytes()), "is_leaf_only": node.is_leaf_only(), @@ -840,6 +931,8 @@ pub fn show_bloom(node: &Node) -> Value { "leaf_dependent_count": bloom.leaf_dependents().len(), "leaf_dependents": leaf_deps, "peer_filters": peer_filters, + "uptree_fill_ratio": uptree_fill_ratio, + "uptree_estimated_count": uptree_estimated_count, "stats": serde_json::to_value(&bloom_stats).unwrap_or_default(), }) } @@ -886,6 +979,8 @@ pub(crate) fn show_bloom_from_handle(handle: &super::read_handle::ControlReadHan "leaf_dependent_count": bloom.leaf_dependents.len(), "leaf_dependents": leaf_deps, "peer_filters": peer_filters, + "uptree_fill_ratio": bloom.uptree_fill_ratio, + "uptree_estimated_count": bloom.uptree_estimated_count, "stats": serde_json::to_value(&bloom_stats).unwrap_or_default(), }) } @@ -984,6 +1079,27 @@ pub fn show_mmp(node: &Node) -> Value { } } + // Session-layer trend indicators (srtt / loss / etx), mirroring the + // link-layer arrow semantics on the session columns. + if metrics.rtt_trend.initialized() { + session_layer["rtt_trend"] = json!(trend_label( + metrics.rtt_trend.short(), + metrics.rtt_trend.long() + )); + } + if metrics.loss_trend.initialized() { + session_layer["loss_trend"] = json!(trend_label( + metrics.loss_trend.short(), + metrics.loss_trend.long() + )); + } + if metrics.etx_trend.initialized() { + session_layer["etx_trend"] = json!(trend_label( + metrics.etx_trend.short(), + metrics.etx_trend.long() + )); + } + Some(json!({ "remote": hex::encode(addr.as_bytes()), "display_name": node.peer_display_name(addr), @@ -1078,6 +1194,16 @@ pub(crate) fn show_mmp_from_handle(handle: &super::read_handle::ControlReadHandl } } + if let Some(t) = session.trends.rtt_trend { + session_layer["rtt_trend"] = json!(t); + } + if let Some(t) = session.trends.loss_trend { + session_layer["loss_trend"] = json!(t); + } + if let Some(t) = session.trends.etx_trend { + session_layer["etx_trend"] = json!(t); + } + json!({ "remote": hex::encode(session.remote.as_bytes()), "display_name": session.display_name, diff --git a/src/control/snapshot.rs b/src/control/snapshot.rs index b83905b..2f9081c 100644 --- a/src/control/snapshot.rs +++ b/src/control/snapshot.rs @@ -53,6 +53,14 @@ pub(crate) struct StatsSnapshot { pub transport_count: usize, /// Number of active sessions. pub session_count: usize, + /// Current spanning-tree root `NodeAddr` (rendered as hex by `show_status`). + pub root: NodeAddr, + /// Whether this node is the spanning-tree root. + pub is_root: bool, + /// Per-configured-transport-type count of peers whose active link rides that + /// transport type. Configured-but-idle types appear with a zero count. + /// Keyed by the transport type name (`"udp"`, `"tcp"`, `"tor"`, ...). + pub transport_peer_counts: std::collections::BTreeMap, /// Configured peer aliases, keyed by `NodeAddr`. Effectively immutable /// after construction; shared to avoid a per-tick map clone. pub peer_aliases: Arc>, @@ -100,6 +108,9 @@ impl StatsSnapshot { link_count: 0, transport_count: 0, session_count: 0, + root: zero_addr(), + is_root: false, + transport_peer_counts: std::collections::BTreeMap::new(), peer_aliases: Arc::new(HashMap::new()), acl_status: empty_acl_status(), peer_meta: Arc::new(HashMap::new()), @@ -204,6 +215,9 @@ fn zero_addr() -> NodeAddr { pub(crate) struct TreeView { pub my_node_addr: NodeAddr, pub root: NodeAddr, + /// Resolved npub of the root node, when discoverable (self when root, a live + /// peer's attested npub, or an identity-cache hit); `None` otherwise. + pub root_npub: Option, pub is_root: bool, pub depth: usize, /// `my_coords` entries as `NodeAddr`s (rendered as hex). @@ -221,6 +235,7 @@ impl Default for TreeView { Self { my_node_addr: zero_addr(), root: zero_addr(), + root_npub: None, is_root: false, depth: 0, my_coords: Vec::new(), @@ -260,6 +275,15 @@ pub(crate) struct BloomView { pub sequence: u64, pub leaf_dependents: Vec, pub peer_filters: Vec, + /// Fill ratio of the last filter actually sent uptree (to the tree parent). + /// `None` for a root node (nothing sent uptree) or before the first + /// announce has been sent. + pub uptree_fill_ratio: Option, + /// Estimated cardinality of the last filter sent uptree — this node's whole + /// subtree (self + tree-descendants, parent excluded), since bloom is + /// split-horizon. `None` for root, pre-first-announce, or when the estimate + /// is undefined for the saturation. + pub uptree_estimated_count: Option, } impl Default for BloomView { @@ -270,6 +294,8 @@ impl Default for BloomView { sequence: 0, leaf_dependents: Vec::new(), peer_filters: Vec::new(), + uptree_fill_ratio: None, + uptree_estimated_count: None, } } } @@ -536,6 +562,12 @@ pub(crate) struct PeerRow { pub transport_addr: Option, pub link_info: Option, pub tree_depth: Option, + /// `effective_depth = tree_depth + link_cost` — the same quantity + /// `evaluate_parent` ranks parent candidates on. `None` when the peer is + /// unmeasured (no SRTT while other peers have it) or has no coords, + /// mirroring the candidacy/cold-start rules. Pre-computed daemon-side so + /// fipstop never recomputes it. + pub effective_depth: Option, pub stats: PeerLinkStats, pub replay_suppressed: u32, pub consecutive_decrypt_failures: u32, @@ -671,6 +703,17 @@ pub(crate) struct MmpPeerRow { pub ecn_ce_count: u32, } +/// MMP trend labels for a session's session-layer block in `show_mmp` (each +/// present only when the corresponding trend is initialized). Mirrors the +/// link-layer [`MmpTrends`] arrow semantics on the session columns: srtt +/// (`rtt_trend`), loss (`loss_trend`), etx (`etx_trend`). +#[derive(Clone, PartialEq)] +pub(crate) struct MmpSessionTrends { + pub rtt_trend: Option<&'static str>, + pub loss_trend: Option<&'static str>, + pub etx_trend: Option<&'static str>, +} + /// One session's session-layer MMP block in `show_mmp`. #[derive(Clone, PartialEq)] pub(crate) struct MmpSessionRow { @@ -685,6 +728,7 @@ pub(crate) struct MmpSessionRow { pub srtt_ms: Option, /// `sqi`: present only when both `srtt_ms` and `smoothed_etx` are present. pub sqi: Option, + pub trends: MmpSessionTrends, } /// Reconcile a freshly-projected entity table against the previously published diff --git a/src/control/snapshots/show_bloom.json b/src/control/snapshots/show_bloom.json index 3e85739..b2a1ff4 100644 --- a/src/control/snapshots/show_bloom.json +++ b/src/control/snapshots/show_bloom.json @@ -25,7 +25,9 @@ "total_compressed_bytes": 0, "total_raw_bytes": 0, "unknown_peer": 0 - } + }, + "uptree_estimated_count": null, + "uptree_fill_ratio": null }, "status": "ok" } \ No newline at end of file diff --git a/src/control/snapshots/show_status.json b/src/control/snapshots/show_status.json index 7351178..ef1b811 100644 --- a/src/control/snapshots/show_status.json +++ b/src/control/snapshots/show_status.json @@ -27,11 +27,14 @@ }, "ipv6_addr": "fd1b:4788:b7ab:7a43:6a61:1fc5:9fb1:e34c", "is_leaf_only": false, + "is_root": true, "link_count": 0, "node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e", "npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r", "peer_count": 0, + "persistent": false, "pid": "", + "root": "1b4788b7ab7a436a611fc59fb1e34c6e", "session_count": 0, "sparklines": { "bytes_in": [], @@ -43,6 +46,7 @@ }, "state": "created", "transport_count": 0, + "transport_peer_counts": {}, "tun_name": "", "tun_state": "disabled", "uptime_secs": "", diff --git a/src/control/snapshots/show_tree.json b/src/control/snapshots/show_tree.json index 1d91d78..148ab46 100644 --- a/src/control/snapshots/show_tree.json +++ b/src/control/snapshots/show_tree.json @@ -13,6 +13,7 @@ "peer_tree_count": 0, "peers": [], "root": "1b4788b7ab7a436a611fc59fb1e34c6e", + "root_npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r", "stats": { "accepted": 0, "addr_mismatch": 0, diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 5d52417..7077a6f 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -1616,9 +1616,6 @@ impl Node { /// Best-effort: send failures are logged and ignored since the transport /// may already be degraded. This runs before transports are shut down. async fn send_disconnect_to_all_peers(&mut self, reason: DisconnectReason) { - let disconnect = Disconnect::new(reason); - let plaintext = disconnect.encode(); - // Collect node_addrs to avoid borrow conflict with send helper let peer_addrs: Vec = self .peers @@ -1637,24 +1634,41 @@ impl Node { let mut sent = 0usize; for node_addr in &peer_addrs { - match self - .send_encrypted_link_message(node_addr, &plaintext) - .await - { - Ok(()) => sent += 1, - Err(e) => { - debug!( - peer = %self.peer_display_name(node_addr), - error = %e, - "Failed to send disconnect (transport may be down)" - ); - } + if self.send_disconnect_to_peer(node_addr, reason).await { + sent += 1; } } info!(sent, total = peer_addrs.len(), reason = %reason, "Sent disconnect notifications"); } + /// Send a Disconnect notification to a single peer. + /// + /// Best-effort: a send failure (peer already gone, transport down) is + /// logged and swallowed so callers can proceed with teardown regardless. + /// Returns `true` if the message was sent successfully. + async fn send_disconnect_to_peer( + &mut self, + node_addr: &NodeAddr, + reason: DisconnectReason, + ) -> bool { + let plaintext = Disconnect::new(reason).encode(); + match self + .send_encrypted_link_message(node_addr, &plaintext) + .await + { + Ok(()) => true, + Err(e) => { + debug!( + peer = %self.peer_display_name(node_addr), + error = %e, + "Failed to send disconnect (transport may be down)" + ); + false + } + } + } + fn static_peer_addresses(&self, peer_config: &PeerConfig) -> Vec { peer_config .addresses_by_priority() @@ -2644,8 +2658,8 @@ impl Node { /// Disconnect a peer via the control API. /// - /// Removes the peer and suppresses auto-reconnect. - pub(crate) fn api_disconnect(&mut self, npub: &str) -> Result { + /// Notifies the peer, removes it locally, and suppresses auto-reconnect. + pub(crate) async fn api_disconnect(&mut self, npub: &str) -> Result { let peer_identity = PeerIdentity::from_npub(npub).map_err(|e| format!("invalid npub '{npub}': {e}"))?; let node_addr = *peer_identity.node_addr(); @@ -2654,6 +2668,14 @@ impl Node { return Err(format!("peer not found: {npub}")); } + // Notify the peer before we tear down the link, so it drops its own + // session and re-handshakes symmetrically rather than holding a stale + // session that never re-emits its tree/filter announcements. The link + // must still exist for the send, so this runs before removal. + // Best-effort: a send failure must not block the local teardown. + self.send_disconnect_to_peer(&node_addr, DisconnectReason::ConfigurationChange) + .await; + // Remove the peer (full cleanup: sessions, indices, links, tree, bloom) self.remove_active_peer(&node_addr); diff --git a/src/node/mod.rs b/src/node/mod.rs index 8cfb644..2b9306e 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1561,6 +1561,29 @@ impl Node { }) .collect(); + // Per-configured-transport-type peer counts (`show_status`). Seed every + // configured transport type at 0 so an idle-but-configured type stays + // visible, then tally the peers whose active link rides that type. + let mut transport_peer_counts: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for id in self.transport_ids() { + if let Some(handle) = self.get_transport(id) { + transport_peer_counts + .entry(handle.transport_type().name.to_string()) + .or_insert(0); + } + } + for peer in self.peers() { + if let Some(link) = self.get_link(&peer.link_id()) + && let Some(handle) = self.get_transport(&link.transport_id()) + { + *transport_peer_counts + .entry(handle.transport_type().name.to_string()) + .or_insert(0) += 1; + } + } + + let tree = self.tree_state(); let snapshot = crate::control::snapshot::StatsSnapshot { history: std::sync::Arc::new(self.stats_history.clone()), estimated_mesh_size: self.estimated_mesh_size, @@ -1573,6 +1596,9 @@ impl Node { link_count: self.links.len(), transport_count: self.transports.len(), session_count: self.sessions.len(), + root: *tree.root(), + is_root: tree.is_root(), + transport_peer_counts, peer_aliases: std::sync::Arc::new(self.peer_aliases.clone()), acl_status: self.peer_acl_status(), peer_meta: std::sync::Arc::new(peer_meta), @@ -1588,6 +1614,28 @@ impl Node { self.publish_entities_snapshot(); } + /// Resolve the npub of the spanning-tree root for `show_tree`'s `root_npub`. + /// + /// Resolution order: this node when it is root, then the root as a live + /// authenticated peer (cryptographically attested npub), then the + /// identity-cache, else `None`. + pub(crate) fn resolve_root_npub(&self, tree: &crate::tree::TreeState) -> Option { + if tree.is_root() { + return Some(self.npub()); + } + let root_addr = tree.root(); + if let Some(peer) = self.get_peer(root_addr) { + return Some(peer.npub()); + } + for (addr, pubkey, _last_seen) in self.identity_cache_iter() { + if addr == root_addr { + let (xonly, _parity) = pubkey.x_only_public_key(); + return Some(crate::identity::encode_npub(&xonly)); + } + } + None + } + /// Project the Category-D derived/routing/cache state into a /// [`RoutingSnapshot`](crate::control::snapshot::RoutingSnapshot) and /// publish it via `ArcSwap`, so `show_tree` / `show_bloom` / `show_cache` @@ -1637,9 +1685,11 @@ impl Node { }) .collect(); let parent_addr = my_coords.parent_id(); + let root_npub = self.resolve_root_npub(tree); let tree_view = snap::TreeView { my_node_addr: *tree.my_node_addr(), root: *tree.root(), + root_npub, is_root: tree.is_root(), depth: my_coords.depth(), my_coords: my_coords.entries().iter().map(|e| e.node_addr).collect(), @@ -1672,12 +1722,30 @@ impl Node { } }) .collect(); + // Uptree filter metrics: the last filter actually sent to the tree + // parent (`record_sent_filter`), which is what the parent currently + // holds for us. `None` for a root node (nothing sent uptree) or before + // the first announce. The estimate is this node's whole subtree + // (split-horizon), not the mesh. + let (uptree_fill_ratio, uptree_estimated_count) = if tree.is_root() { + (None, None) + } else { + match bloom.last_sent_filter(parent_addr) { + Some(filter) => ( + Some(filter.fill_ratio()), + filter.estimated_count(max_inbound_fpr), + ), + None => (None, None), + } + }; let bloom_view = snap::BloomView { own_node_addr: *self.node_addr(), is_leaf_only: self.is_leaf_only(), sequence: bloom.sequence(), leaf_dependents: bloom.leaf_dependents().iter().copied().collect(), peer_filters: bloom_peers, + uptree_fill_ratio, + uptree_estimated_count, }; // --- coord cache (show_cache, show_routing) --- @@ -1819,6 +1887,12 @@ impl Node { }) .unwrap_or_default(); + // Cold-start gate for effective_depth, mirroring `evaluate_parent`: + // if any peer has an SRTT measurement, unmeasured peers are excluded + // (their effective_depth is `None`); during cold start (no peer has + // SRTT) every peer falls back to the default link cost of 1.0. + let any_peer_has_srtt = self.peers().any(|p| p.has_srtt()); + let peer_rows: Vec = self .peers() .map(|peer| { @@ -1855,6 +1929,17 @@ impl Node { .mmp() .map(|mmp| project_entity_mmp(&mmp.metrics, format!("{}", mmp.mode()), None)); + // effective_depth = tree_depth + link_cost, the value + // `evaluate_parent` ranks on. Computed only when the peer has + // coords and passes the cold-start measurement gate. + let effective_depth = peer.coords().and_then(|coords| { + if any_peer_has_srtt && !peer.has_srtt() { + None + } else { + Some(coords.depth() as f64 + peer.link_cost()) + } + }); + snap::PeerRow { node_addr, npub: peer.npub(), @@ -1872,6 +1957,7 @@ impl Node { transport_addr: peer.current_addr().map(|a| format!("{}", a)), link_info, tree_depth: peer.coords().map(|c| c.depth()), + effective_depth, stats: snap::PeerLinkStats { packets_sent: stats.packets_sent, packets_recv: stats.packets_recv, @@ -2054,6 +2140,10 @@ impl Node { (Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)), _ => None, }; + let trend = |dual: &crate::mmp::algorithms::DualEwma| { + dual.initialized() + .then(|| crate::control::queries::trend_label(dual.short(), dual.long())) + }; Some(snap::MmpSessionRow { remote: *addr, display_name: self.peer_display_name(addr), @@ -2065,6 +2155,11 @@ impl Node { smoothed_etx, srtt_ms, sqi, + trends: snap::MmpSessionTrends { + rtt_trend: trend(&metrics.rtt_trend), + loss_trend: trend(&metrics.loss_trend), + etx_trend: trend(&metrics.etx_trend), + }, }) }) .collect(); diff --git a/src/node/tests/disconnect.rs b/src/node/tests/disconnect.rs index 709855c..a82621c 100644 --- a/src/node/tests/disconnect.rs +++ b/src/node/tests/disconnect.rs @@ -325,6 +325,64 @@ async fn test_disconnect_clears_session() { cleanup_nodes(&mut nodes).await; } +/// A manual (control-API) disconnect must notify the peer, not just tear +/// down the local side. +/// +/// Regression test: `api_disconnect` previously removed the peer locally but +/// sent it no Disconnect message. The peer kept its session and never +/// re-emitted its tree/filter announcements, so on reconnect it was never +/// re-adopted as a child and its bloom filter was never recorded. The fix +/// sends the disconnected peer a scoped Disconnect so both sides tear down +/// symmetrically. This test drives `api_disconnect` on node 0 and verifies +/// node 1 receives the notification and removes node 0. +#[tokio::test] +async fn test_api_disconnect_notifies_peer() { + // Two-node topology: 0 -- 1. + let edges = vec![(0, 1)]; + let mut nodes = run_tree_test(2, &edges, false).await; + verify_tree_convergence(&nodes); + + let node0_addr = *nodes[0].node.node_addr(); + let node1_addr = *nodes[1].node.node_addr(); + let node1_npub = nodes[1].node.npub(); + + // Both sides start with each other as a peer. + assert!( + nodes[0].node.get_peer(&node1_addr).is_some(), + "Node 0 should have node 1 before disconnect" + ); + assert!( + nodes[1].node.get_peer(&node0_addr).is_some(), + "Node 1 should have node 0 before disconnect" + ); + + // Operator disconnects node 1 via the control API on node 0. + nodes[0] + .node + .api_disconnect(&node1_npub) + .await + .expect("api_disconnect should succeed"); + + // Node 0 tore down its side immediately. + assert!( + nodes[0].node.get_peer(&node1_addr).is_none(), + "Node 0 should have removed node 1 after api_disconnect" + ); + + // The Disconnect notification must reach node 1. + tokio::time::sleep(Duration::from_millis(50)).await; + process_available_packets(&mut nodes).await; + + // Node 1 must have torn down its side in response — proving the + // notification was actually emitted and received. + assert!( + nodes[1].node.get_peer(&node0_addr).is_none(), + "Node 1 should have removed node 0 after receiving the disconnect notification" + ); + + cleanup_nodes(&mut nodes).await; +} + /// Verify that different disconnect reasons are handled correctly. /// /// Sends each reason code and verifies the peer is removed regardless. diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 964fee2..e573a06 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -451,9 +451,21 @@ impl fmt::Debug for TransportAddr { impl fmt::Display for TransportAddr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - // Best-effort display as string if valid UTF-8, else hex + // Best-effort display as string if valid UTF-8. Otherwise render a + // 6-byte payload as a colon-separated MAC (standard Unix notation, + // matching BLE addrs, `ip link`/`ip neigh`, and packet logs), and + // any other non-UTF-8 byte string as bare hex. match self.as_str() { Some(s) => write!(f, "{}", s), + None if self.0.len() == 6 => { + for (i, byte) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ":")?; + } + write!(f, "{:02x}", byte)?; + } + Ok(()) + } None => { for byte in &self.0 { write!(f, "{:02x}", byte)?; @@ -1356,13 +1368,30 @@ mod tests { #[test] fn test_transport_addr_binary() { - // Binary address with invalid UTF-8 bytes (0xff, 0x80 are invalid UTF-8) + // A 6-byte non-UTF-8 address renders as a colon-separated MAC. let binary = TransportAddr::new(vec![0xff, 0x80, 0x2b, 0x3c, 0x4d, 0x5e]); - assert_eq!(format!("{}", binary), "ff802b3c4d5e"); + assert_eq!(format!("{}", binary), "ff:80:2b:3c:4d:5e"); assert!(binary.as_str().is_none()); assert_eq!(binary.len(), 6); } + #[test] + fn test_transport_addr_mac_display() { + // Raw 6-byte MACs (as Ethernet stores via from_bytes) display in + // standard colon-separated notation, not bare hex. + let mac = TransportAddr::from_bytes(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + assert_eq!(format!("{}", mac), "aa:bb:cc:dd:ee:ff"); + } + + #[test] + fn test_transport_addr_non_mac_binary_is_bare_hex() { + // Non-6-byte non-UTF-8 payloads stay bare hex (no separators). + let three = TransportAddr::new(vec![0xff, 0x80, 0x2b]); + assert_eq!(format!("{}", three), "ff802b"); + let seven = TransportAddr::new(vec![0xff, 0x80, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f]); + assert_eq!(format!("{}", seven), "ff802b3c4d5e6f"); + } + #[test] fn test_transport_addr_from_string() { let addr: TransportAddr = "test:1234".into();