mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 00:04:54 +00:00
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:
+323
-2
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"sent": 0,
|
||||
"stale": 0,
|
||||
"unknown_peer": 0
|
||||
}
|
||||
},
|
||||
"uptree_estimated_count": null,
|
||||
"uptree_fill_ratio": null
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
@@ -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>",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"peer_tree_count": 0,
|
||||
"peers": [],
|
||||
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
|
||||
"root_npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r",
|
||||
"stats": {
|
||||
"accepted": 0,
|
||||
"addr_mismatch": 0,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user