mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 08:14:42 +00:00
Merge branch 'master' into next
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Vendored
+7
@@ -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();
|
||||
|
||||
@@ -374,6 +374,7 @@ pub use windows_impl::ControlSocket;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(windows)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
+505
-7
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user