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: |
|
path: |
|
||||||
target/release/fips
|
target/release/fips
|
||||||
target/release/fipsctl
|
target/release/fipsctl
|
||||||
|
target/release/fipstop
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -264,9 +265,10 @@ jobs:
|
|||||||
- name: Install binary (sidecar)
|
- name: Install binary (sidecar)
|
||||||
if: matrix.type == 'sidecar'
|
if: matrix.type == 'sidecar'
|
||||||
run: |
|
run: |
|
||||||
chmod +x _bin/fips _bin/fipsctl
|
chmod +x _bin/fips _bin/fipsctl _bin/fipstop
|
||||||
cp _bin/fips testing/sidecar/fips
|
cp _bin/fips testing/sidecar/fips
|
||||||
cp _bin/fipsctl testing/sidecar/fipsctl
|
cp _bin/fipsctl testing/sidecar/fipsctl
|
||||||
|
cp _bin/fipstop testing/sidecar/fipstop
|
||||||
|
|
||||||
- name: Run sidecar integration test
|
- name: Run sidecar integration test
|
||||||
if: matrix.type == 'sidecar'
|
if: matrix.type == 'sidecar'
|
||||||
|
|||||||
+2
-1
@@ -14,4 +14,5 @@
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
deploy/
|
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"
|
repository = "https://github.com/jmcorgan/fips"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["tui"]
|
||||||
|
tui = ["dep:ratatui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ratatui = { version = "0.30", optional = true }
|
||||||
secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
@@ -39,3 +44,8 @@ criterion = { version = "0.8.2", features = ["html_reports"] }
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "fipsctl"
|
name = "fipsctl"
|
||||||
path = "src/bin/fipsctl.rs"
|
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 |
|
| Parameter | Type | Default | Description |
|
||||||
|-----------|------|---------|-------------|
|
|-----------|------|---------|-------------|
|
||||||
| `node.control.enabled` | bool | `true` | Enable the Unix domain control socket |
|
| `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
|
The control socket provides read-only access to node state via the
|
||||||
`fipsctl` command-line tool. See the project
|
`fipsctl` command-line tool. See the project
|
||||||
@@ -480,7 +480,7 @@ node:
|
|||||||
owd_window_size: 32
|
owd_window_size: 32
|
||||||
control:
|
control:
|
||||||
enabled: true
|
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:
|
buffers:
|
||||||
packet_channel: 1024
|
packet_channel: 1024
|
||||||
tun_channel: 1024
|
tun_channel: 1024
|
||||||
|
|||||||
+4
-2
@@ -6,7 +6,7 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::os::unix::net::UnixStream;
|
use std::os::unix::net::UnixStream;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// FIPS control client
|
/// FIPS control client
|
||||||
@@ -77,7 +77,9 @@ impl ShowCommands {
|
|||||||
/// Determine the default socket path.
|
/// Determine the default socket path.
|
||||||
fn default_socket_path() -> PathBuf {
|
fn default_socket_path() -> PathBuf {
|
||||||
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
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 {
|
} else {
|
||||||
PathBuf::from("/tmp/fips-control.sock")
|
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 {
|
fn default_socket_path() -> String {
|
||||||
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
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 {
|
} else {
|
||||||
"/tmp/fips-control.sock".to_string()
|
"/tmp/fips-control.sock".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-3
@@ -3,6 +3,7 @@
|
|||||||
//! Each function takes `&Node` and returns a `serde_json::Value`.
|
//! Each function takes `&Node` and returns a `serde_json::Value`.
|
||||||
//! Query logic is kept separate from socket handling.
|
//! Query logic is kept separate from socket handling.
|
||||||
|
|
||||||
|
use crate::identity::encode_npub;
|
||||||
use crate::node::Node;
|
use crate::node::Node;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -31,10 +32,17 @@ fn trend_label(short: f64, long: f64) -> &'static str {
|
|||||||
|
|
||||||
/// `show_status` — Node overview.
|
/// `show_status` — Node overview.
|
||||||
pub fn show_status(node: &Node) -> Value {
|
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!({
|
json!({
|
||||||
"npub": node.npub(),
|
"npub": node.npub(),
|
||||||
"node_addr": hex::encode(node.node_addr().as_bytes()),
|
"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()),
|
"state": format!("{}", node.state()),
|
||||||
"is_leaf_only": node.is_leaf_only(),
|
"is_leaf_only": node.is_leaf_only(),
|
||||||
"peer_count": node.peer_count(),
|
"peer_count": node.peer_count(),
|
||||||
@@ -43,7 +51,13 @@ pub fn show_status(node: &Node) -> Value {
|
|||||||
"transport_count": node.transport_count(),
|
"transport_count": node.transport_count(),
|
||||||
"connection_count": node.connection_count(),
|
"connection_count": node.connection_count(),
|
||||||
"tun_state": format!("{}", node.tun_state()),
|
"tun_state": format!("{}", node.tun_state()),
|
||||||
|
"tun_name": node.tun_name().unwrap_or("-"),
|
||||||
"effective_ipv6_mtu": node.effective_ipv6_mtu(),
|
"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,
|
"node_addr": addr_hex,
|
||||||
"npub": peer.npub(),
|
"npub": peer.npub(),
|
||||||
"display_name": node.peer_display_name(&node_addr),
|
"display_name": node.peer_display_name(&node_addr),
|
||||||
"fips_address": format!("{}", peer.address()),
|
"ipv6_addr": format!("{}", peer.address()),
|
||||||
"connectivity": format!("{}", peer.connectivity()),
|
"connectivity": format!("{}", peer.connectivity()),
|
||||||
"link_id": peer.link_id().as_u64(),
|
"link_id": peer.link_id().as_u64(),
|
||||||
"authenticated_at_ms": peer.authenticated_at(),
|
"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["goodput_bps"] = json!(mmp.metrics.goodput_bps);
|
||||||
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
|
mmp_json["delivery_ratio_forward"] = json!(mmp.metrics.delivery_ratio_forward);
|
||||||
mmp_json["delivery_ratio_reverse"] = json!(mmp.metrics.delivery_ratio_reverse);
|
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;
|
peer_json["mmp"] = mmp_json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,15 +232,44 @@ pub fn show_sessions(node: &Node) -> Value {
|
|||||||
"last_activity_ms": entry.last_activity(),
|
"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
|
// Add session MMP if available
|
||||||
if let Some(mmp) = entry.mmp() {
|
if let Some(mmp) = entry.mmp() {
|
||||||
let mut mmp_json = json!({
|
let mut mmp_json = json!({
|
||||||
"mode": format!("{}", mmp.mode()),
|
"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() {
|
if let Some(srtt) = mmp.metrics.srtt_ms() {
|
||||||
mmp_json["srtt_ms"] = json!(srtt);
|
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;
|
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" },
|
"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() {
|
if let Some(srtt) = metrics.srtt_ms() {
|
||||||
link_layer["srtt_ms"] = json!(srtt);
|
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
|
// Trend indicators
|
||||||
@@ -319,8 +382,17 @@ pub fn show_mmp(node: &Node) -> Value {
|
|||||||
"path_mtu": mmp.path_mtu.current_mtu(),
|
"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() {
|
if let Some(srtt) = metrics.srtt_ms() {
|
||||||
session_layer["srtt_ms"] = json!(srtt);
|
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!({
|
Some(json!({
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct MmpMetrics {
|
|||||||
pub loss_trend: DualEwma,
|
pub loss_trend: DualEwma,
|
||||||
pub goodput_trend: DualEwma,
|
pub goodput_trend: DualEwma,
|
||||||
pub jitter_trend: DualEwma,
|
pub jitter_trend: DualEwma,
|
||||||
|
pub etx_trend: DualEwma,
|
||||||
|
|
||||||
/// Forward delivery ratio (what fraction of our frames the peer received).
|
/// Forward delivery ratio (what fraction of our frames the peer received).
|
||||||
pub delivery_ratio_forward: f64,
|
pub delivery_ratio_forward: f64,
|
||||||
@@ -54,6 +55,7 @@ impl MmpMetrics {
|
|||||||
loss_trend: DualEwma::new(),
|
loss_trend: DualEwma::new(),
|
||||||
goodput_trend: DualEwma::new(),
|
goodput_trend: DualEwma::new(),
|
||||||
jitter_trend: DualEwma::new(),
|
jitter_trend: DualEwma::new(),
|
||||||
|
etx_trend: DualEwma::new(),
|
||||||
delivery_ratio_forward: 1.0,
|
delivery_ratio_forward: 1.0,
|
||||||
delivery_ratio_reverse: 1.0,
|
delivery_ratio_reverse: 1.0,
|
||||||
etx: 1.0,
|
etx: 1.0,
|
||||||
@@ -106,6 +108,7 @@ impl MmpMetrics {
|
|||||||
let loss_rate = 1.0 - self.delivery_ratio_forward;
|
let loss_rate = 1.0 - self.delivery_ratio_forward;
|
||||||
self.loss_trend.update(loss_rate);
|
self.loss_trend.update(loss_rate);
|
||||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
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) {
|
pub fn set_delivery_ratio_reverse(&mut self, ratio: f64) {
|
||||||
self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0);
|
self.delivery_ratio_reverse = ratio.clamp(0.0, 1.0);
|
||||||
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
|
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.
|
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
|
||||||
@@ -162,6 +166,24 @@ impl MmpMetrics {
|
|||||||
1.0 - self.delivery_ratio_forward
|
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.
|
/// Current smoothed goodput in bytes/sec, or 0 if not yet measured.
|
||||||
pub fn goodput_bps(&self) -> f64 {
|
pub fn goodput_bps(&self) -> f64 {
|
||||||
self.goodput_bps
|
self.goodput_bps
|
||||||
|
|||||||
@@ -263,11 +263,12 @@ impl Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only application data resets the idle timer — MMP reports
|
// Only application data resets the idle timer and traffic counters —
|
||||||
// (SenderReport, ReceiverReport, PathMtuNotification) do not.
|
// MMP reports (SenderReport, ReceiverReport, PathMtuNotification) do not.
|
||||||
if msg_type == SessionMessageType::DataPacket.to_byte()
|
if msg_type == SessionMessageType::DataPacket.to_byte()
|
||||||
&& let Some(entry) = self.sessions.get_mut(src_addr)
|
&& let Some(entry) = self.sessions.get_mut(src_addr)
|
||||||
{
|
{
|
||||||
|
entry.record_recv(rest.len());
|
||||||
entry.touch(Self::now_ms());
|
entry.touch(Self::now_ms());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1026,6 +1027,7 @@ impl Node {
|
|||||||
|
|
||||||
// Re-borrow after send (which borrowed &mut self)
|
// Re-borrow after send (which borrowed &mut self)
|
||||||
if let Some(entry) = self.sessions.get_mut(dest_addr) {
|
if let Some(entry) = self.sessions.get_mut(dest_addr) {
|
||||||
|
entry.record_sent(plaintext.len());
|
||||||
if let Some(mmp) = entry.mmp_mut() {
|
if let Some(mmp) = entry.mmp_mut() {
|
||||||
mmp.sender.record_sent(counter, timestamp, ciphertext.len());
|
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.
|
/// Exchanged inside Noise handshake messages so peers can detect restarts.
|
||||||
startup_epoch: [u8; 8],
|
startup_epoch: [u8; 8],
|
||||||
|
|
||||||
|
/// Instant when the node was created, for uptime reporting.
|
||||||
|
started_at: std::time::Instant,
|
||||||
|
|
||||||
// === Configuration ===
|
// === Configuration ===
|
||||||
/// Loaded configuration.
|
/// Loaded configuration.
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -415,6 +418,7 @@ impl Node {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
identity,
|
identity,
|
||||||
startup_epoch,
|
startup_epoch,
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
config,
|
config,
|
||||||
state: NodeState::Created,
|
state: NodeState::Created,
|
||||||
is_leaf_only,
|
is_leaf_only,
|
||||||
@@ -509,6 +513,7 @@ impl Node {
|
|||||||
Self {
|
Self {
|
||||||
identity,
|
identity,
|
||||||
startup_epoch,
|
startup_epoch,
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
config,
|
config,
|
||||||
state: NodeState::Created,
|
state: NodeState::Created,
|
||||||
is_leaf_only: false,
|
is_leaf_only: false,
|
||||||
@@ -764,6 +769,11 @@ impl Node {
|
|||||||
self.state
|
self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the node uptime.
|
||||||
|
pub fn uptime(&self) -> std::time::Duration {
|
||||||
|
self.started_at.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if node is operational.
|
/// Check if node is operational.
|
||||||
pub fn is_running(&self) -> bool {
|
pub fn is_running(&self) -> bool {
|
||||||
self.state.is_operational()
|
self.state.is_operational()
|
||||||
@@ -829,6 +839,11 @@ impl Node {
|
|||||||
self.tun_state
|
self.tun_state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the TUN interface name, if active.
|
||||||
|
pub fn tun_name(&self) -> Option<&str> {
|
||||||
|
self.tun_name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// === Resource Limits ===
|
// === Resource Limits ===
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ pub(crate) struct SessionEntry {
|
|||||||
/// Session-layer MMP state. Initialized on Established transition.
|
/// Session-layer MMP state. Initialized on Established transition.
|
||||||
mmp: Option<MmpSessionState>,
|
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 ===
|
// === Handshake Resend ===
|
||||||
/// Encoded session-layer payload for resend (SessionSetup or SessionAck).
|
/// Encoded session-layer payload for resend (SessionSetup or SessionAck).
|
||||||
/// Cleared on Established transition.
|
/// Cleared on Established transition.
|
||||||
@@ -102,6 +112,10 @@ impl SessionEntry {
|
|||||||
coords_warmup_remaining: 0,
|
coords_warmup_remaining: 0,
|
||||||
is_initiator,
|
is_initiator,
|
||||||
mmp: None,
|
mmp: None,
|
||||||
|
packets_sent: 0,
|
||||||
|
packets_recv: 0,
|
||||||
|
bytes_sent: 0,
|
||||||
|
bytes_recv: 0,
|
||||||
handshake_payload: None,
|
handshake_payload: None,
|
||||||
resend_count: 0,
|
resend_count: 0,
|
||||||
next_resend_at_ms: 0,
|
next_resend_at_ms: 0,
|
||||||
@@ -219,6 +233,25 @@ impl SessionEntry {
|
|||||||
self.mmp = Some(MmpSessionState::new(config, self.is_initiator));
|
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 ===
|
// === Handshake Resend ===
|
||||||
|
|
||||||
/// Store the encoded session-layer payload for potential resend.
|
/// Store the encoded session-layer payload for potential resend.
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ from .topology import SimTopology
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default control socket path inside containers (XDG_RUNTIME_DIR is
|
# Default control socket path inside containers (running as root,
|
||||||
# typically unset in Docker, so fips falls back to /tmp).
|
# so /run/fips/ is created by the daemon).
|
||||||
CONTROL_SOCKET = "/tmp/fips-control.sock"
|
CONTROL_SOCKET = "/run/fips/control.sock"
|
||||||
|
|
||||||
|
|
||||||
def query_node(container: str, command: str, timeout: int = 10) -> dict | None:
|
def query_node(container: str, command: str, timeout: int = 10) -> dict | None:
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
fips
|
fips
|
||||||
fipsctl
|
fipsctl
|
||||||
|
fipstop
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -25,8 +25,8 @@ RUN printf '%s\n' \
|
|||||||
'no-resolv' \
|
'no-resolv' \
|
||||||
>> /etc/dnsmasq.conf
|
>> /etc/dnsmasq.conf
|
||||||
|
|
||||||
COPY fips fipsctl /usr/local/bin/
|
COPY fips fipsctl fipstop /usr/local/bin/
|
||||||
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl
|
RUN chmod +x /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
|
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
|
||||||
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
|
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
|
||||||
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
|
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
|
||||||
|
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-udp}
|
||||||
volumes:
|
volumes:
|
||||||
- ./resolv.conf:/etc/resolv.conf:ro
|
- ./resolv.conf:/etc/resolv.conf:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ set -e
|
|||||||
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
|
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
|
||||||
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
|
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
|
||||||
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
|
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
|
||||||
|
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-udp}"
|
||||||
|
|
||||||
mkdir -p /etc/fips
|
mkdir -p /etc/fips
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
|
|||||||
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
|
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
|
||||||
alias: \"${FIPS_PEER_ALIAS}\"
|
alias: \"${FIPS_PEER_ALIAS}\"
|
||||||
addresses:
|
addresses:
|
||||||
- transport: udp
|
- transport: ${FIPS_PEER_TRANSPORT}
|
||||||
addr: \"${FIPS_PEER_ADDR}\"
|
addr: \"${FIPS_PEER_ADDR}\"
|
||||||
connect_policy: auto_connect"
|
connect_policy: auto_connect"
|
||||||
fi
|
fi
|
||||||
@@ -40,6 +41,7 @@ transports:
|
|||||||
udp:
|
udp:
|
||||||
bind_addr: "${FIPS_UDP_BIND}"
|
bind_addr: "${FIPS_UDP_BIND}"
|
||||||
mtu: 1472
|
mtu: 1472
|
||||||
|
tcp: {}
|
||||||
|
|
||||||
peers:
|
peers:
|
||||||
${PEERS_SECTION:- []}
|
${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 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 --dport 2121 -j ACCEPT
|
||||||
iptables -A INPUT -i eth0 -p udp --sport 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 OUTPUT -o eth0 -j DROP
|
||||||
iptables -A INPUT -i 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..."
|
echo "Copying binaries to docker context..."
|
||||||
cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fips" "$DOCKER_DIR/fips"
|
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/fipsctl" "$DOCKER_DIR/fipsctl"
|
||||||
|
cp "$PROJECT_ROOT/target/$CARGO_TARGET/release/fipstop" "$DOCKER_DIR/fipstop"
|
||||||
else
|
else
|
||||||
echo "Building FIPS (release)..."
|
echo "Building FIPS (release)..."
|
||||||
cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml"
|
cargo build --release --manifest-path="$PROJECT_ROOT/Cargo.toml"
|
||||||
@@ -45,9 +46,10 @@ else
|
|||||||
echo "Copying binaries to docker context..."
|
echo "Copying binaries to docker context..."
|
||||||
cp "$PROJECT_ROOT/target/release/fips" "$DOCKER_DIR/fips"
|
cp "$PROJECT_ROOT/target/release/fips" "$DOCKER_DIR/fips"
|
||||||
cp "$PROJECT_ROOT/target/release/fipsctl" "$DOCKER_DIR/fipsctl"
|
cp "$PROJECT_ROOT/target/release/fipsctl" "$DOCKER_DIR/fipsctl"
|
||||||
|
cp "$PROJECT_ROOT/target/release/fipstop" "$DOCKER_DIR/fipstop"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl}"
|
echo "Done. Binaries at $DOCKER_DIR/{fips,fipsctl,fipstop}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Building Docker image..."
|
echo "Building Docker image..."
|
||||||
docker compose -f "$DOCKER_DIR/docker-compose.yml" build
|
docker compose -f "$DOCKER_DIR/docker-compose.yml" build
|
||||||
|
|||||||
Reference in New Issue
Block a user