diff --git a/share/config/example-multiple.toml b/share/config/example-multiple.toml new file mode 100644 index 0000000..217db44 --- /dev/null +++ b/share/config/example-multiple.toml @@ -0,0 +1,30 @@ +tick_rate = 250 + +[price] +enabled = true +currency = "USD" +big_text = true + +[fees] +enabled = true + +[[nodes]] +provider = "bitcoin_core" +[nodes.bitcoin_core] +host = "127.0.0.1" +rpc_port = 18443 +rpc_user = "polaruser" +rpc_password = "polarpass" +zmq_port = 28334 + +[[nodes]] +provider = "core_lightning" +[nodes.core_lightning] +rest_address = "http://127.0.0.1:3010" +rest_rune = "your_actual_rune" + +[[nodes]] +provider = "lnd" +[nodes.lnd] +rest_address = "https://127.0.0.1:8080" +macaroon_hex = "your_actual_macaroon" diff --git a/share/config/example.toml b/share/config/example.toml index 231067e..b052d33 100644 --- a/share/config/example.toml +++ b/share/config/example.toml @@ -1,4 +1,5 @@ tick_rate = 250 +streamer_mode = false [node] provider = "bitcoin_core" diff --git a/src/app.rs b/src/app.rs index ad30183..586259e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,6 @@ -// app.rs - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use std::error; use std::str::FromStr; -use std::{env, error}; use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -12,7 +10,7 @@ use crate::config::AppConfig; use crate::event::Event; use crate::fees::providers::FeesBlockchainInfo; use crate::fees::{spawn_fees_checker, FeesState}; -use crate::node::{Node, NodeProvider, NodeState}; +use crate::node::{Node, NodeState}; use crate::price::providers::coinbase::PriceCoinbase; use crate::price::{spawn_price_checker, PriceCurrency, PriceState}; use crate::widget::{DynamicNodeStatefulWidget, DynamicState}; @@ -41,15 +39,18 @@ pub struct AppState { pub counter: u8, pub price: PriceState, pub fees: FeesState, - pub node: NodeState, - pub widget: Box, - pub widget_state: Box, + pub node_states: Vec, } pub struct App { - pub node: Node, + pub nodes: Vec, + pub current_node_index: usize, + pub last_node_switch: Option, + pub node_switch_interval: Duration, + pub seconds_until_rotation: u64, pub thread: AppThread, pub config: AppConfig, + pub widgets: Vec>, pub state: AppState, pub running: bool, } @@ -57,32 +58,44 @@ pub struct App { impl App { pub fn new( thread: AppThread, - widget: Box, - widget_state: Box, + widgets: Vec>, + widget_states: Vec>, + config: AppConfig, ) -> Self { - let (args, argv) = argmap::parse(env::args()); - let config = AppConfig::new(args, argv).unwrap(); let cloned_thread = thread.clone(); + let interval = Duration::from_secs(config.node_switch_interval.parse::().unwrap_or(5)); + let num_nodes = widgets.len(); Self { running: true, config, thread, - node: Node::new(cloned_thread), + nodes: (0..num_nodes) + .map(|_| Node::new(cloned_thread.clone())) + .collect(), + current_node_index: 0, + last_node_switch: None, + node_switch_interval: interval, + seconds_until_rotation: interval.as_secs(), + widgets, state: AppState { counter: 0, price: PriceState::new(), fees: FeesState::new(), - node: NodeState::new(), - widget, - widget_state, + node_states: widget_states + .into_iter() + .map(|ws| { + let mut ns = NodeState::new(); + ns.widget_state = ws; + ns.current_node_index = 0; // Will be updated in tick + ns.total_nodes = num_nodes; + ns.seconds_until_rotation = interval.as_secs(); + ns + }) + .collect(), }, } } - pub fn init_node(&mut self, provider: Box) { - self.node.init(provider); - } - pub fn init_price(&mut self) { spawn_price_checker::( self.thread.clone(), @@ -95,21 +108,26 @@ impl App { } pub fn tick(&mut self) { - let now = Instant::now(); - let switch_interval = Duration::from_secs(3); - let keys: Vec<_> = self.state.node.services.keys().cloned().collect(); - - if !keys.is_empty() { - let should_advance = match self.state.node.last_service_switch { - Some(last) => now.duration_since(last) >= switch_interval, - None => true, - }; - - if should_advance { - let new_index = (self.state.node.service_display_index + 1) % keys.len(); - self.state - .node - .set_last_service_switch(Some(now), new_index); + for (_i, node_state) in self.state.node_states.iter_mut().enumerate() { + node_state.tick(); + node_state.current_node_index = self.current_node_index; + node_state.total_nodes = self.nodes.len(); + node_state.seconds_until_rotation = self.seconds_until_rotation; + } + + if self.nodes.len() > 1 { + let now = Instant::now(); + if let Some(last_switch) = self.last_node_switch { + let elapsed = now.duration_since(last_switch).as_secs(); + self.seconds_until_rotation = + self.node_switch_interval.as_secs().saturating_sub(elapsed); + if elapsed >= self.node_switch_interval.as_secs() { + self.current_node_index = (self.current_node_index + 1) % self.nodes.len(); + self.last_node_switch = Some(now); + self.seconds_until_rotation = self.node_switch_interval.as_secs(); + } + } else { + self.last_node_switch = Some(now); } } } @@ -134,12 +152,13 @@ impl App { self.state.price = state; } - pub fn handle_node_update(&mut self, update_fn: F) - where - F: Fn(NodeState) -> NodeState + Send + Sync, - { - self.state.node = update_fn(self.state.node.clone()); - self.state.widget_state = self.state.node.widget_state.clone_box(); + pub fn handle_node_update( + &mut self, + index: usize, + update_fn: &(dyn Fn(NodeState) -> NodeState + Send + Sync), + ) { + let updated = update_fn(self.state.node_states[index].clone()); + self.state.node_states[index] = updated; } pub fn handle_fee_update(&mut self, state: FeesState) { @@ -156,15 +175,63 @@ impl App { self.quit(); } } - KeyCode::Right => { - self.increment_counter(); + KeyCode::Right | KeyCode::Char('n') => { + if self.nodes.len() > 1 { + self.current_node_index = (self.current_node_index + 1) % self.nodes.len(); + self.last_node_switch = Some(Instant::now()); + self.seconds_until_rotation = self.node_switch_interval.as_secs(); + } } KeyCode::Left => { - self.decrement_counter(); + if self.nodes.len() > 1 { + self.current_node_index = if self.current_node_index == 0 { + self.nodes.len() - 1 + } else { + self.current_node_index - 1 + }; + self.last_node_switch = Some(Instant::now()); + self.seconds_until_rotation = self.node_switch_interval.as_secs(); + } + } + KeyCode::Up => { + if self.nodes.len() > 1 { + let new_interval = self.node_switch_interval.as_secs().saturating_add(1); + self.node_switch_interval = Duration::from_secs(new_interval); + self.seconds_until_rotation = new_interval; + self.last_node_switch = Some(Instant::now()); + } + } + KeyCode::Down => { + if self.nodes.len() > 1 { + let new_interval = self.node_switch_interval.as_secs().saturating_sub(1); + self.node_switch_interval = Duration::from_secs(new_interval.max(1)); + self.seconds_until_rotation = new_interval.max(1); + self.last_node_switch = Some(Instant::now()); + } } - KeyCode::Char(' ') => {} _ => {} } Ok(()) } -} + + pub fn handle_mouse_events(&mut self, mouse_event: MouseEvent) -> AppResult<()> { + if self.nodes.len() > 1 { + match mouse_event.kind { + MouseEventKind::Down(_) => { + let x = mouse_event.column; + let y = mouse_event.row; + let total_height = self.config.tick_rate.parse::().unwrap() as u16; + let status_panel_height = 1; + let frame_width = 80; // Fixed width for now + if y >= total_height - status_panel_height && x >= frame_width - 25 { + self.current_node_index = (self.current_node_index + 1) % self.nodes.len(); + self.last_node_switch = Some(Instant::now()); + self.seconds_until_rotation = self.node_switch_interval.as_secs(); + } + } + _ => {} + } + } + Ok(()) + } +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 1ab1c67..5895fee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use config::{Config, ConfigError, File}; use serde_derive::Deserialize; use std::collections::HashMap; -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, Default)] #[allow(unused)] pub struct BitcoinCoreSettings { pub host: String, @@ -13,12 +13,20 @@ pub struct BitcoinCoreSettings { pub zmq_port: String, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, Default)] #[allow(unused)] pub struct CoreLightningSettings { pub rest_address: String, pub rest_rune: String, } + +#[derive(Debug, Deserialize, Clone, Default)] +#[allow(unused)] +pub struct LndSettings { + pub rest_address: String, + pub macaroon_hex: String, +} + #[derive(Debug, Deserialize, Clone)] #[allow(unused)] pub struct PriceSettings { @@ -35,27 +43,26 @@ pub struct FeesSettings { #[derive(Debug, Deserialize, Clone)] #[allow(unused)] -pub struct NodeSettings { +pub struct NodeConfig { pub provider: String, + pub bitcoin_core: Option, + pub core_lightning: Option, + pub lnd: Option, } #[derive(Debug, Deserialize, Clone)] #[allow(unused)] pub struct AppConfig { pub tick_rate: String, + pub streamer_mode: bool, + pub node_switch_interval: String, // New field for rotation time in seconds pub price: PriceSettings, pub fees: FeesSettings, pub bitcoin_core: BitcoinCoreSettings, pub core_lightning: CoreLightningSettings, pub lnd: LndSettings, - pub node: NodeSettings, -} - -#[derive(Debug, Deserialize, Clone)] -#[allow(unused)] -pub struct LndSettings { - pub rest_address: String, - pub macaroon_hex: String, + #[serde(default)] + pub nodes: Vec, } fn match_string_to_bool(value: &str) -> bool { @@ -74,9 +81,9 @@ impl AppConfig { let mut s = Config::builder() // general .set_default("tick_rate", 250)? - // node provider default - .set_default("node.provider", "bitcoin_core")? - // bitcoin core defaults + .set_default("streamer_mode", false)? + .set_default("node_switch_interval", "5")? // Default rotation time of 5 seconds + // bitcoin core defaults (will be cleared if nodes is used) .set_default("bitcoin_core.host", "localhost")? .set_default("bitcoin_core.rpc_port", 8332)? .set_default("bitcoin_core.rpc_user", "username")? @@ -124,7 +131,7 @@ impl AppConfig { .and_then(|v| Some(v.first().unwrap().as_str())) { match key.as_str() { - "price.enabled" | "fees.enabled" => { + "price.enabled" | "fees.enabled" | "streamer_mode" => { s = s.set_override(key, match_string_to_bool(value))?; } _ => { @@ -134,6 +141,15 @@ impl AppConfig { } } - s.build()?.try_deserialize() + let mut config: AppConfig = s.build()?.try_deserialize()?; + + // Clear legacy providers if nodes array is used + if !config.nodes.is_empty() { + config.bitcoin_core = BitcoinCoreSettings::default(); + config.core_lightning = CoreLightningSettings::default(); + config.lnd = LndSettings::default(); + } + + Ok(config) } } diff --git a/src/event.rs b/src/event.rs index 2d06f2c..07675da 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,3 +1,5 @@ +// event.rs + use std::sync::Arc; use crossterm::event::{Event as CrosstermEvent, KeyEvent, MouseEvent}; @@ -5,7 +7,10 @@ use futures::{FutureExt, StreamExt}; use tokio::sync::mpsc; use tokio::time::Duration; -use crate::{app::AppResult, fees::FeesState, node::NodeState, price::PriceState}; +use crate::app::AppResult; +use crate::fees::FeesState; +use crate::node::NodeState; +use crate::price::PriceState; #[derive(Clone)] pub enum Event { @@ -15,7 +20,7 @@ pub enum Event { Resize(u16, u16), PriceUpdate(PriceState), FeeUpdate(FeesState), - NodeUpdate(Arc NodeState + Send + Sync>), + NodeUpdate(usize, Arc NodeState + Send + Sync>), } #[allow(dead_code)] diff --git a/src/main.rs b/src/main.rs index acaa1c4..3f01533 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,20 @@ -// main.rs - use btcmon::app::{App, AppResult, AppThread}; use btcmon::config; use btcmon::event::{Event, EventHandler}; -use btcmon::node::providers::bitcoin_core::{BitcoinCore, BitcoinCoreWidget}; -use btcmon::node::providers::core_lightning::{CoreLightning, CoreLightningWidget}; -use btcmon::node::providers::lnd::{LndNode, LndWidget}; +use btcmon::node::providers::bitcoin_core::{ + BitcoinCore, BitcoinCoreWidget, BitcoinCoreWidgetState, +}; +use btcmon::node::providers::core_lightning::{ + CoreLightning, CoreLightningWidget, CoreLightningWidgetState, +}; +use btcmon::node::providers::lnd::{LndNode, LndWidget, LndWidgetState}; use btcmon::node::NodeProvider; use btcmon::tui::Tui; -use btcmon::widget::{DefaultWidgetState, DynamicNodeStatefulWidget, DynamicState}; +use btcmon::widget::{DynamicNodeStatefulWidget, DynamicState}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use std::{env, io}; +use std::env; +use std::io; use tokio::sync::mpsc; #[tokio::main] @@ -24,36 +27,72 @@ async fn main() -> AppResult<()> { let sender_clone = sender.clone(); let thread = AppThread::new(sender_clone); - let (provider, widget, widget_state): ( - Box, - Box, - Box, - ) = match config.node.provider.as_str() { - "bitcoin_core" => ( - Box::new(BitcoinCore::new(&config)), - Box::new(BitcoinCoreWidget), - Box::new(DefaultWidgetState), - ), - "core_lightning" => ( - Box::new(CoreLightning::new(&config)), - Box::new(CoreLightningWidget), - Box::new(DefaultWidgetState), - ), - "lnd" => ( - Box::new(LndNode::new(&config)), - Box::new(LndWidget), - Box::new(DefaultWidgetState), - ), - other => { - eprintln!( - "Unknown node provider: '{}'. Expected one of: bitcoin_core, core_lightning, lnd", - other - ); + let mut providers: Vec> = vec![]; + let mut widgets: Vec> = vec![]; + let mut widget_states: Vec> = vec![]; + + // Use nodes from config.nodes if present, otherwise use single node configuration + if !config.nodes.is_empty() { + for node in &config.nodes { + match node.provider.as_str() { + "bitcoin_core" => { + if let Some(settings) = &node.bitcoin_core { + if !settings.host.is_empty() { + providers.push(Box::new(BitcoinCore::new(settings))); + widgets.push(Box::new(BitcoinCoreWidget)); + widget_states.push(Box::new(BitcoinCoreWidgetState::default())); + } + } + } + "core_lightning" => { + if let Some(settings) = &node.core_lightning { + if !settings.rest_address.is_empty() { + providers.push(Box::new(CoreLightning::new(settings))); + widgets.push(Box::new(CoreLightningWidget)); + widget_states.push(Box::new(CoreLightningWidgetState::default())); + } + } + } + "lnd" => { + if let Some(settings) = &node.lnd { + if !settings.rest_address.is_empty() { + providers.push(Box::new(LndNode::new(settings))); + widgets.push(Box::new(LndWidget)); + widget_states.push(Box::new(LndWidgetState::default())); + } + } + } + other => { + eprintln!("Unknown node provider: '{}'.", other); + } + } + } + } else { + // Use single node configuration, prioritizing lnd + if !config.lnd.rest_address.is_empty() { + providers.push(Box::new(LndNode::new(&config.lnd))); + widgets.push(Box::new(LndWidget)); + widget_states.push(Box::new(LndWidgetState::default())); + } else if !config.core_lightning.rest_address.is_empty() { + providers.push(Box::new(CoreLightning::new(&config.core_lightning))); + widgets.push(Box::new(CoreLightningWidget)); + widget_states.push(Box::new(CoreLightningWidgetState::default())); + } else if !config.bitcoin_core.host.is_empty() { + providers.push(Box::new(BitcoinCore::new(&config.bitcoin_core))); + widgets.push(Box::new(BitcoinCoreWidget)); + widget_states.push(Box::new(BitcoinCoreWidgetState::default())); + } else { + eprintln!("No nodes or single node configuration found."); std::process::exit(1); } - }; + } + + if providers.is_empty() { + eprintln!("No valid nodes configured."); + std::process::exit(1); + } - let mut app = App::new(thread, widget, widget_state); + let mut app = App::new(thread, widgets, widget_states, config.clone()); let backend = CrosstermBackend::new(io::stderr()); let terminal = Terminal::new(backend)?; @@ -67,7 +106,10 @@ async fn main() -> AppResult<()> { tui.init()?; tui.draw(&config, &mut app)?; - app.init_node(provider); + // Initialize all nodes + for (i, provider) in providers.into_iter().enumerate() { + app.nodes[i].init(provider, i); + } if config.price.enabled { app.init_price(); @@ -82,12 +124,12 @@ async fn main() -> AppResult<()> { match tui.events.next().await? { Event::Tick => app.tick(), Event::Key(key_event) => app.handle_key_events(key_event)?, - Event::Mouse(_) => {} + Event::Mouse(mouse_event) => app.handle_mouse_events(mouse_event)?, Event::Resize(_, _) => {} Event::PriceUpdate(state) => app.handle_price_update(state), Event::FeeUpdate(state) => app.handle_fee_update(state), - Event::NodeUpdate(update_fn) => { - app.handle_node_update(update_fn.as_ref()); + Event::NodeUpdate(index, update_fn) => { + app.handle_node_update(index, update_fn.as_ref()); } } } diff --git a/src/node/mod.rs b/src/node/mod.rs index 93c5e08..d9cb74e 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -1,11 +1,8 @@ -// node/mod.rs - pub mod providers; pub mod widgets; use crate::{ app::AppThread, - config::AppConfig, widget::{DefaultWidgetState, DynamicState}, }; use anyhow::Result; @@ -20,8 +17,8 @@ use ratatui::{ use std::{ collections::HashMap, fmt, - marker::Sized, sync::{Arc, Mutex}, + time::Duration, }; use tokio::{ sync::mpsc::{UnboundedReceiver, UnboundedSender}, @@ -71,44 +68,28 @@ pub struct NodeState { pub service_display_index: usize, pub last_service_switch: Option, pub widget_state: Box, + pub current_node_index: usize, + pub total_nodes: usize, + pub seconds_until_rotation: u64, } -impl Clone for NodeState { - fn clone(&self) -> Self { - Self { - host: self.host.clone(), - message: self.message.clone(), - status: self.status, - height: self.height, - last_hash_instant: self.last_hash_instant, - services: self.services.clone(), - last_service_switch: self.last_service_switch, - service_display_index: self.service_display_index, - widget_state: self.widget_state.clone_box(), - } - } -} - -impl Default for NodeState { - fn default() -> Self { +impl NodeState { + pub fn new() -> Self { Self { - host: "".to_string(), - message: "".to_string(), - status: NodeStatus::Offline, + host: String::new(), + message: String::new(), + status: NodeStatus::default(), height: 0, last_hash_instant: None, services: HashMap::new(), - last_service_switch: None, service_display_index: 0, + last_service_switch: None, widget_state: Box::new(DefaultWidgetState), + current_node_index: 0, + total_nodes: 0, + seconds_until_rotation: 0, } } -} - -impl NodeState { - pub fn new() -> Self { - Self::default() - } pub fn set_last_service_switch( &mut self, @@ -119,6 +100,24 @@ impl NodeState { self.service_display_index = service_display_index; } + pub fn tick(&mut self) { + let now = Instant::now(); + let switch_interval = Duration::from_secs(3); + let keys: Vec<_> = self.services.keys().cloned().collect(); + + if !keys.is_empty() { + let should_advance = match self.last_service_switch { + Some(last) => now.duration_since(last) >= switch_interval, + None => true, + }; + + if should_advance { + let new_index = (self.service_display_index + 1) % keys.len(); + self.set_last_service_switch(Some(now), new_index); + } + } + } + pub fn draw_new_block_popup(&self, frame: &mut Frame, block_height: u64) { let sized_paragraph = SizedWrapper { inner: Paragraph::new(vec![ @@ -139,12 +138,34 @@ impl NodeState { } } +impl Default for NodeState { + fn default() -> Self { + Self::new() + } +} + +impl Clone for NodeState { + fn clone(&self) -> Self { + Self { + host: self.host.clone(), + message: self.message.clone(), + status: self.status, + height: self.height, + last_hash_instant: self.last_hash_instant, + services: self.services.clone(), + last_service_switch: self.last_service_switch, + service_display_index: self.service_display_index, + widget_state: self.widget_state.clone_box(), + current_node_index: self.current_node_index, + total_nodes: self.total_nodes, + seconds_until_rotation: self.seconds_until_rotation, + } + } +} + #[async_trait] pub trait NodeProvider { - fn new(config: &AppConfig) -> Self - where - Self: Sized; - async fn init(&mut self, thread: AppThread) -> Result<()>; + async fn init(&mut self, thread: AppThread, index: usize) -> Result<()>; } pub struct Node { @@ -155,12 +176,12 @@ pub struct Node { impl Node { pub fn new(thread: AppThread) -> Self { Self { - thread: thread.clone(), + thread: thread.clone(), // Clone to avoid move issues handler: None, } } - pub fn init(&mut self, mut provider: Box) { + pub fn init(&mut self, mut provider: Box, index: usize) { if let Some(handler) = &self.handler { handler.abort(); } @@ -169,8 +190,8 @@ impl Node { let thread = self.thread.clone(); self.handler = Some(self.thread.tracker.spawn(async move { tokio::select! { - _ = provider.init(thread) => {}, - () = token.cancelled() => {}, + _ = provider.init(thread, index) => {}, + _ = token.cancelled() => {}, } })); } diff --git a/src/node/providers/bitcoin_core.rs b/src/node/providers/bitcoin_core.rs index d4878ff..cf92389 100644 --- a/src/node/providers/bitcoin_core.rs +++ b/src/node/providers/bitcoin_core.rs @@ -1,7 +1,10 @@ +// node/providers/bitcoin_core.rs + use anyhow::Result; use async_trait::async_trait; -use bitcoin::consensus::deserialize; -use bitcoin::BlockHash; +// use bitcoin::consensus::deserialize; +// use bitcoin::BlockHash; +// use std::str::FromStr; use bitcoincore_rpc::{json::GetBlockchainInfoResult, RpcApi}; use bitcoincore_zmq::subscribe_async_monitor_stream::MessageStream; use bitcoincore_zmq::{subscribe_async_wait_handshake, SocketEvent, SocketMessage}; @@ -11,17 +14,17 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Widget; -use std::str::FromStr; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::time; use tokio::time::Instant; +use crate::app::AppThread; +use crate::config::{AppConfig, BitcoinCoreSettings}; use crate::event::Event; use crate::node::widgets::BlockedParagraph; -use crate::node::{NodeState, NodeStatus}; +use crate::node::{NodeProvider, NodeState, NodeStatus}; use crate::widget::{DynamicNodeStatefulWidget, DynamicState}; -use crate::{app::AppThread, config::AppConfig, node::NodeProvider}; #[derive(Clone)] pub struct BitcoinCore { @@ -52,7 +55,7 @@ impl DynamicState for BitcoinCoreWidgetState { pub struct BitcoinCoreWidget; impl DynamicNodeStatefulWidget for BitcoinCoreWidget { - fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState) { + fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState, _config: &AppConfig) { let mut default = BitcoinCoreWidgetState::default(); let state = node_state .widget_state @@ -88,84 +91,121 @@ impl DynamicNodeStatefulWidget for BitcoinCoreWidget { } impl BitcoinCore { - fn get_op_return_data(&self, block_hash: &str) -> Result { - let block_hex = self - .rpc_client - .get_block_hex(&BlockHash::from_str(block_hash)?)?; - let block_bytes = hex::decode(&block_hex)?; - let block: bitcoin::Block = deserialize(&block_bytes)?; - - let mut op_returns = Vec::new(); - - for tx in block.txdata { - for (_index, output) in tx.output.iter().enumerate() { - if output.script_pubkey.is_op_return() { - if let Some(bytes) = output.script_pubkey.as_bytes().get(1..) { - if let Ok(text) = String::from_utf8(bytes.to_vec()) { - if !text.is_empty() { - op_returns.push(text); - } - } - } - } - } - } + pub fn new(settings: &BitcoinCoreSettings) -> Self { + let rpc = bitcoincore_rpc::Client::new( + &format!("{}:{}", settings.host, settings.rpc_port), + bitcoincore_rpc::Auth::UserPass( + settings.rpc_user.clone(), + settings.rpc_password.clone(), + ), + ) + .unwrap(); - Ok(if op_returns.is_empty() { - "".to_string() + let zmq_url: Option = if settings.host.is_empty() { + None } else { - op_returns.join(" | ") - }) + Some(format!("tcp://{}:{}", settings.host, settings.zmq_port)) + }; + + Self { + rpc_client: Arc::new(rpc), + zmq_url, + host: settings.host.clone(), + } } + // fn get_op_return_data(&self, block_hash: &str) -> Result { + // let block_hex = self + // .rpc_client + // .get_block_hex(&BlockHash::from_str(block_hash)?)?; + // let block_bytes = hex::decode(&block_hex)?; + // let block: bitcoin::Block = deserialize(&block_bytes)?; + + // let mut op_returns = Vec::new(); + + // for tx in block.txdata { + // for (_index, output) in tx.output.iter().enumerate() { + // if output.script_pubkey.is_op_return() { + // if let Some(bytes) = output.script_pubkey.as_bytes().get(1..) { + // if let Ok(text) = String::from_utf8(bytes.to_vec()) { + // if !text.is_empty() { + // op_returns.push(text); + // } + // } + // } + // } + // } + // } + + // Ok(if op_returns.is_empty() { + // "".to_string() + // } else { + // op_returns.join(" | ") + // }) + // } + async fn get_blockchain_info( &mut self, sender: UnboundedSender, + index: usize, ) -> Result { - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - if state.status == NodeStatus::Offline { - state.status = NodeStatus::Connecting; - *state - .services - .entry("RPC".to_string()) - .or_insert(NodeStatus::Connecting) = NodeStatus::Connecting; - } - state - }))); - - match self.rpc_client.get_blockchain_info() { - Ok(blockchain_info) => { - let _ = sender.send(Event::NodeUpdate(Arc::new(move |mut state| { - if state.services.get("ZMQ") != Some(&NodeStatus::Online) - && state.height > 0 - && state.height < blockchain_info.blocks - { - state.last_hash_instant = Some(Instant::now()); - } - - let new_status = if blockchain_info.blocks < blockchain_info.headers { - NodeStatus::Synchronizing - } else { - NodeStatus::Online - }; - - state.status = new_status; - state.message = "".to_string(); - state.height = blockchain_info.blocks; - + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + if state.status == NodeStatus::Offline { + state.status = NodeStatus::Connecting; *state .services .entry("RPC".to_string()) - .or_insert(NodeStatus::Online) = NodeStatus::Online; + .or_insert(NodeStatus::Connecting) = NodeStatus::Connecting; + } + state + }), + )); - state.widget_state = Box::new(BitcoinCoreWidgetState { - title: "Bitcoin Core".to_string(), - headers: blockchain_info.headers, - last_hash: blockchain_info.best_block_hash.to_string(), - }); + match self.rpc_client.get_blockchain_info() { + Ok(blockchain_info) => { + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { + if state.services.get("ZMQ") != Some(&NodeStatus::Online) + && state.height > 0 + && state.height < blockchain_info.blocks + { + state.last_hash_instant = Some(Instant::now()); + } - state - }))); + let new_status = if blockchain_info.blocks < blockchain_info.headers { + NodeStatus::Synchronizing + } else { + NodeStatus::Online + }; + + state.status = new_status; + state.message = "".to_string(); + state.height = blockchain_info.blocks; + + *state + .services + .entry("RPC".to_string()) + .or_insert(NodeStatus::Online) = NodeStatus::Online; + + let title = state + .widget_state + .as_any() + .downcast_ref::() + .map(|ws| ws.title.clone()) + .unwrap_or("Bitcoin Core".to_string()); + + state.widget_state = Box::new(BitcoinCoreWidgetState { + title, + headers: blockchain_info.headers, + last_hash: blockchain_info.best_block_hash.to_string(), + }); + + state + }), + )); Ok(blockchain_info) } @@ -177,24 +217,28 @@ impl BitcoinCore { &self, thread: &AppThread, mut stream: MessageStream, + index: usize, ) -> tokio::task::JoinHandle<()> { let token = thread.token.clone(); let sender = thread.sender.clone(); - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - *state - .services - .entry("ZMQ".to_string()) - .or_insert(NodeStatus::Online) = NodeStatus::Online; + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + *state + .services + .entry("ZMQ".to_string()) + .or_insert(NodeStatus::Online) = NodeStatus::Online; - state - }))); + state + }), + )); thread.tracker.spawn(async move { loop { let recv = tokio::select! { r = stream.next() => r, - () = token.cancelled() => None + _ = token.cancelled() => None }; if let Some(ref msg) = recv { @@ -203,8 +247,9 @@ impl BitcoinCore { bitcoincore_zmq::Message::HashBlock(hash, _) => { let hash = hash.to_string(); - let _ = - sender.send(Event::NodeUpdate(Arc::new(move |mut state| { + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { let widget_state = state .widget_state .as_any() @@ -221,30 +266,37 @@ impl BitcoinCore { state.last_hash_instant = Some(Instant::now()); state - }))); + }), + )); } _ => {} }, Ok(SocketMessage::Event(event)) => match event.event { SocketEvent::Disconnected { .. } => { - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - *state - .services - .entry("ZMQ".to_string()) - .or_insert(NodeStatus::Offline) = NodeStatus::Offline; - - state - }))); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + *state + .services + .entry("ZMQ".to_string()) + .or_insert(NodeStatus::Offline) = NodeStatus::Offline; + + state + }), + )); } SocketEvent::HandshakeSucceeded => { - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - *state - .services - .entry("ZMQ".to_string()) - .or_insert(NodeStatus::Online) = NodeStatus::Online; - - state - }))); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + *state + .services + .entry("ZMQ".to_string()) + .or_insert(NodeStatus::Online) = NodeStatus::Online; + + state + }), + )); } _ => {} }, @@ -257,14 +309,17 @@ impl BitcoinCore { } } - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - *state - .services - .entry("ZMQ".to_string()) - .or_insert(NodeStatus::Offline) = NodeStatus::Offline; + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + *state + .services + .entry("ZMQ".to_string()) + .or_insert(NodeStatus::Offline) = NodeStatus::Offline; - state - }))); + state + }), + )); }) } @@ -272,26 +327,30 @@ impl BitcoinCore { &mut self, thread: &AppThread, zmq_url: &str, + index: usize, ) -> Result> { let urls = [zmq_url]; let sender = thread.sender.clone(); - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - *state - .services - .entry("ZMQ".to_string()) - .or_insert(NodeStatus::Connecting) = NodeStatus::Connecting; + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + *state + .services + .entry("ZMQ".to_string()) + .or_insert(NodeStatus::Connecting) = NodeStatus::Connecting; - state - }))); + state + }), + )); let select = tokio::select! { r = tokio::time::timeout( tokio::time::Duration::from_millis(5000), subscribe_async_wait_handshake(&urls), ) => r.ok(), - () = thread.token.cancelled() => None + _ = thread.token.cancelled() => None }; let stream = match select { @@ -301,15 +360,16 @@ impl BitcoinCore { } }; - Ok(self.spawn_zmq_listener(thread, stream)) + Ok(self.spawn_zmq_listener(thread, stream, index)) } async fn try_subscribe( &mut self, thread: &AppThread, + index: usize, ) -> Option>> { if let Some(url) = self.zmq_url.clone() { - return Some(self.subscribe(thread, &url).await); + return Some(self.subscribe(thread, &url, index).await); }; None @@ -318,53 +378,18 @@ impl BitcoinCore { #[async_trait] impl NodeProvider for BitcoinCore { - fn new(config: &AppConfig) -> Self { - let rpc = bitcoincore_rpc::Client::new( - vec![ - config.bitcoin_core.host.as_str(), - config.bitcoin_core.rpc_port.as_str(), - ] - .join(":") - .as_str(), - bitcoincore_rpc::Auth::UserPass( - config.bitcoin_core.rpc_user.to_string(), - config.bitcoin_core.rpc_password.to_string(), - ), - ) - .unwrap(); - - let zmq_url: Option = match config.bitcoin_core.host.as_str() { - "" => None, - _ => Some( - vec![ - "tcp://", - &config.bitcoin_core.host, - ":", - &config.bitcoin_core.zmq_port, - ] - .join(""), - ), - }; - - Self { - rpc_client: Arc::new(rpc), - zmq_url, - host: config.bitcoin_core.host.clone(), - } - } - - async fn init(&mut self, thread: AppThread) -> Result<()> { + async fn init(&mut self, thread: AppThread, index: usize) -> Result<()> { let check_interval = time::Duration::from_millis(15 * 1000); let host = self.host.clone(); - let _ = thread - .sender - .send(Event::NodeUpdate(Arc::new(move |mut state| { + let _ = thread.sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { state.host = host.clone(); state.message = "Initializing Bitcoin Core...".to_string(); state.widget_state = Box::new(BitcoinCoreWidgetState { - title: "Bitcoin Core".to_string(), + title: format!("Bitcoin Core ({})", host), headers: 0, last_hash: "".to_string(), }); @@ -375,30 +400,27 @@ impl NodeProvider for BitcoinCore { .services .insert("ZMQ".to_string(), NodeStatus::Offline); state - }))); + }), + )); - let _ = self.get_blockchain_info(thread.sender.clone()).await; + let _ = self.get_blockchain_info(thread.sender.clone(), index).await; - let mut sub_handlers = Box::new(self.try_subscribe(&thread).await); + let mut sub_handlers = self.try_subscribe(&thread, index).await; loop { if thread.token.is_cancelled() { break; } - match *sub_handlers { - Some(Ok(ref handler)) => { - if handler.is_finished() { - sub_handlers = Box::new(self.try_subscribe(&thread).await); - } - } - Some(Err(_)) => { - sub_handlers = Box::new(self.try_subscribe(&thread).await); + if let Some(Ok(handler)) = &sub_handlers { + if handler.is_finished() { + sub_handlers = self.try_subscribe(&thread, index).await; } - _ => {} + } else if let Some(Err(_)) = &sub_handlers { + sub_handlers = self.try_subscribe(&thread, index).await; } - let _ = self.get_blockchain_info(thread.sender.clone()).await; + let _ = self.get_blockchain_info(thread.sender.clone(), index).await; tokio::time::sleep(check_interval).await; } diff --git a/src/node/providers/core_lightning.rs b/src/node/providers/core_lightning.rs index b7b3c45..cca7c06 100644 --- a/src/node/providers/core_lightning.rs +++ b/src/node/providers/core_lightning.rs @@ -1,3 +1,5 @@ +// node/providers/core_lightning.rs + use anyhow::{anyhow, Result}; use async_trait::async_trait; use ratatui::buffer::Buffer; @@ -11,11 +13,12 @@ use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::time::{self, Duration, Instant}; +use crate::app::AppThread; +use crate::config::{AppConfig, CoreLightningSettings}; use crate::event::Event; -use crate::node::widgets::BlockedParagraphWithGauge; -use crate::node::{NodeState, NodeStatus}; +use crate::node::widgets::{BlockedParagraph, BlockedParagraphWithGauge}; +use crate::node::{NodeProvider, NodeState, NodeStatus}; use crate::widget::{DynamicNodeStatefulWidget, DynamicState}; -use crate::{app::AppThread, config::AppConfig, node::NodeProvider}; #[derive(Debug, Deserialize)] struct GetInfoResponse { @@ -84,7 +87,7 @@ impl DynamicState for CoreLightningWidgetState { pub struct CoreLightningWidget; impl DynamicNodeStatefulWidget for CoreLightningWidget { - fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState) { + fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState, config: &AppConfig) { let mut default = CoreLightningWidgetState::default(); let state = node_state .widget_state @@ -92,6 +95,11 @@ impl DynamicNodeStatefulWidget for CoreLightningWidget { .downcast_mut::() .unwrap_or(&mut default); + let alias_text = match config.streamer_mode { + true => "****".to_string(), + false => state.alias.clone(), + }; + let lines = vec![ Line::from(vec![ Span::raw("Block Height: "), @@ -99,11 +107,7 @@ impl DynamicNodeStatefulWidget for CoreLightningWidget { ]), Line::from(vec![ Span::raw("Alias: "), - Span::styled(state.alias.clone(), Style::new().fg(Color::White)), - ]), - Line::from(vec![ - Span::raw("Peers: "), - Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), + Span::styled(alias_text, Style::new().fg(Color::White)), ]), Line::from(vec![ Span::raw("Active Channels: "), @@ -126,6 +130,10 @@ impl DynamicNodeStatefulWidget for CoreLightningWidget { Style::new().fg(Color::White), ), ]), + Line::from(vec![ + Span::raw("Peers: "), + Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), + ]), Line::from(vec![ Span::raw("Pending HTLCs: "), Span::styled( @@ -136,14 +144,19 @@ impl DynamicNodeStatefulWidget for CoreLightningWidget { Line::raw(""), ]; - let widget = BlockedParagraphWithGauge::new( - &state.title, - node_state.status, - lines, - state.local_balance, - state.total_capacity, - ); - widget.render(area, buf); + if config.streamer_mode { + let widget = BlockedParagraph::new(&state.title, node_state.status, lines); + widget.render(area, buf); + } else { + let widget = BlockedParagraphWithGauge::new( + &state.title, + node_state.status, + lines, + state.local_balance, + state.total_capacity, + ); + widget.render(area, buf); + } } } @@ -163,15 +176,15 @@ struct NodeInfo { } impl CoreLightning { - fn new(rest_address: String, rune: String) -> Self { + pub fn new(settings: &CoreLightningSettings) -> Self { let client = Client::builder() .danger_accept_invalid_certs(true) .build() .unwrap(); Self { - rest_address, - rune, + rest_address: settings.rest_address.clone(), + rune: settings.rest_rune.clone(), client: Arc::new(client), } } @@ -242,23 +255,23 @@ impl CoreLightning { let (total_capacity, local_balance, num_pending_htlcs, message) = match self.fetch_channels().await { Ok(peers) => { - let channels = &peers.channels; + let channels = peers.channels; let capacity = channels - .into_iter() + .iter() .filter(|channel| channel.state == "CHANNELD_NORMAL") .map(|c| c.total_msat / 1000) .sum::(); let balance = channels - .into_iter() + .iter() .filter(|channel| channel.state == "CHANNELD_NORMAL") .map(|c| c.to_us_msat / 1000) .sum::(); let pending_htlcs = channels - .into_iter() - .flat_map(|channel| &channel.htlcs) + .iter() + .flat_map(|channel| channel.htlcs.iter()) .count() as u32; (capacity, balance, pending_htlcs, String::new()) @@ -281,39 +294,45 @@ impl CoreLightning { }) } - async fn update_node_state(&self, sender: UnboundedSender) -> Result<()> { + async fn update_node_state(&self, sender: UnboundedSender, index: usize) -> Result<()> { let node_info = self.get_node_info().await?; - let _ = sender.send(Event::NodeUpdate(Arc::new(move |mut state| { - let widget_state = state - .widget_state - .as_any() - .downcast_ref::() - .unwrap(); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { + let widget_state = state + .widget_state + .as_any() + .downcast_ref::() + .unwrap(); - state.services.insert("REST".to_string(), node_info.status); + *state + .services + .entry("REST".to_string()) + .or_insert(node_info.status) = node_info.status; - if state.height > 0 && state.height < node_info.height { - state.last_hash_instant = Some(Instant::now()); - } + if state.height > 0 && state.height < node_info.height { + state.last_hash_instant = Some(Instant::now()); + } + + state.status = node_info.status; + state.message = node_info.message.clone(); + state.height = node_info.height; + state.widget_state = Box::new(CoreLightningWidgetState { + title: widget_state.title.clone(), + alias: node_info.alias.clone(), + num_peers: node_info.num_peers, + num_pending_channels: node_info.num_pending_channels, + num_active_channels: node_info.num_active_channels, + num_inactive_channels: node_info.num_inactive_channels, + total_capacity: node_info.total_capacity, + local_balance: node_info.local_balance, + num_pending_htlcs: node_info.num_pending_htlcs, + }); - state.status = node_info.status; - state.message = node_info.message.clone(); - state.height = node_info.height; - state.widget_state = Box::new(CoreLightningWidgetState { - title: widget_state.title.clone(), - alias: node_info.alias.clone(), - num_peers: node_info.num_peers, - num_pending_channels: node_info.num_pending_channels, - num_active_channels: node_info.num_active_channels, - num_inactive_channels: node_info.num_inactive_channels, - total_capacity: node_info.total_capacity, - local_balance: node_info.local_balance, - num_pending_htlcs: node_info.num_pending_htlcs, - }); - - state - }))); + state + }), + )); if node_info.status == NodeStatus::Offline { return Err(anyhow!("Node info fetch failed")); @@ -324,38 +343,32 @@ impl CoreLightning { #[async_trait] impl NodeProvider for CoreLightning { - fn new(config: &AppConfig) -> Self { - Self::new( - config.core_lightning.rest_address.clone(), - config.core_lightning.rest_rune.clone(), - ) - } - - async fn init(&mut self, thread: AppThread) -> Result<()> { + async fn init(&mut self, thread: AppThread, index: usize) -> Result<()> { let check_interval = Duration::from_secs(15); let host = self.rest_address.clone(); - let _ = thread - .sender - .send(Event::NodeUpdate(Arc::new(move |mut state| { + let _ = thread.sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { state.host = host.clone(); state.message = "Initializing CLN REST...".to_string(); state .services .insert("REST".to_string(), NodeStatus::Offline); state.widget_state = Box::new(CoreLightningWidgetState { - title: "Core Lightning".to_string(), + title: format!("Core Lightning ({})", host), ..Default::default() }); state - }))); + }), + )); loop { if thread.token.is_cancelled() { break; } - let _ = self.update_node_state(thread.sender.clone()).await; + let _ = self.update_node_state(thread.sender.clone(), index).await; time::sleep(check_interval).await; } diff --git a/src/node/providers/lnd.rs b/src/node/providers/lnd.rs index c2b014b..46d7246 100644 --- a/src/node/providers/lnd.rs +++ b/src/node/providers/lnd.rs @@ -11,11 +11,12 @@ use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tokio::time::{self, Duration, Instant}; +use crate::config::{AppConfig, LndSettings}; use crate::event::Event; -use crate::node::widgets::BlockedParagraphWithGauge; -use crate::node::{NodeState, NodeStatus}; +use crate::node::widgets::{BlockedParagraph, BlockedParagraphWithGauge}; +use crate::node::{NodeProvider, NodeState, NodeStatus}; use crate::widget::{DynamicNodeStatefulWidget, DynamicState}; -use crate::{app::AppThread, config::AppConfig, node::NodeProvider}; +use crate::{app::AppThread}; #[derive(Debug, Deserialize)] struct GetInfoResponse { @@ -88,7 +89,7 @@ impl DynamicState for LndWidgetState { pub struct LndWidget; impl DynamicNodeStatefulWidget for LndWidget { - fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState) { + fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState, config: &AppConfig) { let mut default = LndWidgetState::default(); let state = node_state .widget_state @@ -107,15 +108,37 @@ impl DynamicNodeStatefulWidget for LndWidget { ]), }; + let alias_text = match config.streamer_mode { + true => "****".to_string(), + false => state.alias.clone(), + }; + let lines = vec![ block_height, Line::from(vec![ Span::raw("Alias: "), - Span::styled(state.alias.clone(), Style::new().fg(Color::White)), + Span::styled(alias_text, Style::new().fg(Color::White)), ]), Line::from(vec![ - Span::raw("Peers: "), - Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), + Span::raw("Active Channels: "), + Span::styled( + state.num_active_channels.to_string(), + Style::new().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::raw("Pending Channels: "), + Span::styled( + state.num_pending_channels.to_string(), + Style::new().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::raw("Inactive Channels: "), + Span::styled( + state.num_inactive_channels.to_string(), + Style::new().fg(Color::White), + ), ]), Line::from(vec![ Span::raw("Synced to Bitcoin: "), @@ -140,25 +163,8 @@ impl DynamicNodeStatefulWidget for LndWidget { ), ]), Line::from(vec![ - Span::raw("Active Channels: "), - Span::styled( - state.num_active_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Pending Channels: "), - Span::styled( - state.num_pending_channels.to_string(), - Style::new().fg(Color::White), - ), - ]), - Line::from(vec![ - Span::raw("Inactive Channels: "), - Span::styled( - state.num_inactive_channels.to_string(), - Style::new().fg(Color::White), - ), + Span::raw("Peers: "), + Span::styled(state.num_peers.to_string(), Style::new().fg(Color::White)), ]), Line::from(vec![ Span::raw("Pending HTLCs: "), @@ -170,18 +176,36 @@ impl DynamicNodeStatefulWidget for LndWidget { Line::raw(""), ]; - let widget = BlockedParagraphWithGauge::new( - &state.title, - node_state.status, - lines, - state.local_balance, - state.capacity, - ); - widget.render(area, buf); + if config.streamer_mode { + let widget = BlockedParagraph::new(&state.title, node_state.status, lines); + widget.render(area, buf); + } else { + let widget = BlockedParagraphWithGauge::new( + &state.title, + node_state.status, + lines, + state.local_balance, + state.capacity, + ); + widget.render(area, buf); + } } } impl LndNode { + pub fn new(settings: &LndSettings) -> Self { + let client = Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap(); + + Self { + address: settings.rest_address.clone(), + macaroon: settings.macaroon_hex.clone(), + client: Arc::new(client), + } + } + async fn get_channels(&self) -> Result { let url = format!("{}/v1/channels", self.address); let resp = self @@ -205,7 +229,7 @@ impl LndNode { Ok(channels) } - async fn get_node_info(&self, sender: UnboundedSender) -> Result<()> { + async fn get_node_info(&self, sender: UnboundedSender, index: usize) -> Result<()> { let url = format!("{}/v1/getinfo", self.address); let response_result = self @@ -219,10 +243,13 @@ impl LndNode { Ok(resp) => { let status = resp.status(); if !status.is_success() { - let _ = sender.send(Event::NodeUpdate(Arc::new(move |mut state| { - state.message = format!("LND REST error: HTTP {}", status); - state - }))); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { + state.message = format!("LND REST error: HTTP {}", status); + state + }), + )); return Err(anyhow::anyhow!("LND REST non-200: {}", status)); } @@ -264,93 +291,84 @@ impl LndNode { NodeStatus::Synchronizing }; - let _ = sender.send(Event::NodeUpdate(Arc::new(move |mut state| { - let widget_state = state - .widget_state - .as_any() - .downcast_ref::() - .unwrap(); - - if state.height > 0 && state.height < info.block_height { - state.last_hash_instant = Some(Instant::now()); - } - - state.message = "".to_string(); - state.status = new_status; - state.height = info.block_height; - *state - .services - .entry("REST".to_string()) - .or_insert(NodeStatus::Online) = NodeStatus::Online; - state.widget_state = Box::new(LndWidgetState { - title: widget_state.title.clone(), - alias: info.alias.clone(), - num_peers: info.num_peers, - num_pending_channels: info.num_pending_channels, - num_active_channels: info.num_active_channels, - num_inactive_channels: info.num_inactive_channels, - capacity, - local_balance, - remote_balance, - synced_to_chain: info.synced_to_chain, - synced_to_graph: info.synced_to_graph, - num_pending_htlcs, - }); - state - }))); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { + let widget_state = state + .widget_state + .as_any() + .downcast_ref::() + .unwrap(); + + if state.height > 0 && state.height < info.block_height { + state.last_hash_instant = Some(Instant::now()); + } + + state.message = "".to_string(); + state.status = new_status; + state.height = info.block_height; + *state + .services + .entry("REST".to_string()) + .or_insert(NodeStatus::Online) = NodeStatus::Online; + state.widget_state = Box::new(LndWidgetState { + title: widget_state.title.clone(), + alias: info.alias.clone(), + num_peers: info.num_peers, + num_pending_channels: info.num_pending_channels, + num_active_channels: info.num_active_channels, + num_inactive_channels: info.num_inactive_channels, + capacity, + local_balance, + remote_balance, + synced_to_chain: info.synced_to_chain, + synced_to_graph: info.synced_to_graph, + num_pending_htlcs, + }); + state + }), + )); Ok(()) } Err(e) => { - let _ = sender.send(Event::NodeUpdate(Arc::new(|mut state| { - state.status = NodeStatus::Offline; - *state - .services - .entry("REST".to_string()) - .or_insert(NodeStatus::Offline) = NodeStatus::Offline; - state - }))); + let _ = sender.send(Event::NodeUpdate( + index, + Arc::new(|mut state| { + state.status = NodeStatus::Offline; + *state + .services + .entry("REST".to_string()) + .or_insert(NodeStatus::Offline) = NodeStatus::Offline; + state + }), + )); Err(anyhow::anyhow!("Request error: {}", e)) } } } - async fn check_node_status(&self, sender: UnboundedSender) -> Result<()> { - self.get_node_info(sender).await + async fn check_node_status(&self, sender: UnboundedSender, index: usize) -> Result<()> { + self.get_node_info(sender, index).await } } #[async_trait] impl NodeProvider for LndNode { - fn new(config: &AppConfig) -> Self { - let address = config.lnd.rest_address.clone(); - let macaroon = config.lnd.macaroon_hex.clone(); - let client = Client::builder() - .danger_accept_invalid_certs(true) - .build() - .unwrap(); - - Self { - address, - macaroon, - client: Arc::new(client), - } - } - - async fn init(&mut self, thread: AppThread) -> Result<()> { + async fn init(&mut self, thread: AppThread, index: usize) -> Result<()> { let check_interval = Duration::from_secs(15); let host = self.address.clone(); - let _ = thread - .sender - .send(Event::NodeUpdate(Arc::new(move |mut state| { + let _ = thread.sender.send(Event::NodeUpdate( + index, + Arc::new(move |mut state| { state.host = host.clone(); state.message = "Initializing LND REST...".to_string(); state .services .insert("REST".to_string(), NodeStatus::Offline); state.widget_state = Box::new(LndWidgetState { - title: "LND".to_string(), + title: format!("LND ({})", host), alias: "".to_string(), num_peers: 0, num_pending_channels: 0, @@ -364,14 +382,15 @@ impl NodeProvider for LndNode { num_pending_htlcs: 0, }); state - }))); + }), + )); loop { if thread.token.is_cancelled() { break; } - let _ = self.check_node_status(thread.sender.clone()).await; + let _ = self.check_node_status(thread.sender.clone(), index).await; time::sleep(check_interval).await; } diff --git a/src/tui.rs b/src/tui.rs index e04b11c..86184fc 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -52,7 +52,7 @@ impl Tui { /// [`Draw`]: ratatui::Terminal::draw /// [`rendering`]: crate::ui::render pub fn draw(&mut self, config: &AppConfig, app: &mut App) -> AppResult<()> { - self.terminal.draw(|frame| ui::render(config, &mut app.state, frame))?; + self.terminal.draw(|frame| ui::render(config, app, frame))?; Ok(()) } @@ -74,4 +74,4 @@ impl Tui { self.terminal.show_cursor()?; Ok(()) } -} +} \ No newline at end of file diff --git a/src/ui/fees.rs b/src/ui/fees.rs index 53782db..4738a58 100644 --- a/src/ui/fees.rs +++ b/src/ui/fees.rs @@ -5,16 +5,16 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Padding, Paragraph, StatefulWidget, Widget}; use crate::app::AppState; -use crate::ui::get_status_style; -pub struct FeesWidget; +pub struct FeesWidget { + pub style: Style, +} impl StatefulWidget for FeesWidget { type State = AppState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let fee_state = state.fees.result.clone(); - let style = get_status_style(&state.node.status); let fees: Vec> = vec![ Some(Line::from(Span::raw("Priority"))), @@ -33,7 +33,7 @@ impl StatefulWidget for FeesWidget { .title_alignment(Alignment::Center) .border_type(BorderType::Plain), ) - .style(style); + .style(self.style); fees_block.render(area, buf); } @@ -49,4 +49,4 @@ fn get_fee_line<'a>(name: &'a str, value: Option) -> Option> { ])); } None -} +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e728816..bb2cda0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,5 @@ -// ui/mod.rs - use crate::{ - app::AppState, + app::App, config::AppConfig, node::NodeStatus, ui::{ @@ -20,11 +18,11 @@ pub mod fees; pub mod node; pub mod price; -pub fn render(config: &AppConfig, state: &mut AppState, frame: &mut Frame) { - let mut node_state = state.node.clone(); +pub fn render(config: &AppConfig, app: &mut App, frame: &mut Frame) { + let current_index = app.current_node_index; let (layout_constraints, status_panel_i): (Vec, usize) = - if config.price.enabled | config.fees.enabled { + if config.price.enabled || config.fees.enabled { ( vec![ Constraint::Length(frame.area().height / 2), @@ -56,35 +54,45 @@ pub fn render(config: &AppConfig, state: &mut AppState, frame: &mut Frame) { .constraints(vec![Constraint::Percentage(40), Constraint::Percentage(60)]) .split(*bottom_panel); + let node_status = app.state.node_states[current_index].status; + let style = get_status_style(&node_status); + let price_widget = PriceWidget::new(PriceWidgetOptions { big_text: config.price.big_text, + style, }); - let fees_widget = FeesWidget; + let fees_widget = FeesWidget { style }; match (config.price.enabled, config.fees.enabled) { (true, true) => { let bottom_panel_left = &bottom_panel_layout[0]; let bottom_panel_right = &bottom_panel_layout[1]; - frame.render_stateful_widget(price_widget, *bottom_panel_right, state); - frame.render_stateful_widget(fees_widget, *bottom_panel_left, state); + frame.render_stateful_widget(price_widget, *bottom_panel_right, &mut app.state); + frame.render_stateful_widget(fees_widget, *bottom_panel_left, &mut app.state); } (true, false) => { - frame.render_stateful_widget(price_widget, *bottom_panel, state); + frame.render_stateful_widget(price_widget, *bottom_panel, &mut app.state); } (false, true) => { - frame.render_stateful_widget(fees_widget, *bottom_panel, state); + frame.render_stateful_widget(fees_widget, *bottom_panel, &mut app.state); } _ => {} } - state - .widget - .render(*top_panel, frame.buffer_mut(), &mut node_state); + app.widgets[current_index].render( + *top_panel, + frame.buffer_mut(), + &mut app.state.node_states[current_index], + &app.config, + ); - frame.render_stateful_widget(NodeStatusWidget, *status_panel, &mut node_state); + // Use only NodeState for NodeStatusWidget + let mut state = app.state.node_states[current_index].clone(); + frame.render_stateful_widget(NodeStatusWidget, *status_panel, &mut state); + let node_state = &app.state.node_states[current_index]; if let Some(time) = node_state.last_hash_instant { if time.elapsed().as_secs() < 15 && node_state.status == NodeStatus::Online { node_state.draw_new_block_popup(frame, node_state.height); diff --git a/src/ui/node.rs b/src/ui/node.rs index dcdee73..53dfc56 100644 --- a/src/ui/node.rs +++ b/src/ui/node.rs @@ -4,8 +4,8 @@ use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Padding, Paragraph, StatefulWidget, Widget}; use throbber_widgets_tui::Throbber; -use super::get_status_style; // Adjust based on your module structure use crate::node::{NodeState, NodeStatus}; +use crate::ui::get_status_style; pub struct NodeStatusWidget; @@ -14,13 +14,29 @@ impl StatefulWidget for NodeStatusWidget { fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let zmq_status_width = 20; + let indicator_width = 25; // Width for the combined node status and indicator + + // Adjust layout based on the number of nodes + let constraints = if state.total_nodes > 1 { + vec![ + Constraint::Length(1), // Throbber/empty block + Constraint::Length(zmq_status_width), // Service status + Constraint::Length( + area.width + .saturating_sub(zmq_status_width + indicator_width + 1), + ), // Placeholder + Constraint::Length(indicator_width), // Combined node status and indicator + ] + } else { + vec![ + Constraint::Length(1), // Throbber/empty block + Constraint::Length(zmq_status_width), // Service status + Constraint::Length(area.width.saturating_sub(zmq_status_width + 1)), // Node status (full remaining width) + ] + }; let status_bar_layout = Layout::default() .direction(Direction::Horizontal) - .constraints(vec![ - Constraint::Length(1), - Constraint::Length(area.width.saturating_sub(zmq_status_width + 1)), - Constraint::Length(zmq_status_width), - ]) + .constraints(constraints) .split(area); // Throbber or empty block @@ -34,17 +50,6 @@ impl StatefulWidget for NodeStatusWidget { .render(status_bar_layout[0], buf); } - // Status message - let message = if state.message.is_empty() { - &state.host - } else { - &state.message - }; - Paragraph::new(format!("Node {} | {}", state.status, message)) - .block(Block::new().padding(Padding::left(1))) - .style(Style::default().fg(Color::White)) - .render(status_bar_layout[1], buf); - // Service status let keys: Vec<_> = state.services.keys().cloned().collect(); if !keys.is_empty() { @@ -55,6 +60,38 @@ impl StatefulWidget for NodeStatusWidget { .unwrap_or(&NodeStatus::Offline); Paragraph::new(format!("{} {:?}", current_key, status)) .style(get_status_style(status)) + .alignment(Alignment::Left) + .render(status_bar_layout[1], buf); + } + + if state.total_nodes > 1 { + // Placeholder for the old status message area (can be empty or removed) + Block::new() + .style(Style::default().fg(Color::Black)) + .render(status_bar_layout[2], buf); + + // Combined node status and indicator (only for multiple nodes) + let current_node = state.current_node_index + 1; // 1-based index + let total_nodes = state.total_nodes; + let seconds = state.seconds_until_rotation; + let indicator_text = format!( + "Node {}/{} {} ({}s)", + current_node, total_nodes, state.status, seconds + ); + Paragraph::new(indicator_text) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Right) + .render(status_bar_layout[3], buf); + } else { + // For a single node, show only the node status + let message = if state.message.is_empty() { + &state.host + } else { + &state.message + }; + Paragraph::new(format!("Node {} | {}", state.status, message)) + .block(Block::new().padding(Padding::left(1))) + .style(Style::default().fg(Color::White)) .alignment(Alignment::Right) .render(status_bar_layout[2], buf); } diff --git a/src/ui/price.rs b/src/ui/price.rs index acbd33c..99685c4 100644 --- a/src/ui/price.rs +++ b/src/ui/price.rs @@ -1,20 +1,20 @@ use ratatui::buffer::Buffer; use ratatui::layout::{Alignment, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::widgets::{Block, BorderType, Padding, Paragraph, StatefulWidget, Widget}; use tui_widgets::big_text::{BigText, PixelSize}; use crate::app::AppState; -use crate::ui::get_status_style; #[derive(Clone, Debug)] pub struct PriceWidgetOptions { pub big_text: bool, + pub style: Style, } impl Default for PriceWidgetOptions { fn default() -> Self { - PriceWidgetOptions { big_text: true } + PriceWidgetOptions { big_text: true, style: Style::default() } } } @@ -32,8 +32,6 @@ impl StatefulWidget for PriceWidget { type State = AppState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let style = get_status_style(&state.node.status); - let price_with_currency_lines = vec![match state.price.last_price_in_currency { Some(v) => vec![v.trunc().to_string(), state.price.currency.to_string()] .join(" ") @@ -46,7 +44,7 @@ impl StatefulWidget for PriceWidget { .title("Price") .title_alignment(Alignment::Center) .border_type(BorderType::Plain) - .style(style); + .style(self.options.style); let price_block_area = price_block.inner(area); price_block.render(area, buf); @@ -56,7 +54,7 @@ impl StatefulWidget for PriceWidget { let big_text = BigText::builder() .alignment(Alignment::Center) .pixel_size(PixelSize::Sextant) - .style(style) + .style(self.options.style) .lines(price_with_currency_lines) .build(); @@ -75,7 +73,7 @@ impl StatefulWidget for PriceWidget { let big_text = BigText::builder() .alignment(Alignment::Center) .pixel_size(PixelSize::Sextant) - .style(style) + .style(self.options.style) .lines(price_lines) .build(); @@ -86,8 +84,8 @@ impl StatefulWidget for PriceWidget { } Paragraph::new(price_with_currency_lines) - .style(Style::default().fg(Color::White)) + .style(self.options.style) .alignment(Alignment::Center) .render(price_block_area, buf); } -} +} \ No newline at end of file diff --git a/src/widget.rs b/src/widget.rs index a2c6e05..1985083 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -6,7 +6,7 @@ use ratatui::{ use std::any::Any; use std::fmt::Debug; -use crate::node::NodeState; +use crate::{config::AppConfig, node::NodeState}; pub trait DynamicState: Any + Debug + Send + Sync { fn as_any(&self) -> &dyn Any; @@ -19,7 +19,7 @@ pub trait DynamicStatefulWidget { } pub trait DynamicNodeStatefulWidget { - fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState); + fn render(&self, area: Rect, buf: &mut Buffer, node_state: &mut NodeState, config: &AppConfig); } #[derive(Clone, Debug)]