Merge branch 'master' into next

This commit is contained in:
Johnathan Corgan
2026-04-14 09:34:05 +00:00
19 changed files with 3076 additions and 28 deletions
+30
View File
@@ -155,6 +155,27 @@ with v0.2.x peers.
loads config before initializing tracing so the configured level
takes effect; `RUST_LOG` still overrides when set
#### Operator Tooling
- `fipsctl show identity-cache` lists every cached node identity
(npub, IPv6 address, display name, LRU age) alongside the
configured cache capacity
- `fipsctl show peers` extended with per-peer security signals
(replay suppression count, consecutive decrypt failures), Noise
session counters, session indices, and rekey lifecycle state
- `fipsctl show sessions` extended with handshake resend count
during establishment and rekey/session health fields when
established (session start, K-bit epoch, coords warmup remaining,
drain state)
- `fipsctl show cache` now includes individual coordinate cache
entries (tree coordinates, depth, path MTU, age). The top-level
count field was renamed from `entries` to `count` for clarity
- `fipsctl show routing` expands `pending_lookups` from a count to
per-target detail (attempt, age, last sent), adds pending TUN
packet queue depth, and adds per-peer connection retry state
([#42](https://github.com/jmcorgan/fips/pull/42),
[@osh](https://github.com/osh))
#### Documentation
- Pre-implementation proposal for NAT traversal using Nostr relays
@@ -184,6 +205,15 @@ with v0.2.x peers.
shutdown, retry scheduling). Info output now focuses on
operator-relevant state changes: lifecycle events, peer promotions,
session establishment, parent switches, transport start/stop
- **Breaking (control socket JSON):** `show_cache` response field
`entries` has changed type from a `u64` count to an array of entry
objects; a new `count` field carries the previous scalar value.
`show_routing` response field `pending_lookups` has changed type
from a `u64` count to an array of per-target lookup objects.
External consumers parsing these fields as numbers must be
updated. In-tree `fipstop` is adjusted to the new schema. The
control socket interface is still pre-1.0 and not covered by
stability guarantees
### Fixed
+12 -7
View File
@@ -300,13 +300,18 @@ ssh -6 npub1bbb....fips
Use `fipsctl` to query a running node:
```bash
fipsctl show status # Node status overview
fipsctl show peers # Authenticated peers
fipsctl show links # Active links
fipsctl show tree # Spanning tree state
fipsctl show sessions # End-to-end sessions
fipsctl show transports # Transport instances
fipsctl show routing # Routing table summary
fipsctl show status # Node status overview
fipsctl show peers # Authenticated peers and security state
fipsctl show links # Active links
fipsctl show tree # Spanning tree state
fipsctl show sessions # End-to-end sessions and rekey health
fipsctl show bloom # Bloom filter state
fipsctl show mmp # MMP metrics summary
fipsctl show cache # Coordinate cache entries and routes
fipsctl show connections # Pending handshake connections
fipsctl show transports # Transport instances
fipsctl show routing # Routing, discovery, and retry state
fipsctl show identity-cache # Known node identities (npubs)
```
`fipstop` provides an interactive TUI dashboard with live-updating
+177
View File
@@ -66,6 +66,38 @@ enum Commands {
/// Peer identifier: npub (bech32) or hostname from /etc/fips/hosts
peer: String,
},
/// Query historical node statistics
Stats {
#[command(subcommand)]
what: StatsCommands,
},
}
#[derive(Subcommand, Debug)]
enum StatsCommands {
/// List available history metrics
List,
/// List peers tracked in the stats history
Peers,
/// Fetch a time-series window for a metric
History {
/// Metric name (see `fipsctl stats list`). Node-level metrics
/// need no `--peer`; per-peer metrics require it.
metric: String,
/// Peer npub (bech32) or hostname from /etc/fips/hosts for
/// per-peer metrics
#[arg(long)]
peer: Option<String>,
/// Window duration — `<N>s`, `<N>m`, `<N>h`
#[arg(long, default_value = "10m")]
window: String,
/// Sample resolution — `1s` (fast ring) or `1m` (slow ring)
#[arg(long, default_value = "1s")]
granularity: String,
/// Render a Unicode block sparkline instead of JSON
#[arg(long)]
plot: bool,
},
}
#[derive(Subcommand, Debug)]
@@ -92,6 +124,8 @@ enum ShowCommands {
Transports,
/// Routing table summary
Routing,
/// Identity cache entries (known node pubkeys)
IdentityCache,
}
impl ShowCommands {
@@ -108,6 +142,7 @@ impl ShowCommands {
ShowCommands::Connections => "show_connections",
ShowCommands::Transports => "show_transports",
ShowCommands::Routing => "show_routing",
ShowCommands::IdentityCache => "show_identity_cache",
}
}
}
@@ -391,9 +426,49 @@ fn main() {
let npub = resolve_peer(peer);
build_command("disconnect", serde_json::json!({"npub": npub}))
}
Commands::Stats { what } => match what {
StatsCommands::List => build_query("show_stats_list"),
StatsCommands::Peers => build_query("show_stats_peers"),
StatsCommands::History {
metric,
peer,
window,
granularity,
..
} => {
let mut params = serde_json::json!({
"metric": metric,
"window": window,
"granularity": granularity,
});
if let Some(p) = peer {
let resolved = resolve_peer(p);
params["peer"] = serde_json::json!(resolved);
}
build_command("show_stats_history", params)
}
},
Commands::Keygen { .. } => unreachable!(),
};
// For plot output we need to post-process the JSON response rather
// than pretty-print it.
if let Commands::Stats {
what: StatsCommands::History {
plot: true, metric, ..
},
} = &cli.command
{
match send_request(&socket_path, &request) {
Ok(value) => print_plot(&value, metric),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
return;
}
match send_request(&socket_path, &request) {
Ok(value) => print_response(&value),
Err(e) => {
@@ -403,6 +478,108 @@ fn main() {
}
}
/// Render the response as a Unicode block sparkline plot.
fn print_plot(value: &serde_json::Value, metric: &str) {
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if status == "error" {
let msg = value
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("error: {msg}");
std::process::exit(1);
}
let data = match value.get("data") {
Some(d) => d,
None => {
eprintln!("error: no data in response");
std::process::exit(1);
}
};
let values: Vec<f64> = data
.get("values")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect())
.unwrap_or_default();
let unit = data.get("unit").and_then(|v| v.as_str()).unwrap_or("");
let granularity_seconds = data
.get("granularity_seconds")
.and_then(|v| v.as_u64())
.unwrap_or(1);
if values.is_empty() {
println!("{metric}: no data yet");
return;
}
let (min, max) = values
.iter()
.filter(|v| !v.is_nan())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
(lo.min(v), hi.max(v))
});
let (min, max) = if min.is_finite() {
(min, max)
} else {
(0.0, 0.0)
};
let last = values
.iter()
.rev()
.find(|v| !v.is_nan())
.copied()
.unwrap_or(f64::NAN);
let width_secs = (values.len() as u64) * granularity_seconds;
let gap_count = values.iter().filter(|v| v.is_nan()).count();
println!(
"{metric} ({unit}) — {n} samples @ {g}s = {w}s window{gap}",
n = values.len(),
g = granularity_seconds,
w = width_secs,
gap = if gap_count > 0 {
format!(" ({gap_count} gaps)")
} else {
String::new()
},
);
let last_str = if last.is_nan() {
"-".to_string()
} else {
format!("{last:.3}")
};
println!(" min={min:.3} max={max:.3} last={last_str}");
println!(" {}", sparkline(&values, min, max));
}
/// Render a slice of values as Unicode block characters.
///
/// Uses eight discrete levels: `▁▂▃▄▅▆▇█`. Constant series and empty
/// inputs render as a single-level line (`▄`).
fn sparkline(values: &[f64], min: f64, max: f64) -> String {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let range = max - min;
values
.iter()
.map(|&v| {
if v.is_nan() {
' '
} else if !range.is_finite() || range <= 0.0 {
BLOCKS[3]
} else {
let norm = ((v - min) / range).clamp(0.0, 1.0);
let idx = (norm * (BLOCKS.len() as f64 - 1.0)).round() as usize;
BLOCKS[idx.min(BLOCKS.len() - 1)]
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+166 -1
View File
@@ -15,10 +15,11 @@ pub enum Tab {
Transports,
Routing,
Gateway,
Graphs,
}
impl Tab {
pub const ALL: [Tab; 9] = [
pub const ALL: [Tab; 10] = [
Tab::Node,
Tab::Peers,
Tab::Transports,
@@ -27,6 +28,7 @@ impl Tab {
Tab::Bloom,
Tab::Mmp,
Tab::Routing,
Tab::Graphs,
Tab::Gateway,
];
@@ -36,6 +38,7 @@ impl Tab {
Tab::Node => 0,
Tab::Peers | Tab::Transports => 1,
Tab::Gateway => 3,
Tab::Graphs => 2,
_ => 2,
}
}
@@ -53,6 +56,7 @@ impl Tab {
Tab::Transports => "Transports",
Tab::Routing => "Routing",
Tab::Gateway => "Gateway",
Tab::Graphs => "Graphs",
}
}
@@ -69,6 +73,10 @@ impl Tab {
Tab::Transports => "show_transports",
Tab::Routing => "show_routing",
Tab::Gateway => "show_gateway",
// Graphs uses show_stats_history with params; fetched via a
// dedicated path in main.rs rather than the generic command()
// dispatcher.
Tab::Graphs => "show_stats_history",
}
}
@@ -124,6 +132,67 @@ pub enum SelectedTreeItem {
Link,
}
/// Options for the Graphs tab window selector.
pub const GRAPHS_WINDOWS: &[(&str, &str)] =
&[("1m", "1s"), ("10m", "1s"), ("1h", "1s"), ("24h", "1m")];
/// Node-level metric display order for Graphs tab Node mode. Must
/// match names returned by `show_stats_all_history` (no `peer` param).
pub const GRAPHS_METRICS: &[&str] = &[
"mesh_size",
"tree_depth",
"peer_count",
"parent_switches",
"bytes_in",
"bytes_out",
"packets_in",
"packets_out",
"loss_rate",
"active_sessions",
];
/// Per-peer metric display order for Graphs tab PeerByMetric mode and
/// MetricByPeer selector. Must match names returned by
/// `show_stats_all_history` with a `peer` param.
pub const PEER_GRAPHS_METRICS: &[&str] = &[
"srtt_ms",
"loss_rate",
"bytes_in",
"bytes_out",
"packets_in",
"packets_out",
"ecn_ce",
];
/// Which variety of plot the Graphs tab shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GraphsMode {
/// Stacked node-level metrics (the original view).
Node,
/// Grid: one metric, small-multiples across peers.
MetricByPeer,
/// Stacked per-peer metrics for one selected peer.
PeerByMetric,
}
impl GraphsMode {
pub fn next(self) -> Self {
match self {
GraphsMode::Node => GraphsMode::MetricByPeer,
GraphsMode::MetricByPeer => GraphsMode::PeerByMetric,
GraphsMode::PeerByMetric => GraphsMode::Node,
}
}
}
/// Cached peer summary for Graphs-tab selector (peer-list population
/// is independent of the per-tick metric data).
#[derive(Clone, Debug)]
pub struct GraphsPeer {
pub npub: String,
pub display_name: String,
}
pub struct App {
pub active_tab: Tab,
pub should_quit: bool,
@@ -141,6 +210,20 @@ pub struct App {
pub gateway_running: bool,
/// Mappings data fetched from the gateway (separate from summary).
pub gateway_mappings: Option<serde_json::Value>,
/// Scroll offset (rows) for the stacked Graphs tab.
pub graphs_scroll: u16,
/// Selected (window, granularity) index for the Graphs tab.
pub graphs_window_idx: usize,
/// Current Graphs-tab view mode.
pub graphs_mode: GraphsMode,
/// Selected metric index for MetricByPeer mode (into
/// `PEER_GRAPHS_METRICS`).
pub graphs_peer_metric_idx: usize,
/// Selected peer index for PeerByMetric mode (into `graphs_peers`).
pub graphs_peer_idx: usize,
/// Cached peer list from `show_stats_peers`, populated when the
/// Graphs tab is active in a non-Node mode.
pub graphs_peers: Vec<GraphsPeer>,
}
impl App {
@@ -160,9 +243,91 @@ impl App {
selected_tree_item: SelectedTreeItem::None,
gateway_running: false,
gateway_mappings: None,
graphs_scroll: 0,
graphs_window_idx: 1, // default 10m
graphs_mode: GraphsMode::Node,
graphs_peer_metric_idx: 0,
graphs_peer_idx: 0,
graphs_peers: Vec::new(),
}
}
/// Cycle the Graphs-tab view mode.
pub fn graphs_next_mode(&mut self) {
self.graphs_mode = self.graphs_mode.next();
self.graphs_scroll = 0;
}
/// Advance the mode-specific selector (metric or peer).
pub fn graphs_next_selector(&mut self) {
match self.graphs_mode {
GraphsMode::Node => {}
GraphsMode::MetricByPeer => {
let n = PEER_GRAPHS_METRICS.len();
self.graphs_peer_metric_idx = (self.graphs_peer_metric_idx + 1) % n;
}
GraphsMode::PeerByMetric => {
let n = self.graphs_peers.len();
if n > 0 {
self.graphs_peer_idx = (self.graphs_peer_idx + 1) % n;
}
}
}
}
/// Reverse the mode-specific selector.
pub fn graphs_prev_selector(&mut self) {
match self.graphs_mode {
GraphsMode::Node => {}
GraphsMode::MetricByPeer => {
let n = PEER_GRAPHS_METRICS.len();
self.graphs_peer_metric_idx = (self.graphs_peer_metric_idx + n - 1) % n;
}
GraphsMode::PeerByMetric => {
let n = self.graphs_peers.len();
if n > 0 {
self.graphs_peer_idx = (self.graphs_peer_idx + n - 1) % n;
}
}
}
}
/// Current per-peer metric name for MetricByPeer mode.
pub fn graphs_selected_peer_metric(&self) -> &'static str {
PEER_GRAPHS_METRICS[self.graphs_peer_metric_idx % PEER_GRAPHS_METRICS.len()]
}
/// Current selected peer for PeerByMetric mode, if any.
pub fn graphs_selected_peer(&self) -> Option<&GraphsPeer> {
if self.graphs_peers.is_empty() {
return None;
}
let idx = self.graphs_peer_idx % self.graphs_peers.len();
Some(&self.graphs_peers[idx])
}
/// Current Graphs-tab (window, granularity) pair.
pub fn graphs_window(&self) -> (&'static str, &'static str) {
GRAPHS_WINDOWS[self.graphs_window_idx % GRAPHS_WINDOWS.len()]
}
pub fn graphs_scroll_up(&mut self) {
self.graphs_scroll = self.graphs_scroll.saturating_sub(1);
}
pub fn graphs_scroll_down(&mut self) {
self.graphs_scroll = self.graphs_scroll.saturating_add(1);
}
pub fn graphs_next_window(&mut self) {
self.graphs_window_idx = (self.graphs_window_idx + 1) % GRAPHS_WINDOWS.len();
}
pub fn graphs_prev_window(&mut self) {
self.graphs_window_idx =
(self.graphs_window_idx + GRAPHS_WINDOWS.len() - 1) % GRAPHS_WINDOWS.len();
}
/// Number of rows in the active tab's data array.
pub fn row_count(&self) -> usize {
if self.active_tab == Tab::Transports {
+10 -1
View File
@@ -18,11 +18,20 @@ impl ControlClient {
}
pub async fn query(&self, command: &str) -> Result<Value, String> {
self.send(&format!("{{\"command\":\"{command}\"}}\n")).await
}
pub async fn query_with_params(&self, command: &str, params: Value) -> Result<Value, String> {
let req = serde_json::json!({"command": command, "params": params});
let line = format!("{}\n", serde_json::to_string(&req).unwrap());
self.send(&line).await
}
async fn send(&self, request: &str) -> Result<Value, String> {
let stream = self.connect().await?;
let (reader, mut writer) = tokio::io::split(stream);
let request = format!("{{\"command\":\"{command}\"}}\n");
timeout(IO_TIMEOUT, writer.write_all(request.as_bytes()))
.await
.map_err(|_| "write timed out".to_string())?
+126 -7
View File
@@ -95,12 +95,97 @@ fn fetch_data(
// 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);
// Graphs tab pulls all metrics in one round trip via
// show_stats_all_history; all other tabs use the generic
// command() path.
if app.active_tab == Tab::Graphs {
let (window, granularity) = app.graphs_window();
// Always refresh the peer list on Graphs-tab fetches so
// selector indices stay current across peer churn.
if let Ok(data) = rt.block_on(client.query("show_stats_peers")) {
app.graphs_peers = data
.get("peers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|p| crate::app::GraphsPeer {
npub: p
.get("npub")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
display_name: p
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
.collect()
})
.unwrap_or_default();
if app.graphs_peer_idx >= app.graphs_peers.len().max(1) {
app.graphs_peer_idx = 0;
}
}
Err(e) => {
app.last_error = Some((std::time::Instant::now(), e));
let (command, params) = match app.graphs_mode {
crate::app::GraphsMode::Node => (
"show_stats_all_history",
serde_json::json!({
"window": window,
"granularity": granularity,
}),
),
crate::app::GraphsMode::MetricByPeer => (
"show_stats_history_all_peers",
serde_json::json!({
"metric": app.graphs_selected_peer_metric(),
"window": window,
"granularity": granularity,
}),
),
crate::app::GraphsMode::PeerByMetric => {
let npub = app
.graphs_selected_peer()
.map(|p| p.npub.clone())
.unwrap_or_default();
(
"show_stats_all_history",
serde_json::json!({
"peer": npub,
"window": window,
"granularity": granularity,
}),
)
}
};
// Only issue the per-peer query if there is a peer to query.
let should_query = match app.graphs_mode {
crate::app::GraphsMode::PeerByMetric => !app.graphs_peers.is_empty(),
_ => true,
};
if should_query {
match rt.block_on(client.query_with_params(command, params)) {
Ok(data) => {
app.data.insert(Tab::Graphs, data);
}
Err(e) => {
app.last_error = Some((std::time::Instant::now(), e));
}
}
} else {
app.data.insert(Tab::Graphs, serde_json::json!({}));
}
} else {
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));
}
}
}
}
@@ -193,6 +278,8 @@ fn main() {
(KeyCode::Down, _) => {
if app.detail_view.is_some() {
app.scroll_detail_down();
} else if app.active_tab == Tab::Graphs {
app.graphs_scroll_down();
} else if app.active_tab.has_table() {
app.select_next();
}
@@ -200,6 +287,8 @@ fn main() {
(KeyCode::Up, _) => {
if app.detail_view.is_some() {
app.scroll_detail_up();
} else if app.active_tab == Tab::Graphs {
app.graphs_scroll_up();
} else if app.active_tab.has_table() {
app.select_prev();
}
@@ -210,7 +299,10 @@ fn main() {
}
}
(KeyCode::Char(' '), _) | (KeyCode::Right, _) => {
if app.active_tab == Tab::Transports
if app.active_tab == Tab::Graphs && app.detail_view.is_none() {
app.graphs_next_window();
fetch_data(&rt, &client, &gateway_client, &mut app);
} else if app.active_tab == Tab::Transports
&& app.detail_view.is_none()
&& let SelectedTreeItem::Transport(tid) = app.selected_tree_item
{
@@ -221,8 +313,29 @@ fn main() {
}
}
}
(KeyCode::Char('m'), KeyModifiers::NONE)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
app.graphs_next_mode();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Char('n'), KeyModifiers::NONE)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
app.graphs_next_selector();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Char('N'), KeyModifiers::SHIFT)
if app.active_tab == Tab::Graphs && app.detail_view.is_none() =>
{
app.graphs_prev_selector();
fetch_data(&rt, &client, &gateway_client, &mut app);
}
(KeyCode::Left, _) => {
if app.active_tab == Tab::Transports
if app.active_tab == Tab::Graphs && app.detail_view.is_none() {
app.graphs_prev_window();
fetch_data(&rt, &client, &gateway_client, &mut app);
} else if app.active_tab == Tab::Transports
&& app.detail_view.is_none()
&& let SelectedTreeItem::Transport(tid) = app.selected_tree_item
{
@@ -251,6 +364,12 @@ fn main() {
app.expanded_transports.clear();
}
}
(KeyCode::Char('g'), KeyModifiers::NONE) => {
app.close_detail();
app.active_tab = Tab::Graphs;
app.graphs_scroll = 0;
fetch_data(&rt, &client, &gateway_client, &mut app);
}
_ => {}
}
}
+26 -1
View File
@@ -22,7 +22,7 @@ pub fn draw(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(7), // Runtime
Constraint::Length(7), // Identity
Constraint::Length(5), // State
Constraint::Length(6), // State (sparkline row adds one line)
Constraint::Length(9), // Traffic
Constraint::Min(0), // remaining
])
@@ -133,6 +133,12 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
.map(|n| format!("~{n}"))
.unwrap_or_else(|| "-".into());
let mesh_spark =
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "mesh_size"));
let peer_spark =
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "peer_count"));
let spark_style = Style::default().fg(Color::DarkGray);
let lines = vec![
Line::from(vec![
Span::styled(" state: ", label),
@@ -158,6 +164,12 @@ fn draw_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
Span::styled(" mesh: ", label),
Span::styled(mesh_size, count),
]),
Line::from(vec![
Span::styled(" peers: ", label),
Span::styled(peer_spark, spark_style),
Span::styled(" mesh: ", label),
Span::styled(mesh_spark, spark_style),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
@@ -168,6 +180,13 @@ fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
let inner = block.inner(area);
frame.render_widget(block, area);
let spark_style = Style::default().fg(Color::DarkGray);
let label = Style::default().fg(Color::DarkGray);
let bytes_in_spark =
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "bytes_in"));
let bytes_out_spark =
helpers::sparkline(&helpers::nested_f64_array(data, "sparklines", "bytes_out"));
let lines = vec![
helpers::section_header("TUN (IPv6)"),
fwd_line(data, "Tx", "delivered_packets", "delivered_bytes"),
@@ -175,6 +194,12 @@ fn draw_node_stats(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
Line::from(""),
helpers::section_header("Forwarded (transit)"),
fwd_line(data, "Packets", "forwarded_packets", "forwarded_bytes"),
Line::from(vec![
Span::styled(" rate in: ", label),
Span::styled(bytes_in_spark, spark_style),
Span::styled(" rate out: ", label),
Span::styled(bytes_out_spark, spark_style),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
+634
View File
@@ -0,0 +1,634 @@
//! Graphs tab — stack of per-metric mini plots, btop-style.
//!
//! All node-level history metrics render as independent mini-graphs
//! stacked vertically: each has its own title line, its own
//! autoscaled min/max, and its own braille 2×4 filled-area plot
//! colored per-row on a green → yellow → red gradient. Content
//! scrolls with Up/Down; Left/Right cycles the (window, granularity)
//! pair which applies to the whole stack.
//!
//! Rendering follows btop's algorithm: 25-entry braille lookup table
//! `BRAILLE[left_level][right_level]` where each level is 0..=4, two
//! time samples per character cell, row-position-keyed gradient
//! coloring for the characteristic btop "vertical bands" look.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use crate::app::{App, GRAPHS_METRICS, GraphsMode, PEER_GRAPHS_METRICS, Tab};
/// 5×5 braille lookup table indexed by (left fill 0..=4, right fill
/// 0..=4). Direct transcription of btop's `braille_up` glyph set.
const BRAILLE: [[char; 5]; 5] = [
[' ', '⢀', '⢠', '⢰', '⢸'],
['⡀', '⣀', '⣠', '⣰', '⣸'],
['⡄', '⣄', '⣤', '⣴', '⣼'],
['⡆', '⣆', '⣦', '⣶', '⣾'],
['⡇', '⣇', '⣧', '⣷', '⣿'],
];
/// Height in rows of each per-metric mini block (title row plus plot).
const METRIC_TITLE_ROWS: u16 = 1;
const METRIC_PLOT_ROWS: u16 = 4;
const METRIC_SEPARATOR_ROWS: u16 = 1;
const METRIC_BLOCK_ROWS: u16 = METRIC_TITLE_ROWS + METRIC_PLOT_ROWS + METRIC_SEPARATOR_ROWS;
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(2), // selector strip
Constraint::Min(5), // scrollable stack
])
.split(area);
draw_selector(frame, app, chunks[0]);
draw_stack(frame, app, chunks[1]);
}
fn draw_selector(frame: &mut Frame, app: &App, area: Rect) {
let label = Style::default().fg(Color::DarkGray);
let dim = Style::default().fg(Color::White);
let emph = Style::default().fg(Color::Cyan);
let (window, granularity) = app.graphs_window();
let mode_str = match app.graphs_mode {
GraphsMode::Node => "node".to_string(),
GraphsMode::MetricByPeer => {
format!("metric-by-peer [{}]", app.graphs_selected_peer_metric())
}
GraphsMode::PeerByMetric => {
let name = app
.graphs_selected_peer()
.map(|p| p.display_name.clone())
.unwrap_or_else(|| "(no peers)".into());
format!("peer-by-metric [{}]", name)
}
};
let line1 = Line::from(vec![
Span::styled(" mode: ", label),
Span::styled(mode_str, emph),
Span::styled(" window: ", label),
Span::styled(window.to_string(), dim),
Span::styled(" granularity: ", label),
Span::styled(granularity.to_string(), dim),
Span::styled(" scroll: ", label),
Span::styled(format!("{}", app.graphs_scroll), dim),
]);
let line2 = Line::from(Span::styled(
" [↑/↓] scroll [←/→] window [m] mode [n/N] cycle [g] graphs [q] quit",
label,
));
frame.render_widget(Paragraph::new(vec![line1, line2]), area);
}
fn draw_stack(frame: &mut Frame, app: &mut App, area: Rect) {
let title = match app.graphs_mode {
GraphsMode::Node => " Graphs — Node ".to_string(),
GraphsMode::MetricByPeer => {
format!(" Graphs — {} by peer ", app.graphs_selected_peer_metric())
}
GraphsMode::PeerByMetric => {
let name = app
.graphs_selected_peer()
.map(|p| p.display_name.clone())
.unwrap_or_else(|| "(no peers)".into());
format!(" Graphs — peer {} ", name)
}
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
title,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
match app.graphs_mode {
GraphsMode::Node | GraphsMode::PeerByMetric => draw_stacked(frame, app, inner),
GraphsMode::MetricByPeer => draw_metric_by_peer(frame, app, inner),
}
}
/// Parse `data.series` into `[(name, values)]` for stacked views.
fn series_from_data(data: &serde_json::Value) -> Vec<(String, Vec<f64>)> {
data.get("series")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|s| {
let name = s
.get("metric")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let values: Vec<f64> = s
.get("values")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect())
.unwrap_or_default();
(name, values)
})
.collect()
})
.unwrap_or_default()
}
fn draw_stacked(frame: &mut Frame, app: &mut App, inner: Rect) {
let data = app.data.get(&Tab::Graphs);
let all_series = match data {
Some(d) => series_from_data(d),
None => Vec::new(),
};
if all_series.is_empty() {
frame.render_widget(
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
// Pick metric list by mode.
let metrics: &[&str] = match app.graphs_mode {
GraphsMode::Node => GRAPHS_METRICS,
GraphsMode::PeerByMetric => PEER_GRAPHS_METRICS,
_ => unreachable!("draw_stacked only renders Node or PeerByMetric modes"),
};
let mut lines: Vec<Line<'static>> = Vec::new();
for metric_name in metrics {
let series = all_series
.iter()
.find(|(n, _)| n == metric_name)
.map(|(_, v)| v.as_slice())
.unwrap_or(&[]);
lines.extend(render_metric_block(metric_name, series, inner.width));
}
let total_rows = lines.len() as u16;
let visible = inner.height;
let max_scroll = total_rows.saturating_sub(visible);
if app.graphs_scroll > max_scroll {
app.graphs_scroll = max_scroll;
}
let paragraph = Paragraph::new(lines).scroll((app.graphs_scroll, 0));
frame.render_widget(paragraph, inner);
}
fn draw_metric_by_peer(frame: &mut Frame, app: &mut App, inner: Rect) {
let data = match app.data.get(&Tab::Graphs) {
Some(d) => d,
None => {
frame.render_widget(
Paragraph::new(" Waiting for data...").style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
};
let metric_name = app.graphs_selected_peer_metric();
let peers = data.get("peers").and_then(|v| v.as_array());
let peer_series: Vec<(String, Vec<f64>)> = peers
.map(|arr| {
arr.iter()
.map(|p| {
let name = p
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let values: Vec<f64> = p
.get("values")
.and_then(|v| v.as_array())
.map(|a| a.iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect())
.unwrap_or_default();
(name, values)
})
.collect()
})
.unwrap_or_default();
if peer_series.is_empty() {
frame.render_widget(
Paragraph::new(" No peers tracked yet in stats history.")
.style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
// Pick a column count that keeps each cell wide enough for a
// readable braille plot. Each cell needs ~30 columns minimum.
let cols = if inner.width < 40 {
1
} else if inner.width < 100 {
2
} else {
3
};
let rows = peer_series.len().div_ceil(cols);
// Stack of cell-rows; each row is a horizontal split of cell cells.
let row_constraints: Vec<Constraint> = (0..rows)
.map(|_| Constraint::Length(METRIC_BLOCK_ROWS))
.collect();
let row_areas = Layout::vertical(row_constraints).split(inner);
for row_idx in 0..rows {
let col_constraints: Vec<Constraint> = (0..cols)
.map(|_| Constraint::Ratio(1, cols as u32))
.collect();
let col_areas = Layout::horizontal(col_constraints).split(row_areas[row_idx]);
for col_idx in 0..cols {
let peer_idx = row_idx * cols + col_idx;
if peer_idx >= peer_series.len() {
break;
}
let (peer_name, values) = &peer_series[peer_idx];
let cell_lines = render_metric_block_labeled(
metric_name,
peer_name,
values,
col_areas[col_idx].width,
);
frame.render_widget(Paragraph::new(cell_lines), col_areas[col_idx]);
}
}
}
/// Variant of `render_metric_block` that labels the block with the
/// peer name in addition to the metric. Used by the metric-by-peer grid.
fn render_metric_block_labeled(
metric: &str,
peer_name: &str,
values: &[f64],
width: u16,
) -> Vec<Line<'static>> {
let unit = metric_unit(metric);
let mut out: Vec<Line<'static>> = Vec::with_capacity(METRIC_BLOCK_ROWS as usize);
let (min, max, last, n) = summarize(values);
let title_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(Color::DarkGray);
let title = Line::from(vec![
Span::styled(format!(" {peer_name}"), title_style),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" max ", label),
Span::raw(format_value(max)),
Span::styled(" last ", label),
Span::raw(format_value(last)),
Span::styled(" n=", label),
Span::raw(format!("{n}")),
]);
out.push(title);
let gutter = 2u16;
let plot_cols = width.saturating_sub(gutter) as usize;
if plot_cols == 0 || values.is_empty() {
for _ in 0..METRIC_PLOT_ROWS {
out.push(Line::from(Span::styled(
" (no samples)",
Style::default().fg(Color::DarkGray),
)));
}
out.push(Line::from(""));
return out;
}
let sampled = resample(values, plot_cols * 2);
let rows = METRIC_PLOT_ROWS as usize;
let plot_lines = render_btop_graph(&sampled, rows, min, max, gutter as usize);
out.extend(plot_lines);
out.push(Line::from(""));
out
}
/// Render a single metric's mini block: one title row, four plot rows,
/// one separator row. The plot is autoscaled against its own min/max
/// so every metric uses the full vertical resolution regardless of
/// its unit.
fn render_metric_block(metric: &str, values: &[f64], width: u16) -> Vec<Line<'static>> {
let unit = metric_unit(metric);
let mut out: Vec<Line<'static>> = Vec::with_capacity(METRIC_BLOCK_ROWS as usize);
// Title row.
let (min, max, last, n) = summarize(values);
let title_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let label = Style::default().fg(Color::DarkGray);
let title = Line::from(vec![
Span::styled(format!(" {metric}"), title_style),
Span::styled(format!(" [{unit}]"), label),
Span::styled(" max ", label),
Span::raw(format_value(max)),
Span::styled(" last ", label),
Span::raw(format_value(last)),
Span::styled(" samples ", label),
Span::raw(format!("{n}")),
]);
out.push(title);
// Plot rows. Width budget: leave the first two columns for a tiny
// left gutter so titles and plots align consistently.
let gutter = 2u16;
let plot_cols = width.saturating_sub(gutter) as usize;
if plot_cols == 0 || values.is_empty() {
for _ in 0..METRIC_PLOT_ROWS {
out.push(Line::from(Span::styled(
" (no samples)",
Style::default().fg(Color::DarkGray),
)));
}
out.push(Line::from(""));
return out;
}
let sampled = resample(values, plot_cols * 2);
let rows = METRIC_PLOT_ROWS as usize;
let plot_lines = render_btop_graph(&sampled, rows, min, max, gutter as usize);
out.extend(plot_lines);
// Blank separator.
out.push(Line::from(""));
out
}
fn summarize(values: &[f64]) -> (f64, f64, f64, usize) {
if values.is_empty() {
return (0.0, 0.0, 0.0, 0);
}
let (min, max) = values
.iter()
.filter(|v| !v.is_nan())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
(lo.min(v), hi.max(v))
});
let (min, max) = if min.is_finite() {
(min, max)
} else {
(0.0, 0.0)
};
let last = values
.iter()
.rev()
.find(|v| !v.is_nan())
.copied()
.unwrap_or(f64::NAN);
(min, max, last, values.len())
}
/// Down- or up-sample to exactly `target` points using linear
/// interpolation across the source index space. Returns empty on
/// empty input.
fn resample(values: &[f64], target: usize) -> Vec<f64> {
if values.is_empty() || target == 0 {
return Vec::new();
}
if values.len() == target {
return values.to_vec();
}
if values.len() == 1 {
return vec![values[0]; target];
}
let mut out = Vec::with_capacity(target);
let last_src = (values.len() - 1) as f64;
let last_dst = (target - 1).max(1) as f64;
for i in 0..target {
let src = i as f64 * last_src / last_dst;
let lo = src.floor() as usize;
let hi = (src.ceil() as usize).min(values.len() - 1);
let frac = src - lo as f64;
out.push(values[lo] * (1.0 - frac) + values[hi] * frac);
}
out
}
/// Render a filled-area graph using btop's braille algorithm.
///
/// - Normalize values to 0..=100 against the visible min/max.
/// - For each row `horizon` (0 = top), compute the percent band it
/// covers; each sample's fill level is 0..=4 based on where the
/// sample falls within that band (+0.1 modulus bias so small
/// non-zero values still draw).
/// - Two samples per character cell via the 25-entry BRAILLE table.
/// - Per-row gradient color keyed by row position: top rows hot, bottom
/// rows cool. This matches btop's "vertical color bands" look.
fn render_btop_graph(
values: &[f64],
rows: usize,
min: f64,
max: f64,
gutter: usize,
) -> Vec<Line<'static>> {
if rows == 0 || values.is_empty() {
return Vec::new();
}
let range = max - min;
// NaN samples pass through normalize as NaN so the cell loop below
// can blank them. Non-NaN samples are clamped into 0..=100.
let normalized: Vec<f64> = values
.iter()
.map(|&v| {
if v.is_nan() {
f64::NAN
} else if !range.is_finite() || range <= 0.0 {
50.0
} else {
((v - min) / range * 100.0).clamp(0.0, 100.0)
}
})
.collect();
let clamp_min = 0i32;
let mod_bias = 0.1f64;
let gutter_str: String = " ".repeat(gutter);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(rows);
for horizon in 0..rows {
let cur_high = ((rows - horizon) as f64 * 100.0 / rows as f64).round() as i32;
let cur_low = ((rows - horizon - 1) as f64 * 100.0 / rows as f64).round() as i32;
let color = row_gradient(horizon, rows);
let style = Style::default().fg(color);
let mut chars = String::with_capacity(values.len() / 2 + 1);
let mut i = 0;
while i + 1 < normalized.len() {
let l = normalized[i];
let r = normalized[i + 1];
let c = if l.is_nan() || r.is_nan() {
' '
} else {
let level_l = sample_level(l, cur_low, cur_high, clamp_min, mod_bias);
let level_r = sample_level(r, cur_low, cur_high, clamp_min, mod_bias);
BRAILLE[level_l as usize][level_r as usize]
};
chars.push(c);
i += 2;
}
if i < normalized.len() {
let l = normalized[i];
let c = if l.is_nan() {
' '
} else {
let level_l = sample_level(l, cur_low, cur_high, clamp_min, mod_bias);
BRAILLE[level_l as usize][0]
};
chars.push(c);
}
lines.push(Line::from(vec![
Span::raw(gutter_str.clone()),
Span::styled(chars, style),
]));
}
lines
}
fn sample_level(value: f64, cur_low: i32, cur_high: i32, clamp_min: i32, mod_bias: f64) -> i32 {
let v = value.round() as i32;
if v >= cur_high {
4
} else if v <= cur_low {
clamp_min
} else {
let span = (cur_high - cur_low).max(1) as f64;
let scaled = ((value - cur_low as f64) * 4.0 / span + mod_bias).round() as i32;
scaled.clamp(clamp_min, 4)
}
}
fn row_gradient(horizon: usize, rows: usize) -> Color {
let t = 1.0 - (horizon as f64 / rows.max(1) as f64);
gradient_rgb(t)
}
fn gradient_rgb(t: f64) -> Color {
let t = t.clamp(0.0, 1.0);
let (sr, sg, sb) = (0.0, 200.0, 0.0);
let (mr, mg, mb) = (240.0, 200.0, 0.0);
let (er, eg, eb) = (240.0, 40.0, 40.0);
let (r, g, b) = if t < 0.5 {
let k = t * 2.0;
(lerp(sr, mr, k), lerp(sg, mg, k), lerp(sb, mb, k))
} else {
let k = (t - 0.5) * 2.0;
(lerp(mr, er, k), lerp(mg, eg, k), lerp(mb, eb, k))
};
Color::Rgb(r as u8, g as u8, b as u8)
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
fn format_value(v: f64) -> String {
if v.is_nan() {
return "-".to_string();
}
if v.abs() < 10.0 {
format!("{:.2}", v)
} else if v.abs() < 1000.0 {
format!("{:.1}", v)
} else if v.abs() < 1_000_000.0 {
format!("{:.1}K", v / 1000.0)
} else {
format!("{:.1}M", v / 1_000_000.0)
}
}
fn metric_unit(name: &str) -> &'static str {
match name {
"mesh_size" => "nodes",
"tree_depth" => "hops",
"peer_count" => "peers",
"parent_switches" => "events/s",
"bytes_in" | "bytes_out" => "bytes/s",
"packets_in" | "packets_out" => "packets/s",
"loss_rate" => "fraction",
"active_sessions" => "sessions",
"srtt_ms" => "ms",
"ecn_ce" => "events/s",
_ => "",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_identity_when_length_matches() {
let v = vec![1.0, 2.0, 3.0];
assert_eq!(resample(&v, 3), v);
}
#[test]
fn resample_returns_requested_length() {
let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let out = resample(&v, 10);
assert_eq!(out.len(), 10);
}
#[test]
fn braille_table_corners() {
assert_eq!(BRAILLE[0][0], ' ');
assert_eq!(BRAILLE[4][4], '⣿');
assert_eq!(BRAILLE[4][0], '⡇');
assert_eq!(BRAILLE[0][4], '⢸');
}
#[test]
fn sample_level_boundaries() {
assert_eq!(sample_level(0.0, 0, 25, 0, 0.1), 0);
assert_eq!(sample_level(25.0, 0, 25, 0, 0.1), 4);
}
#[test]
fn metric_block_has_expected_rows() {
let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let lines = render_metric_block("mesh_size", &v, 40);
assert_eq!(lines.len(), METRIC_BLOCK_ROWS as usize);
}
#[test]
fn metric_block_handles_empty() {
let lines = render_metric_block("mesh_size", &[], 40);
assert_eq!(lines.len(), METRIC_BLOCK_ROWS as usize);
}
#[test]
fn gradient_spans_stops() {
if let Color::Rgb(r, g, _) = gradient_rgb(0.0) {
assert!(g > r, "bottom of gradient is greener than it is red");
} else {
panic!("expected RGB");
}
if let Color::Rgb(r, g, _) = gradient_rgb(1.0) {
assert!(r > g, "top of gradient is redder than it is green");
} else {
panic!("expected RGB");
}
}
}
+39
View File
@@ -171,3 +171,42 @@ pub fn kv_line(key: &str, value: &str) -> Line<'static> {
Span::raw(value.to_string()),
])
}
/// Render a sequence of values as Unicode block characters.
///
/// Returns an empty string for empty input. Constant series render as a
/// mid-level row. Used inline beside metric values in the dashboard and
/// as the per-column renderer for the Graphs tab.
pub fn sparkline(values: &[f64]) -> String {
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if values.is_empty() {
return String::new();
}
let (min, max) = values
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
(lo.min(v), hi.max(v))
});
let range = max - min;
values
.iter()
.map(|&v| {
if !range.is_finite() || range <= 0.0 {
BLOCKS[3]
} else {
let norm = ((v - min) / range).clamp(0.0, 1.0);
let idx = (norm * (BLOCKS.len() as f64 - 1.0)).round() as usize;
BLOCKS[idx.min(BLOCKS.len() - 1)]
}
})
.collect()
}
/// Extract a `Vec<f64>` from a nested JSON array (e.g., `sparklines.mesh_size`).
pub fn nested_f64_array(data: &Value, outer: &str, inner: &str) -> Vec<f64> {
data.get(outer)
.and_then(|o| o.get(inner))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
.unwrap_or_default()
}
+2
View File
@@ -1,6 +1,7 @@
mod bloom;
mod dashboard;
mod gateway;
mod graphs;
mod helpers;
mod mmp;
mod peers;
@@ -77,6 +78,7 @@ fn draw_content(frame: &mut Frame, app: &mut App, area: Rect) {
Tab::Routing => routing::draw(frame, app, area),
Tab::Links => {} // not a navigable tab; data stored for cross-references
Tab::Gateway => gateway::draw(frame, app, area),
Tab::Graphs => graphs::draw(frame, app, area),
}
}
+6 -2
View File
@@ -43,7 +43,11 @@ fn draw_routing_state(frame: &mut Frame, data: &serde_json::Value, area: Rect) {
),
helpers::kv_line(
"Pending Lookups",
&helpers::u64_field(data, "pending_lookups"),
&data
.get("pending_lookups")
.and_then(|v| v.as_array())
.map(|a| a.len().to_string())
.unwrap_or_else(|| "0".into()),
),
helpers::kv_line(
"Recent Requests",
@@ -261,7 +265,7 @@ fn draw_coord_cache(frame: &mut Frame, app: &App, area: Rect) {
}
};
let entries = helpers::u64_field(data, "entries");
let entries = helpers::u64_field(data, "count");
let max_entries = helpers::u64_field(data, "max_entries");
let fill_pct = data
.get("fill_ratio")
+7
View File
@@ -185,6 +185,13 @@ impl CoordCache {
self.entries.is_empty()
}
/// Iterate over non-expired entries.
pub fn iter(&self, current_time_ms: u64) -> impl Iterator<Item = (&NodeAddr, &CacheEntry)> {
self.entries
.iter()
.filter(move |(_, entry)| !entry.is_expired(current_time_ms))
}
/// Remove all expired entries.
pub fn purge_expired(&mut self, current_time_ms: u64) -> usize {
let before = self.entries.len();
+1
View File
@@ -374,6 +374,7 @@ pub use windows_impl::ControlSocket;
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::*;
#[cfg(windows)]
+505 -7
View File
@@ -3,9 +3,19 @@
//! Each function takes `&Node` and returns a `serde_json::Value`.
//! Query logic is kept separate from socket handling.
use crate::identity::encode_npub;
use crate::identity::{NodeAddr, PeerIdentity, encode_npub};
use crate::node::Node;
use crate::node::stats_history::{ALL_METRICS, ALL_PEER_METRICS, Granularity, Metric, PeerMetric};
use serde_json::{Value, json};
use std::str::FromStr;
use std::time::Duration;
/// Resolve an `npub1...` string to the corresponding `NodeAddr`.
fn parse_peer_npub(s: &str) -> Result<NodeAddr, String> {
PeerIdentity::from_npub(s)
.map(|p| *p.node_addr())
.map_err(|e| format!("invalid peer npub: {e}"))
}
/// Helper: get current Unix time in milliseconds.
fn now_ms() -> u64 {
@@ -39,6 +49,20 @@ pub fn show_status(node: &Node) -> Value {
let uptime_secs = node.uptime().as_secs();
let fwd = node.stats().snapshot().forwarding;
// Inline last-N-second sparklines for dashboard rendering. Kept
// short so the status payload stays compact; longer windows use
// `show_stats_history`.
const SPARK_N: usize = 30;
let hist = node.stats_history();
let sparklines = json!({
"mesh_size": hist.recent(Metric::MeshSize, SPARK_N),
"tree_depth": hist.recent(Metric::TreeDepth, SPARK_N),
"peer_count": hist.recent(Metric::PeerCount, SPARK_N),
"bytes_in": hist.recent(Metric::BytesIn, SPARK_N),
"bytes_out": hist.recent(Metric::BytesOut, SPARK_N),
"loss_rate": hist.recent(Metric::LossRate, SPARK_N),
});
json!({
"version": crate::version::short_version(),
"npub": node.npub(),
@@ -60,6 +84,7 @@ pub fn show_status(node: &Node) -> Value {
"uptime_secs": uptime_secs,
"estimated_mesh_size": node.estimated_mesh_size(),
"forwarding": serde_json::to_value(&fwd).unwrap_or_default(),
"sparklines": sparklines,
})
}
@@ -127,6 +152,32 @@ pub fn show_peers(node: &Node) -> Value {
"bytes_recv": stats.bytes_recv,
});
// Security signals
peer_json["replay_suppressed"] = json!(peer.replay_suppressed_count());
peer_json["consecutive_decrypt_failures"] = json!(peer.consecutive_decrypt_failures());
// Noise session counters (rekey urgency, replay window state)
if let Some(session) = peer.noise_session() {
peer_json["noise"] = json!({
"send_counter": session.current_send_counter(),
"highest_recv_counter": session.highest_received_counter(),
});
}
// Session indices (hijack detection)
if let Some(idx) = peer.our_index() {
peer_json["our_session_index"] = json!(format!("{:08x}", idx.as_u32()));
}
// Rekey state
if peer.rekey_in_progress() {
peer_json["rekey_in_progress"] = json!(true);
}
if peer.is_draining() {
peer_json["rekey_draining"] = json!(true);
}
peer_json["current_k_bit"] = json!(peer.current_k_bit());
// Add MMP metrics if available
if let Some(mmp) = peer.mmp() {
let mut mmp_json = json!({
@@ -283,6 +334,19 @@ pub fn show_sessions(node: &Node) -> Value {
"bytes_recv": bytes_rx,
});
// Handshake health (visible during initiating/awaiting_msg3)
if !entry.is_established() {
session_json["resend_count"] = json!(entry.resend_count());
}
// Rekey and session health (visible when established)
if entry.is_established() {
session_json["session_start_ms"] = json!(entry.session_start_ms());
session_json["current_k_bit"] = json!(entry.current_k_bit());
session_json["coords_warmup_remaining"] = json!(entry.coords_warmup_remaining());
session_json["is_draining"] = json!(entry.is_draining());
}
// Add session MMP if available
if let Some(mmp) = entry.mmp() {
let mut mmp_json = json!({
@@ -470,18 +534,47 @@ pub fn show_mmp(node: &Node) -> Value {
})
}
/// `show_cache` — Coordinate cache stats.
/// `show_cache` — Coordinate cache stats and entries.
pub fn show_cache(node: &Node) -> Value {
let cache = node.coord_cache();
let stats = cache.stats(now_ms());
let now = now_ms();
let stats = cache.stats(now);
// Include individual entries for route debugging
let entries: Vec<Value> = cache
.iter(now)
.map(|(addr, entry)| {
let fips_addr = crate::identity::FipsAddress::from_node_addr(addr);
let coord_path: Vec<String> = entry
.coords()
.entries()
.iter()
.map(|e| hex::encode(e.node_addr.as_bytes()))
.collect();
let mut entry_json = json!({
"node_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"ipv6_addr": format!("{}", fips_addr),
"depth": entry.coords().depth(),
"coords": coord_path,
"age_ms": now.saturating_sub(entry.created_at()),
"last_used_ms": entry.last_used(),
});
if let Some(mtu) = entry.path_mtu() {
entry_json["path_mtu"] = json!(mtu);
}
entry_json
})
.collect();
json!({
"entries": stats.entries,
"count": stats.entries,
"max_entries": stats.max_entries,
"fill_ratio": stats.fill_ratio(),
"default_ttl_ms": cache.default_ttl_ms(),
"expired": stats.expired,
"avg_age_ms": stats.avg_age_ms,
"entries": entries,
})
}
@@ -554,14 +647,47 @@ pub fn show_transports(node: &Node) -> Value {
/// `show_routing` — Routing table summary and node statistics.
pub fn show_routing(node: &Node) -> Value {
let cache = node.coord_cache();
let cache_stats = cache.stats(now_ms());
let now = now_ms();
let cache_stats = cache.stats(now);
let node_stats = node.stats().snapshot();
// Pending discovery lookups (individual targets)
let lookups: Vec<Value> = node
.pending_lookups_iter()
.map(|(addr, lookup)| {
json!({
"target": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"initiated_ms": lookup.initiated_ms,
"last_sent_ms": lookup.last_sent_ms,
"attempt": lookup.attempt,
"age_ms": now.saturating_sub(lookup.initiated_ms),
})
})
.collect();
// Connection retry state
let retries: Vec<Value> = node
.retry_state_iter()
.map(|(addr, state)| {
json!({
"node_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"retry_count": state.retry_count,
"retry_after_ms": state.retry_after_ms,
"auto_reconnect": state.reconnect,
})
})
.collect();
json!({
"coord_cache_entries": cache_stats.entries,
"identity_cache_entries": node.identity_cache_len(),
"pending_lookups": node.pending_lookup_count(),
"pending_lookups": lookups,
"pending_tun_destinations": node.pending_tun_destinations(),
"pending_tun_packets": node.pending_tun_total_packets(),
"recent_requests": node.recent_request_count(),
"retries": retries,
"forwarding": serde_json::to_value(&node_stats.forwarding).unwrap_or_default(),
"discovery": serde_json::to_value(&node_stats.discovery).unwrap_or_default(),
"error_signals": serde_json::to_value(&node_stats.errors).unwrap_or_default(),
@@ -569,8 +695,374 @@ pub fn show_routing(node: &Node) -> Value {
})
}
/// `show_identity_cache` — Known node identities.
///
/// Lists every node whose public key has been cached by this daemon.
/// Identities are learned from DNS resolution, peer handshakes, session
/// establishment, and configured peer npubs. The cache uses LRU eviction
/// bounded by `node.cache.identity_size`.
pub fn show_identity_cache(node: &Node) -> Value {
let now = now_ms();
let entries: Vec<Value> = node
.identity_cache_iter()
.map(|(node_addr, pubkey, last_seen_ms)| {
let (xonly, _parity) = pubkey.x_only_public_key();
let fips_addr = crate::identity::FipsAddress::from_node_addr(node_addr);
json!({
"node_addr": hex::encode(node_addr.as_bytes()),
"npub": encode_npub(&xonly),
"display_name": node.peer_display_name(node_addr),
"ipv6_addr": format!("{}", fips_addr),
"last_seen_ms": last_seen_ms,
"age_ms": now.saturating_sub(last_seen_ms),
})
})
.collect();
let count = entries.len();
json!({
"entries": entries,
"count": count,
"max_entries": node.identity_cache_max(),
})
}
/// `show_stats_list` — Enumerate available history metrics and their units.
pub fn show_stats_list() -> Value {
let metrics: Vec<Value> = ALL_METRICS
.iter()
.map(|m| {
json!({
"name": m.name(),
"unit": m.unit(),
"scope": "node",
})
})
.chain(ALL_PEER_METRICS.iter().map(|m| {
json!({
"name": m.name(),
"unit": m.unit(),
"scope": "peer",
})
}))
.collect();
json!({
"metrics": metrics,
"fast_ring_seconds": crate::node::stats_history::FAST_RING_CAPACITY,
"slow_ring_minutes": crate::node::stats_history::SLOW_RING_CAPACITY,
"peer_retention_seconds": crate::node::stats_history::PEER_EVICTION_SECS,
})
}
/// `show_stats_history` — Time-series samples for one metric.
///
/// Params:
/// - `metric` (required): metric name. Node-level metrics (e.g.
/// `mesh_size`) are resolved against `Metric`; per-peer metrics (e.g.
/// `srtt_ms`, `ecn_ce`) require the `peer` param and resolve against
/// `PeerMetric`.
/// - `peer` (optional): `npub1...` of the peer; required for per-peer
/// metrics.
/// - `window` (default `10m`): duration `<N>s`, `<N>m`, or `<N>h`.
/// - `granularity` (default `1s`): `1s` or `1m`.
pub fn show_stats_history(node: &Node, params: Option<&Value>) -> super::protocol::Response {
use super::protocol::Response;
let Some(params) = params else {
return Response::error("missing params for show_stats_history");
};
let metric_name = match params.get("metric").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'metric' parameter"),
};
let window_str = params
.get("window")
.and_then(|v| v.as_str())
.unwrap_or("10m");
let window = match parse_duration(window_str) {
Ok(d) => d,
Err(e) => return Response::error(e),
};
let granularity_str = params
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("1s");
let granularity = match Granularity::from_str(granularity_str) {
Ok(g) => g,
Err(e) => return Response::error(e),
};
let peer_npub = params.get("peer").and_then(|v| v.as_str());
let hist = node.stats_history();
if let Some(npub) = peer_npub {
let addr = match parse_peer_npub(npub) {
Ok(a) => a,
Err(e) => return Response::error(e),
};
let peer_metric = match PeerMetric::from_str(metric_name) {
Ok(m) => m,
Err(e) => return Response::error(e),
};
match hist.peer_query(&addr, peer_metric, window, granularity) {
Some(series) => Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null)),
None => Response::error(format!(
"peer not tracked in stats history: {}",
node.peer_display_name(&addr)
)),
}
} else {
let metric = match Metric::from_str(metric_name) {
Ok(m) => m,
Err(e) => return Response::error(e),
};
let series = hist.query(metric, window, granularity);
Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null))
}
}
/// Parse a duration of the form `<N>s`, `<N>m`, or `<N>h` into a `Duration`.
fn parse_duration(s: &str) -> Result<Duration, String> {
if s.is_empty() {
return Err("empty duration".to_string());
}
let (num_part, unit) = s.split_at(s.len() - 1);
let n: u64 = num_part
.parse()
.map_err(|_| format!("invalid duration: {s}"))?;
let secs = match unit {
"s" => n,
"m" => n * 60,
"h" => n * 3600,
_ => return Err(format!("unknown duration unit: {unit} (expected s, m, h)")),
};
Ok(Duration::from_secs(secs))
}
/// `show_stats_all_history` — Return a series for every tracked metric
/// in one round trip. Intended for the fipstop Graphs tab.
///
/// Without `peer`: returns the 10 node-level metrics.
/// With `peer` (npub): returns the 7 per-peer metrics for that peer.
///
/// Params: `{"peer": "<npub>"?, "window": "<dur>", "granularity": "<1s|1m>"}`.
pub fn show_stats_all_history(node: &Node, params: Option<&Value>) -> super::protocol::Response {
use super::protocol::Response;
let params = params.cloned().unwrap_or_else(|| json!({}));
let window_str = params
.get("window")
.and_then(|v| v.as_str())
.unwrap_or("10m");
let window = match parse_duration(window_str) {
Ok(d) => d,
Err(e) => return Response::error(e),
};
let granularity_str = params
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("1s");
let granularity = match Granularity::from_str(granularity_str) {
Ok(g) => g,
Err(e) => return Response::error(e),
};
let peer_npub = params.get("peer").and_then(|v| v.as_str());
let hist = node.stats_history();
let series: Vec<Value> = if let Some(npub) = peer_npub {
let addr = match parse_peer_npub(npub) {
Ok(a) => a,
Err(e) => return Response::error(e),
};
if !hist.has_peer(&addr) {
return Response::error(format!(
"peer not tracked in stats history: {}",
node.peer_display_name(&addr)
));
}
ALL_PEER_METRICS
.iter()
.map(|m| {
let s = hist
.peer_query(&addr, *m, window, granularity)
.unwrap_or_else(|| {
// Unreachable: has_peer checked above, but degrade
// gracefully rather than panic.
crate::node::stats_history::Series {
metric: m.name(),
unit: m.unit(),
granularity_seconds: granularity.seconds(),
values: Vec::new(),
}
});
serde_json::to_value(&s).unwrap_or(Value::Null)
})
.collect()
} else {
ALL_METRICS
.iter()
.map(|m| {
let s = hist.query(*m, window, granularity);
serde_json::to_value(&s).unwrap_or(Value::Null)
})
.collect()
};
Response::ok(json!({
"granularity_seconds": granularity.seconds(),
"window_seconds": window.as_secs(),
"peer": peer_npub,
"series": series,
}))
}
/// `show_stats_peers` — Enumerate peers tracked in the stats history
/// with their lifecycle metadata. Used by operator tools to populate
/// peer selectors and to confirm a peer is in the retention window.
pub fn show_stats_peers(node: &Node) -> Value {
let hist = node.stats_history();
let now = std::time::Instant::now();
let mut peers: Vec<Value> = hist
.peers()
.map(|(addr, rings)| {
let last_contact_secs = now.duration_since(rings.last_contact()).as_secs();
let first_seen_secs = now.duration_since(rings.first_seen()).as_secs();
let is_active = node.peers().any(|p| p.node_addr() == addr);
let npub = node
.peers()
.find(|p| p.node_addr() == addr)
.map(|p| p.npub())
.unwrap_or_else(|| hex::encode(addr.as_bytes()));
json!({
"npub": npub,
"node_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"is_active": is_active,
"first_seen_secs_ago": first_seen_secs,
"last_contact_secs_ago": last_contact_secs,
})
})
.collect();
// Stable display order: active peers first, then by display name.
peers.sort_by(|a, b| {
let a_active = a
.get("is_active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let b_active = b
.get("is_active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
match (b_active, a_active) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => a
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("display_name").and_then(|v| v.as_str()).unwrap_or("")),
}
});
json!({ "peers": peers, "count": peers.len() })
}
/// `show_stats_history_all_peers` — One metric across every tracked
/// peer in one round trip. Backs the fipstop MetricByPeer grid view.
///
/// Params: `{"metric": "<name>", "window": "<dur>", "granularity": "<1s|1m>"}`.
/// `metric` must be a per-peer metric name (see `PeerMetric`).
pub fn show_stats_history_all_peers(
node: &Node,
params: Option<&Value>,
) -> super::protocol::Response {
use super::protocol::Response;
let Some(params) = params else {
return Response::error("missing params for show_stats_history_all_peers");
};
let metric_name = match params.get("metric").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'metric' parameter"),
};
let metric = match PeerMetric::from_str(metric_name) {
Ok(m) => m,
Err(e) => return Response::error(e),
};
let window_str = params
.get("window")
.and_then(|v| v.as_str())
.unwrap_or("10m");
let window = match parse_duration(window_str) {
Ok(d) => d,
Err(e) => return Response::error(e),
};
let granularity_str = params
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("1s");
let granularity = match Granularity::from_str(granularity_str) {
Ok(g) => g,
Err(e) => return Response::error(e),
};
let hist = node.stats_history();
let peer_addrs: Vec<NodeAddr> = hist.peer_addrs().copied().collect();
let mut peers: Vec<Value> = peer_addrs
.iter()
.filter_map(|addr| {
let s = hist.peer_query(addr, metric, window, granularity)?;
let is_active = node.peers().any(|p| p.node_addr() == addr);
Some(json!({
"node_addr": hex::encode(addr.as_bytes()),
"display_name": node.peer_display_name(addr),
"is_active": is_active,
"values": serde_json::to_value(&s.values).unwrap_or(Value::Null),
}))
})
.collect();
// Active peers first, then by display name.
peers.sort_by(|a, b| {
let a_active = a
.get("is_active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let b_active = b
.get("is_active")
.and_then(|v| v.as_bool())
.unwrap_or(false);
match (b_active, a_active) {
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
_ => a
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("display_name").and_then(|v| v.as_str()).unwrap_or("")),
}
});
Response::ok(json!({
"metric": metric.name(),
"unit": metric.unit(),
"granularity_seconds": granularity.seconds(),
"window_seconds": window.as_secs(),
"peers": peers,
}))
}
/// Dispatch a command string to the appropriate query function.
pub fn dispatch(node: &Node, command: &str) -> super::protocol::Response {
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
match command {
"show_status" => super::protocol::Response::ok(show_status(node)),
"show_peers" => super::protocol::Response::ok(show_peers(node)),
@@ -583,6 +1075,12 @@ pub fn dispatch(node: &Node, command: &str) -> super::protocol::Response {
"show_connections" => super::protocol::Response::ok(show_connections(node)),
"show_transports" => super::protocol::Response::ok(show_transports(node)),
"show_routing" => super::protocol::Response::ok(show_routing(node)),
"show_identity_cache" => super::protocol::Response::ok(show_identity_cache(node)),
"show_stats_list" => super::protocol::Response::ok(show_stats_list()),
"show_stats_history" => show_stats_history(node, params),
"show_stats_all_history" => show_stats_all_history(node, params),
"show_stats_peers" => super::protocol::Response::ok(show_stats_peers(node)),
"show_stats_history_all_peers" => show_stats_history_all_peers(node, params),
_ => super::protocol::Response::error(format!("unknown command: {}", command)),
}
}
+1 -1
View File
@@ -618,7 +618,7 @@ impl Node {
}
/// Tracks a pending discovery lookup with retry state.
pub(crate) struct PendingLookup {
pub struct PendingLookup {
/// When the lookup was first initiated.
pub initiated_ms: u64,
/// When the last attempt was sent.
+2 -1
View File
@@ -102,7 +102,7 @@ impl Node {
}
Some((request, response_tx)) = control_rx.recv() => {
let response = if request.command.starts_with("show_") {
queries::dispatch(self, &request.command)
queries::dispatch(self, &request.command, request.params.as_ref())
} else {
commands::dispatch(
self,
@@ -127,6 +127,7 @@ impl Node {
self.check_tree_state().await;
self.check_bloom_state().await;
self.compute_mesh_size();
self.record_stats_history();
self.check_mmp_reports().await;
self.check_session_mmp_reports().await;
self.check_link_heartbeats().await;
+109
View File
@@ -14,6 +14,7 @@ mod routing_error_rate_limit;
pub(crate) mod session;
pub(crate) mod session_wire;
pub(crate) mod stats;
pub(crate) mod stats_history;
#[cfg(test)]
mod tests;
mod tree;
@@ -363,6 +364,9 @@ pub struct Node {
/// Routing, forwarding, discovery, and error signal counters.
stats: stats::NodeStats,
/// Time-series history of node-level metrics (1s/1m rings).
stats_history: stats_history::StatsHistory,
// === TUN Interface ===
/// TUN device state.
tun_state: TunState,
@@ -546,6 +550,7 @@ impl Node {
next_link_id: 1,
next_transport_id: 1,
stats: stats::NodeStats::new(),
stats_history: stats_history::StatsHistory::new(),
tun_state,
tun_name: None,
tun_tx: None,
@@ -657,6 +662,7 @@ impl Node {
next_link_id: 1,
next_transport_id: 1,
stats: stats::NodeStats::new(),
stats_history: stats_history::StatsHistory::new(),
tun_state,
tun_name: None,
tun_tx: None,
@@ -1143,6 +1149,70 @@ impl Node {
&mut self.stats
}
/// Get the stats history collector.
pub fn stats_history(&self) -> &stats_history::StatsHistory {
&self.stats_history
}
/// Sample the current node state into the stats history ring.
/// Called once per tick from the RX loop.
pub(crate) fn record_stats_history(&mut self) {
let fwd = &self.stats.forwarding;
let peers_with_mmp: Vec<f64> = self
.peers
.values()
.filter_map(|p| p.mmp().map(|m| m.metrics.loss_rate()))
.collect();
let loss_rate = if peers_with_mmp.is_empty() {
0.0
} else {
peers_with_mmp.iter().sum::<f64>() / peers_with_mmp.len() as f64
};
let snap = stats_history::Snapshot {
mesh_size: self.estimated_mesh_size,
tree_depth: self.tree_state.my_coords().depth() as u32,
peer_count: self.peers.len() as u64,
parent_switches_total: self.stats.tree.parent_switches,
bytes_in_total: fwd.received_bytes,
bytes_out_total: fwd.forwarded_bytes + fwd.originated_bytes,
packets_in_total: fwd.received_packets,
packets_out_total: fwd.forwarded_packets + fwd.originated_packets,
loss_rate,
active_sessions: self.sessions.len() as u64,
};
let now = std::time::Instant::now();
let peer_snaps: Vec<stats_history::PeerSnapshot> = self
.peers
.values()
.map(|p| {
let stats = p.link_stats();
let (srtt_ms, loss_rate, ecn_ce) = match p.mmp() {
Some(m) => (
m.metrics.srtt_ms(),
Some(m.metrics.loss_rate()),
m.receiver.ecn_ce_count() as u64,
),
None => (None, None, 0),
};
stats_history::PeerSnapshot {
node_addr: *p.node_addr(),
last_seen: now,
srtt_ms,
loss_rate,
bytes_in_total: stats.bytes_recv,
bytes_out_total: stats.bytes_sent,
packets_in_total: stats.packets_recv,
packets_out_total: stats.packets_sent,
ecn_ce_total: ecn_ce,
}
})
.collect();
self.stats_history.tick(now, &snap, &peer_snaps);
}
// === TUN Interface ===
/// Get the TUN state.
@@ -1453,16 +1523,55 @@ impl Node {
self.identity_cache.len()
}
/// Iterate over identity cache entries.
///
/// Returns `(NodeAddr, PublicKey, last_seen_ms)` for each cached identity.
/// Used by the `show_identity_cache` control query.
pub fn identity_cache_iter(
&self,
) -> impl Iterator<Item = (&NodeAddr, &secp256k1::PublicKey, u64)> {
self.identity_cache
.values()
.map(|(addr, pk, ts)| (addr, pk, *ts))
}
/// Configured maximum identity cache size.
pub fn identity_cache_max(&self) -> usize {
self.config.node.cache.identity_size
}
/// Number of pending discovery lookups.
pub fn pending_lookup_count(&self) -> usize {
self.pending_lookups.len()
}
/// Iterate over pending discovery lookups for diagnostics.
pub fn pending_lookups_iter(
&self,
) -> impl Iterator<Item = (&NodeAddr, &handlers::discovery::PendingLookup)> {
self.pending_lookups.iter()
}
/// Number of recent discovery requests tracked.
pub fn recent_request_count(&self) -> usize {
self.recent_requests.len()
}
/// Count of destinations with queued TUN packets awaiting session setup.
pub fn pending_tun_destinations(&self) -> usize {
self.pending_tun_packets.len()
}
/// Total TUN packets queued across all destinations.
pub fn pending_tun_total_packets(&self) -> usize {
self.pending_tun_packets.values().map(|q| q.len()).sum()
}
/// Iterate over retry state for diagnostics.
pub fn retry_state_iter(&self) -> impl Iterator<Item = (&NodeAddr, &retry::RetryState)> {
self.retry_pending.iter()
}
// === Routing ===
/// Check if a peer is a tree neighbor (parent or child in the spanning tree).
File diff suppressed because it is too large Load Diff
+5
View File
@@ -527,6 +527,11 @@ impl ActivePeer {
self.consecutive_decrypt_failures = 0;
}
/// Current consecutive decryption failure count.
pub fn consecutive_decrypt_failures(&self) -> u32 {
self.consecutive_decrypt_failures
}
// === Epoch Accessors ===
/// Get the remote peer's startup epoch (from handshake).