mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Add historical node and per-peer statistics with btop-style graphs (#64)
In-memory time-series history on the daemon: fast ring (1s × 3600) plus slow ring (1m × 1440) per metric, covering node-level gauges (mesh size, tree depth, peer count, active sessions), counters (parent switches, aggregate bytes/packets in/out), loss rate, and seven per-peer metrics keyed by NodeAddr (srtt_ms, loss_rate, bytes_in/out, packets_in/out, ecn_ce). The slow ring is produced by downsampling the fast ring on minute boundaries with Last / Sum / Mean aggregation chosen per metric type. Missing data is first-class. New peers back-fill NaN so every ring shares a time axis with the node rings; peers absent from a tick sample NaN (keeps alignment, shows as a visible gap); counter metrics emit NaN on decrease (new link_stats baseline after reconnect) so deltas aren't polluted. Peers are evicted 24h after last contact. Downsampling is NaN-aware: mean skips NaN, all-NaN slow windows stay NaN. Each history window always returns its full span at the chosen density (1m / 10m / 1h / 24h), front-padded with NaN when the ring hasn't yet accumulated enough samples, so switching between windows feels like zooming in or out rather than clipping to whatever has arrived. NaN serializes to JSON null via a custom serializer. Control socket queries: - show_stats_list enumerates registered metrics plus scope field and peer_retention_seconds. - show_stats_history returns one metric's series for a given window and granularity; accepts optional peer (npub) for per-peer metrics. - show_stats_all_history returns every metric in a single round trip; accepts optional peer to fetch all seven per-peer metrics. - show_stats_peers enumerates tracked peers with lifecycle metadata. - show_stats_history_all_peers returns one metric across all peers for grid rendering. - show_status carries short sparkline windows for the dashboard so the client can render without extra fetches. fipsctl gains `stats list`, `stats peers`, and `stats history <metric>` with `--peer` (hostname or npub) and `--plot` for a Unicode block sparkline. Plot header reports sample count, granularity, window, and gap count; NaN renders as a blank cell. fipstop dashboard grows inline sparklines (peer count, mesh size, aggregate bytes in/out). A new Graphs tab stacks every metric as an independent mini plot with its own autoscaled range; each plot uses btop's braille 2×4 filled-area algorithm (25-entry lookup table packing two samples per character, per-row gradient coloring for the characteristic btop vertical-band look, rounded borders with embedded titles). Three modes are cycled with `m`: Node (node-level stack), MetricByPeer (small-multiples grid, 1 / 2 / 3 columns by terminal width), PeerByMetric (existing stack scoped to one peer). `n` / `N` cycles the mode-specific selector (metric or peer), a selector row shows the current choice, and Graphs-tab refreshes re-fetch show_stats_peers so selectors track peer churn. Up / Down scrolls the stack, Left / Right cycles the window, `g` jumps to the tab. Implements IDEA-0084 (TASK-2026-0062).
This commit is contained in:
@@ -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)]
|
||||
@@ -394,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) => {
|
||||
@@ -406,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::*;
|
||||
|
||||
@@ -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())?
|
||||
|
||||
@@ -95,6 +95,90 @@ fn fetch_data(
|
||||
|
||||
// Fetch active tab data (if not Dashboard, which we already fetched)
|
||||
if app.active_tab != Tab::Node {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -104,6 +188,7 @@ fn fetch_data(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-reference fetches for detail views
|
||||
if app.active_tab == Tab::Peers {
|
||||
@@ -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);
|
||||
|
||||
634
src/bin/fipstop/ui/graphs.rs
Normal file
634
src/bin/fipstop/ui/graphs.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ pub use windows_impl::ControlSocket;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(windows)]
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -688,8 +713,342 @@ pub fn show_identity_cache(node: &Node) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// `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)),
|
||||
@@ -703,6 +1062,11 @@ pub fn dispatch(node: &Node, command: &str) -> super::protocol::Response {
|
||||
"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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,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,
|
||||
@@ -125,6 +125,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -358,6 +359,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,
|
||||
@@ -535,6 +539,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,
|
||||
@@ -644,6 +649,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,
|
||||
@@ -1115,6 +1121,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.
|
||||
|
||||
1218
src/node/stats_history.rs
Normal file
1218
src/node/stats_history.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user