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

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

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

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

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

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

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

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

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

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

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

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

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

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

172 lines
5.3 KiB
Rust

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()),
])
}