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
This commit is contained in:
Johnathan Corgan
2026-03-01 16:33:33 +00:00
parent 71a5c68fa9
commit 77ac8c822e
33 changed files with 4122 additions and 46 deletions
+3 -1
View File
@@ -66,6 +66,7 @@ jobs:
path: |
target/release/fips
target/release/fipsctl
target/release/fipstop
retention-days: 1
# ─────────────────────────────────────────────────────────────────────────────
@@ -264,9 +265,10 @@ jobs:
- name: Install binary (sidecar)
if: matrix.type == 'sidecar'
run: |
chmod +x _bin/fips _bin/fipsctl
chmod +x _bin/fips _bin/fipsctl _bin/fipstop
cp _bin/fips testing/sidecar/fips
cp _bin/fipsctl testing/sidecar/fipsctl
cp _bin/fipstop testing/sidecar/fipstop
- name: Run sidecar integration test
if: matrix.type == 'sidecar'
+1
View File
@@ -15,3 +15,4 @@
.claude/
deploy/
vps.env
Generated
+969 -25
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -8,7 +8,12 @@ authors = ["Johnathan Corgan <jcorgan@corganlabs.com>"]
repository = "https://github.com/jmcorgan/fips"
readme = "README.md"
[features]
default = ["tui"]
tui = ["dep:ratatui"]
[dependencies]
ratatui = { version = "0.30", optional = true }
secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
sha2 = "0.10"
hkdf = "0.12"
@@ -39,3 +44,8 @@ criterion = { version = "0.8.2", features = ["html_reports"] }
[[bin]]
name = "fipsctl"
path = "src/bin/fipsctl.rs"
[[bin]]
name = "fipstop"
path = "src/bin/fipstop/main.rs"
required-features = ["tui"]
+2 -2
View File
@@ -54,7 +54,7 @@ peers: # Static peer list
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `node.control.enabled` | bool | `true` | Enable the Unix domain control socket |
| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock` if `XDG_RUNTIME_DIR` is set, otherwise `/tmp/fips-control.sock` |
| `node.control.socket_path` | string | *(auto)* | Socket file path. Default: `$XDG_RUNTIME_DIR/fips/control.sock`, then `/run/fips/control.sock` (if root), then `/tmp/fips-control.sock` |
The control socket provides read-only access to node state via the
`fipsctl` command-line tool. See the project
@@ -480,7 +480,7 @@ node:
owd_window_size: 32
control:
enabled: true
socket_path: null # null = auto ($XDG_RUNTIME_DIR/fips/control.sock or /tmp/fips-control.sock)
socket_path: null # null = auto ($XDG_RUNTIME_DIR → /run/fips → /tmp fallback)
buffers:
packet_channel: 1024
tun_channel: 1024
+4 -2
View File
@@ -6,7 +6,7 @@
use clap::{Parser, Subcommand};
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// FIPS control client
@@ -77,7 +77,9 @@ impl ShowCommands {
/// Determine the default socket path.
fn default_socket_path() -> PathBuf {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{}/fips/control.sock", runtime_dir))
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else if Path::new("/run/fips/control.sock").exists() {
PathBuf::from("/run/fips/control.sock")
} else {
PathBuf::from("/tmp/fips-control.sock")
}
+216
View File
@@ -0,0 +1,216 @@
use ratatui::widgets::TableState;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tab {
Node,
Peers,
Links,
Sessions,
Tree,
Bloom,
Mmp,
Cache,
Transports,
Routing,
}
impl Tab {
pub const ALL: [Tab; 8] = [
Tab::Node,
Tab::Peers,
Tab::Transports,
Tab::Sessions,
Tab::Tree,
Tab::Bloom,
Tab::Mmp,
Tab::Routing,
];
/// Tab group index: 0 = Node, 1 = Connectivity, 2 = Internals.
pub fn group(&self) -> usize {
match self {
Tab::Node => 0,
Tab::Peers | Tab::Transports => 1,
_ => 2,
}
}
pub fn label(&self) -> &'static str {
match self {
Tab::Node => "Node",
Tab::Peers => "Peers",
Tab::Links => "Links",
Tab::Sessions => "Sessions",
Tab::Tree => "Tree",
Tab::Bloom => "Filters",
Tab::Mmp => "Performance",
Tab::Cache => "Cache",
Tab::Transports => "Transports",
Tab::Routing => "Routing",
}
}
pub fn command(&self) -> &'static str {
match self {
Tab::Node => "show_status",
Tab::Peers => "show_peers",
Tab::Links => "show_links",
Tab::Sessions => "show_sessions",
Tab::Tree => "show_tree",
Tab::Bloom => "show_bloom",
Tab::Mmp => "show_mmp",
Tab::Cache => "show_cache",
Tab::Transports => "show_transports",
Tab::Routing => "show_routing",
}
}
pub fn index(&self) -> usize {
Tab::ALL.iter().position(|t| t == self).unwrap()
}
pub fn next(&self) -> Tab {
let i = self.index();
Tab::ALL[(i + 1) % Tab::ALL.len()]
}
pub fn prev(&self) -> Tab {
let i = self.index();
Tab::ALL[(i + Tab::ALL.len() - 1) % Tab::ALL.len()]
}
/// The JSON key containing the data array for this tab's response.
pub fn command_data_key(&self) -> &'static str {
match self {
Tab::Peers => "peers",
Tab::Links => "links",
Tab::Sessions => "sessions",
Tab::Transports => "transports",
_ => "",
}
}
/// Whether this tab has a table view with row selection.
pub fn has_table(&self) -> bool {
matches!(
self,
Tab::Peers | Tab::Sessions | Tab::Transports
)
}
}
#[derive(Clone)]
pub enum ConnectionState {
Connected,
Disconnected(String),
}
pub struct DetailView {
pub scroll: u16,
}
#[derive(Clone, Copy)]
pub enum SelectedTreeItem {
None,
Transport(u64),
Link,
}
pub struct App {
pub active_tab: Tab,
pub should_quit: bool,
pub connection_state: ConnectionState,
pub refresh_interval: Duration,
pub data: HashMap<Tab, serde_json::Value>,
pub table_states: HashMap<Tab, TableState>,
pub detail_view: Option<DetailView>,
pub last_fetch: Instant,
pub last_error: Option<(Instant, String)>,
pub expanded_transports: HashSet<u64>,
pub tree_row_count: usize,
pub selected_tree_item: SelectedTreeItem,
}
impl App {
pub fn new(refresh_interval: Duration) -> Self {
Self {
active_tab: Tab::Node,
should_quit: false,
connection_state: ConnectionState::Disconnected("Not yet connected".into()),
refresh_interval,
data: HashMap::new(),
table_states: HashMap::new(),
detail_view: None,
last_fetch: Instant::now(),
last_error: None,
expanded_transports: HashSet::new(),
tree_row_count: 0,
selected_tree_item: SelectedTreeItem::None,
}
}
/// Number of rows in the active tab's data array.
pub fn row_count(&self) -> usize {
if self.active_tab == Tab::Transports {
return self.tree_row_count;
}
let key = self.active_tab.command_data_key();
self.data
.get(&self.active_tab)
.and_then(|v| v.get(key))
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0)
}
/// Move table selection down by one row.
pub fn select_next(&mut self) {
let count = self.row_count();
if count == 0 {
return;
}
let state = self.table_states.entry(self.active_tab).or_default();
let i = state.selected().map(|s| (s + 1).min(count - 1)).unwrap_or(0);
state.select(Some(i));
}
/// Move table selection up by one row.
pub fn select_prev(&mut self) {
let count = self.row_count();
if count == 0 {
return;
}
let state = self.table_states.entry(self.active_tab).or_default();
let i = state.selected().map(|s| s.saturating_sub(1)).unwrap_or(0);
state.select(Some(i));
}
/// Open detail view for the currently selected row.
pub fn open_detail(&mut self) {
let state = self.table_states.get(&self.active_tab);
if state.and_then(|s| s.selected()).is_some() {
self.detail_view = Some(DetailView { scroll: 0 });
}
}
/// Close detail view.
pub fn close_detail(&mut self) {
self.detail_view = None;
}
/// Scroll detail view down.
pub fn scroll_detail_down(&mut self) {
if let Some(ref mut dv) = self.detail_view {
dv.scroll = dv.scroll.saturating_add(1);
}
}
/// Scroll detail view up.
pub fn scroll_detail_up(&mut self) {
if let Some(ref mut dv) = self.detail_view {
dv.scroll = dv.scroll.saturating_sub(1);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::timeout;
const IO_TIMEOUT: Duration = Duration::from_secs(5);
pub struct ControlClient {
socket_path: PathBuf,
}
impl ControlClient {
pub fn new(socket_path: &Path) -> Self {
Self {
socket_path: socket_path.to_path_buf(),
}
}
pub async fn query(&self, command: &str) -> Result<Value, String> {
let stream = timeout(IO_TIMEOUT, UnixStream::connect(&self.socket_path))
.await
.map_err(|_| "connection timed out".to_string())?
.map_err(|e| format!("connect: {e}"))?;
let (reader, mut writer) = stream.into_split();
let request = format!("{{\"command\":\"{command}\"}}\n");
timeout(IO_TIMEOUT, writer.write_all(request.as_bytes()))
.await
.map_err(|_| "write timed out".to_string())?
.map_err(|e| format!("write: {e}"))?;
writer
.shutdown()
.await
.map_err(|e| format!("shutdown: {e}"))?;
let mut buf_reader = BufReader::new(reader);
let mut line = String::new();
timeout(IO_TIMEOUT, buf_reader.read_line(&mut line))
.await
.map_err(|_| "read timed out".to_string())?
.map_err(|e| format!("read: {e}"))?;
let response: Value =
serde_json::from_str(line.trim()).map_err(|e| format!("parse: {e}"))?;
let status = response
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if status == "error" {
let msg = response
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(msg.to_string());
}
Ok(response.get("data").cloned().unwrap_or(Value::Null))
}
}
+51
View File
@@ -0,0 +1,51 @@
use ratatui::crossterm::event::{self, Event as CrosstermEvent, KeyEvent};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
pub enum Event {
Key(KeyEvent),
Resize,
Tick,
}
pub struct EventHandler {
rx: mpsc::Receiver<Event>,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || loop {
if event::poll(tick_rate).unwrap_or(false) {
if let Ok(evt) = event::read() {
match evt {
CrosstermEvent::Key(key) => {
if tx.send(Event::Key(key)).is_err() {
return;
}
}
CrosstermEvent::Resize(..) => {
if tx.send(Event::Resize).is_err() {
return;
}
}
_ => {}
}
}
} else {
// Poll timed out — send a tick
if tx.send(Event::Tick).is_err() {
return;
}
}
});
Self { rx }
}
pub fn next(&self) -> Result<Event, mpsc::RecvError> {
self.rx.recv()
}
}
+226
View File
@@ -0,0 +1,226 @@
mod app;
mod client;
mod event;
mod ui;
use app::{App, ConnectionState, SelectedTreeItem, Tab};
use clap::Parser;
use client::ControlClient;
use event::{Event, EventHandler};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// FIPS mesh monitoring TUI
#[derive(Parser, Debug)]
#[command(name = "fipstop", version, about = "Monitor a running FIPS daemon")]
struct Cli {
/// Control socket path override
#[arg(short = 's', long)]
socket: Option<PathBuf>,
/// Refresh interval in seconds
#[arg(short = 'r', long, default_value = "2")]
refresh: u64,
}
fn default_socket_path() -> PathBuf {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else if Path::new("/run/fips/control.sock").exists() {
PathBuf::from("/run/fips/control.sock")
} else {
PathBuf::from("/tmp/fips-control.sock")
}
}
fn restore_terminal() {
ratatui::restore();
}
fn fetch_data(rt: &tokio::runtime::Runtime, client: &ControlClient, app: &mut App) {
// Always fetch status for the status bar
match rt.block_on(client.query("show_status")) {
Ok(data) => {
app.data.insert(Tab::Node, data);
app.connection_state = ConnectionState::Connected;
}
Err(e) => {
app.connection_state = ConnectionState::Disconnected(e.clone());
app.last_error = Some((std::time::Instant::now(), e));
return;
}
}
// Fetch active tab data (if not Dashboard, which we already fetched)
if app.active_tab != Tab::Node {
match rt.block_on(client.query(app.active_tab.command())) {
Ok(data) => {
app.data.insert(app.active_tab, data);
}
Err(e) => {
app.last_error = Some((std::time::Instant::now(), e));
}
}
}
// Cross-reference fetches for detail views
if app.active_tab == Tab::Peers {
if let Ok(data) = rt.block_on(client.query("show_links")) {
app.data.insert(Tab::Links, data);
}
if let Ok(data) = rt.block_on(client.query("show_transports")) {
app.data.insert(Tab::Transports, data);
}
}
if app.active_tab == Tab::Transports {
if let Ok(data) = rt.block_on(client.query("show_links")) {
app.data.insert(Tab::Links, data);
}
if let Ok(data) = rt.block_on(client.query("show_peers")) {
app.data.insert(Tab::Peers, data);
}
}
if app.active_tab == Tab::Routing
&& let Ok(data) = rt.block_on(client.query("show_cache"))
{
app.data.insert(Tab::Cache, data);
}
app.last_fetch = std::time::Instant::now();
}
fn main() {
let cli = Cli::parse();
let socket_path = cli.socket.unwrap_or_else(default_socket_path);
let refresh = Duration::from_secs(cli.refresh);
// Install panic hook that restores terminal before printing panic
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to create tokio runtime");
let client = ControlClient::new(&socket_path);
let mut terminal = ratatui::init();
let mut app = App::new(refresh);
let events = EventHandler::new(refresh);
// Initial fetch
fetch_data(&rt, &client, &mut app);
// Main loop
loop {
terminal
.draw(|frame| ui::draw(frame, &mut app))
.expect("failed to draw frame");
match events.next() {
Ok(Event::Key(key)) => {
// Ignore key release events
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
continue;
}
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
app.should_quit = true;
}
(KeyCode::Tab, KeyModifiers::NONE) => {
app.close_detail();
app.active_tab = app.active_tab.next();
fetch_data(&rt, &client, &mut app);
}
(KeyCode::BackTab, _) => {
app.close_detail();
app.active_tab = app.active_tab.prev();
fetch_data(&rt, &client, &mut app);
}
(KeyCode::Down, _) => {
if app.detail_view.is_some() {
app.scroll_detail_down();
} else if app.active_tab.has_table() {
app.select_next();
}
}
(KeyCode::Up, _) => {
if app.detail_view.is_some() {
app.scroll_detail_up();
} else if app.active_tab.has_table() {
app.select_prev();
}
}
(KeyCode::Enter, _) => {
if app.active_tab.has_table() && app.detail_view.is_none() {
app.open_detail();
}
}
(KeyCode::Char(' '), _) | (KeyCode::Right, _) => {
if app.active_tab == Tab::Transports
&& app.detail_view.is_none()
&& let SelectedTreeItem::Transport(tid) = app.selected_tree_item
{
if app.expanded_transports.contains(&tid) {
app.expanded_transports.remove(&tid);
} else {
app.expanded_transports.insert(tid);
}
}
}
(KeyCode::Left, _) => {
if app.active_tab == Tab::Transports
&& app.detail_view.is_none()
&& let SelectedTreeItem::Transport(tid) = app.selected_tree_item
{
app.expanded_transports.remove(&tid);
}
}
(KeyCode::Esc, _) => {
if app.detail_view.is_some() {
app.close_detail();
}
}
(KeyCode::Char('e'), KeyModifiers::NONE) => {
if app.active_tab == Tab::Transports
&& let Some(data) = app.data.get(&Tab::Transports)
&& let Some(arr) = data.get("transports").and_then(|v| v.as_array())
{
for t in arr {
if let Some(tid) = t.get("transport_id").and_then(|v| v.as_u64()) {
app.expanded_transports.insert(tid);
}
}
}
}
(KeyCode::Char('c'), KeyModifiers::NONE) => {
if app.active_tab == Tab::Transports {
app.expanded_transports.clear();
}
}
_ => {}
}
}
Ok(Event::Resize) => {
// Redraw happens at top of loop
}
Ok(Event::Tick) => {
fetch_data(&rt, &client, &mut app);
}
Err(_) => {
app.should_quit = true;
}
}
if app.should_quit {
break;
}
}
restore_terminal();
}
+150
View File
@@ -0,0 +1,150 @@
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 crate::app::{App, Tab};
use super::helpers;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Bloom) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // 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]);
}
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line(
"Node Addr",
&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 Deps",
&helpers::u64_field(data, "leaf_dependent_count"),
),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Bloom Filter State ");
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Bloom Announce Stats ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut 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")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line("Invalid", &helpers::nested_u64(data, "stats", "invalid")),
helpers::kv_line("Non-V1", &helpers::nested_u64(data, "stats", "non_v1")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Debounce Suppressed", &helpers::nested_u64(data, "stats", "debounce_suppressed")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
];
let max_lines = inner.height as usize;
lines.truncate(max_lines);
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_peer_filters(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let filters = data
.get("peer_filters")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let count = filters.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Peer Filters ({count}) "));
let inner = block.inner(area);
frame.render_widget(block, area);
if filters.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
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)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
+209
View File
@@ -0,0 +1,209 @@
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 crate::app::App;
use super::helpers;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&crate::app::Tab::Node) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(9), // Traffic
Constraint::Min(0), // remaining
])
.split(area);
draw_runtime(frame, data, chunks[0]);
draw_identity(frame, data, chunks[1]);
draw_state(frame, data, chunks[2]);
draw_node_stats(frame, data, chunks[3]);
}
fn draw_runtime(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Runtime ");
let inner = block.inner(area);
frame.render_widget(block, area);
let pid = helpers::u64_field(data, "pid");
let exe = helpers::str_field(data, "exe_path");
let uptime_secs = data.get("uptime_secs").and_then(|v| v.as_u64()).unwrap_or(0);
let uptime = format_uptime(uptime_secs);
let socket = helpers::str_field(data, "control_socket");
let tun_name = helpers::str_field(data, "tun_name");
let label = Style::default().fg(Color::DarkGray);
let lines = vec![
Line::from(vec![
Span::styled(" pid: ", label),
Span::raw(pid),
Span::styled(" uptime: ", label),
Span::raw(uptime),
]),
Line::from(vec![
Span::styled(" exe: ", label),
Span::raw(exe.to_string()),
]),
Line::from(vec![
Span::styled(" ctl: ", label),
Span::raw(socket.to_string()),
]),
Line::from(vec![
Span::styled(" tun: ", label),
Span::raw(tun_name.to_string()),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_identity(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Identity ");
let inner = block.inner(area);
frame.render_widget(block, area);
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");
let label = Style::default().fg(Color::DarkGray);
let lines = vec![
Line::from(vec![
Span::styled(" npub: ", label),
Span::raw(npub.to_string()),
]),
Line::from(vec![
Span::styled(" node_addr: ", label),
Span::raw(node_addr.to_string()),
]),
Line::from(vec![
Span::styled(" ipv6: ", label),
Span::raw(ipv6_addr.to_string()),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" State ");
let inner = block.inner(area);
frame.render_widget(block, area);
let state = helpers::str_field(data, "state");
let tun = helpers::str_field(data, "tun_state");
let mtu = helpers::u64_field(data, "effective_ipv6_mtu");
let leaf = helpers::bool_field(data, "is_leaf_only");
let label = Style::default().fg(Color::DarkGray);
let count = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let peers = helpers::u64_field(data, "peer_count");
let sessions = helpers::u64_field(data, "session_count");
let links = helpers::u64_field(data, "link_count");
let transports = helpers::u64_field(data, "transport_count");
let connections = helpers::u64_field(data, "connection_count");
let lines = vec![
Line::from(vec![
Span::styled(" state: ", label),
Span::raw(state.to_string()),
Span::styled(" tun: ", label),
Span::raw(tun.to_string()),
Span::styled(" mtu: ", label),
Span::raw(mtu),
Span::styled(" leaf: ", label),
Span::raw(leaf.to_string()),
]),
Line::from(vec![
Span::styled(" peers: ", label),
Span::styled(peers, count),
Span::styled(" sessions: ", label),
Span::styled(sessions, count),
Span::styled(" links: ", label),
Span::styled(links, count),
Span::styled(" transports: ", label),
Span::styled(transports, count),
Span::styled(" connections: ", label),
Span::styled(connections, count),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
}
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);
frame.render_widget(block, area);
let lines = vec![
helpers::section_header("TUN (IPv6)"),
fwd_line(data, "Tx", "delivered_packets", "delivered_bytes"),
fwd_line(data, "Rx", "originated_packets", "originated_bytes"),
Line::from(""),
helpers::section_header("Forwarded (transit)"),
fwd_line(data, "Packets", "forwarded_packets", "forwarded_bytes"),
];
frame.render_widget(Paragraph::new(lines), 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> {
let pkts = data
.get("forwarding")
.and_then(|f| f.get(pkt_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let bytes = data
.get("forwarding")
.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 seconds as human-readable uptime (e.g., "3d 2h 15m 4s").
fn format_uptime(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let mins = (secs % 3600) / 60;
let s = secs % 60;
if days > 0 {
format!("{days}d {hours}h {mins}m {s}s")
} else if hours > 0 {
format!("{hours}h {mins}m {s}s")
} else if mins > 0 {
format!("{mins}m {s}s")
} else {
format!("{s}s")
}
}
+171
View File
@@ -0,0 +1,171 @@
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use serde_json::Value;
/// 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("-")
}
/// Extract a u64 field from JSON, returning "-" if missing.
pub fn u64_field(data: &Value, key: &str) -> String {
data.get(key)
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
}
/// Truncate a hex string to the given length, adding "..." if truncated.
pub fn truncate_hex(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
/// 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 {
return "0 B/s".into();
}
let (scaled, unit) = if bytes_per_sec < 1_000.0 {
(bytes_per_sec, "B/s")
} else if bytes_per_sec < 1_000_000.0 {
(bytes_per_sec / 1_000.0, "KB/s")
} else if bytes_per_sec < 1_000_000_000.0 {
(bytes_per_sec / 1_000_000.0, "MB/s")
} else {
(bytes_per_sec / 1_000_000_000.0, "GB/s")
};
let decimals = if scaled >= 100.0 {
0
} else if scaled >= 10.0 {
1
} else {
2
};
format!("{:.prec$} {unit}", scaled, prec = decimals)
}
/// Extract a nested f64 field and format as engineering-unit throughput.
pub fn nested_throughput(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_f64())
.map(format_throughput)
.unwrap_or_else(|| "-".into())
}
/// Format a byte count as human-readable (B, KB, MB, GB).
pub fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Format millisecond timestamp as relative duration from now (e.g., "3.2s ago").
pub fn format_elapsed_ms(ms: u64) -> String {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if ms == 0 || ms > now_ms {
return "-".into();
}
let elapsed = now_ms - ms;
if elapsed < 1000 {
format!("{elapsed}ms")
} else if elapsed < 60_000 {
format!("{:.1}s", elapsed as f64 / 1000.0)
} else if elapsed < 3_600_000 {
format!("{:.1}m", elapsed as f64 / 60_000.0)
} else {
format!("{:.1}h", elapsed as f64 / 3_600_000.0)
}
}
/// Get a nested string field.
pub fn nested_str(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string()
}
/// Get a nested field value (e.g., "stats.packets_sent").
pub fn nested_u64(data: &Value, outer: &str, inner: &str) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into())
}
/// Get a nested f64 field formatted to given decimal places.
pub fn nested_f64(data: &Value, outer: &str, inner: &str, decimals: usize) -> String {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_f64())
.map(|n| format!("{:.prec$}", n, prec = decimals))
.unwrap_or_else(|| "-".into())
}
/// Get a nested f64 field, preferring `preferred` key with fallback to `fallback` key.
pub fn nested_f64_prefer(data: &Value, outer: &str, preferred: &str, fallback: &str, decimals: usize) -> String {
data.get(outer)
.and_then(|o| o.get(preferred).or_else(|| o.get(fallback)))
.and_then(|v| v.as_f64())
.map(|n| format!("{:.prec$}", n, prec = decimals))
.unwrap_or_else(|| "-".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)
.and_then(|v| v.as_bool())
.map(|b| if b { "yes" } else { "no" })
.unwrap_or("-")
}
/// Format a duration in milliseconds as compact string (e.g., "42ms", "3.2s", "5.0m").
pub fn format_duration_ms(ms: u64) -> String {
if ms < 1000 {
format!("{ms}ms")
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else if ms < 3_600_000 {
format!("{:.1}m", ms as f64 / 60_000.0)
} else {
format!("{:.1}h", ms as f64 / 3_600_000.0)
}
}
/// Section header line for detail views.
pub fn section_header(title: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" {title}"),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
}
/// Key-value line for detail views.
pub fn kv_line(key: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {key}: "),
Style::default().fg(Color::DarkGray),
),
Span::raw(value.to_string()),
])
}
+237
View File
@@ -0,0 +1,237 @@
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 crate::app::{App, Tab};
use super::helpers;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Mmp) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
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]);
}
fn draw_link_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let 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 inner = block.inner(area);
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
let mut lines: Vec<Line> = Vec::new();
for peer in &peers {
let name = helpers::str_field(peer, "display_name");
let ll = peer.get("link_layer");
let srtt = ll
.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 = ll
.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 = ll
.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 lqi = ll
.and_then(|l| l.get("lqi"))
.and_then(|v| v.as_f64())
.map(|v| format!("{:.2}", v))
.unwrap_or_else(|| "-".into());
let goodput = ll
.and_then(|l| l.get("goodput_bps"))
.and_then(|v| v.as_f64())
.map(helpers::format_throughput)
.unwrap_or_else(|| "-".into());
// Line 1: primary metrics
lines.push(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("lqi: ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("{lqi:<8}")),
Span::styled("gp: ", Style::default().fg(Color::DarkGray)),
Span::raw(goodput),
]));
// 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);
}
fn draw_session_mmp(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let 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 inner = block.inner(area);
frame.render_widget(block, area);
if sessions.is_empty() {
let msg = Paragraph::new(" No sessions")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
let lines: Vec<Line> = sessions
.iter()
.map(|s| {
let name = helpers::str_field(s, "display_name");
let sl = s.get("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());
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();
frame.render_widget(Paragraph::new(lines), inner);
}
/// Color a trend value based on whether "rising" is bad or good for this metric.
fn trend_color(trend: &str, bad_rising: bool) -> Color {
match trend {
"rising" => {
if bad_rising {
Color::Red
} else {
Color::Green
}
}
"falling" => {
if bad_rising {
Color::Green
} else {
Color::Red
}
}
_ => Color::DarkGray, // "stable"
}
}
+99
View File
@@ -0,0 +1,99 @@
mod bloom;
mod dashboard;
mod helpers;
mod mmp;
mod peers;
mod routing;
mod sessions;
mod transports;
mod tree;
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 crate::app::{App, ConnectionState, Tab};
pub fn draw(frame: &mut Frame, app: &mut App) {
let chunks = Layout::vertical([
Constraint::Length(3), // tab bar
Constraint::Min(1), // content
Constraint::Length(1), // status bar
])
.split(frame.area());
draw_tab_bar(frame, app, chunks[0]);
draw_content(frame, app, chunks[1]);
draw_status_bar(frame, app, chunks[2]);
}
fn draw_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default().borders(Borders::ALL).title(" fipstop ");
let inner = block.inner(area);
frame.render_widget(block, area);
let highlight = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let normal = Style::default().fg(Color::White);
let divider = Style::default().fg(Color::DarkGray);
let group_sep = Style::default().fg(Color::DarkGray);
let mut spans: Vec<Span> = Vec::new();
for (i, tab) in Tab::ALL.iter().enumerate() {
if i > 0 {
if tab.group() != Tab::ALL[i - 1].group() {
spans.push(Span::styled(" \u{2502} ", group_sep));
} else {
spans.push(Span::styled(" | ", divider));
}
}
let style = if *tab == app.active_tab { highlight } else { normal };
spans.push(Span::styled(tab.label(), style));
}
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), inner);
}
fn draw_content(frame: &mut Frame, app: &mut App, area: Rect) {
match app.active_tab {
Tab::Node => dashboard::draw(frame, app, area),
Tab::Peers => peers::draw(frame, app, area),
Tab::Sessions => sessions::draw(frame, app, area),
Tab::Transports => transports::draw(frame, app, area),
Tab::Tree => tree::draw(frame, app, area),
Tab::Bloom => bloom::draw(frame, app, area),
Tab::Mmp => mmp::draw(frame, app, area),
Tab::Cache => {} // data stored for Routing tab cross-reference
Tab::Routing => routing::draw(frame, app, area),
Tab::Links => {} // not a navigable tab; data stored for cross-references
}
}
fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) {
let conn = match &app.connection_state {
ConnectionState::Connected => {
Span::styled(" Connected ", Style::default().fg(Color::Green))
}
ConnectionState::Disconnected(msg) => Span::styled(
format!(" Disconnected: {} ", msg),
Style::default().fg(Color::Red),
),
};
let elapsed = app.last_fetch.elapsed();
let timing = Span::raw(format!(
"| Refresh: {}s | Updated: {:.1}s ago ",
app.refresh_interval.as_secs(),
elapsed.as_secs_f64()
));
let help = Span::styled("[?] Help ", Style::default().fg(Color::DarkGray));
let line = Line::from(vec![conn, timing, help]);
let bar = Paragraph::new(line);
frame.render_widget(bar, area);
}
+273
View File
@@ -0,0 +1,273 @@
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))
}
+174
View File
@@ -0,0 +1,174 @@
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 crate::app::{App, Tab};
use super::helpers;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Routing) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(7), // Routing State
Constraint::Length(8), // Coordinate Cache
Constraint::Min(3), // Routing Statistics
])
.split(area);
draw_routing_state(frame, data, chunks[0]);
draw_coord_cache(frame, app, chunks[1]);
draw_routing_stats(frame, data, chunks[2]);
}
fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let lines = vec![
helpers::kv_line("Coord Cache", &helpers::u64_field(data, "coord_cache_entries")),
helpers::kv_line(
"Identity Cache",
&helpers::u64_field(data, "identity_cache_entries"),
),
helpers::kv_line(
"Pending Lookups",
&helpers::u64_field(data, "pending_lookups"),
),
helpers::kv_line(
"Recent Requests",
&helpers::u64_field(data, "recent_requests"),
),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing State ");
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), 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> {
let pkts = data
.get("forwarding")
.and_then(|f| f.get(pkt_key))
.and_then(|v| v.as_u64())
.unwrap_or(0);
let bytes = data
.get("forwarding")
.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)))
}
fn draw_routing_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Routing Statistics ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = 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("Already Visited", &helpers::nested_u64(data, "discovery", "req_already_visited")),
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")),
Line::from(""),
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")),
];
let max_lines = inner.height as usize;
lines.truncate(max_lines);
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_coord_cache(frame: &mut Frame, app: &App, 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 inner = block.inner(area);
frame.render_widget(block, area);
let msg =
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
}
};
let entries = helpers::u64_field(data, "entries");
let max_entries = helpers::u64_field(data, "max_entries");
let fill_pct = data
.get("fill_ratio")
.and_then(|v| v.as_f64())
.map(|r| format!("{:.1}%", r * 100.0))
.unwrap_or_else(|| "-".into());
let ttl = data
.get("default_ttl_ms")
.and_then(|v| v.as_u64())
.map(helpers::format_duration_ms)
.unwrap_or_else(|| "-".into());
let expired = helpers::u64_field(data, "expired");
let avg_age = data
.get("avg_age_ms")
.and_then(|v| v.as_u64())
.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 block = Block::default()
.borders(Borders::ALL)
.title(" Coordinate Cache ");
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
}
+274
View File
@@ -0,0 +1,274 @@
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 sessions = get_sessions(app);
let row_count = sessions.len();
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
draw_table(frame, app, chunks[0], &sessions, row_count);
draw_detail(frame, app, chunks[1], &sessions);
} else {
draw_table(frame, app, area, &sessions, row_count);
}
}
fn get_sessions(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Sessions)
.and_then(|v| v.get("sessions"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn draw_table(
frame: &mut Frame,
app: &mut App,
area: Rect,
sessions: &[serde_json::Value],
row_count: usize,
) {
let header = Row::new(vec![
Cell::from("Name"),
Cell::from("Remote Addr"),
Cell::from("State"),
Cell::from("Role"),
Cell::from("SRTT"),
Cell::from("Loss"),
Cell::from("SQI"),
Cell::from("Path MTU"),
Cell::from("Last Activity"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = sessions
.iter()
.map(|session| {
let name = helpers::str_field(session, "display_name");
let addr = helpers::truncate_hex(helpers::str_field(session, "remote_addr"), 10);
let state = helpers::str_field(session, "state");
let role = session
.get("is_initiator")
.and_then(|v| v.as_bool())
.map(|b| if b { "init" } else { "resp" })
.unwrap_or("-");
let srtt = helpers::nested_f64(session, "mmp", "srtt_ms", 1);
let loss = helpers::nested_f64_prefer(session, "mmp", "smoothed_loss", "loss_rate", 3);
let sqi = helpers::nested_f64(session, "mmp", "sqi", 2);
let path_mtu = helpers::nested_u64(session, "mmp", "path_mtu");
let activity = helpers::format_elapsed_ms(
session
.get("last_activity_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
);
Row::new(vec![
Cell::from(name.to_string()),
Cell::from(addr),
Cell::from(state_styled(state)),
Cell::from(role.to_string()),
Cell::from(srtt),
Cell::from(loss),
Cell::from(sqi),
Cell::from(path_mtu),
Cell::from(activity),
])
})
.collect();
let widths = [
Constraint::Min(12),
Constraint::Length(13),
Constraint::Length(13),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(7),
Constraint::Length(6),
Constraint::Length(9),
Constraint::Length(14),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Sessions ({}) ", row_count)),
)
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
let state = app.table_states.entry(Tab::Sessions).or_default();
frame.render_stateful_widget(table, area, state);
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, sessions: &[serde_json::Value]) {
let state = app.table_states.get(&Tab::Sessions);
let selected = state.and_then(|s| s.selected()).unwrap_or(0);
let block = Block::default()
.borders(Borders::ALL)
.title(" Session Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(session) = sessions.get(selected) else {
let msg =
Paragraph::new(" No session selected").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
let role = session
.get("is_initiator")
.and_then(|v| v.as_bool())
.map(|b| if b { "Initiator" } else { "Responder" })
.unwrap_or("-");
let mut lines: Vec<Line> = vec![
helpers::section_header("Identity"),
helpers::kv_line("Name", helpers::str_field(session, "display_name")),
helpers::kv_line("Npub", helpers::str_field(session, "npub")),
helpers::kv_line("Remote Addr", helpers::str_field(session, "remote_addr")),
Line::from(""),
helpers::section_header("Session"),
helpers::kv_line("State", helpers::str_field(session, "state")),
helpers::kv_line("Role", role),
helpers::kv_line(
"Last Activity",
&helpers::format_elapsed_ms(
session
.get("last_activity_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
];
// Traffic stats
lines.push(helpers::section_header("Traffic"));
lines.push(helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(session, "stats", "packets_sent"),
));
lines.push(helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(session, "stats", "packets_recv"),
));
lines.push(helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(
session
.get("stats")
.and_then(|s| s.get("bytes_sent"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
lines.push(helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(
session
.get("stats")
.and_then(|s| s.get("bytes_recv"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
lines.push(Line::from(""));
if session.get("mmp").is_some() {
lines.push(helpers::section_header("MMP Metrics"));
lines.push(helpers::kv_line(
"Mode",
&helpers::nested_str(session, "mmp", "mode"),
));
lines.push(helpers::kv_line(
"SRTT",
&format!(
"{}ms",
helpers::nested_f64(session, "mmp", "srtt_ms", 1)
),
));
lines.push(helpers::kv_line(
"Loss Rate",
&helpers::nested_f64_prefer(session, "mmp", "smoothed_loss", "loss_rate", 4),
));
lines.push(helpers::kv_line(
"ETX",
&helpers::nested_f64_prefer(session, "mmp", "smoothed_etx", "etx", 2),
));
lines.push(helpers::kv_line(
"SQI",
&helpers::nested_f64(session, "mmp", "sqi", 2),
));
lines.push(helpers::kv_line(
"Goodput",
&helpers::nested_throughput(session, "mmp", "goodput_bps"),
));
lines.push(helpers::kv_line(
"Delivery Fwd",
&helpers::nested_f64(session, "mmp", "delivery_ratio_forward", 3),
));
lines.push(helpers::kv_line(
"Delivery Rev",
&helpers::nested_f64(session, "mmp", "delivery_ratio_reverse", 3),
));
lines.push(helpers::kv_line(
"Path MTU",
&helpers::nested_u64(session, "mmp", "path_mtu"),
));
}
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);
}
fn state_styled(state: &str) -> Span<'static> {
let color = match state {
"established" => Color::Green,
"initiating" | "awaiting_msg3" => Color::Yellow,
_ => Color::Red,
};
Span::styled(state.to_string(), Style::default().fg(color))
}
+605
View File
@@ -0,0 +1,605 @@
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, SelectedTreeItem, Tab};
use super::helpers;
/// A single visible row in the tree view — either a transport or a nested link.
enum TreeRow {
Transport {
index: usize,
transport_id: u64,
link_count: usize,
},
Link {
index: usize,
is_last: bool,
},
}
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let transports = get_transports(app);
let links = get_links(app);
let tree_rows = build_tree_rows(&transports, &links, app);
// Update app state for navigation
app.tree_row_count = tree_rows.len();
update_selected_tree_item(app, &tree_rows);
if app.detail_view.is_some() {
let chunks = Layout::horizontal([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.split(area);
draw_table(frame, app, chunks[0], &transports, &links, &tree_rows);
draw_detail(frame, app, chunks[1], &transports, &links, &tree_rows);
} else {
draw_table(frame, app, area, &transports, &links, &tree_rows);
}
}
fn get_transports(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Transports)
.and_then(|v| v.get("transports"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn get_links(app: &App) -> Vec<serde_json::Value> {
app.data
.get(&Tab::Links)
.and_then(|v| v.get("links"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
}
fn build_tree_rows(
transports: &[serde_json::Value],
links: &[serde_json::Value],
app: &App,
) -> Vec<TreeRow> {
let mut rows = Vec::new();
for (t_idx, transport) in transports.iter().enumerate() {
let tid = transport
.get("transport_id")
.and_then(|v| v.as_u64())
.unwrap_or(0);
// Gather links belonging to this transport
let transport_links: Vec<(usize, &serde_json::Value)> = links
.iter()
.enumerate()
.filter(|(_, l)| l.get("transport_id").and_then(|v| v.as_u64()) == Some(tid))
.collect();
rows.push(TreeRow::Transport {
index: t_idx,
transport_id: tid,
link_count: transport_links.len(),
});
if app.expanded_transports.contains(&tid) {
let last_idx = transport_links.len().saturating_sub(1);
for (pos, (l_idx, _)) in transport_links.iter().enumerate() {
rows.push(TreeRow::Link {
index: *l_idx,
is_last: pos == last_idx,
});
}
}
}
rows
}
fn update_selected_tree_item(app: &mut App, tree_rows: &[TreeRow]) {
let selected = app
.table_states
.get(&Tab::Transports)
.and_then(|s| s.selected())
.unwrap_or(0);
app.selected_tree_item = match tree_rows.get(selected) {
Some(TreeRow::Transport { transport_id, .. }) => {
SelectedTreeItem::Transport(*transport_id)
}
Some(TreeRow::Link { .. }) => SelectedTreeItem::Link,
None => SelectedTreeItem::None,
};
}
fn draw_table(
frame: &mut Frame,
app: &mut App,
area: Rect,
transports: &[serde_json::Value],
links: &[serde_json::Value],
tree_rows: &[TreeRow],
) {
let header = Row::new(vec![
Cell::from("Transport / Link"),
Cell::from("State"),
Cell::from("Peer"),
Cell::from("Tx"),
Cell::from("Rx"),
])
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = tree_rows
.iter()
.map(|tree_row| match tree_row {
TreeRow::Transport {
index,
transport_id,
link_count,
} => {
let t = &transports[*index];
let indicator = if *link_count == 0 {
" "
} else if app.expanded_transports.contains(transport_id) {
"\u{25BC} " // ▼
} else {
"\u{25B6} " // ▶
};
let typ = helpers::str_field(t, "type");
let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
let addr = t.get("local_addr").and_then(|v| v.as_str()).unwrap_or("");
let label = if !name.is_empty() {
format!("{indicator}{typ} {name}")
} else if !addr.is_empty() {
format!("{indicator}{typ} {addr}")
} else {
format!("{indicator}{typ} #{transport_id}")
};
let state = helpers::str_field(t, "state");
let tx = t
.get("stats")
.and_then(|s| s.get("packets_sent").or_else(|| s.get("frames_sent")))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
let rx = t
.get("stats")
.and_then(|s| s.get("packets_recv").or_else(|| s.get("frames_recv")))
.and_then(|v| v.as_u64())
.map(|n| n.to_string())
.unwrap_or_else(|| "-".into());
Row::new(vec![
Cell::from(label),
Cell::from(state.to_string()),
Cell::from(""),
Cell::from(tx),
Cell::from(rx),
])
.style(Style::default().fg(Color::White))
}
TreeRow::Link { index, is_last } => {
let link = &links[*index];
let tree_char = if *is_last { "\u{2514}\u{2500}" } else { "\u{251C}\u{2500}" }; // └─ or ├─
let dir = helpers::str_field(link, "direction");
let dir_short = match dir {
"Outbound" => "Out",
"Inbound" => "In",
other => other,
};
let addr = helpers::truncate_hex(helpers::str_field(link, "remote_addr"), 16);
let label = format!(" {tree_char} {dir_short} {addr}");
let state = helpers::str_field(link, "state");
let peer_name = lookup_peer_for_link(app, link)
.map(|p| helpers::str_field(&p, "display_name").to_string())
.unwrap_or_default();
Row::new(vec![
Cell::from(Span::styled(
label,
Style::default().fg(if dir == "Outbound" {
Color::Cyan
} else {
Color::Green
}),
)),
Cell::from(state.to_string()),
Cell::from(peer_name),
Cell::from(""),
Cell::from(""),
])
}
})
.collect();
let transport_count = transports.len();
let link_count: usize = tree_rows
.iter()
.filter(|r| matches!(r, TreeRow::Link { .. }))
.count();
let title = if link_count > 0 {
format!(" Transports ({transport_count}) Links ({link_count}) ")
} else {
format!(" Transports ({transport_count}) ")
};
let widths = [
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(14),
Constraint::Length(9),
Constraint::Length(9),
];
let table = Table::new(rows, widths)
.header(header)
.block(Block::default().borders(Borders::ALL).title(title))
.row_highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{25B8} "); // ▸
let row_count = tree_rows.len();
let state = app.table_states.entry(Tab::Transports).or_default();
frame.render_stateful_widget(table, area, state);
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,
transports: &[serde_json::Value],
links: &[serde_json::Value],
tree_rows: &[TreeRow],
) {
let selected = app
.table_states
.get(&Tab::Transports)
.and_then(|s| s.selected())
.unwrap_or(0);
let Some(tree_row) = tree_rows.get(selected) else {
let block = Block::default()
.borders(Borders::ALL)
.title(" Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let msg = Paragraph::new(" No item selected")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
return;
};
match tree_row {
TreeRow::Transport { index, .. } => {
draw_transport_detail(frame, app, area, &transports[*index]);
}
TreeRow::Link { index, .. } => {
draw_link_detail(frame, app, area, &links[*index]);
}
}
}
fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_json::Value) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Transport Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = vec![
helpers::section_header("Transport Info"),
helpers::kv_line("Transport ID", &helpers::u64_field(t, "transport_id")),
helpers::kv_line("Type", helpers::str_field(t, "type")),
helpers::kv_line("State", helpers::str_field(t, "state")),
helpers::kv_line("MTU", &helpers::u64_field(t, "mtu")),
];
if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Name", name));
}
if let Some(addr) = t.get("local_addr").and_then(|v| v.as_str()) {
lines.push(helpers::kv_line("Local Addr", addr));
}
// Transport stats
if let Some(stats) = t.get("stats") {
let typ = helpers::str_field(t, "type");
lines.push(Line::from(""));
lines.push(helpers::section_header("Traffic"));
match typ {
"ethernet" => {
lines.push(helpers::kv_line(
"Frames Sent",
&helpers::nested_u64(t, "stats", "frames_sent"),
));
lines.push(helpers::kv_line(
"Frames Recv",
&helpers::nested_u64(t, "stats", "frames_recv"),
));
}
_ => {
lines.push(helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(t, "stats", "packets_sent"),
));
lines.push(helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(t, "stats", "packets_recv"),
));
}
}
let bytes_sent = stats
.get("bytes_sent")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let bytes_recv = stats
.get("bytes_recv")
.and_then(|v| v.as_u64())
.unwrap_or(0);
lines.push(helpers::kv_line(
"Bytes Sent",
&helpers::format_bytes(bytes_sent),
));
lines.push(helpers::kv_line(
"Bytes Recv",
&helpers::format_bytes(bytes_recv),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Errors"));
lines.push(helpers::kv_line(
"Send Errors",
&helpers::nested_u64(t, "stats", "send_errors"),
));
lines.push(helpers::kv_line(
"Recv Errors",
&helpers::nested_u64(t, "stats", "recv_errors"),
));
match typ {
"udp" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"Kernel Drops",
&helpers::nested_u64(t, "stats", "kernel_drops"),
));
}
"tcp" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Accepted",
&helpers::nested_u64(t, "stats", "connections_accepted"),
));
lines.push(helpers::kv_line(
"Rejected",
&helpers::nested_u64(t, "stats", "connections_rejected"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
lines.push(helpers::kv_line(
"Refused",
&helpers::nested_u64(t, "stats", "connect_refused"),
));
}
"ethernet" => {
lines.push(Line::from(""));
lines.push(helpers::section_header("Beacons"));
lines.push(helpers::kv_line(
"Beacons Sent",
&helpers::nested_u64(t, "stats", "beacons_sent"),
));
lines.push(helpers::kv_line(
"Beacons Recv",
&helpers::nested_u64(t, "stats", "beacons_recv"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Frame Errors"));
lines.push(helpers::kv_line(
"Too Short",
&helpers::nested_u64(t, "stats", "frames_too_short"),
));
lines.push(helpers::kv_line(
"Too Long",
&helpers::nested_u64(t, "stats", "frames_too_long"),
));
}
_ => {}
}
}
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);
}
fn draw_link_detail(frame: &mut Frame, app: &App, area: Rect, link: &serde_json::Value) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Link Detail ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = vec![
helpers::section_header("Link Info"),
helpers::kv_line("Link ID", &helpers::u64_field(link, "link_id")),
helpers::kv_line("Direction", helpers::str_field(link, "direction")),
helpers::kv_line("State", helpers::str_field(link, "state")),
helpers::kv_line("Remote Addr", helpers::str_field(link, "remote_addr")),
helpers::kv_line(
"Created",
&helpers::format_elapsed_ms(
link.get("created_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
),
Line::from(""),
];
// Transport cross-reference
if let Some(transport) = lookup_transport(app, link) {
lines.push(helpers::section_header("Transport"));
lines.push(helpers::kv_line(
"Transport ID",
&helpers::u64_field(link, "transport_id"),
));
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(""));
}
// Peer cross-reference
if let Some(peer) = lookup_peer_for_link(app, link) {
lines.push(helpers::section_header("Peer"));
lines.push(helpers::kv_line(
"Name",
helpers::str_field(&peer, "display_name"),
));
lines.push(helpers::kv_line(
"Connectivity",
helpers::str_field(&peer, "connectivity"),
));
lines.push(helpers::kv_line(
"Last Seen",
&helpers::format_elapsed_ms(
peer.get("last_seen_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0),
),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Peer Stats"));
lines.push(helpers::kv_line(
"Pkts Sent",
&helpers::nested_u64(&peer, "stats", "packets_sent"),
));
lines.push(helpers::kv_line(
"Pkts Recv",
&helpers::nested_u64(&peer, "stats", "packets_recv"),
));
lines.push(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),
),
));
lines.push(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),
),
));
if peer.get("mmp").is_some() {
lines.push(Line::from(""));
lines.push(helpers::section_header("MMP Metrics"));
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),
));
}
}
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 peer associated with a link by matching link_id.
fn lookup_peer_for_link(app: &App, link: &serde_json::Value) -> Option<serde_json::Value> {
let link_id = link.get("link_id").and_then(|v| v.as_u64())?;
let peers = app.data.get(&Tab::Peers)?;
peers
.get("peers")?
.as_array()?
.iter()
.find(|p| p.get("link_id").and_then(|v| v.as_u64()) == Some(link_id))
.cloned()
}
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()
}
+213
View File
@@ -0,0 +1,213 @@
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 crate::app::{App, Tab};
use super::helpers;
pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let data = match app.data.get(&Tab::Tree) {
Some(d) => d,
None => {
let msg = Paragraph::new(" Waiting for data...")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, area);
return;
}
};
let chunks = Layout::vertical([
Constraint::Length(10), // Tree Position
Constraint::Length(22), // Tree Announce Stats
Constraint::Min(3), // Tree Peers
])
.split(area);
draw_position(frame, data, chunks[0]);
draw_stats(frame, data, chunks[1]);
draw_peers(frame, data, chunks[2]);
}
fn draw_position(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let root_hex = helpers::str_field(data, "root");
let is_root = data
.get("is_root")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let depth = helpers::u64_field(data, "depth");
let parent_name = helpers::str_field(data, "parent_display_name");
let decl_seq = helpers::u64_field(data, "declaration_sequence");
let decl_signed = helpers::bool_field(data, "declaration_signed");
let root_display = if is_root {
format!("{} (self)", helpers::truncate_hex(root_hex, 16))
} else {
helpers::truncate_hex(root_hex, 16)
};
let parent_display = if is_root {
"self (root)".to_string()
} else {
parent_name.to_string()
};
let mut lines = vec![
helpers::kv_line("Root", &root_display),
helpers::kv_line("Depth", &depth),
helpers::kv_line("Parent", &parent_display),
helpers::kv_line("Declaration", &format!("seq {decl_seq}, {decl_signed}")),
Line::from(""),
];
// Coordinate path: my_coords array is self→root, render root→self
if let Some(coords) = data.get("my_coords").and_then(|v| v.as_array()) {
let mut path_parts: Vec<Span> = vec![Span::styled(
" Path: ",
Style::default().fg(Color::DarkGray),
)];
if coords.is_empty() {
path_parts.push(Span::styled(
"[root]",
Style::default().fg(Color::Yellow),
));
} else {
// Reverse: root first, self last
for (i, entry) in coords.iter().rev().enumerate() {
if i > 0 {
path_parts
.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
}
let hex = entry.as_str().unwrap_or("-");
let color = if i == 0 {
Color::Yellow // root
} else {
Color::White
};
path_parts
.push(Span::styled(helpers::truncate_hex(hex, 8), Style::default().fg(color)));
}
path_parts.push(Span::styled(" > ", Style::default().fg(Color::DarkGray)));
path_parts.push(Span::styled(
"[self]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(path_parts));
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Tree Position ");
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Tree Announce Stats ");
let inner = block.inner(area);
frame.render_widget(block, area);
let mut 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")),
helpers::kv_line("Decode Error", &helpers::nested_u64(data, "stats", "decode_error")),
helpers::kv_line("Unknown Peer", &helpers::nested_u64(data, "stats", "unknown_peer")),
helpers::kv_line("Addr Mismatch", &helpers::nested_u64(data, "stats", "addr_mismatch")),
helpers::kv_line("Sig Failed", &helpers::nested_u64(data, "stats", "sig_failed")),
helpers::kv_line("Stale", &helpers::nested_u64(data, "stats", "stale")),
helpers::kv_line("Parent Switched", &helpers::nested_u64(data, "stats", "parent_switched")),
helpers::kv_line("Loop Detected", &helpers::nested_u64(data, "stats", "loop_detected")),
helpers::kv_line("Ancestry Changed", &helpers::nested_u64(data, "stats", "ancestry_changed")),
Line::from(""),
helpers::section_header("Outbound"),
helpers::kv_line("Sent", &helpers::nested_u64(data, "stats", "sent")),
helpers::kv_line("Rate Limited", &helpers::nested_u64(data, "stats", "rate_limited")),
helpers::kv_line("Send Failed", &helpers::nested_u64(data, "stats", "send_failed")),
Line::from(""),
helpers::section_header("Cumulative"),
helpers::kv_line("Parent Switches", &helpers::nested_u64(data, "stats", "parent_switches")),
helpers::kv_line("Parent Losses", &helpers::nested_u64(data, "stats", "parent_losses")),
helpers::kv_line("Flap Dampened", &helpers::nested_u64(data, "stats", "flap_dampened")),
];
// Trim to fit available height
let max_lines = inner.height as usize;
lines.truncate(max_lines);
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_peers(frame: &mut Frame, data: &serde_json::Value, 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 count = peers.len();
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Tree Peers ({count}) "));
let inner = block.inner(area);
frame.render_widget(block, area);
if peers.is_empty() {
let msg =
Paragraph::new(" No peers").style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
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)),
])
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
+3 -1
View File
@@ -375,7 +375,9 @@ impl ControlConfig {
fn default_socket_path() -> String {
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
format!("{}/fips/control.sock", runtime_dir)
format!("{runtime_dir}/fips/control.sock")
} else if std::fs::create_dir_all("/run/fips").is_ok() {
"/run/fips/control.sock".to_string()
} else {
"/tmp/fips-control.sock".to_string()
}
+75 -3
View File
@@ -3,6 +3,7 @@
//! Each function takes `&Node` and returns a `serde_json::Value`.
//! Query logic is kept separate from socket handling.
use crate::identity::encode_npub;
use crate::node::Node;
use serde_json::{json, Value};
@@ -31,10 +32,17 @@ fn trend_label(short: f64, long: f64) -> &'static str {
/// `show_status` — Node overview.
pub fn show_status(node: &Node) -> Value {
let pid = std::process::id();
let exe_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "-".into());
let uptime_secs = node.uptime().as_secs();
let fwd = node.stats().snapshot().forwarding;
json!({
"npub": node.npub(),
"node_addr": hex::encode(node.node_addr().as_bytes()),
"fips_address": format!("{}", node.identity().address()),
"ipv6_addr": format!("{}", node.identity().address()),
"state": format!("{}", node.state()),
"is_leaf_only": node.is_leaf_only(),
"peer_count": node.peer_count(),
@@ -43,7 +51,13 @@ pub fn show_status(node: &Node) -> Value {
"transport_count": node.transport_count(),
"connection_count": node.connection_count(),
"tun_state": format!("{}", node.tun_state()),
"tun_name": node.tun_name().unwrap_or("-"),
"effective_ipv6_mtu": node.effective_ipv6_mtu(),
"control_socket": &node.config().node.control.socket_path,
"pid": pid,
"exe_path": exe_path,
"uptime_secs": uptime_secs,
"forwarding": serde_json::to_value(&fwd).unwrap_or_default(),
})
}
@@ -57,7 +71,7 @@ pub fn show_peers(node: &Node) -> Value {
"node_addr": addr_hex,
"npub": peer.npub(),
"display_name": node.peer_display_name(&node_addr),
"fips_address": format!("{}", peer.address()),
"ipv6_addr": format!("{}", peer.address()),
"connectivity": format!("{}", peer.connectivity()),
"link_id": peer.link_id().as_u64(),
"authenticated_at_ms": peer.authenticated_at(),
@@ -99,6 +113,17 @@ pub fn show_peers(node: &Node) -> Value {
mmp_json["goodput_bps"] = json!(mmp.metrics.goodput_bps);
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms() {
if let Some(setx) = mmp.metrics.smoothed_etx() {
mmp_json["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
peer_json["mmp"] = mmp_json;
}
@@ -207,15 +232,44 @@ pub fn show_sessions(node: &Node) -> Value {
"last_activity_ms": entry.last_activity(),
});
// Derive npub from session's remote public key
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
session_json["npub"] = json!(encode_npub(&xonly));
// Traffic counters
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
session_json["stats"] = json!({
"packets_sent": pkts_tx,
"packets_recv": pkts_rx,
"bytes_sent": bytes_tx,
"bytes_recv": bytes_rx,
});
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
"mode": format!("{}", mmp.mode()),
"loss_rate": mmp.metrics.loss_rate(),
"etx": mmp.metrics.etx,
"goodput_bps": mmp.metrics.goodput_bps,
"delivery_ratio_forward": mmp.metrics.delivery_ratio_forward,
"delivery_ratio_reverse": mmp.metrics.delivery_ratio_reverse,
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(srtt) = mmp.metrics.srtt_ms() {
mmp_json["srtt_ms"] = json!(srtt);
}
mmp_json["path_mtu"] = json!(mmp.path_mtu.current_mtu());
if let Some(smoothed_loss) = mmp.metrics.smoothed_loss() {
mmp_json["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = mmp.metrics.smoothed_etx() {
mmp_json["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = mmp.metrics.srtt_ms() {
if let Some(setx) = mmp.metrics.smoothed_etx() {
mmp_json["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
session_json["mmp"] = mmp_json;
}
@@ -279,8 +333,17 @@ pub fn show_mmp(node: &Node) -> Value {
"spin_bit_role": if mmp.spin_bit.is_initiator() { "initiator" } else { "responder" },
});
if let Some(smoothed_loss) = metrics.smoothed_loss() {
link_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
link_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
link_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
link_layer["lqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
// Trend indicators
@@ -319,8 +382,17 @@ pub fn show_mmp(node: &Node) -> Value {
"path_mtu": mmp.path_mtu.current_mtu(),
});
if let Some(smoothed_loss) = metrics.smoothed_loss() {
session_layer["smoothed_loss"] = json!(smoothed_loss);
}
if let Some(smoothed_etx) = metrics.smoothed_etx() {
session_layer["smoothed_etx"] = json!(smoothed_etx);
}
if let Some(srtt) = metrics.srtt_ms() {
session_layer["srtt_ms"] = json!(srtt);
if let Some(setx) = metrics.smoothed_etx() {
session_layer["sqi"] = json!(setx * (1.0 + srtt / 100.0));
}
}
Some(json!({
+22
View File
@@ -24,6 +24,7 @@ pub struct MmpMetrics {
pub loss_trend: DualEwma,
pub goodput_trend: DualEwma,
pub jitter_trend: DualEwma,
pub etx_trend: DualEwma,
/// Forward delivery ratio (what fraction of our frames the peer received).
pub delivery_ratio_forward: f64,
@@ -54,6 +55,7 @@ impl MmpMetrics {
loss_trend: DualEwma::new(),
goodput_trend: DualEwma::new(),
jitter_trend: DualEwma::new(),
etx_trend: DualEwma::new(),
delivery_ratio_forward: 1.0,
delivery_ratio_reverse: 1.0,
etx: 1.0,
@@ -106,6 +108,7 @@ impl MmpMetrics {
let loss_rate = 1.0 - self.delivery_ratio_forward;
self.loss_trend.update(loss_rate);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
}
}
@@ -146,6 +149,7 @@ impl MmpMetrics {
pub fn set_delivery_ratio_reverse(&mut self, ratio: f64) {
self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
}
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
@@ -162,6 +166,24 @@ impl MmpMetrics {
1.0 - self.delivery_ratio_forward
}
/// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized.
pub fn smoothed_loss(&self) -> Option<f64> {
if self.loss_trend.initialized() {
Some(self.loss_trend.long())
} else {
None
}
}
/// Smoothed ETX (long-term EWMA), or `None` if not yet initialized.
pub fn smoothed_etx(&self) -> Option<f64> {
if self.etx_trend.initialized() {
Some(self.etx_trend.long())
} else {
None
}
}
/// Current smoothed goodput in bytes/sec, or 0 if not yet measured.
pub fn goodput_bps(&self) -> f64 {
self.goodput_bps
+4 -2
View File
@@ -263,11 +263,12 @@ impl Node {
}
}
// Only application data resets the idle timer — MMP reports
// (SenderReport, ReceiverReport, PathMtuNotification) do not.
// Only application data resets the idle timer and traffic counters —
// MMP reports (SenderReport, ReceiverReport, PathMtuNotification) do not.
if msg_type == SessionMessageType::DataPacket.to_byte()
&& let Some(entry) = self.sessions.get_mut(src_addr)
{
entry.record_recv(rest.len());
entry.touch(Self::now_ms());
}
@@ -1026,6 +1027,7 @@ impl Node {
// Re-borrow after send (which borrowed &mut self)
if let Some(entry) = self.sessions.get_mut(dest_addr) {
entry.record_sent(plaintext.len());
if let Some(mmp) = entry.mmp_mut() {
mmp.sender.record_sent(counter, timestamp, ciphertext.len());
}
+15
View File
@@ -216,6 +216,9 @@ pub struct Node {
/// Exchanged inside Noise handshake messages so peers can detect restarts.
startup_epoch: [u8; 8],
/// Instant when the node was created, for uptime reporting.
started_at: std::time::Instant,
// === Configuration ===
/// Loaded configuration.
config: Config,
@@ -415,6 +418,7 @@ impl Node {
Ok(Self {
identity,
startup_epoch,
started_at: std::time::Instant::now(),
config,
state: NodeState::Created,
is_leaf_only,
@@ -509,6 +513,7 @@ impl Node {
Self {
identity,
startup_epoch,
started_at: std::time::Instant::now(),
config,
state: NodeState::Created,
is_leaf_only: false,
@@ -764,6 +769,11 @@ impl Node {
self.state
}
/// Get the node uptime.
pub fn uptime(&self) -> std::time::Duration {
self.started_at.elapsed()
}
/// Check if node is operational.
pub fn is_running(&self) -> bool {
self.state.is_operational()
@@ -829,6 +839,11 @@ impl Node {
self.tun_state
}
/// Get the TUN interface name, if active.
pub fn tun_name(&self) -> Option<&str> {
self.tun_name.as_deref()
}
// === Resource Limits ===
+33
View File
@@ -73,6 +73,16 @@ pub(crate) struct SessionEntry {
/// Session-layer MMP state. Initialized on Established transition.
mmp: Option<MmpSessionState>,
// === Traffic Counters ===
/// Total data packets sent on this session.
packets_sent: u64,
/// Total data packets received on this session.
packets_recv: u64,
/// Total data bytes sent on this session (FSP payload).
bytes_sent: u64,
/// Total data bytes received on this session (FSP payload).
bytes_recv: u64,
// === Handshake Resend ===
/// Encoded session-layer payload for resend (SessionSetup or SessionAck).
/// Cleared on Established transition.
@@ -102,6 +112,10 @@ impl SessionEntry {
coords_warmup_remaining: 0,
is_initiator,
mmp: None,
packets_sent: 0,
packets_recv: 0,
bytes_sent: 0,
bytes_recv: 0,
handshake_payload: None,
resend_count: 0,
next_resend_at_ms: 0,
@@ -219,6 +233,25 @@ impl SessionEntry {
self.mmp = Some(MmpSessionState::new(config, self.is_initiator));
}
// === Traffic Counters ===
/// Record a sent data packet.
pub(crate) fn record_sent(&mut self, bytes: usize) {
self.packets_sent += 1;
self.bytes_sent += bytes as u64;
}
/// Record a received data packet.
pub(crate) fn record_recv(&mut self, bytes: usize) {
self.packets_recv += 1;
self.bytes_recv += bytes as u64;
}
/// Get traffic counters: (packets_sent, packets_recv, bytes_sent, bytes_recv).
pub(crate) fn traffic_counters(&self) -> (u64, u64, u64, u64) {
(self.packets_sent, self.packets_recv, self.bytes_sent, self.bytes_recv)
}
// === Handshake Resend ===
/// Store the encoded session-layer payload for potential resend.
+3 -3
View File
@@ -16,9 +16,9 @@ from .topology import SimTopology
log = logging.getLogger(__name__)
# Default control socket path inside containers (XDG_RUNTIME_DIR is
# typically unset in Docker, so fips falls back to /tmp).
CONTROL_SOCKET = "/tmp/fips-control.sock"
# Default control socket path inside containers (running as root,
# so /run/fips/ is created by the daemon).
CONTROL_SOCKET = "/run/fips/control.sock"
def query_node(container: str, command: str, timeout: int = 10) -> dict | None:
+1
View File
@@ -1,2 +1,3 @@
fips
fipsctl
fipstop
+3 -3
View File
@@ -1,4 +1,4 @@
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
@@ -25,8 +25,8 @@ RUN printf '%s\n' \
'no-resolv' \
>> /etc/dnsmasq.conf
COPY fips fipsctl /usr/local/bin/
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl
COPY fips fipsctl fipstop /usr/local/bin/
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop
COPY entrypoint.sh /entrypoint.sh
+1 -1
View File
@@ -1,4 +1,4 @@
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
+1
View File
@@ -26,6 +26,7 @@ services:
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
volumes:
- ./resolv.conf:/etc/resolv.conf:ro
networks:
+5 -1
View File
@@ -7,6 +7,7 @@ set -e
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
mkdir -p /etc/fips
@@ -17,7 +18,7 @@ if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
alias: \"${FIPS_PEER_ALIAS}\"
addresses:
- transport: udp
- transport: ${FIPS_PEER_TRANSPORT}
addr: \"${FIPS_PEER_ADDR}\"
connect_policy: auto_connect"
fi
@@ -40,6 +41,7 @@ transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
mtu: 1472
tcp: {}
peers:
${PEERS_SECTION:- []}
@@ -61,6 +63,8 @@ iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
+3 -1
View File
@@ -38,6 +38,7 @@ if [ "$UNAME_S" = "Darwin" ]; then
echo "Copying binaries to docker context..."
cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fips" "$DOCKER_DIR/fips"
cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipsctl" "$DOCKER_DIR/fipsctl"
cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipstop" "$DOCKER_DIR/fipstop"
else
echo "Building FIPS (release)..."
cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml"
@@ -45,9 +46,10 @@ else
echo "Copying binaries to docker context..."
cp "$PROJECT_ROOT/target/release/fips" "$DOCKER_DIR/fips"
cp "$PROJECT_ROOT/target/release/fipsctl" "$DOCKER_DIR/fipsctl"
cp "$PROJECT_ROOT/target/release/fipstop" "$DOCKER_DIR/fipstop"
fi
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl}"
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl,fipstop}"
echo ""
echo "Building Docker image..."
docker compose -f "$DOCKER_DIR/docker-compose.yml" build