fipstop: TUI overhaul with render-snapshot harness and navigation model

Reworks the fipstop TUI across its rendering, the control read surface it
draws from, and its interaction model, on a machine-verified base.

Test infrastructure:
- Add a ratatui TestBackend snapshot harness (testkit + snapshots
  modules) that renders any ui::draw_* into an in-memory Buffer from
  canned show_* JSON and asserts the text grid plus per-cell style.
  Layout, columns, alignment, labels, grouping, and colour are now
  checkable under cargo test; every render below ships a snapshot.

Control read surface (each new field emitted byte-identically on the
live and off-loop builders, published once from the tick, with schema
fixtures regenerated and the parity asserts holding):
- show_status: effective persistence (persistent || nsec.is_some());
  root and is_root; and a per-configured-transport-type peer-count map
  in which idle-but-configured types stay visible at zero.
- show_peers: per-peer effective_depth (depth + link_cost, the value
  evaluate_parent ranks on), null when unmeasured or coordless so
  fipstop never recomputes it.
- show_tree: root_npub, resolved once daemon-side (self when root, an
  attested peer npub, or an identity-cache hit).
- show_bloom: the last-actually-sent uptree filter fill ratio and
  subtree estimate, null for a root or before the first announce.
- show_mmp: session-layer srtt, loss, and etx trend labels.

Rendering:
- Display a 6-byte non-UTF-8 TransportAddr as a colon-separated MAC at
  the type layer, so daemon logs, fipsctl, and JSON consumers all
  benefit; non-6-byte payloads stay bare hex.
- Right-justify the Bloom Peer Filters numerics into aligned fixed-width
  columns, render the Routing panes through a kv_lines helper that shares
  one value column across a key-value group, and right-justify the Graphs
  by-peer summary columns.
- Truncate an over-long peer name (the npub shown when no friendly name
  exists) in the Tree, Bloom, and MMP peer lists so it no longer runs
  into the next column.
- Group the Peers table by role (parent, then STP children, then other)
  and render it as a full grouped view with styled group labels and
  blank separators; the selection stays a peer index and the cursor only
  ever lands on a peer row. Apply the same role grouping to the Tree and
  Bloom peer lists, joining each peer's role from the peers view by node
  address.
- Show min in the Graphs plot titles, rest a steady non-zero metric on
  the baseline as a row of dots, render a genuine zero as an empty plot,
  and keep a distinct no-data placeholder.
- Replace the metric-by-peer grid, which squeezed plots to nothing once
  peers overflowed, with a master/detail Graphs view: a scrollable
  per-peer summary list that expands (Enter) to a full-pane btop plot,
  with up/down to flip peer, n/N to switch statistic, m to cycle mode,
  and Esc to return.
- Put inline colored trend arrows on the Link and Session MMP values
  (drawn only on a rising or falling trend, with a fixed blank slot when
  stable so the value columns stay aligned), via a shared helper.
- Cycle column sorting on the Link MMP, Session MMP, and Graphs by-peer
  tables (one key cycles the active column, another toggles direction),
  with the active column marked in each table's header.
- Render the new daemon-surfaced fields: the dashboard root line (a
  self-is-root marker, otherwise a truncated root hex), a
  transports-by-type line, and an "approx. mesh estimate" line; an
  effective_depth column and lines on the Peers, peer-detail, and Tree
  sites from the single daemon derivation, showing a dash placeholder
  when unmeasured rather than a misleading zero; the full Tree root hex
  plus an Npub line; and the Bloom uptree fill and subtree-estimate lines.

Interaction model:
- Add a declarative keybinding registry keyed by (Tab, UiMode) that both
  the context footer and the ? help overlay render from, so the two
  cannot drift; a test asserts every registry key has a dispatch handler.
- Add a modal ? help overlay, and a context-aware footer that shows the
  current state's actions first, drops global hints when the terminal is
  narrow, and always keeps a Help affordance as the overflow path.
- Generalize per-pane focus and scroll state on App, wired across the
  Tree, Filters, Routing, and MMP tabs (f cycles pane focus and the
  focused pane scrolls instead of clipping its overflow); on the MMP tab
  the column sort acts on the focused pane. Esc deselects the active row
  when no detail is open (detail-close still takes priority).
- Add a Del-disconnect confirmation modal naming the peer, the only
  state-mutating action, issuing the control-socket disconnect on confirm
  and noting that the peer stays disconnected until manually reconnected.
This commit is contained in:
Johnathan Corgan
2026-06-12 23:05:58 +00:00
parent 81cd10d5db
commit 5fc2359432
23 changed files with 3912 additions and 635 deletions
+323 -2
View File
@@ -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<Tab, serde_json::Value>,
pub table_states: HashMap<Tab, TableState>,
pub detail_view: Option<DetailView>,
/// 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<ConfirmDisconnect>,
/// 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<Tab, usize>,
/// 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<u64>,
@@ -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<GraphsPeer>,
/// 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<String> {
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 {
+151 -16
View File
@@ -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();
}
_ => {}
}
}
+145 -73
View File
@@ -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")),
@@ -88,13 +140,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())
@@ -102,9 +159,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);
@@ -114,48 +169,65 @@ fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
return;
}
let lines: Vec<Line> = 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<serde_json::Value> = 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)
}
+62 -2
View File
@@ -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::<Vec<_>>()
.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);
+280 -89
View File
@@ -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<f64>)> = 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<f64>)> {
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<Constraint> = (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<Constraint> = (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<Line<'static>> = 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<Line<'static>> {
peer_series: &[(String, Vec<f64>)],
) {
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<Line<'static>> = 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<Line<'static>> = 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<Line<'st
let title = Line::from(vec![
Span::styled(format!(" {metric}"), title_style),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" max ", 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)),
@@ -372,6 +432,69 @@ fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'st
out
}
/// Build a display-order permutation of `peer_series` indices per the sort
/// state. Column 0 sorts by name; columns 1..=4 sort by the corresponding
/// summary scalar (min/max/last/n). Descending reverses the order.
fn sorted_order(peer_series: &[(String, Vec<f64>)], sort: SortState) -> Vec<usize> {
let mut order: Vec<usize> = (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<Span<'static>> = 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<f64> = 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::<String>()
}
#[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) {
+367
View File
@@ -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<Span<'static>> {
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<Span<'static>> {
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
let help_w = "[?] Help ".len();
let mut spans: Vec<Span<'static>> = 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<Span<'static>>, 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<Line<'static>> = 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<Hint> = 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
);
}
}
}
+200
View File
@@ -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 `{:<width}`
/// format this guarantees the field never exceeds `width`, so a long npub-style
/// name can't push past its column and butt against the next label. Counts and
/// pads by `char`, which is correct for the ASCII/BMP names the daemon emits.
pub fn truncate_name(s: &str, width: usize) -> String {
let len = s.chars().count();
if len <= width {
format!("{s:<width$}")
} else if width <= 1 {
"\u{2026}".chars().take(width).collect()
} else {
let head: String = s.chars().take(width - 1).collect();
format!("{head}\u{2026}")
}
}
/// Format bytes-per-second with engineering units (B/s, KB/s, MB/s, GB/s) and 3 significant digits.
pub fn format_throughput(bytes_per_sec: f64) -> 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<f64>` 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<Line<'static>> {
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:<key_width$}: "),
Style::default().fg(Color::DarkGray),
),
Span::raw(value.clone()),
])
})
.collect()
}
/// Build a node-address -> (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<String, (bool, bool)> {
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<String, (bool, bool)>,
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<F>(peers: &[Value], render_peer: F) -> Vec<Line<'static>>
where
F: Fn(&Value) -> Line<'static>,
{
let label_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut last_group: Option<u8> = 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
+234 -106
View File
@@ -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<Span<'static>> = 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<Line> = Vec::new();
sort_peers(&mut peers, sort, "link_layer");
let mut lines: Vec<Line> = 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<Span> = 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<Line> = 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<Line> = 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.
+24 -2
View File
@@ -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);
}
+126 -80
View File
@@ -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<serde_json::Value> {
let mut peers = app
.data
@@ -37,20 +40,25 @@ fn get_peers_sorted(app: &App) -> Vec<serde_json::Value> {
.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<Row> = 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<Row> = Vec::new();
let mut peer_display_idx: Vec<usize> = Vec::with_capacity(peers.len());
let mut last_group: Option<u8> = 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")),
+158 -196
View File
@@ -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<Line<'static>> {
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);
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -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<F>(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<String> {
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<usize> = 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<Color> {
let (x, y) = find(buf, needle)?;
buf.cell((x, y)).map(|c| c.fg)
}
+4 -1
View File
@@ -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");
+124 -64
View File
@@ -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 == "-" {
"<unknown>".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<Line> = 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<serde_json::Value> = 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)),
])
}
+10
View File
@@ -144,6 +144,16 @@ impl BloomState {
self.last_sent_filters.insert(peer_id, filter);
}
/// Read back the last outgoing filter actually sent to a peer, if any.
///
/// Returns the filter recorded by [`record_sent_filter`](Self::record_sent_filter)
/// — i.e. what the peer currently holds for us — or `None` when no announce
/// has been sent to that peer yet (or the node is root, with no parent to
/// send to).
pub fn last_sent_filter(&self, peer_id: &NodeAddr) -> Option<&BloomFilter> {
self.last_sent_filters.get(peer_id)
}
/// Remove stored filter state for a peer that was removed.
pub fn remove_peer_state(&mut self, peer_id: &NodeAddr) {
self.last_sent_filters.remove(peer_id);
+126
View File
@@ -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<String, usize> = 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<Value> = 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<f64> = 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<f64>, Option<f64>) = 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(),
})
}
@@ -970,6 +1065,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),
@@ -1065,6 +1181,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,
+44
View File
@@ -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<String, usize>,
/// Configured peer aliases, keyed by `NodeAddr`. Effectively immutable
/// after construction; shared to avoid a per-tick map clone.
pub peer_aliases: Arc<HashMap<NodeAddr, String>>,
@@ -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<String>,
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<NodeAddr>,
pub peer_filters: Vec<BloomPeerRow>,
/// 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<f64>,
/// 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<f64>,
}
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<String>,
pub link_info: Option<PeerLinkInfo>,
pub tree_depth: Option<usize>,
/// `effective_depth = tree_depth + link_cost` — the same quantity
/// `evaluate_parent` ranks parent candidates on. `None` when the peer is
/// 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<f64>,
pub stats: PeerLinkStats,
pub replay_suppressed: u32,
pub consecutive_decrypt_failures: u32,
@@ -672,6 +704,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 {
@@ -686,6 +729,7 @@ pub(crate) struct MmpSessionRow {
pub srtt_ms: Option<f64>,
/// `sqi`: present only when both `srtt_ms` and `smoothed_etx` are present.
pub sqi: Option<f64>,
pub trends: MmpSessionTrends,
}
/// Reconcile a freshly-projected entity table against the previously published
+3 -1
View File
@@ -18,7 +18,9 @@
"sent": 0,
"stale": 0,
"unknown_peer": 0
}
},
"uptree_estimated_count": null,
"uptree_fill_ratio": null
},
"status": "ok"
}
+4
View File
@@ -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": "<redacted>",
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
"session_count": 0,
"sparklines": {
"bytes_in": [],
@@ -43,6 +46,7 @@
},
"state": "created",
"transport_count": 0,
"transport_peer_counts": {},
"tun_name": "<redacted>",
"tun_state": "disabled",
"uptime_secs": "<redacted>",
+1
View File
@@ -13,6 +13,7 @@
"peer_tree_count": 0,
"peers": [],
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
"root_npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r",
"stats": {
"accepted": 0,
"addr_mismatch": 0,
+95
View File
@@ -1521,6 +1521,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<String, usize> =
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,
@@ -1533,6 +1556,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),
@@ -1548,6 +1574,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<String> {
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`
@@ -1597,9 +1645,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(),
@@ -1632,12 +1682,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) ---
@@ -1779,6 +1847,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<snap::PeerRow> = self
.peers()
.map(|peer| {
@@ -1815,6 +1889,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(),
@@ -1832,6 +1917,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,
@@ -2015,6 +2101,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),
@@ -2026,6 +2116,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();
+32 -3
View File
@@ -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();