diff --git a/CHANGELOG.md b/CHANGELOG.md index e97ba34403..dc66778ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 *When editing this file, please respect a line length of 100.* +## 2024-11-07 + +### Launchpad + +#### Added + +- You can select a node. Pressing L will show its logs. +- The upgrade screen has an estimated time. + +#### Changed + +- Launchpad now uses multiple threads. This allows the UI to be functional while nodes are being + started, upgraded, and so on. +- Mbps vs Mb units on status screen. + +#### Fixed + +- Spinners now move when updating. + ## 2024-11-06 ### Network diff --git a/Cargo.lock b/Cargo.lock index c68d6a0a6e..d6bf9f17fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5891,7 +5891,7 @@ dependencies = [ [[package]] name = "node-launchpad" -version = "0.4.3" +version = "0.4.4" dependencies = [ "arboard", "atty", diff --git a/node-launchpad/.config/config.json5 b/node-launchpad/.config/config.json5 index ac376945d3..63786942ce 100644 --- a/node-launchpad/.config/config.json5 +++ b/node-launchpad/.config/config.json5 @@ -17,6 +17,8 @@ "": {"StatusActions":"TriggerRewardsAddress"}, "": {"StatusActions":"TriggerRewardsAddress"}, "": {"StatusActions":"TriggerRewardsAddress"}, + "": {"StatusActions":"TriggerNodeLogs"}, + "": {"StatusActions":"TriggerNodeLogs"}, "up" : {"StatusActions":"PreviousTableItem"}, "down": {"StatusActions":"NextTableItem"}, diff --git a/node-launchpad/Cargo.toml b/node-launchpad/Cargo.toml index 73cdcffb38..cc18203ccc 100644 --- a/node-launchpad/Cargo.toml +++ b/node-launchpad/Cargo.toml @@ -2,7 +2,7 @@ authors = ["MaidSafe Developers "] description = "Node Launchpad" name = "node-launchpad" -version = "0.4.3" +version = "0.4.4" edition = "2021" license = "GPL-3.0" homepage = "https://maidsafe.net" diff --git a/node-launchpad/src/action.rs b/node-launchpad/src/action.rs index 2cc81ca675..5f4669a4d7 100644 --- a/node-launchpad/src/action.rs +++ b/node-launchpad/src/action.rs @@ -61,6 +61,7 @@ pub enum StatusActions { TriggerManageNodes, TriggerRewardsAddress, + TriggerNodeLogs, PreviousTableItem, NextTableItem, diff --git a/node-launchpad/src/app.rs b/node-launchpad/src/app.rs index f4247b114b..dac3f1e4a3 100644 --- a/node-launchpad/src/app.rs +++ b/node-launchpad/src/app.rs @@ -120,7 +120,7 @@ impl App { let change_connection_mode = ChangeConnectionModePopUp::new(connection_mode)?; let port_range = PortRangePopUp::new(connection_mode, port_from, port_to); let rewards_address = RewardsAddress::new(app_data.discord_username.clone()); - let upgrade_nodes = UpgradeNodesPopUp::default(); + let upgrade_nodes = UpgradeNodesPopUp::new(app_data.nodes_to_start); Ok(Self { config, diff --git a/node-launchpad/src/bin/tui/main.rs b/node-launchpad/src/bin/tui/main.rs index d3074018af..9f6266e019 100644 --- a/node-launchpad/src/bin/tui/main.rs +++ b/node-launchpad/src/bin/tui/main.rs @@ -22,7 +22,6 @@ use node_launchpad::{ use sn_node_manager::config::is_running_as_root; use sn_peers_acquisition::PeersArgs; use std::{env, path::PathBuf}; -use tokio::task::LocalSet; #[derive(Parser, Debug)] #[command(disable_version_flag = true)] @@ -68,7 +67,36 @@ pub struct Cli { version: bool, } -async fn tokio_main() -> Result<()> { +fn is_running_in_terminal() -> bool { + atty::is(atty::Stream::Stdout) +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + initialize_logging()?; + configure_winsw().await?; + + if !is_running_in_terminal() { + info!("Running in non-terminal mode. Launching terminal."); + // If we weren't already running in a terminal, this process returns early, having spawned + // a new process that launches a terminal. + let terminal_type = terminal::detect_and_setup_terminal()?; + terminal::launch_terminal(&terminal_type) + .inspect_err(|err| error!("Error while launching terminal: {err:?}"))?; + return Ok(()); + } else { + // Windows spawns the terminal directly, so the check for root has to happen here as well. + debug!("Running inside a terminal!"); + #[cfg(target_os = "windows")] + if !is_running_as_root() { + { + // TODO: There is no terminal to show this error message when double clicking on the exe. + error!("Admin privileges required to run on Windows. Exiting."); + color_eyre::eyre::bail!("Admin privileges required to run on Windows. Exiting."); + } + } + } + initialize_panic_handler()?; let args = Cli::parse(); @@ -108,48 +136,3 @@ async fn tokio_main() -> Result<()> { Ok(()) } - -fn is_running_in_terminal() -> bool { - atty::is(atty::Stream::Stdout) -} - -#[tokio::main] -async fn main() -> Result<()> { - initialize_logging()?; - configure_winsw().await?; - - if !is_running_in_terminal() { - info!("Running in non-terminal mode. Launching terminal."); - // If we weren't already running in a terminal, this process returns early, having spawned - // a new process that launches a terminal. - let terminal_type = terminal::detect_and_setup_terminal()?; - terminal::launch_terminal(&terminal_type) - .inspect_err(|err| error!("Error while launching terminal: {err:?}"))?; - return Ok(()); - } else { - // Windows spawns the terminal directly, so the check for root has to happen here as well. - debug!("Running inside a terminal!"); - #[cfg(target_os = "windows")] - if !is_running_as_root() { - { - // TODO: There is no terminal to show this error message when double clicking on the exe. - error!("Admin privileges required to run on Windows. Exiting."); - color_eyre::eyre::bail!("Admin privileges required to run on Windows. Exiting."); - } - } - } - - // Construct a local task set that can run `!Send` futures. - let local = LocalSet::new(); - local - .run_until(async { - if let Err(e) = tokio_main().await { - eprintln!("{} failed:", env!("CARGO_PKG_NAME")); - - Err(e) - } else { - Ok(()) - } - }) - .await -} diff --git a/node-launchpad/src/components/footer.rs b/node-launchpad/src/components/footer.rs index c1d74db1a1..11750fa44d 100644 --- a/node-launchpad/src/components/footer.rs +++ b/node-launchpad/src/components/footer.rs @@ -41,9 +41,12 @@ impl StatefulWidget for Footer { Span::styled("[Ctrl+S] ", command_style), Span::styled("Start Nodes", text_style), Span::styled(" ", Style::default()), + Span::styled("[L] ", command_style), + Span::styled("Open Logs", Style::default().fg(EUCALYPTUS)), + Span::styled(" ", Style::default()), Span::styled("[Ctrl+X] ", command_style), Span::styled( - "Stop Nodes", + "Stop All", if matches!(state, NodesToStart::Running) { Style::default().fg(EUCALYPTUS) } else { diff --git a/node-launchpad/src/components/options.rs b/node-launchpad/src/components/options.rs index 4f59a89f3c..7916efcb06 100644 --- a/node-launchpad/src/components/options.rs +++ b/node-launchpad/src/components/options.rs @@ -1,6 +1,6 @@ use std::{cmp::max, path::PathBuf}; -use color_eyre::eyre::{eyre, Ok, Result}; +use color_eyre::eyre::Result; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, @@ -8,10 +8,9 @@ use ratatui::{ widgets::{Block, Borders, Cell, Row, Table}, Frame, }; -use sn_releases::ReleaseType; use tokio::sync::mpsc::UnboundedSender; -use super::{header::SelectedMenuItem, Component}; +use super::{header::SelectedMenuItem, utils::open_logs, Component}; use crate::{ action::{Action, OptionsActions}, components::header::Header, @@ -20,9 +19,7 @@ use crate::{ style::{ COOL_GREY, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VERY_LIGHT_AZURE, VIVID_SKY_BLUE, }, - system, }; -use sn_node_manager::config::get_service_log_dir_path; #[derive(Clone)] pub struct Options { @@ -416,15 +413,7 @@ impl Component for Options { self.rewards_address = rewards_address; } OptionsActions::TriggerAccessLogs => { - if let Err(e) = system::open_folder( - get_service_log_dir_path(ReleaseType::NodeLaunchpad, None, None)? - .to_str() - .ok_or_else(|| { - eyre!("We cannot get the log dir path for Node-Launchpad") - })?, - ) { - error!("Failed to open folder: {}", e); - } + open_logs(None)?; } OptionsActions::TriggerUpdateNodes => { return Ok(Some(Action::SwitchScene(Scene::UpgradeNodesPopUp))); diff --git a/node-launchpad/src/components/popup/upgrade_nodes.rs b/node-launchpad/src/components/popup/upgrade_nodes.rs index d658970867..3fcddc5839 100644 --- a/node-launchpad/src/components/popup/upgrade_nodes.rs +++ b/node-launchpad/src/components/popup/upgrade_nodes.rs @@ -10,6 +10,7 @@ use super::super::utils::centered_rect_fixed; use super::super::Component; use crate::{ action::{Action, OptionsActions}, + components::status, mode::{InputMode, Scene}, style::{clear_area, EUCALYPTUS, GHOST_WHITE, LIGHT_PERIWINKLE, VIVID_SKY_BLUE}, }; @@ -18,19 +19,17 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{prelude::*, widgets::*}; pub struct UpgradeNodesPopUp { + nodes_to_start: usize, /// Whether the component is active right now, capturing keystrokes + draw things. active: bool, } impl UpgradeNodesPopUp { - pub fn new() -> Self { - Self { active: false } - } -} - -impl Default for UpgradeNodesPopUp { - fn default() -> Self { - Self::new() + pub fn new(nodes_to_start: usize) -> Self { + Self { + nodes_to_start, + active: false, + } } } @@ -69,6 +68,10 @@ impl Component for UpgradeNodesPopUp { None } }, + Action::StoreNodesToStart(ref nodes_to_start) => { + self.nodes_to_start = *nodes_to_start; + None + } _ => None, }; Ok(send_back) @@ -133,7 +136,15 @@ impl Component for UpgradeNodesPopUp { "No data will be lost.", Style::default().fg(LIGHT_PERIWINKLE), )), - Line::from(Span::styled("\n\n", Style::default())), + Line::from(Span::styled( + format!( + "Upgrade time ~ {:.1?} mins ({:?} nodes * {:?} secs)", + self.nodes_to_start * (status::FIXED_INTERVAL / 1_000) as usize / 60, + self.nodes_to_start, + status::FIXED_INTERVAL / 1_000, + ), + Style::default().fg(LIGHT_PERIWINKLE), + )), Line::from(Span::styled("\n\n", Style::default())), Line::from(vec![ Span::styled("You’ll need to ", Style::default().fg(LIGHT_PERIWINKLE)), diff --git a/node-launchpad/src/components/status.rs b/node-launchpad/src/components/status.rs index e4dea1afb6..f8d505a565 100644 --- a/node-launchpad/src/components/status.rs +++ b/node-launchpad/src/components/status.rs @@ -14,10 +14,11 @@ use super::{ }; use crate::action::OptionsActions; use crate::components::popup::port_range::PORT_ALLOCATION; +use crate::components::utils::open_logs; use crate::config::get_launchpad_nodes_data_dir_path; use crate::connection_mode::ConnectionMode; use crate::error::ErrorPopup; -use crate::node_mgmt::{upgrade_nodes, MaintainNodesArgs, UpgradeNodesArgs}; +use crate::node_mgmt::{MaintainNodesArgs, NodeManagement, NodeManagementTask, UpgradeNodesArgs}; use crate::node_mgmt::{PORT_MAX, PORT_MIN}; use crate::style::{COOL_GREY, INDIGO}; use crate::tui::Event; @@ -47,12 +48,10 @@ use std::{ vec, }; use strum::Display; -use tokio::sync::mpsc::UnboundedSender; - -use super::super::node_mgmt::{maintain_n_running_nodes, reset_nodes, stop_nodes}; - use throbber_widgets_tui::{self, Throbber, ThrobberState}; +use tokio::sync::mpsc::UnboundedSender; +pub const FIXED_INTERVAL: u64 = 60_000; pub const NODE_STAT_UPDATE_INTERVAL: Duration = Duration::from_secs(5); /// If nat detection fails for more than 3 times, we don't want to waste time running during every node start. const MAX_ERRORS_WHILE_RUNNING_NAT_DETECTION: usize = 3; @@ -62,7 +61,7 @@ const NODE_WIDTH: usize = 10; const VERSION_WIDTH: usize = 7; const ATTOS_WIDTH: usize = 5; const MEMORY_WIDTH: usize = 7; -const MBPS_WIDTH: usize = 15; +const MB_WIDTH: usize = 15; const RECORDS_WIDTH: usize = 4; const PEERS_WIDTH: usize = 5; const CONNS_WIDTH: usize = 5; @@ -84,6 +83,8 @@ pub struct Status<'a> { // Nodes node_services: Vec, items: Option>>, + // Node Management + node_management: NodeManagement, // Amount of nodes nodes_to_start: usize, // Rewards address @@ -137,6 +138,7 @@ impl Status<'_> { node_stats: NodeStats::default(), node_stats_last_update: Instant::now(), node_services: Default::default(), + node_management: NodeManagement::new()?, items: None, nodes_to_start: config.allocated_disk_space, lock_registry: None, @@ -180,7 +182,9 @@ impl Status<'_> { { if let Some(status) = new_status { item.status = status; - } else { + } else if item.status == NodeStatus::Updating { + item.spinner_state.calc_next(); + } else if new_status != Some(NodeStatus::Updating) { // Update status based on current node status item.status = match node_item.status { ServiceStatus::Running => { @@ -216,8 +220,8 @@ impl Status<'_> { { item.attos = stats.rewards_wallet_balance; item.memory = stats.memory_usage_mb; - item.mbps = format!( - "↓{:06.2} ↑{:06.2}", + item.mb = format!( + "↓{:06.02} ↑{:06.02}", stats.bandwidth_inbound as f64 / (1024_f64 * 1024_f64), stats.bandwidth_outbound as f64 / (1024_f64 * 1024_f64) ); @@ -231,7 +235,7 @@ impl Status<'_> { version: node_item.version.to_string(), attos: 0, memory: 0, - mbps: "-".to_string(), + mb: "-".to_string(), records: 0, peers: 0, connections: 0, @@ -265,7 +269,7 @@ impl Status<'_> { version: node_item.version.to_string(), attos: 0, memory: 0, - mbps: "-".to_string(), + mb: "-".to_string(), records: 0, peers: 0, connections: 0, @@ -416,7 +420,11 @@ impl Component for Status<'_> { self.lock_registry = Some(LockRegistryState::ResettingNodes); info!("Resetting safenode services because the Rewards Address was reset."); let action_sender = self.get_actions_sender()?; - reset_nodes(action_sender, false); + self.node_management + .send_task(NodeManagementTask::ResetNodes { + start_nodes_after_reset: false, + action_sender, + })?; } } Action::StoreStorageDrive(ref drive_mountpoint, ref _drive_name) => { @@ -424,7 +432,11 @@ impl Component for Status<'_> { self.lock_registry = Some(LockRegistryState::ResettingNodes); info!("Resetting safenode services because the Storage Drive was changed."); let action_sender = self.get_actions_sender()?; - reset_nodes(action_sender, false); + self.node_management + .send_task(NodeManagementTask::ResetNodes { + start_nodes_after_reset: false, + action_sender, + })?; self.data_dir_path = get_launchpad_nodes_data_dir_path(&drive_mountpoint.to_path_buf(), false)?; } @@ -434,7 +446,11 @@ impl Component for Status<'_> { self.connection_mode = connection_mode; info!("Resetting safenode services because the Connection Mode range was changed."); let action_sender = self.get_actions_sender()?; - reset_nodes(action_sender, false); + self.node_management + .send_task(NodeManagementTask::ResetNodes { + start_nodes_after_reset: false, + action_sender, + })?; } Action::StorePortRange(port_from, port_range) => { debug!("Setting lock_registry to ResettingNodes"); @@ -443,7 +459,11 @@ impl Component for Status<'_> { self.port_to = Some(port_range); info!("Resetting safenode services because the Port Range was changed."); let action_sender = self.get_actions_sender()?; - reset_nodes(action_sender, false); + self.node_management + .send_task(NodeManagementTask::ResetNodes { + start_nodes_after_reset: false, + action_sender, + })?; } Action::StatusActions(status_action) => match status_action { StatusActions::NodesStatsObtained(stats) => { @@ -549,10 +569,14 @@ impl Component for Status<'_> { return Ok(Some(Action::SwitchScene(Scene::ManageNodesPopUp))); } StatusActions::PreviousTableItem => { - // self.select_previous_table_item(); + if let Some(items) = &mut self.items { + items.previous(); + } } StatusActions::NextTableItem => { - // self.select_next_table_item(); + if let Some(items) = &mut self.items { + items.next(); + } } StatusActions::StartNodes => { debug!("Got action to start nodes"); @@ -604,7 +628,10 @@ impl Component for Status<'_> { debug!("Calling maintain_n_running_nodes"); - maintain_n_running_nodes(maintain_nodes_args); + self.node_management + .send_task(NodeManagementTask::MaintainNodes { + args: maintain_nodes_args, + })?; } StatusActions::StopNodes => { debug!("Got action to stop nodes"); @@ -622,7 +649,11 @@ impl Component for Status<'_> { let action_sender = self.get_actions_sender()?; info!("Stopping node service: {running_nodes:?}"); - stop_nodes(running_nodes, action_sender); + self.node_management + .send_task(NodeManagementTask::StopNodes { + services: running_nodes, + action_sender, + })?; } StatusActions::TriggerRewardsAddress => { if self.rewards_address.is_empty() { @@ -631,6 +662,15 @@ impl Component for Status<'_> { return Ok(None); } } + StatusActions::TriggerNodeLogs => { + if let Some(node) = self.items.as_ref().and_then(|items| items.selected_item()) + { + debug!("Got action to open node logs {:?}", node.name); + open_logs(Some(node.name.clone()))?; + } else { + debug!("Got action to open node logs but no node was selected."); + } + } }, Action::OptionsActions(OptionsActions::UpdateNodes) => { debug!("Got action to Update Nodes"); @@ -657,14 +697,17 @@ impl Component for Status<'_> { do_not_start: true, custom_bin_path: None, force: false, - fixed_interval: Some(300_000), // 5 mins in millis + fixed_interval: Some(FIXED_INTERVAL), peer_ids, provided_env_variables: None, service_names, url: None, version: None, }; - upgrade_nodes(upgrade_nodes_args); + self.node_management + .send_task(NodeManagementTask::UpgradeNodes { + args: upgrade_nodes_args, + })?; } Action::OptionsActions(OptionsActions::ResetNodes) => { debug!("Got action to reset nodes"); @@ -680,7 +723,11 @@ impl Component for Status<'_> { self.lock_registry = Some(LockRegistryState::ResettingNodes); let action_sender = self.get_actions_sender()?; info!("Got action to reset nodes"); - reset_nodes(action_sender, false); + self.node_management + .send_task(NodeManagementTask::ResetNodes { + start_nodes_after_reset: false, + action_sender, + })?; } _ => {} } @@ -883,7 +930,7 @@ impl Component for Status<'_> { Constraint::Min(VERSION_WIDTH as u16), Constraint::Min(ATTOS_WIDTH as u16), Constraint::Min(MEMORY_WIDTH as u16), - Constraint::Min(MBPS_WIDTH as u16), + Constraint::Min(MB_WIDTH as u16), Constraint::Min(RECORDS_WIDTH as u16), Constraint::Min(PEERS_WIDTH as u16), Constraint::Min(CONNS_WIDTH as u16), @@ -898,8 +945,7 @@ impl Component for Status<'_> { Cell::new("Attos").fg(COOL_GREY), Cell::new("Memory").fg(COOL_GREY), Cell::new( - format!("{}{}", " ".repeat(MBPS_WIDTH - "Mbps".len()), "Mbps") - .fg(COOL_GREY), + format!("{}{}", " ".repeat(MB_WIDTH - "Mb".len()), "Mb").fg(COOL_GREY), ), Cell::new("Recs").fg(COOL_GREY), Cell::new("Peers").fg(COOL_GREY), @@ -909,15 +955,13 @@ impl Component for Status<'_> { ]) .style(Style::default().add_modifier(Modifier::BOLD)); - let items: Vec = self - .items - .as_mut() - .unwrap() - .items - .iter_mut() - .enumerate() - .map(|(i, node_item)| node_item.render_as_row(i, layout[2], f)) - .collect(); + let mut items: Vec = Vec::new(); + if let Some(ref mut items_table) = self.items { + for (i, node_item) in items_table.items.iter_mut().enumerate() { + let is_selected = items_table.state.selected() == Some(i); + items.push(node_item.render_as_row(i, layout[2], f, is_selected)); + } + } // Table items let table = Table::new(items, node_widths) @@ -1080,6 +1124,7 @@ impl StatefulTable { None => self.last_selected.unwrap_or(0), }; self.state.select(Some(i)); + self.last_selected = Some(i); } fn previous(&mut self) { @@ -1094,6 +1139,13 @@ impl StatefulTable { None => self.last_selected.unwrap_or(0), }; self.state.select(Some(i)); + self.last_selected = Some(i); + } + + fn selected_item(&self) -> Option<&T> { + self.state + .selected() + .and_then(|index| self.items.get(index)) } } @@ -1127,7 +1179,7 @@ pub struct NodeItem<'a> { version: String, attos: usize, memory: usize, - mbps: String, + mb: String, records: usize, peers: usize, connections: usize, @@ -1137,8 +1189,18 @@ pub struct NodeItem<'a> { } impl NodeItem<'_> { - fn render_as_row(&mut self, index: usize, area: Rect, f: &mut Frame<'_>) -> Row { - let mut row_style = Style::default().fg(GHOST_WHITE); + fn render_as_row( + &mut self, + index: usize, + area: Rect, + f: &mut Frame<'_>, + is_selected: bool, + ) -> Row { + let mut row_style = if is_selected { + Style::default().fg(GHOST_WHITE).bg(INDIGO) + } else { + Style::default().fg(GHOST_WHITE) + }; let mut spinner_state = self.spinner_state.clone(); match self.status { NodeStatus::Running => { @@ -1148,7 +1210,11 @@ impl NodeItem<'_> { .throbber_style(Style::default().fg(EUCALYPTUS).add_modifier(Modifier::BOLD)) .throbber_set(throbber_widgets_tui::BRAILLE_SIX_DOUBLE) .use_type(throbber_widgets_tui::WhichUse::Spin); - row_style = Style::default().fg(EUCALYPTUS); + row_style = if is_selected { + Style::default().fg(EUCALYPTUS).bg(INDIGO) + } else { + Style::default().fg(EUCALYPTUS) + }; } NodeStatus::Starting => { self.spinner = self @@ -1180,7 +1246,7 @@ impl NodeItem<'_> { .add_modifier(Modifier::BOLD), ) .throbber_set(throbber_widgets_tui::VERTICAL_BLOCK) - .use_type(throbber_widgets_tui::WhichUse::Full); + .use_type(throbber_widgets_tui::WhichUse::Spin); } _ => {} }; @@ -1200,8 +1266,8 @@ impl NodeItem<'_> { ), format!( "{}{}", - " ".repeat(MBPS_WIDTH.saturating_sub(self.mbps.to_string().len())), - self.mbps.to_string() + " ".repeat(MB_WIDTH.saturating_sub(self.mb.to_string().len())), + self.mb.to_string() ), format!( "{}{}", diff --git a/node-launchpad/src/components/utils.rs b/node-launchpad/src/components/utils.rs index 0c5393f023..c2f2a47e1c 100644 --- a/node-launchpad/src/components/utils.rs +++ b/node-launchpad/src/components/utils.rs @@ -6,7 +6,11 @@ // KIND, either express or implied. Please review the Licences for the specific language governing // permissions and limitations relating to use of the SAFE Network Software. +use crate::system; +use color_eyre::eyre::{self}; use ratatui::prelude::*; +use sn_node_manager::config::get_service_log_dir_path; +use sn_releases::ReleaseType; /// helper function to create a centered rect using up certain percentage of the available rect `r` pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { @@ -41,3 +45,28 @@ pub fn centered_rect_fixed(x: u16, y: u16, r: Rect) -> Rect { ]) .split(popup_layout[1])[1] } + +/// Opens the logs folder for a given node service name or the default service log directory. +/// +/// # Parameters +/// +/// * `node_name`: Optional node service name. If `None`, the default service log directory is used. +/// +/// # Returns +/// +/// A `Result` indicating the success or failure of the operation. +pub fn open_logs(node_name: Option) -> Result<(), eyre::Report> { + let service_path = get_service_log_dir_path(ReleaseType::NodeLaunchpad, None, None)? + .to_string_lossy() + .into_owned(); + + let folder = if let Some(node_name) = node_name { + format!("{}/{}", service_path, node_name) + } else { + service_path.to_string() + }; + if let Err(e) = system::open_folder(&folder) { + error!("Failed to open folder: {}", e); + } + Ok(()) +} diff --git a/node-launchpad/src/node_mgmt.rs b/node-launchpad/src/node_mgmt.rs index 1e2f8a4371..3ca62e3f7f 100644 --- a/node-launchpad/src/node_mgmt.rs +++ b/node-launchpad/src/node_mgmt.rs @@ -1,6 +1,7 @@ use crate::action::{Action, StatusActions}; use crate::connection_mode::ConnectionMode; use color_eyre::eyre::{eyre, Error}; +use color_eyre::Result; use sn_evm::{EvmNetwork, RewardsAddress}; use sn_node_manager::{ add_services::config::PortRange, config::get_node_registry_path, VerbosityLevel, @@ -9,36 +10,117 @@ use sn_peers_acquisition::PeersArgs; use sn_releases::{self, ReleaseType, SafeReleaseRepoActions}; use sn_service_management::NodeRegistry; use std::{path::PathBuf, str::FromStr}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::runtime::Builder; +use tokio::sync::mpsc::{self, UnboundedSender}; +use tokio::task::LocalSet; pub const PORT_MAX: u32 = 65535; pub const PORT_MIN: u32 = 1024; const NODE_ADD_MAX_RETRIES: u32 = 5; +#[derive(Debug)] +pub enum NodeManagementTask { + MaintainNodes { + args: MaintainNodesArgs, + }, + ResetNodes { + start_nodes_after_reset: bool, + action_sender: UnboundedSender, + }, + StopNodes { + services: Vec, + action_sender: UnboundedSender, + }, + UpgradeNodes { + args: UpgradeNodesArgs, + }, +} + +#[derive(Clone)] +pub struct NodeManagement { + task_sender: mpsc::UnboundedSender, +} + +impl NodeManagement { + pub fn new() -> Result { + let (send, mut recv) = mpsc::unbounded_channel(); + + let rt = Builder::new_current_thread().enable_all().build()?; + + std::thread::spawn(move || { + let local = LocalSet::new(); + + local.spawn_local(async move { + while let Some(new_task) = recv.recv().await { + match new_task { + NodeManagementTask::MaintainNodes { args } => { + maintain_n_running_nodes(args).await; + } + NodeManagementTask::ResetNodes { + start_nodes_after_reset, + action_sender, + } => { + reset_nodes(action_sender, start_nodes_after_reset).await; + } + NodeManagementTask::StopNodes { + services, + action_sender, + } => { + stop_nodes(services, action_sender).await; + } + NodeManagementTask::UpgradeNodes { args } => upgrade_nodes(args).await, + } + } + // If the while loop returns, then all the LocalSpawner + // objects have been dropped. + }); + + // This will return once all senders are dropped and all + // spawned tasks have returned. + rt.block_on(local); + }); + + Ok(Self { task_sender: send }) + } + + /// Send a task to the NodeManagement local set + /// These tasks will be executed on a different thread to avoid blocking the main thread + /// + /// The results are returned via the standard `UnboundedSender` that is passed to each task. + /// + /// If this function returns an error, it means that the task could not be sent to the local set. + pub fn send_task(&self, task: NodeManagementTask) -> Result<()> { + self.task_sender + .send(task) + .inspect_err(|err| error!("The node management local set is down {err:?}")) + .map_err(|_| eyre!("Failed to send task to the node management local set"))?; + Ok(()) + } +} + /// Stop the specified services -pub fn stop_nodes(services: Vec, action_sender: UnboundedSender) { - tokio::task::spawn_local(async move { - if let Err(err) = - sn_node_manager::cmd::node::stop(None, vec![], services, VerbosityLevel::Minimal).await - { - error!("Error while stopping services {err:?}"); - send_action( - action_sender, - Action::StatusActions(StatusActions::ErrorStoppingNodes { - raw_error: err.to_string(), - }), - ); - } else { - info!("Successfully stopped services"); - send_action( - action_sender, - Action::StatusActions(StatusActions::StopNodesCompleted), - ); - } - }); +async fn stop_nodes(services: Vec, action_sender: UnboundedSender) { + if let Err(err) = + sn_node_manager::cmd::node::stop(None, vec![], services, VerbosityLevel::Minimal).await + { + error!("Error while stopping services {err:?}"); + send_action( + action_sender, + Action::StatusActions(StatusActions::ErrorStoppingNodes { + raw_error: err.to_string(), + }), + ); + } else { + info!("Successfully stopped services"); + send_action( + action_sender, + Action::StatusActions(StatusActions::StopNodesCompleted), + ); + } } +#[derive(Debug)] pub struct MaintainNodesArgs { pub count: u16, pub owner: String, @@ -53,75 +135,72 @@ pub struct MaintainNodesArgs { } /// Maintain the specified number of nodes -pub fn maintain_n_running_nodes(args: MaintainNodesArgs) { +async fn maintain_n_running_nodes(args: MaintainNodesArgs) { debug!("Maintaining {} nodes", args.count); - tokio::task::spawn_local(async move { - if args.run_nat_detection { - run_nat_detection(&args.action_sender).await; - } + if args.run_nat_detection { + run_nat_detection(&args.action_sender).await; + } - let config = prepare_node_config(&args); - debug_log_config(&config, &args); + let config = prepare_node_config(&args); + debug_log_config(&config, &args); - let node_registry = match load_node_registry(&args.action_sender).await { - Ok(registry) => registry, - Err(err) => { - error!("Failed to load node registry: {:?}", err); - return; - } - }; - let mut used_ports = get_used_ports(&node_registry); - let (mut current_port, max_port) = get_port_range(&config.custom_ports); + let node_registry = match load_node_registry(&args.action_sender).await { + Ok(registry) => registry, + Err(err) => { + error!("Failed to load node registry: {:?}", err); + return; + } + }; + let mut used_ports = get_used_ports(&node_registry); + let (mut current_port, max_port) = get_port_range(&config.custom_ports); - let nodes_to_add = args.count as i32 - node_registry.nodes.len() as i32; + let nodes_to_add = args.count as i32 - node_registry.nodes.len() as i32; - if nodes_to_add <= 0 { - debug!("Scaling down nodes to {}", nodes_to_add); - scale_down_nodes(&config, args.count).await; - } else { - debug!("Scaling up nodes to {}", nodes_to_add); - add_nodes( - &args.action_sender, - &config, - nodes_to_add, - &mut used_ports, - &mut current_port, - max_port, - ) - .await; - } + if nodes_to_add <= 0 { + debug!("Scaling down nodes to {}", nodes_to_add); + scale_down_nodes(&config, args.count).await; + } else { + debug!("Scaling up nodes to {}", nodes_to_add); + add_nodes( + &args.action_sender, + &config, + nodes_to_add, + &mut used_ports, + &mut current_port, + max_port, + ) + .await; + } - debug!("Finished maintaining {} nodes", args.count); - send_action( - args.action_sender, - Action::StatusActions(StatusActions::StartNodesCompleted), - ); - }); + debug!("Finished maintaining {} nodes", args.count); + send_action( + args.action_sender, + Action::StatusActions(StatusActions::StartNodesCompleted), + ); } /// Reset all the nodes -pub fn reset_nodes(action_sender: UnboundedSender, start_nodes_after_reset: bool) { - tokio::task::spawn_local(async move { - if let Err(err) = sn_node_manager::cmd::node::reset(true, VerbosityLevel::Minimal).await { - error!("Error while resetting services {err:?}"); - send_action( - action_sender, - Action::StatusActions(StatusActions::ErrorResettingNodes { - raw_error: err.to_string(), - }), - ); - } else { - info!("Successfully reset services"); - send_action( - action_sender, - Action::StatusActions(StatusActions::ResetNodesCompleted { - trigger_start_node: start_nodes_after_reset, - }), - ); - } - }); +async fn reset_nodes(action_sender: UnboundedSender, start_nodes_after_reset: bool) { + if let Err(err) = sn_node_manager::cmd::node::reset(true, VerbosityLevel::Minimal).await { + error!("Error while resetting services {err:?}"); + send_action( + action_sender, + Action::StatusActions(StatusActions::ErrorResettingNodes { + raw_error: err.to_string(), + }), + ); + } else { + info!("Successfully reset services"); + send_action( + action_sender, + Action::StatusActions(StatusActions::ResetNodesCompleted { + trigger_start_node: start_nodes_after_reset, + }), + ); + } } +#[derive(Debug)] pub struct UpgradeNodesArgs { pub action_sender: UnboundedSender, pub connection_timeout_s: u64, @@ -136,38 +215,36 @@ pub struct UpgradeNodesArgs { pub version: Option, } -pub fn upgrade_nodes(args: UpgradeNodesArgs) { - tokio::task::spawn_local(async move { - if let Err(err) = sn_node_manager::cmd::node::upgrade( - args.connection_timeout_s, - args.do_not_start, - args.custom_bin_path, - args.force, - args.fixed_interval, - args.peer_ids, - args.provided_env_variables, - args.service_names, - args.url, - args.version, - VerbosityLevel::Minimal, - ) - .await - { - error!("Error while updating services {err:?}"); - send_action( - args.action_sender, - Action::StatusActions(StatusActions::ErrorUpdatingNodes { - raw_error: err.to_string(), - }), - ); - } else { - info!("Successfully updated services"); - send_action( - args.action_sender, - Action::StatusActions(StatusActions::UpdateNodesCompleted), - ); - } - }); +async fn upgrade_nodes(args: UpgradeNodesArgs) { + if let Err(err) = sn_node_manager::cmd::node::upgrade( + args.connection_timeout_s, + args.do_not_start, + args.custom_bin_path, + args.force, + args.fixed_interval, + args.peer_ids, + args.provided_env_variables, + args.service_names, + args.url, + args.version, + VerbosityLevel::Minimal, + ) + .await + { + error!("Error while updating services {err:?}"); + send_action( + args.action_sender, + Action::StatusActions(StatusActions::ErrorUpdatingNodes { + raw_error: err.to_string(), + }), + ); + } else { + info!("Successfully updated services"); + send_action( + args.action_sender, + Action::StatusActions(StatusActions::UpdateNodesCompleted), + ); + } } // --- Helper functions --- diff --git a/node-launchpad/src/node_stats.rs b/node-launchpad/src/node_stats.rs index 339ab24b36..3a17835e4f 100644 --- a/node-launchpad/src/node_stats.rs +++ b/node-launchpad/src/node_stats.rs @@ -91,7 +91,7 @@ impl NodeStats { .collect::>(); if !node_details.is_empty() { debug!("Fetching stats from {} nodes", node_details.len()); - tokio::task::spawn_local(async move { + tokio::spawn(async move { Self::fetch_all_node_stats_inner(node_details, action_sender).await; }); } else { diff --git a/node-launchpad/src/style.rs b/node-launchpad/src/style.rs index 10e0cda89d..0ca4121c20 100644 --- a/node-launchpad/src/style.rs +++ b/node-launchpad/src/style.rs @@ -21,7 +21,7 @@ pub const EUCALYPTUS: Color = Color::Indexed(115); pub const SIZZLING_RED: Color = Color::Indexed(197); pub const SPACE_CADET: Color = Color::Indexed(17); pub const DARK_GUNMETAL: Color = Color::Indexed(235); // 266 is incorrect -pub const INDIGO: Color = Color::Indexed(60); +pub const INDIGO: Color = Color::Indexed(24); pub const VIVID_SKY_BLUE: Color = Color::Indexed(45); pub const RED: Color = Color::Indexed(196); diff --git a/release-cycle-info b/release-cycle-info index 25eb9d78ce..b75976efb5 100644 --- a/release-cycle-info +++ b/release-cycle-info @@ -15,4 +15,4 @@ release-year: 2024 release-month: 10 release-cycle: 4 -release-cycle-counter: 5 +release-cycle-counter: 6 diff --git a/sn_build_info/src/release_info.rs b/sn_build_info/src/release_info.rs index c5d9ad7bfc..1f67bd7304 100644 --- a/sn_build_info/src/release_info.rs +++ b/sn_build_info/src/release_info.rs @@ -1,4 +1,4 @@ pub const RELEASE_YEAR: &str = "2024"; pub const RELEASE_MONTH: &str = "10"; pub const RELEASE_CYCLE: &str = "4"; -pub const RELEASE_CYCLE_COUNTER: &str = "5"; +pub const RELEASE_CYCLE_COUNTER: &str = "6";