mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
.claude/
|
||||
|
||||
deploy/
|
||||
vps.env
|
||||
Generated
+969
-25
File diff suppressed because it is too large
Load Diff
+10
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
fips
|
||||
fipsctl
|
||||
fipstop
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user