Files
fips/src/bin/fipstop/ui/peers.rs
T
Johnathan Corgan 77ac8c822e Add fipstop TUI monitoring tool with smoothed metrics and quality indices
fipstop: ratatui-based TUI for real-time monitoring of a running FIPS daemon.

Tabs and navigation:
- 8 navigable tabs: Node, Peers, Transports, Sessions, Tree, Filters,
  Performance, Routing
- Tab/BackTab navigation with group separators in tab bar
- Table views with selectable rows, detail drill-down panels, and scrollbars

Node tab:
- Runtime info: pid, exe path, uptime, control socket path, TUN adapter name
- Identity: npub, node_addr, ipv6 address
- State summary with peer/session/link/transport/connection counts
- TUN IPv6 traffic and forwarded transit traffic counters

Peers tab:
- Table with Name, Address, Conn, Depth, SRTT, Loss, LQI, Pkts Tx/Rx
- Detail panel: identity, connection info, transport cross-reference,
  tree/bloom state, link stats, MMP metrics with LQI

Sessions tab:
- Table with Name, Remote Addr, State, Role, SRTT, Loss, SQI, Path MTU,
  Last Activity
- Detail panel: identity, session info, traffic stats, MMP metrics with SQI

Transports tab:
- Hierarchical tree view: expandable transport parents with nested links
  (▼/▶ indicators, ├─/└─ tree chars, Space/Arrow to expand/collapse)
- Transport detail: type-specific stats (UDP/TCP/Ethernet)
- Link detail: peer cross-reference with MMP metrics and LQI

Performance tab:
- Link-layer MMP: SRTT, loss, ETX, LQI, goodput per peer
- Session-layer MMP: SRTT, loss, ETX, SQI, path MTU per session
- Trend indicators (rising/falling/stable) with context-aware coloring

Routing tab:
- Routing state: cache sizes, pending lookups, recent requests
- Coordinate cache: entries, fill ratio, TTL, expiry, avg age
- Statistics: forwarding, discovery request/response, error signal counters

Tree tab:
- Spanning tree position with 16 announce stats (inbound/outbound/cumulative)

Filters tab:
- Bloom filter announce stats, per-peer fill ratio and estimated node count

MMP metrics enhancements:
- Add etx_trend DualEwma for smoothed ETX tracking
- Add smoothed_loss() and smoothed_etx() accessors (long-term EWMA)
- LQI (Link Quality Index) = smoothed_etx * (1 + srtt_ms / 100)
- SQI (Session Quality Index) = same formula for session layer
- All loss/ETX displays prefer smoothed values with raw fallback

Control socket:
- Add smoothed_loss, smoothed_etx, lqi/sqi to show_peers, show_sessions,
  and show_mmp JSON responses
- Rename fips_address to ipv6_addr in show_status and show_peers
- Add tun_name and control_socket to show_status
- FHS-compliant 3-tier default path: $XDG_RUNTIME_DIR, /run/fips, /tmp

Node extensions:
- Add started_at/uptime() to Node
- Add tun_name() accessor

Docker sidecar updates:
- TCP transport support via FIPS_PEER_TRANSPORT env var
- Build scripts include fipstop binary
2026-03-01 16:33:33 +00:00

274 lines
10 KiB
Rust

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, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table};
use crate::app::{App, Tab};
use super::helpers;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let peers = get_peers(app);
let row_count = peers.len();
if app.detail_view.is_some() {
// Split: left 40% table, right 60% detail
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
draw_table(frame, app, chunks[0], &peers, row_count);
draw_detail(frame, app, chunks[1], &peers);
} else {
draw_table(frame, app, area, &peers, row_count);
}
}
fn get_peers(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Peers)
.and_then(|v| v.get("peers"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn draw_table(frame: &mut Frame, app: &mut App, area: Rect, peers: &[serde_json::Value], row_count: usize) {
let header = Row::new(vec![
Cell::from("Name"),
Cell::from("Address"),
Cell::from("Conn"),
Cell::from("Depth"),
Cell::from("SRTT"),
Cell::from("Loss"),
Cell::from("LQI"),
Cell::from("Pkts Tx"),
Cell::from("Pkts Rx"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = peers
.iter()
.map(|peer| {
let name = helpers::str_field(peer, "display_name");
let addr = helpers::truncate_hex(helpers::str_field(peer, "ipv6_addr"), 10);
let conn = helpers::str_field(peer, "connectivity");
let depth = peer
.get("tree_depth")
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
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 pkts_tx = helpers::nested_u64(peer, "stats", "packets_sent");
let pkts_rx = helpers::nested_u64(peer, "stats", "packets_recv");
Row::new(vec![
Cell::from(name.to_string()),
Cell::from(addr),
Cell::from(connectivity_styled(conn)),
Cell::from(depth),
Cell::from(srtt),
Cell::from(loss),
Cell::from(lqi),
Cell::from(pkts_tx),
Cell::from(pkts_rx),
])
})
.collect();
let widths = [
Constraint::Min(12),
Constraint::Length(13),
Constraint::Length(8),
Constraint::Length(5),
Constraint::Length(8),
Constraint::Length(7),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(9),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Peers ({}) ", row_count)),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let state = app.table_states.entry(Tab::Peers).or_default();
frame.render_stateful_widget(table, area, state);
// Scrollbar
if row_count > 0 {
let selected = state.selected().unwrap_or(0);
let mut scrollbar_state = ScrollbarState::new(row_count).position(selected);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
area,
&mut scrollbar_state,
);
}
}
fn draw_detail(frame: &mut Frame, app: &App, area: Rect, peers: &[serde_json::Value]) {
let state = app.table_states.get(&Tab::Peers);
let selected = state.and_then(|s| s.selected()).unwrap_or(0);
let block = Block::default()
.borders(Borders::ALL)
.title(" Peer Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(peer) = peers.get(selected) else {
let msg = Paragraph::new(" No peer selected").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
let has_tree = peer.get("has_tree_position").and_then(|v| v.as_bool()).unwrap_or(false);
let has_bloom = peer.get("has_bloom_filter").and_then(|v| v.as_bool()).unwrap_or(false);
let mut lines: Vec<Line> = vec![
// Identity
helpers::section_header("Identity"),
helpers::kv_line("Name", helpers::str_field(peer, "display_name")),
helpers::kv_line("Node Addr", helpers::str_field(peer, "node_addr")),
helpers::kv_line("Npub", helpers::str_field(peer, "npub")),
helpers::kv_line("IPv6 Addr", helpers::str_field(peer, "ipv6_addr")),
Line::from(""),
// Connection
helpers::section_header("Connection"),
helpers::kv_line("Connectivity", helpers::str_field(peer, "connectivity")),
helpers::kv_line("Link ID", &helpers::u64_field(peer, "link_id")),
];
if let Some(addr) = peer.get("transport_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Transport Addr", addr));
}
// Link details (cross-referenced)
let link_id = peer.get("link_id").and_then(|v| v.as_u64());
let link = lookup_link(app, link_id);
if let Some(ref link) = link {
lines.push(helpers::kv_line("Direction", helpers::str_field(link, "direction")));
lines.push(helpers::kv_line("Link State", helpers::str_field(link, "state")));
}
lines.extend([
helpers::kv_line("Authenticated", &helpers::format_elapsed_ms(
peer.get("authenticated_at_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Last Seen", &helpers::format_elapsed_ms(
peer.get("last_seen_ms").and_then(|v| v.as_u64()).unwrap_or(0),
)),
Line::from(""),
]);
// Transport info (cross-referenced from link → transport)
if let Some(transport) = link.as_ref().and_then(|l| lookup_transport(app, l)) {
lines.push(helpers::section_header("Transport"));
lines.push(helpers::kv_line("Type", helpers::str_field(&transport, "type")));
if let Some(name) = transport.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
lines.push(helpers::kv_line("MTU", &helpers::u64_field(&transport, "mtu")));
if let Some(addr) = transport.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
lines.push(helpers::kv_line("State", helpers::str_field(&transport, "state")));
lines.push(Line::from(""));
}
lines.extend([
// Tree & Bloom
helpers::section_header("Tree / Bloom"),
helpers::kv_line("Tree Position", if has_tree { "yes" } else { "no" }),
]);
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.extend([
helpers::kv_line("Bloom Filter", if has_bloom { "yes" } else { "no" }),
helpers::kv_line("Filter Seq", &helpers::u64_field(peer, "filter_sequence")),
Line::from(""),
// Stats
helpers::section_header("Link Stats"),
helpers::kv_line("Pkts Sent", &helpers::nested_u64(peer, "stats", "packets_sent")),
helpers::kv_line("Pkts Recv", &helpers::nested_u64(peer, "stats", "packets_recv")),
helpers::kv_line("Bytes Sent", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_sent")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
helpers::kv_line("Bytes Recv", &helpers::format_bytes(
peer.get("stats").and_then(|s| s.get("bytes_recv")).and_then(|v| v.as_u64()).unwrap_or(0),
)),
Line::from(""),
]);
// MMP (if present)
if peer.get("mmp").is_some() {
lines.push(helpers::section_header("MMP Metrics"));
lines.push(helpers::kv_line("Mode", &helpers::nested_str(peer, "mmp", "mode")));
lines.push(helpers::kv_line("SRTT", &format!("{}ms", helpers::nested_f64(peer, "mmp", "srtt_ms", 1))));
lines.push(helpers::kv_line("Loss Rate", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_loss", "loss_rate", 4)));
lines.push(helpers::kv_line("ETX", &helpers::nested_f64_prefer(peer, "mmp", "smoothed_etx", "etx", 2)));
lines.push(helpers::kv_line("LQI", &helpers::nested_f64(peer, "mmp", "lqi", 2)));
lines.push(helpers::kv_line("Goodput", &helpers::nested_throughput(peer, "mmp", "goodput_bps")));
lines.push(helpers::kv_line("Delivery Fwd", &helpers::nested_f64(peer, "mmp", "delivery_ratio_forward", 3)));
lines.push(helpers::kv_line("Delivery Rev", &helpers::nested_f64(peer, "mmp", "delivery_ratio_reverse", 3)));
}
let detail_scroll = app.detail_view.as_ref().map(|d| d.scroll).unwrap_or(0);
let paragraph = Paragraph::new(lines).scroll((detail_scroll, 0));
frame.render_widget(paragraph, inner);
}
/// Look up the link for a peer by link_id.
fn lookup_link(app: &App, link_id: Option<u64>) -> Option<serde_json::Value> {
let link_id = link_id?;
let links = app.data.get(&Tab::Links)?;
links
.get("links")?
.as_array()?
.iter()
.find(|l| l.get("link_id").and_then(|v| v.as_u64()) == Some(link_id))
.cloned()
}
/// Look up transport info by chaining: link → transport_id → transport.
fn lookup_transport(app: &App, link: &serde_json::Value) -> Option<serde_json::Value> {
let transport_id = link.get("transport_id").and_then(|v| v.as_u64())?;
let transports = app.data.get(&Tab::Transports)?;
transports
.get("transports")?
.as_array()?
.iter()
.find(|t| t.get("transport_id").and_then(|v| v.as_u64()) == Some(transport_id))
.cloned()
}
fn connectivity_styled(conn: &str) -> Span<'static> {
let color = match conn {
"Full" => Color::Green,
"Partial" => Color::Yellow,
_ => Color::Red,
};
Span::styled(conn.to_string(), Style::default().fg(color))
}