diff --git a/Cargo.lock b/Cargo.lock index e925ef3be4..365410d1ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,12 +551,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.95" @@ -718,6 +733,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.4.0" @@ -824,7 +852,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -845,7 +873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", ] [[package]] @@ -897,7 +925,10 @@ dependencies = [ "bitflags 2.5.0", "crossterm_winapi", "libc", + "mio", "parking_lot", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -2306,6 +2337,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inout" version = "0.1.3" @@ -2503,6 +2540,7 @@ dependencies = [ "colored", "comfy-table", "console", + "crossterm", "derive_more", "dialoguer", "dirs-next", @@ -2522,7 +2560,9 @@ dependencies = [ "postcard", "quic-rpc", "rand", + "ratatui", "regex", + "reqwest", "rustyline", "serde", "serde_with", @@ -2869,6 +2909,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -3048,6 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.48.0", ] @@ -4071,6 +4121,26 @@ dependencies = [ "smallvec", ] +[[package]] +name = "ratatui" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" +dependencies = [ + "bitflags 2.5.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum 0.26.2", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "raw-cpuid" version = "11.0.1" @@ -4827,6 +4897,27 @@ dependencies = [ "dirs", ] +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -4959,12 +5050,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stability" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" +dependencies = [ + "quote", + "syn 2.0.60", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "str-buf" version = "1.0.6" diff --git a/iroh-cli/Cargo.toml b/iroh-cli/Cargo.toml index 5ae4aa4d8c..32f5651a6c 100644 --- a/iroh-cli/Cargo.toml +++ b/iroh-cli/Cargo.toml @@ -29,7 +29,8 @@ clap = { version = "4", features = ["derive"] } colored = "2.0.4" comfy-table = "7.0.1" console = "0.15.5" -derive_more = { version = "1.0.0-beta.6", features = ["display"] } +crossterm = "0.27.0" +derive_more = { version = "1.0.0-beta.1", features = ["display"] } dialoguer = { version = "0.11.0", default-features = false } dirs-next = "2.0.0" flume = "0.11.0" @@ -46,6 +47,8 @@ portable-atomic = "1" postcard = "1.0.8" quic-rpc = { version = "0.9.0", features = ["flume-transport", "quinn-transport"] } rand = "0.8.5" +ratatui = "0.26.2" +reqwest = { version = "0.11.19", default-features = false, features = ["json", "rustls-tls"] } rustyline = "12.0.0" serde = { version = "1.0.197", features = ["derive"] } serde_with = "3.7.0" diff --git a/iroh-cli/src/commands/doctor.rs b/iroh-cli/src/commands/doctor.rs index 0bb72d8664..e1e8e31e31 100644 --- a/iroh-cli/src/commands/doctor.rs +++ b/iroh-cli/src/commands/doctor.rs @@ -2,6 +2,7 @@ //! and to test connectivity to specific other nodes. use std::{ collections::HashMap, + io, net::SocketAddr, num::NonZeroU16, path::PathBuf, @@ -43,12 +44,21 @@ use iroh::{ }; use portable_atomic::AtomicU64; use postcard::experimental::max_size::MaxSize; +use ratatui::backend::Backend; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, sync}; use iroh::net::metrics::MagicsockMetrics; use iroh_metrics::core::Core; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use rand::Rng; +use ratatui::{prelude::*, widgets::*}; + #[derive(Debug, Clone, derive_more::Display)] pub enum SecretKeyOption { /// Generate random secret key @@ -212,6 +222,20 @@ pub enum Commands { #[clap(long)] repair: bool, }, + /// Plot metric counters + Plot { + /// How often to collect samples in milliseconds. + #[clap(long, default_value_t = 500)] + interval: u64, + /// Which metrics to plot. Commas separated list of metric names. + metrics: String, + /// What the plotted time frame should be in seconds. + #[clap(long, default_value_t = 60)] + timeframe: usize, + /// Endpoint to scrape for prometheus metrics + #[clap(long, default_value = "http://localhost:9090")] + scrape_url: String, + }, } #[derive(Debug, Serialize, Deserialize, MaxSize)] @@ -1146,5 +1170,275 @@ pub async fn run(command: Commands, config: &NodeConfig) -> anyhow::Result<()> { task.await?; Ok(()) } + Commands::Plot { + interval, + metrics, + timeframe, + scrape_url, + } => { + let metrics: Vec = metrics.split(',').map(|s| s.to_string()).collect(); + let interval = Duration::from_millis(interval); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let app = PlotterApp::new(metrics, timeframe, scrape_url); + let res = run_plotter(&mut terminal, app, interval).await; + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{err:?}"); + } + + Ok(()) + } + } +} + +async fn run_plotter( + terminal: &mut Terminal, + mut app: PlotterApp, + tick_rate: Duration, +) -> anyhow::Result<()> { + let mut last_tick = Instant::now(); + loop { + terminal.draw(|f| plotter_draw(f, &mut app))?; + + if crossterm::event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if let KeyCode::Char(c) = key.code { + app.on_key(c) + } + } + } + } + if last_tick.elapsed() >= tick_rate { + app.on_tick().await; + last_tick = Instant::now(); + } + if app.should_quit { + return Ok(()); + } + } +} + +fn area_into_chunks(area: Rect, n: usize, horizontal: bool) -> std::rc::Rc<[Rect]> { + let mut constraints = vec![]; + for _ in 0..n { + constraints.push(Constraint::Percentage(100 / n as u16)); + } + let layout = match horizontal { + true => Layout::horizontal(constraints), + false => Layout::vertical(constraints), + }; + layout.split(area) +} + +fn generate_layout_chunks(area: Rect, n: usize) -> Vec { + if n < 4 { + let chunks = area_into_chunks(area, n, false); + return chunks.iter().copied().collect(); + } + let main_chunks = area_into_chunks(area, 2, true); + let left_chunks = area_into_chunks(main_chunks[0], n / 2 + n % 2, false); + let right_chunks = area_into_chunks(main_chunks[1], n / 2, false); + let mut chunks = vec![]; + chunks.extend(left_chunks.iter()); + chunks.extend(right_chunks.iter()); + chunks +} + +fn plotter_draw(f: &mut Frame, app: &mut PlotterApp) { + let area = f.size(); + + let metrics_cnt = app.metrics.len(); + let areas = generate_layout_chunks(area, metrics_cnt); + + for (i, metric) in app.metrics.iter().enumerate() { + plot_chart(f, areas[i], app, metric); + } +} + +fn plot_chart(frame: &mut Frame, area: Rect, app: &PlotterApp, metric: &str) { + let elapsed = app.internal_ts.as_secs_f64(); + let data = app.data.get(metric).unwrap().clone(); + let data_y_range = app.data_y_range.get(metric).unwrap(); + + let moved = (elapsed / 15.0).floor() * 15.0 - app.timeframe as f64; + let moved = moved.max(0.0); + let x_start = 0.0 + moved; + let x_end = moved + app.timeframe as f64 + 25.0; + + let y_start = data_y_range.0; + let y_end = data_y_range.1; + + let datasets = vec![Dataset::default() + .name(metric) + .marker(symbols::Marker::Dot) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&data)]; + + // TODO(arqu): labels are incorrectly spaced for > 3 labels https://github.com/ratatui-org/ratatui/issues/334 + let x_labels = vec![ + Span::styled( + format!("{:.1}s", x_start), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:.1}s", x_start + (x_end - x_start) / 2.0)), + Span::styled( + format!("{:.1}s", x_end), + Style::default().add_modifier(Modifier::BOLD), + ), + ]; + + let mut y_labels = vec![Span::styled( + format!("{:.1}", y_start), + Style::default().add_modifier(Modifier::BOLD), + )]; + + for i in 1..=10 { + y_labels.push(Span::raw(format!( + "{:.1}", + y_start + (y_end - y_start) / 10.0 * i as f64 + ))); + } + + y_labels.push(Span::styled( + format!("{:.1}", y_end), + Style::default().add_modifier(Modifier::BOLD), + )); + + let chart = Chart::new(datasets) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Chart: {}", metric)), + ) + .x_axis( + Axis::default() + .title("X Axis") + .style(Style::default().fg(Color::Gray)) + .labels(x_labels) + .bounds([x_start, x_end]), + ) + .y_axis( + Axis::default() + .title("Y Axis") + .style(Style::default().fg(Color::Gray)) + .labels(y_labels) + .bounds([y_start, y_end]), + ); + + frame.render_widget(chart, area); +} + +struct PlotterApp { + should_quit: bool, + metrics: Vec, + start_ts: Instant, + data: HashMap>, + data_y_range: HashMap, + timeframe: usize, + rng: rand::rngs::ThreadRng, + freeze: bool, + internal_ts: Duration, + scrape_url: String, +} + +impl PlotterApp { + fn new(metrics: Vec, timeframe: usize, scrape_url: String) -> Self { + let data = metrics.iter().map(|m| (m.clone(), vec![])).collect(); + let data_y_range = metrics.iter().map(|m| (m.clone(), (0.0, 0.0))).collect(); + Self { + should_quit: false, + metrics, + start_ts: Instant::now(), + data, + data_y_range, + timeframe: timeframe - 25, + rng: rand::thread_rng(), + freeze: false, + internal_ts: Duration::default(), + scrape_url, + } + } + + fn on_key(&mut self, c: char) { + match c { + 'q' => { + self.should_quit = true; + } + 'f' => { + self.freeze = !self.freeze; + } + _ => {} + } + } + + async fn on_tick(&mut self) { + if self.freeze { + return; + } + + let req = reqwest::Client::new().get(&self.scrape_url).send().await; + if req.is_err() { + return; + } + let data = req.unwrap().text().await.unwrap(); + let metrics_response = parse_prometheus_metrics(&data); + if metrics_response.is_err() { + return; + } + let metrics_response = metrics_response.unwrap(); + self.internal_ts = self.start_ts.elapsed(); + for metric in &self.metrics { + let val = if metric.eq("random") { + self.rng.gen_range(0..101) as f64 + } else if let Some(v) = metrics_response.get(metric) { + *v + } else { + 0.0 + }; + let e = self.data.entry(metric.clone()).or_default(); + e.push((self.internal_ts.as_secs_f64(), val)); + let yr = self.data_y_range.get_mut(metric).unwrap(); + if val * 1.1 < yr.0 { + yr.0 = val * 1.2; + } + if val * 1.1 > yr.1 { + yr.1 = val * 1.2; + } + } + } +} + +fn parse_prometheus_metrics(data: &str) -> anyhow::Result> { + let mut metrics = HashMap::new(); + for line in data.lines() { + if line.starts_with('#') { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let metric = parts[0]; + let value = parts[1].parse::(); + if value.is_err() { + continue; + } + metrics.insert(metric.to_string(), value.unwrap()); } + Ok(metrics) }